PageSourceSearch

https://mackuba.eu/javascripts/comments.js?1790026422

js mackuba.eu collected 2026-10-02 04:19:16 UTC 16,219 bytes, 550 lines download raw bytes

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.