1window.Comments = { 2 HTML_ALLOWED_TAGS: [ 3 'a', 'b', 'bdi', 'blockquote', 'br', 'cite', 'code', 'dd', 'del', 'div', 'dl', 'dt', 'em', 4 'h1', 'h2', 'h3', 'h4', 'h5', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'q', 'rp', 'rt', 'ruby', 5 's', 'small', 'span', 'strong', 'sub', 'sup', 'u', 'ul', 'wbr' 6 ], 7 8 HTML_ALLOWED_ATTRIBUTES: [ 9 'align', 'class', 'dir', 'href', 'lang', 'title', 'translate' 10 ], 11 12 BLANK_AVATAR_URL: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAIBRAA7', 13 BLUESKY_THREAD_DEPTH: 6, 14 15 DID_REGEXP: /^did:(plc|web):[A-Za-z0-9\.\-]+$/, 16 RKEY_REGEXP: /^[A-Za-z0-9]{13}$/, 17 18 init() { 19 this.loadSocialComments(); 20 }, 21 22 loadSocialComments() { 23 let config = window.postData; 24 let commentsSection = document.querySelector('#comments'); 25 26 if (!config || !commentsSection) return; 27 28 let commentGroup = null; 29 30 if (config.blueskyURI) { 31 commentGroup = commentGroup || this.findOrCreateCommentGroup(commentsSection); 32 33 this.loadBlueskyComments(commentGroup, config.blueskyURI).catch(err => { 34 console.log(err); 35 }); 36 } 37 38 if (config.mastodonInstance && config.mastodonStatusId) { 39 commentGroup = commentGroup || this.findOrCreateCommentGroup(commentsSection); 40 41 this.loadMastodonComments(commentGroup, config.mastodonInstance, config.mastodonStatusId).catch(err => { 42 console.log(err); 43 }); 44 } 45 }, 46 47 findOrCreateCommentGroup(commentsSection) { 48 let group = commentsSection.querySelector('.comments-group'); 49 50 if (!group) { 51 group = document.createElement('div'); 52 group.className = 'comments-group comments-merged'; 53 group.hidden = true; 54 group.append(document.createElement('h2')); 55 commentsSection.append(group); 56 } 57 58 return group; 59 }, 60 61 async loadBlueskyComments(groupSection, uri) { 62 let params = new URLSearchParams({ uri: uri, depth: this.BLUESKY_THREAD_DEPTH, parentHeight: 0 }); 63 let response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?${params}`); 64 65 if (!response.ok) { 66 throw new Error(`Could not load Bluesky comments for ${uri}: ${response.status}`); 67 } 68 69 let json = await response.json(); 70 let threadgate = json.threadgate || json.thread?.post?.threadgate; 71 let hiddenReplies = new Set(threadgate && threadgate.record.hiddenReplies || []); 72 let comments = this.filterBlueskyComments(json.thread?.replies || [], hiddenReplies, 1); 73 74 if (comments.length === 0) { 75 return; 76 } 77 78 this.appendBlueskyReplies(groupSection, comments); 79 this.updateCommentGroup(groupSection); 80 }, 81 82 filterBlueskyComments(replies, hiddenReplies, depth) { 83 replies = replies.filter(r => r.post.record.text.trim() !== "ð" && !hiddenReplies.has(r.post.uri)); 84 85 return replies.map(reply => { 86 let subReplies = reply.replies || []; 87 let loadedReplyCount = subReplies.length; 88 let filteredReplies = this.filterBlueskyComments(subReplies, hiddenReplies, depth + 1); 89 let hasMoreReplies = (depth >= this.BLUESKY_THREAD_DEPTH && reply.post.replyCount > loadedReplyCount); 90 91 return { ...reply, hasMoreReplies, replies: filteredReplies }; 92 }).sort((a, b) => this.compareCommentDates(a.post.record.createdAt, b.post.record.createdAt)); 93 }, 94 95 appendBlueskyReplies(container, replies) { 96 for (let reply of replies) { 97 let article = this.createBlueskyComment(reply); 98 container.append(article); 99 100 let repliesDiv = article.querySelector('[data-field="replies"]'); 101 this.appendBlueskyReplies(repliesDiv, reply.replies || []); 102 } 103 }, 104 105 createBlueskyComment(entry) { 106 let post = entry.post; 107 let author = post.author; 108 let rkey = post.uri.split('/').at(-1); 109 let facets = this.blueskyFacets(post.record.text, post.record.facets); 110 let facetURLs = new Set(facets.filter(facet => facet.type === 'link').map(facet => facet.url)); 111 let attachments = this.blueskyAttachmentLinks(post.embed, facetURLs); 112 113 if (entry.hasMoreReplies) { 114 let moreRepliesURL = this.blueskyPostURL(post.uri); 115 116 if (moreRepliesURL) { 117 attachments.push({ url: moreRepliesURL, label: 'More repliesâ¦' }); 118 } 119 } 120 121 return this.createCommentView({ 122 source: 'bluesky', 123 authorName: this.cleanupBlueskyName(author.displayName), 124 authorHandle: author.handle, 125 authorLink: `https://bsky.app/profile/${author.did}`, 126 avatar: author.avatar, 127 createdAt: post.record.createdAt, 128 postLink: `https://bsky.app/profile/${author.did}/post/${rkey}`, 129 bodyText: post.record.text, 130 bodyFacets: facets, 131 attachments: attachments 132 }); 133 }, 134 135 cleanupBlueskyName(name) {
136 return name?.replace(/([\s\u200d\ufe0f]|\p{Extended_Pictographic}|\p{Emoji_Presentation})+$/gu, '').trim(); 137 }, 138 139 blueskyFacets(text, facets) { 140 if (!text || !facets || facets.length === 0) { return [] } 141 142 let bytes = new TextEncoder().encode(text); 143 let decoder = new TextDecoder('utf-8', { fatal: true }); 144 let links = []; 145 146 for (let facet of facets) { 147 let start = facet.index?.byteStart; 148 let end = facet.index?.byteEnd; 149 150 if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start || end > bytes.length) { 151 continue; 152 } 153 154 try { 155 // check if the string is correctly encoded 156 decoder.decode(bytes.slice(start, end)); 157 } catch { 158 continue; 159 } 160 161 for (let feature of facet.features || []) { 162 let link = this.blueskyFacetLink(feature); 163 164 if (link) { 165 links.push({ ...link, start: start, end: end }); 166 break; 167 } 168 } 169 } 170 171 links.sort((a, b) => { 172 if (a.start !== b.start) { 173 return a.start - b.start; 174 } else { 175 return a.end - b.end; 176 } 177 }); 178 179 let filtered = []; 180 181 for (let link of links) { 182 if (filtered.length === 0 || link.start >= filtered.at(-1).end) { 183 filtered.push(link); 184 } 185 } 186 187 return filtered; 188 }, 189 190 truncateURL(url) { 191 return (url && url.length > 80) ? (url.slice(0, 79) + "â¦") : url; 192 }, 193 194 blueskyFacetLink(feature) { 195 switch (feature.$type) { 196 case 'app.bsky.richtext.facet#link': { 197 let url = this.ensureSafeURL(feature.uri); 198 199 if (url) { 200 return { type: 'link', url: url, label: this.truncateURL(url) }; 201 } else { 202 return null; 203 } 204 } 205 206 case 'app.bsky.richtext.facet#mention': { 207 let url = this.blueskyProfileURL(feature.did); 208 return url ? { type: 'mention', url: url } : null; 209 } 210 211 case 'app.bsky.richtext.facet#tag': { 212 let tag = feature.tag; 213 214 if (typeof tag !== 'string' || !tag || tag.length > 100 || /[\u0000-\u001f\u007f]/.test(tag)) { 215 return null; 216 } 217 218 let url = this.ensureSafeURL(`https://bsky.app/hashtag/${encodeURIComponent(tag)}`, ['bsky.app']); 219 return url ? { type: 'tag', url: url } : null; 220 } 221 222 default: 223 return null; 224 } 225 }, 226 227 blueskyAttachmentLinks(embed, facetURLs) { 228 if (!embed) return []; 229 230 switch (embed.$type) { 231 case 'app.bsky.embed.images#view': 232 return this.blueskyImageLinks(embed.images); 233 234 case 'app.bsky.embed.gallery#view': 235 return this.blueskyImageLinks(embed.items); 236 237 case 'app.bsky.embed.record#view': 238 return this.blueskyQuoteLinks(embed.record?.uri); 239 240 case 'app.bsky.embed.recordWithMedia#view': 241 let quoteLinks = this.blueskyQuoteLinks(embed.record?.record?.uri); 242 let mediaLinks = this.blueskyAttachmentLinks(embed.media, facetURLs); 243 return quoteLinks.concat(mediaLinks); 244 245 case 'app.bsky.embed.external#view': 246 return this.blueskyExternalLinks(embed.external?.uri, facetURLs); 247 248 default: 249 return []; 250 } 251 }, 252 253 blueskyImageLinks(images) { 254 let links = []; 255 256 for (let image of images || []) { 257 let url = this.ensureSafeURL(image.fullsize, ['cdn.bsky.app']); 258 259 if (url) { 260 links.push({ url: url, label: '(Image)' }); 261 } 262 } 263 264 return links; 265 }, 266 267 blueskyQuoteLinks(uri) { 268 let url = this.blueskyPostURL(uri); 269 270 return url ? [{ url: url, label: '(Quote)' }] : []; 271 }, 272 273 blueskyExternalLinks(uri, facetURLs) { 274 let url = this.ensureSafeURL(uri); 275 276 if (!url || facetURLs.has(url)) return []; 277 278 let label = url.startsWith('https://media.tenor.com') ? '(GIF)' : this.truncateURL(url); 279 return [{ url: url, label: label }]; 280 }, 281 282 blueskyPostURL(uri) { 283 if (typeof uri !== 'string') { return null; } 284 285 let match = uri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/); 286 287 if (!match) { return null; } 288 289 let repo = match[1]; 290 let rkey = match[2]; 291 292 if (!this.DID_REGEXP.test(repo) || !this.RKEY_REGEXP.test(rkey)) { 293 return null; 294 } 295 296 return this.ensureSafeURL(`https://bsky.app/profile/${repo}/post/${rkey}`, ['bsky.app']); 297 }, 298 299 blueskyProfileURL(did) { 300 if (typeof did !== 'string' || !this.DID_REGEXP.test(did)) return null; 301 302 return this.ensureSafeURL(`https://bsky.app/profile/${did}`, ['bsky.app']); 303 }, 304 305 ensureSafeURL(value, allowedHosts = null) { 306 if (typeof value !== 'string') { return null; } 307 308 try { 309 let url = new URL(value); 310 let hostAllowed = !allowedHosts || allowedHosts.includes(url.hostname); 311 312 if (url.protocol !== 'https:' || url.username || url.password || !hostAllowed) { 313 return null; 314 } 315 316 return url.href; 317 } catch { 318 return null; 319 } 320 }, 321 322 async loadMastodonComments(groupSection, instance, statusId) { 323 let statusById = new Map(); 324 let topLevelReplies = []; 325 326 let response = await fetch(`https://${instance}/api/v1/statuses/${statusId}/context`); 327 328 if (!response.ok) { 329 throw new Error(`Could not load Mastodon comments for ${statusId}: ${response.status}`); 330 } 331 332 let json = await response.json(); 333 334 for (let status of json.descendants || []) { 335 status.replies = []; 336 statusById.set(status.id, status); 337 338 if (statusById.has(status.in_reply_to_id)) { 339 statusById.get(status.in_reply_to_id).replies.push(status); 340 } else { 341 topLevelReplies.push(status); 342 } 343 } 344 345 if (topLevelReplies.length === 0) { 346 return; 347 } 348 349 this.sortMastodonReplies(topLevelReplies); 350 this.appendMastodonReplies(groupSection, instance, topLevelReplies); 351 this.updateCommentGroup(groupSection); 352 }, 353 354 updateCommentGroup(group) {
355 let count = group.querySelectorAll('article').length; 356 let heading = group.querySelector('h2'); 357 358 if (heading) { 359 heading.textContent = `${count} ${count === 1 ? 'comment' : 'comments'}:`; 360 } 361 362 this.sortTopLevelComments(group); 363 group.hidden = (count === 0); 364 }, 365 366 sortTopLevelComments(group) { 367 let articles = Array.from(group.querySelectorAll(':scope > article')); 368 articles.sort((a, b) => this.compareCommentDates(a.dataset.createdAt, b.dataset.createdAt)); 369 370 for (let article of articles) { 371 group.append(article); 372 } 373 }, 374 375 sortMastodonReplies(replies) { 376 replies.sort((a, b) => this.compareCommentDates(a.created_at, b.created_at)); 377 378 for (let status of replies) { 379 this.sortMastodonReplies(status.replies || []); 380 } 381 }, 382 383 appendMastodonReplies(container, instance, replies) { 384 for (let status of replies) { 385 let article = this.createMastodonComment(instance, status); 386 container.append(article); 387 388 let repliesDiv = article.querySelector('[data-field="replies"]'); 389 this.appendMastodonReplies(repliesDiv, instance, status.replies); 390 } 391 }, 392 393 createMastodonComment(instance, status) { 394 let account = status.account || {}; 395 396 return this.createCommentView({ 397 source: 'mastodon', 398 authorName: account.display_name, 399 authorHandle: account.acct.includes('@') ? account.acct : `${account.acct}@${instance}`, 400 authorLink: this.ensureSafeURL(account.url), 401 avatar: account.avatar_static || account.avatar, 402 createdAt: status.created_at, 403 postLink: this.ensureSafeURL(status.url), 404 bodyHTML: status.content || '' 405 }); 406 }, 407 408 createCommentView(data) { 409 let template = document.querySelector('#comment-template'); 410 411 if (!template) { 412 throw new Error('Missing social comment template'); 413 } 414 415 let article = template.content.firstElementChild.cloneNode(true); 416 article.classList.add(`${data.source}-comment`); 417 article.dataset.createdAt = data.createdAt || ''; 418 419 let avatar = article.querySelector('[data-field="avatar"]'); 420 avatar.src = data.avatar || this.BLANK_AVATAR_URL; 421 422 let author = article.querySelector('[data-field="author"]'); 423 let handle = article.querySelector('[data-field="handle"]'); 424 425 if (data.authorLink) { 426 handle.href = data.authorLink; 427 } 428 429 if (data.authorName) { 430 author.textContent = data.authorName; 431 } else { 432 author.textContent = data.authorHandle; 433 } 434 435 handle.textContent = data.authorHandle.includes('@') ? data.authorHandle : `@${data.authorHandle}`; 436 437 let time = article.querySelector('[data-field="created-at"]'); 438 time.dateTime = data.createdAt; 439 time.title = data.createdAt; 440 time.textContent = this.formatCommentDate(data.createdAt); 441 442 let timestamp = article.querySelector('[data-field="timestamp"]'); 443 444 if (data.postLink) { 445 timestamp.href = data.postLink; 446 } else { 447 timestamp.replaceWith(time); 448 } 449 450 let body = article.querySelector('[data-field="body"]'); 451 452 if (data.bodyHTML !== undefined) { 453 body.innerHTML = DOMPurify.sanitize(data.bodyHTML, { 454 ALLOWED_TAGS: this.HTML_ALLOWED_TAGS, 455 ALLOWED_ATTR: this.HTML_ALLOWED_ATTRIBUTES 456 }); 457 } else if (data.bodyText) { 458 this.appendBlueskyBody(body, data.bodyText, data.bodyFacets); 459 } 460 461 this.appendCommentAttachments(body, data.attachments); 462 463 for (let link of article.querySelectorAll('a')) { 464 link.rel = 'nofollow noopener noreferrer'; 465 } 466 467 return article; 468 }, 469 470 appendBlueskyBody(body, text, facets) { 471 let bytes = new TextEncoder().encode(text); 472 let decoder = new TextDecoder(); 473 let position = 0; 474 475 let paragraph = document.createElement('p'); 476 body.append(paragraph); 477 478 function appendText(value, url = null) { 479 let lines = value.split(/\n+/); 480 481 for (let i = 0; i < lines.length; i++) { 482 if (i > 0) { 483 paragraph = document.createElement('p'); 484 body.append(paragraph); 485 } 486 487 if (!lines[i]) continue; 488 489 if (url) { 490 let link = document.createElement('a'); 491 link.href = url; 492 link.textContent = lines[i]; 493 paragraph.append(link); 494 } else { 495 paragraph.append(document.createTextNode(lines[i])); 496 } 497 } 498 } 499 500 for (let facet of facets || []) { 501 appendText(decoder.decode(bytes.slice(position, facet.start))); 502 appendText(facet.label || decoder.decode(bytes.slice(facet.start, facet.end)), facet.url); 503 position = facet.end; 504 } 505 506 appendText(decoder.decode(bytes.slice(position))); 507 }, 508 509 appendCommentAttachments(body, attachments) { 510 if (!attachments || attachments.length === 0) { return; } 511 512 let paragraph = document.createElement('p'); 513 paragraph.className = 'comment-attachments'; 514 515 for (let attachment of attachments) { 516 if (paragraph.childNodes.length > 0) { 517 paragraph.append(document.createTextNode(' · ')); 518 } 519 520 let link = document.createElement('a'); 521 link.href = attachment.url; 522 link.textContent = attachment.label; 523 paragraph.append(link); 524 } 525 526 body.append(paragraph); 527 }, 528 529 formatCommentDate(value) { 530 let date = new Date(value); 531 if (Number.isNaN(date.valueOf())) { return value || ''; } 532
533 return date.toLocaleString('en-GB', { 534 year: 'numeric', 535 month: 'short', 536 day: 'numeric', 537 hour: '2-digit', 538 minute: '2-digit' 539 }).replace(' at ', ', '); 540 }, 541 542 compareCommentDates(a, b) { 543 return this.commentTimestamp(a) - this.commentTimestamp(b); 544 }, 545 546 commentTimestamp(value) { 547 let timestamp = new Date(value).valueOf(); 548 return Number.isNaN(timestamp) ? 0 : timestamp; 549 } 550};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.