Thread viewer for Bluesky
1/**
2 * Renders a post/thread view and its subviews.
3 */
4
5class PostComponent {
6 /**
7 * Post component's root HTML element, if built.
8 * @type {HTMLElement | undefined}
9 */
10 _rootElement;
11
12 /**
13 Contexts:
14 - thread - a post in the thread tree
15 - parent - parent reference above the thread root
16 - quote - a quote embed
17 - quotes - a post on the quotes page
18 - feed - a post on the hashtag feed page
19
20 @typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext
21 @param {AnyPost} post, @param {PostContext} context
22 */
23 constructor(post, context) {
24 this.post = /** @type {Post}, TODO */ (post);
25 this.context = context;
26 }
27
28 /**
29 * @returns {HTMLElement}
30 */
31 get rootElement() {
32 if (!this._rootElement) {
33 throw new Error("rootElement not initialized");
34 }
35
36 return this._rootElement;
37 }
38
39 /** @returns {boolean} */
40 get isRoot() {
41 return this.post.isRoot;
42 }
43
44 /** @returns {string} */
45 get linkToAuthor() {
46 if (this.post.author.handle != 'handle.invalid') {
47 return 'https://bsky.app/profile/' + this.post.author.handle;
48 } else {
49 return 'https://bsky.app/profile/' + this.post.author.did;
50 }
51 }
52
53 /** @returns {string} */
54 get linkToPost() {
55 return this.linkToAuthor + '/post/' + this.post.rkey;
56 }
57
58 /** @returns {string} */
59 get didLinkToAuthor() {
60 let { repo } = atURI(this.post.uri);
61 return `https://bsky.app/profile/${repo}`;
62 }
63
64 /** @returns {string} */
65 get didLinkToPost() {
66 let { repo, rkey } = atURI(this.post.uri);
67 return `https://bsky.app/profile/${repo}/post/${rkey}`;
68 }
69
70 /** @returns {string} */
71 get authorName() {
72 if (this.post.author.displayName) {
73 return this.post.author.displayName;
74 } else if (this.post.author.handle.endsWith('.bsky.social')) {
75 return this.post.author.handle.replace(/\.bsky\.social$/, '');
76 } else {
77 return this.post.author.handle;
78 }
79 }
80
81 /** @returns {json} */
82 get timeFormatForTimestamp() {
83 if (this.context == 'quotes' || this.context == 'feed') {
84 return { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' };
85 } else if (this.isRoot || this.context != 'thread') {
86 return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' };
87 } else if (this.post.pageRoot && !sameDay(this.post.createdAt, this.post.pageRoot.createdAt)) {
88 return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' };
89 } else {
90 return { hour: 'numeric', minute: 'numeric' };
91 }
92 }
93
94 /** @param {HTMLElement} nodeToUpdate */
95 installIntoElement(nodeToUpdate) {
96 let view = this.buildElement();
97
98 let oldContent = $(nodeToUpdate.querySelector('.content'));
99 let newContent = $(view.querySelector('.content'));
100 oldContent.replaceWith(newContent);
101
102 this._rootElement = nodeToUpdate;
103 }
104
105 /** @returns {HTMLElement} */
106 buildElement() {
107 if (this._rootElement) {
108 return this._rootElement;
109 }
110
111 let div = $tag('div.post', `post-${this.context}`);
112 this._rootElement = div;
113
114 if (this.post.muted) {
115 div.classList.add('muted');
116 }
117
118 if (this.post instanceof BlockedPost) {
119 this.buildBlockedPostElement(div);
120 return div;
121 } else if (this.post instanceof DetachedQuotePost) {
122 this.buildDetachedQuoteElement(div);
123 return div;
124 } else if (this.post instanceof MissingPost) {
125 this.buildMissingPostElement(div);
126 return div;
127 }
128
129 let header = this.buildPostHeader();
130 div.appendChild(header);
131
132 let content = $tag('div.content');
133
134 if (this.context == 'thread' && !this.isRoot) {
135 let edgeMargin = this.buildEdgeMargin();
136 div.appendChild(edgeMargin);
137 }
138
139 let wrapper;
140
141 if (this.post.muted) {
142 let details = $tag('details');
143
144 let summary = $tag('summary');
145 summary.innerText = this.post.muteList ? `Muted (${this.post.muteList})` : 'Muted - click to show';
146 details.appendChild(summary);
147
148 content.appendChild(details);
149 wrapper = details;
150 } else {
151 wrapper = content;
152 }
153
154 let p = this.buildPostBody();
155 wrapper.appendChild(p);
156
157 if (this.post.tags) {
158 let tagsRow = this.buildTagsRow(this.post.tags);
159 wrapper.appendChild(tagsRow);
160 }
161
162 if (this.post.embed) {
163 let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
164 wrapper.appendChild(embed);
165
166 if (this.post.originalFediURL) {
167 if (this.post.embed instanceof InlineLinkEmbed && this.post.embed.title.startsWith('Original post on ')) {
168 embed.remove();
169 }
170 }
171 }
172
173 if (this.post.originalFediURL) {
174 let link = this.buildFediSourceLink(this.post.originalFediURL);
175 if (link) {
176 wrapper.appendChild(link);
177 }
178 }
179
180 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
181 let stats = this.buildStatsFooter();
182 wrapper.appendChild(stats);
183 }
184
185 if (this.post.replyCount == 1 && this.post.replies[0]?.author?.did == this.post.author.did) {
186 let component = new PostComponent(this.post.replies[0], 'thread');
187 let element = component.buildElement();
188 element.classList.add('flat');
189 content.appendChild(element);
190 } else {
191 for (let reply of this.post.replies) {
192 if (reply instanceof MissingPost) { continue }
193 if (reply instanceof BlockedPost && window.biohazardEnabled === false) { continue }
194
195 let component = new PostComponent(reply, 'thread');
196 content.appendChild(component.buildElement());
197 }
198 }
199
200 if (this.context == 'thread') {
201 if (this.post.hasMoreReplies) {
202 let loadMore = this.buildLoadMoreLink();
203 content.appendChild(loadMore);
204 } else if (this.post.hasHiddenReplies && window.biohazardEnabled !== false) {
205 let loadMore = this.buildHiddenRepliesLink();
206 content.appendChild(loadMore);
207 }
208 }
209
210 div.appendChild(content);
211
212 return div;
213 }
214
215 /** @returns {HTMLElement} */
216
217 buildPostHeader() {
218 let timeFormat = this.timeFormatForTimestamp;
219 let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat);
220 let isoTime = this.post.createdAt.toISOString();
221
222 let h = $tag('h2');
223
224 h.innerHTML = `${escapeHTML(this.authorName)} `;
225
226 if (this.post.isFediPost) {
227 let handle = `@${this.post.authorFediHandle}`;
228 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> ` +
229 `<img src="icons/mastodon.svg" class="mastodon"> `;
230 } else {
231 let handle = (this.post.author.handle != 'handle.invalid') ? `@${this.post.author.handle}` : '[invalid handle]';
232 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> `;
233 }
234
235 h.innerHTML += `<span class="separator">•</span> ` +
236 `<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `;
237
238 if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(this.context)) {
239 h.innerHTML +=
240 `<span class="separator">•</span> ` +
241 `<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` +
242 `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a> `;
243 }
244
245 if (this.post.muted) {
246 h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x'));
247 } else if (this.post.author.avatar) {
248 h.prepend(this.buildUserAvatar(this.post.author.avatar));
249 } else {
250 h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x'));
251 }
252
253 return h;
254 }
255
256 buildEdgeMargin() {
257 let div = $tag('div.margin');
258
259 let edge = $tag('div.edge');
260 let line = $tag('div.line');
261 edge.appendChild(line);
262 div.appendChild(edge);
263
264 let plus = $tag('img.plus', { src: 'icons/subtract-square.png' });
265 div.appendChild(plus);
266
267 [edge, plus].forEach(x => {
268 x.addEventListener('click', (e) => {
269 e.preventDefault();
270 this.toggleSectionFold();
271 });
272 });
273
274 return div;
275 }
276
277 /** @param {string} url, @returns {HTMLImageElement} */
278
279 buildUserAvatar(url) {
280 let avatar = $tag('img.avatar', { loading: 'lazy' }, HTMLImageElement); // needs to be set before src!
281 avatar.src = url;
282 window.avatarPreloader.observe(avatar);
283 return avatar;
284 }
285
286 /** @returns {HTMLElement} */
287
288 buildPostBody() {
289 if (this.post.originalFediContent) {
290 return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) });
291 }
292
293 let p = $tag('p.body');
294 let richText = new RichText({ text: this.post.text, facets: this.post.facets });
295
296 for (let seg of richText.segments()) {
297 if (seg.mention) {
298 p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text }));
299 } else if (seg.link) {
300 p.append($tag('a', { href: seg.link.uri, text: seg.text }));
301 } else if (seg.tag) {
302 let url = new URL(getLocation());
303 url.searchParams.set('hash', seg.tag.tag);
304 p.append($tag('a', { href: url.toString(), text: seg.text }));
305 } else if (seg.text.includes("\n")) {
306 let span = $tag('span', { text: seg.text });
307 span.innerHTML = span.innerHTML.replaceAll("\n", "<br>");
308 p.append(span);
309 } else {
310 p.append(seg.text);
311 }
312 }
313
314 return p;
315 }
316
317 /** @param {string[]} terms */
318
319 highlightSearchResults(terms) {
320 let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi');
321
322 let root = this.rootElement;
323 let body = $(root.querySelector(':scope > .content > .body, :scope > .content > details .body'));
324 let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
325 let textNodes = [];
326
327 while (walker.nextNode()) {
328 textNodes.push(walker.currentNode);
329 }
330
331 for (let node of textNodes) {
332 if (!node.textContent) { continue; }
333
334 let markedText = document.createDocumentFragment();
335 let currentPosition = 0;
336
337 for (;;) {
338 let match = regexp.exec(node.textContent);
339 if (match === null) break;
340
341 if (match.index > currentPosition) {
342 let earlierText = node.textContent.slice(currentPosition, match.index);
343 markedText.appendChild(document.createTextNode(earlierText));
344 }
345
346 let span = $tag('span.highlight', { text: match[0] });
347 markedText.appendChild(span);
348
349 currentPosition = match.index + match[0].length;
350 }
351
352 if (currentPosition < node.textContent.length) {
353 let remainingText = node.textContent.slice(currentPosition);
354 markedText.appendChild(document.createTextNode(remainingText));
355 }
356
357 $(node.parentNode).replaceChild(markedText, node);
358 }
359 }
360
361 /** @param {string[]} tags, @returns {HTMLElement} */
362
363 buildTagsRow(tags) {
364 let p = $tag('p.tags');
365
366 for (let tag of tags) {
367 let url = new URL(getLocation());
368 url.searchParams.set('hash', tag);
369
370 let tagLink = $tag('a', { href: url.toString(), text: '# ' + tag });
371 p.append(tagLink);
372 }
373
374 return p;
375 }
376
377 /** @returns {HTMLElement} */
378
379 buildStatsFooter() {
380 let stats = $tag('p.stats');
381
382 let span = $tag('span');
383 let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : ''));
384 heart.addEventListener('click', (e) => this.onHeartClick(heart));
385
386 span.append(heart, ' ', $tag('output', { text: this.post.likeCount }));
387 stats.append(span);
388
389 if (this.post.repostCount > 0) {
390 let span = $tag('span', { html: `<i class="fa-solid fa-retweet"></i> ${this.post.repostCount}` });
391 stats.append(span);
392 }
393
394 if (this.post.replyCount > 0 && (this.context == 'quotes' || this.context == 'feed')) {
395 let pluralizedCount = (this.post.replyCount > 1) ? `${this.post.replyCount} replies` : '1 reply';
396 let span = $tag('span', {
397 html: `<i class="fa-regular fa-message"></i> <a href="${linkToPostThread(this.post)}">${pluralizedCount}</a>`
398 });
399 stats.append(span);
400 }
401
402 if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) {
403 let expanded = this.context == 'quotes' || this.context == 'feed';
404 let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, expanded);
405 stats.append(quotesLink);
406 }
407
408 if (this.context == 'thread' && this.post.isRestrictingReplies) {
409 let span = $tag('span', { html: `<i class="fa-solid fa-ban"></i> Limited replies` });
410 stats.append(span);
411 }
412
413 return stats;
414 }
415
416 /** @param {number} count, @param {boolean} expanded, @returns {HTMLElement} */
417
418 buildQuotesIconLink(count, expanded) {
419 let q = new URL(getLocation());
420 q.searchParams.set('quotes', this.linkToPost);
421
422 let url = q.toString();
423 let icon = `<i class="fa-regular fa-comments"></i>`;
424
425 if (expanded) {
426 let span = $tag('span', { html: `${icon} ` });
427 let link = $tag('a', { text: (count > 1) ? `${count} quotes` : '1 quote', href: url });
428 span.append(link);
429 return span;
430 } else {
431 return $tag('a', { html: `${icon} ${count}`, href: url });
432 }
433 }
434
435 /** @param {number} quoteCount, @param {boolean} expanded */
436
437 appendQuotesIconLink(quoteCount, expanded) {
438 let stats = $(this.rootElement.querySelector(':scope > .content > p.stats'));
439 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded);
440 stats.append(quotesLink);
441 }
442
443 /** @returns {HTMLElement} */
444
445 buildLoadMoreLink() {
446 let loadMore = $tag('p');
447
448 let link = $tag('a', {
449 href: linkToPostThread(this.post),
450 text: "Load more replies…"
451 });
452
453 link.addEventListener('click', (e) => {
454 e.preventDefault();
455 loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`;
456 this.loadSubtree(this.post, this.rootElement);
457 });
458
459 loadMore.appendChild(link);
460 return loadMore;
461 }
462
463 /** @returns {HTMLElement} */
464
465 buildHiddenRepliesLink() {
466 let loadMore = $tag('p.hidden-replies');
467
468 let link = $tag('a', {
469 href: linkToPostThread(this.post),
470 text: "Load hidden replies…"
471 });
472
473 link.addEventListener('click', (e) => {
474 e.preventDefault();
475
476 if (window.biohazardEnabled === true) {
477 this.loadHiddenReplies(loadMore);
478 } else {
479 window.loadInfohazard = () => this.loadHiddenReplies(loadMore);
480 showDialog($id('biohazard_dialog'));
481 }
482 });
483
484 loadMore.append("☣️ ", link);
485 return loadMore;
486 }
487
488 /** @param {HTMLElement} loadMoreButton */
489
490 loadHiddenReplies(loadMoreButton) {
491 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`;
492 this.loadHiddenSubtree(this.post, this.rootElement);
493 }
494
495 /** @param {string} url, @returns {HTMLElement | undefined} */
496
497 buildFediSourceLink(url) {
498 try {
499 let hostname = new URL(url).hostname;
500 let a = $tag('a.fedi-link', { href: url, target: '_blank' });
501
502 let box = $tag('div', { html: `<i class="fa-solid fa-arrow-up-right-from-square fa-sm"></i> View on ${hostname}` });
503 a.append(box);
504 return a;
505 } catch (error) {
506 console.log("Invalid Fedi URL:" + error);
507 return undefined;
508 }
509 }
510
511 /** @param {HTMLLinkElement} authorLink */
512
513 loadReferencedPostAuthor(authorLink) {
514 let did = atURI(this.post.uri).repo;
515
516 api.fetchHandleForDid(did).then(handle => {
517 if (this.post.author) {
518 this.post.author.handle = handle;
519 } else {
520 this.post.author = { did, handle };
521 }
522
523 authorLink.href = this.linkToAuthor;
524 authorLink.innerText = `@${handle}`;
525 });
526 }
527
528 /** @param {HTMLElement} div, @returns {HTMLElement} */
529
530 buildBlockedPostElement(div) {
531 let p = $tag('p.blocked-header');
532 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span>`;
533
534 if (window.biohazardEnabled === false) {
535 div.appendChild(p);
536 div.classList.add('blocked');
537 return p;
538 }
539
540 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
541 blockStatus = blockStatus ? `, ${blockStatus}` : '';
542
543 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
544 p.append(' (', authorLink, blockStatus, ') ');
545 div.appendChild(p);
546
547 this.loadReferencedPostAuthor(authorLink);
548
549 let loadPost = $tag('p.load-post');
550 let a = $tag('a', { href: '#', text: "Load post…" });
551
552 a.addEventListener('click', (e) => {
553 e.preventDefault();
554 loadPost.innerHTML = ' ';
555 this.loadBlockedPost(this.post.uri, div);
556 });
557
558 loadPost.appendChild(a);
559 div.appendChild(loadPost);
560 div.classList.add('blocked');
561 return div;
562 }
563
564 /** @param {HTMLElement} div, @returns {HTMLElement} */
565
566 buildDetachedQuoteElement(div) {
567 let p = $tag('p.blocked-header');
568 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Hidden quote</span>`;
569
570 if (window.biohazardEnabled === false) {
571 div.appendChild(p);
572 div.classList.add('blocked');
573 return p;
574 }
575
576 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
577 p.append(' (', authorLink, ') ');
578 div.appendChild(p);
579
580 this.loadReferencedPostAuthor(authorLink);
581
582 let loadPost = $tag('p.load-post');
583 let a = $tag('a', { href: '#', text: "Load post…" });
584
585 a.addEventListener('click', (e) => {
586 e.preventDefault();
587 loadPost.innerHTML = ' ';
588 this.loadBlockedPost(this.post.uri, div);
589 });
590
591 loadPost.appendChild(a);
592 div.appendChild(loadPost);
593 div.classList.add('blocked');
594 return div;
595 }
596
597 /** @param {HTMLElement} div, @returns {HTMLElement} */
598
599 buildMissingPostElement(div) {
600 let p = $tag('p.blocked-header');
601 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`;
602
603 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
604 p.append(' (', authorLink, ') ');
605
606 this.loadReferencedPostAuthor(authorLink);
607
608 div.appendChild(p);
609 div.classList.add('blocked');
610 return div;
611 }
612
613 /** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */
614
615 async loadBlockedPost(uri, div) {
616 let record = await appView.loadPostIfExists(this.post.uri);
617
618 if (!record) {
619 let post = new MissingPost({ uri: this.post.uri });
620 let postView = new PostComponent(post, 'quote').buildElement();
621 div.replaceWith(postView);
622 return;
623 }
624
625 this.post = new Post(record);
626
627 let userView = await api.getRequest('app.bsky.actor.getProfile', { actor: this.post.author.did });
628
629 if (!userView.viewer || !(userView.viewer.blockedBy || userView.viewer.blocking)) {
630 let { repo, rkey } = atURI(this.post.uri);
631
632 let a = $tag('a', {
633 href: linkToPostById(repo, rkey),
634 className: 'action',
635 title: "Load thread",
636 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>`
637 });
638
639 let header = $(div.querySelector('p.blocked-header'));
640 let separator = $tag('span.separator', { html: '•' });
641 header.append(separator, ' ', a);
642 }
643
644 let loadPost = $(div.querySelector('p.load-post'));
645 loadPost.remove();
646
647 if (this.isRoot && this.post.parentReference) {
648 let { repo, rkey } = atURI(this.post.parentReference.uri);
649 let url = linkToPostById(repo, rkey);
650
651 let handle = api.findHandleByDid(repo);
652 let link = handle ? `See parent post (@${handle})` : "See parent post";
653
654 let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` });
655 div.appendChild(p);
656 }
657
658 let p = this.buildPostBody();
659 div.appendChild(p);
660
661 if (this.post.embed) {
662 let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
663 div.appendChild(embed);
664
665 // TODO
666 Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove());
667 }
668 }
669
670 /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */
671
672 async loadSubtree(post, nodeToUpdate) {
673 try {
674 let json = await api.loadThreadByAtURI(post.uri);
675
676 let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel);
677 post.updateDataFromPost(root);
678 window.subtreeRoot = post;
679
680 let component = new PostComponent(post, 'thread');
681 component.installIntoElement(nodeToUpdate);
682 } catch (error) {
683 showError(error);
684 }
685 }
686
687
688 /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */
689
690 async loadHiddenSubtree(post, nodeToUpdate) {
691 let content = $(nodeToUpdate.querySelector('.content'));
692 let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies'));
693
694 try {
695 var expectedReplyURIs = await blueAPI.getReplies(post.uri);
696 } catch (error) {
697 hiddenRepliesDiv.remove();
698
699 if (error instanceof APIError && error.code == 404) {
700 let info = $tag('p.missing-replies-info', {
701 html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)`
702 });
703 content.append(info);
704 } else {
705 setTimeout(() => showError(error), 1);
706 }
707
708 return;
709 }
710
711 let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r));
712 let promises = missingReplyURIs.map(uri => api.loadThreadByAtURI(uri));
713
714 try {
715 // TODO
716 var responses = await Promise.allSettled(promises);
717 } catch (error) {
718 hiddenRepliesDiv.remove();
719 setTimeout(() => showError(error), 1);
720 return;
721 }
722
723 let replies = responses
724 .map(r => r.status == 'fulfilled' ? r.value : undefined)
725 .filter(v => v)
726 .map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1));
727
728 post.setReplies(replies);
729 hiddenRepliesDiv.remove();
730
731 for (let reply of post.replies) {
732 let component = new PostComponent(reply, 'thread');
733 let view = component.buildElement();
734 content.append(view);
735 }
736
737 if (replies.length < responses.length) {
738 let notFoundCount = responses.length - replies.length;
739 let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is';
740
741 let info = $tag('p.missing-replies-info', {
742 html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)`
743 });
744 content.append(info);
745 }
746 }
747
748 /** @returns {boolean} */
749 isCollapsed() {
750 return this.rootElement.classList.contains('collapsed');
751 }
752
753 toggleSectionFold() {
754 let plus = $(this.rootElement.querySelector(':scope > .margin .plus'), HTMLImageElement);
755
756 if (this.isCollapsed()) {
757 this.rootElement.classList.remove('collapsed');
758 plus.src = 'icons/subtract-square.png'
759 } else {
760 this.rootElement.classList.add('collapsed');
761 plus.src = 'icons/add-square.png'
762 }
763 }
764
765 /** @param {HTMLElement} heart, @returns {Promise<void>} */
766
767 async onHeartClick(heart) {
768 try {
769 if (!this.post.hasViewerInfo) {
770 if (accountAPI.isLoggedIn) {
771 let data = await this.loadViewerInfo();
772
773 if (data) {
774 if (this.post.liked) {
775 heart.classList.add('liked');
776 return;
777 } else {
778 // continue down
779 }
780 } else {
781 this.showPostAsBlocked();
782 return;
783 }
784 } else {
785 // not logged in
786 showDialog(loginDialog);
787 return;
788 }
789 }
790
791 let countField = $(heart.nextElementSibling);
792 let likeCount = parseInt(countField.innerText, 10);
793
794 if (!heart.classList.contains('liked')) {
795 let like = await accountAPI.likePost(this.post);
796 this.post.viewerLike = like.uri;
797 heart.classList.add('liked');
798 countField.innerText = String(likeCount + 1);
799 } else {
800 await accountAPI.removeLike(this.post.viewerLike);
801 this.post.viewerLike = undefined;
802 heart.classList.remove('liked');
803 countField.innerText = String(likeCount - 1);
804 }
805 } catch (error) {
806 showError(error);
807 }
808 }
809
810 showPostAsBlocked() {
811 let stats = $(this.rootElement.querySelector(':scope > .content > p.stats'));
812
813 if (!stats.querySelector('.blocked-info')) {
814 let span = $tag('span.blocked-info', { text: '🚫 Post unavailable' });
815 stats.append(span);
816 }
817 }
818
819 /** @returns {Promise<json | undefined>} */
820
821 async loadViewerInfo() {
822 let data = await accountAPI.loadPostIfExists(this.post.uri);
823
824 if (data) {
825 this.post.author = data.author;
826 this.post.viewerData = data.viewer;
827 this.post.viewerLike = data.viewer?.like;
828 }
829
830 return data;
831 }
832}