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
167 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
168 let stats = this.buildStatsFooter();
169 wrapper.appendChild(stats);
170 }
171
172 if (this.post.replyCount == 1 && this.post.replies[0]?.author?.did == this.post.author.did) {
173 let component = new PostComponent(this.post.replies[0], 'thread');
174 let element = component.buildElement();
175 element.classList.add('flat');
176 content.appendChild(element);
177 } else {
178 for (let reply of this.post.replies) {
179 if (reply instanceof MissingPost) { continue }
180 if (reply instanceof BlockedPost && window.biohazardEnabled === false) { continue }
181
182 let component = new PostComponent(reply, 'thread');
183 content.appendChild(component.buildElement());
184 }
185 }
186
187 if (this.context == 'thread') {
188 if (this.post.hasMoreReplies) {
189 let loadMore = this.buildLoadMoreLink();
190 content.appendChild(loadMore);
191 } else if (this.post.hasHiddenReplies && window.biohazardEnabled !== false) {
192 let loadMore = this.buildHiddenRepliesLink();
193 content.appendChild(loadMore);
194 }
195 }
196
197 div.appendChild(content);
198
199 return div;
200 }
201
202 /** @returns {HTMLElement} */
203
204 buildPostHeader() {
205 let timeFormat = this.timeFormatForTimestamp;
206 let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat);
207 let isoTime = this.post.createdAt.toISOString();
208
209 let h = $tag('h2');
210
211 h.innerHTML = `${escapeHTML(this.authorName)} `;
212
213 if (this.post.isFediPost) {
214 let handle = `@${this.post.authorFediHandle}`;
215 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> ` +
216 `<img src="icons/mastodon.svg" class="mastodon"> `;
217 } else {
218 let handle = (this.post.author.handle != 'handle.invalid') ? `@${this.post.author.handle}` : '[invalid handle]';
219 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> `;
220 }
221
222 h.innerHTML += `<span class="separator">•</span> ` +
223 `<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `;
224
225 if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(this.context)) {
226 h.innerHTML +=
227 `<span class="separator">•</span> ` +
228 `<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` +
229 `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a> `;
230 }
231
232 if (this.post.muted) {
233 h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x'));
234 } else if (this.post.author.avatar) {
235 h.prepend(this.buildUserAvatar(this.post.author.avatar));
236 } else {
237 h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x'));
238 }
239
240 return h;
241 }
242
243 buildEdgeMargin() {
244 let div = $tag('div.margin');
245
246 let edge = $tag('div.edge');
247 let line = $tag('div.line');
248 edge.appendChild(line);
249 div.appendChild(edge);
250
251 let plus = $tag('img.plus', { src: 'icons/subtract-square.png' });
252 div.appendChild(plus);
253
254 [edge, plus].forEach(x => {
255 x.addEventListener('click', (e) => {
256 e.preventDefault();
257 this.toggleSectionFold();
258 });
259 });
260
261 return div;
262 }
263
264 /** @param {string} url, @returns {HTMLImageElement} */
265
266 buildUserAvatar(url) {
267 let avatar = $tag('img.avatar', { loading: 'lazy' }, HTMLImageElement); // needs to be set before src!
268 avatar.src = url;
269 window.avatarPreloader.observe(avatar);
270 return avatar;
271 }
272
273 /** @returns {HTMLElement} */
274
275 buildPostBody() {
276 if (this.post.originalFediContent) {
277 return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) });
278 }
279
280 let p = $tag('p.body');
281 let richText = new RichText({ text: this.post.text, facets: this.post.facets });
282
283 for (let seg of richText.segments()) {
284 if (seg.mention) {
285 p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text }));
286 } else if (seg.link) {
287 p.append($tag('a', { href: seg.link.uri, text: seg.text }));
288 } else if (seg.tag) {
289 let url = new URL(getLocation());
290 url.searchParams.set('hash', seg.tag.tag);
291 p.append($tag('a', { href: url.toString(), text: seg.text }));
292 } else if (seg.text.includes("\n")) {
293 let span = $tag('span', { text: seg.text });
294 span.innerHTML = span.innerHTML.replaceAll("\n", "<br>");
295 p.append(span);
296 } else {
297 p.append(seg.text);
298 }
299 }
300
301 return p;
302 }
303
304 /** @param {string[]} tags, @returns {HTMLElement} */
305
306 buildTagsRow(tags) {
307 let p = $tag('p.tags');
308
309 for (let tag of tags) {
310 let url = new URL(getLocation());
311 url.searchParams.set('hash', tag);
312
313 let tagLink = $tag('a', { href: url.toString(), text: '# ' + tag });
314 p.append(tagLink);
315 }
316
317 return p;
318 }
319
320 /** @returns {HTMLElement} */
321
322 buildStatsFooter() {
323 let stats = $tag('p.stats');
324
325 let span = $tag('span');
326 let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : ''));
327 heart.addEventListener('click', (e) => this.onHeartClick(heart));
328
329 span.append(heart, ' ', $tag('output', { text: this.post.likeCount }));
330 stats.append(span);
331
332 if (this.post.repostCount > 0) {
333 let span = $tag('span', { html: `<i class="fa-solid fa-retweet"></i> ${this.post.repostCount}` });
334 stats.append(span);
335 }
336
337 if (this.post.replyCount > 0 && (this.context == 'quotes' || this.context == 'feed')) {
338 let pluralizedCount = (this.post.replyCount > 1) ? `${this.post.replyCount} replies` : '1 reply';
339 let span = $tag('span', {
340 html: `<i class="fa-regular fa-message"></i> <a href="${linkToPostThread(this.post)}">${pluralizedCount}</a>`
341 });
342 stats.append(span);
343 }
344
345 if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) {
346 let expanded = this.context == 'quotes' || this.context == 'feed';
347 let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, expanded);
348 stats.append(quotesLink);
349 }
350
351 if (this.context == 'thread' && this.post.isRestrictingReplies) {
352 let span = $tag('span', { html: `<i class="fa-solid fa-ban"></i> Limited replies` });
353 stats.append(span);
354 }
355
356 return stats;
357 }
358
359 /** @param {number} count, @param {boolean} expanded, @returns {HTMLElement} */
360
361 buildQuotesIconLink(count, expanded) {
362 let q = new URL(getLocation());
363 q.searchParams.set('quotes', this.linkToPost);
364
365 let url = q.toString();
366 let icon = `<i class="fa-regular fa-comments"></i>`;
367
368 if (expanded) {
369 let span = $tag('span', { html: `${icon} ` });
370 let link = $tag('a', { text: (count > 1) ? `${count} quotes` : '1 quote', href: url });
371 span.append(link);
372 return span;
373 } else {
374 return $tag('a', { html: `${icon} ${count}`, href: url });
375 }
376 }
377
378 /** @param {number} quoteCount, @param {boolean} expanded */
379
380 appendQuotesIconLink(quoteCount, expanded) {
381 let stats = $(this.rootElement.querySelector(':scope > .content > p.stats'));
382 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded);
383 stats.append(quotesLink);
384 }
385
386 /** @returns {HTMLElement} */
387
388 buildLoadMoreLink() {
389 let loadMore = $tag('p');
390
391 let link = $tag('a', {
392 href: linkToPostThread(this.post),
393 text: "Load more replies…"
394 });
395
396 link.addEventListener('click', (e) => {
397 e.preventDefault();
398 loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`;
399 this.loadSubtree(this.post, this.rootElement);
400 });
401
402 loadMore.appendChild(link);
403 return loadMore;
404 }
405
406 /** @returns {HTMLElement} */
407
408 buildHiddenRepliesLink() {
409 let loadMore = $tag('p.hidden-replies');
410
411 let link = $tag('a', {
412 href: linkToPostThread(this.post),
413 text: "Load hidden replies…"
414 });
415
416 link.addEventListener('click', (e) => {
417 e.preventDefault();
418
419 if (window.biohazardEnabled === true) {
420 this.loadHiddenReplies(loadMore);
421 } else {
422 window.loadInfohazard = () => this.loadHiddenReplies(loadMore);
423 showDialog($id('biohazard_dialog'));
424 }
425 });
426
427 loadMore.append("☣️ ", link);
428 return loadMore;
429 }
430
431 /** @param {HTMLElement} loadMoreButton */
432
433 loadHiddenReplies(loadMoreButton) {
434 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`;
435 this.loadHiddenSubtree(this.post, this.rootElement);
436 }
437
438 /** @param {HTMLLinkElement} authorLink */
439
440 loadReferencedPostAuthor(authorLink) {
441 let did = atURI(this.post.uri).repo;
442
443 api.fetchHandleForDid(did).then(handle => {
444 if (this.post.author) {
445 this.post.author.handle = handle;
446 } else {
447 this.post.author = { did, handle };
448 }
449
450 authorLink.href = this.linkToAuthor;
451 authorLink.innerText = `@${handle}`;
452 });
453 }
454
455 /** @param {HTMLElement} div, @returns {HTMLElement} */
456
457 buildBlockedPostElement(div) {
458 let p = $tag('p.blocked-header');
459 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span>`;
460
461 if (window.biohazardEnabled === false) {
462 div.appendChild(p);
463 div.classList.add('blocked');
464 return p;
465 }
466
467 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
468 blockStatus = blockStatus ? `, ${blockStatus}` : '';
469
470 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
471 p.append(' (', authorLink, blockStatus, ') ');
472 div.appendChild(p);
473
474 this.loadReferencedPostAuthor(authorLink);
475
476 let loadPost = $tag('p.load-post');
477 let a = $tag('a', { href: '#', text: "Load post…" });
478
479 a.addEventListener('click', (e) => {
480 e.preventDefault();
481 loadPost.innerHTML = ' ';
482 this.loadBlockedPost(this.post.uri, div);
483 });
484
485 loadPost.appendChild(a);
486 div.appendChild(loadPost);
487 div.classList.add('blocked');
488 return div;
489 }
490
491 /** @param {HTMLElement} div, @returns {HTMLElement} */
492
493 buildDetachedQuoteElement(div) {
494 let p = $tag('p.blocked-header');
495 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Hidden quote</span>`;
496
497 if (window.biohazardEnabled === false) {
498 div.appendChild(p);
499 div.classList.add('blocked');
500 return p;
501 }
502
503 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
504 p.append(' (', authorLink, ') ');
505 div.appendChild(p);
506
507 this.loadReferencedPostAuthor(authorLink);
508
509 let loadPost = $tag('p.load-post');
510 let a = $tag('a', { href: '#', text: "Load post…" });
511
512 a.addEventListener('click', (e) => {
513 e.preventDefault();
514 loadPost.innerHTML = ' ';
515 this.loadBlockedPost(this.post.uri, div);
516 });
517
518 loadPost.appendChild(a);
519 div.appendChild(loadPost);
520 div.classList.add('blocked');
521 return div;
522 }
523
524 /** @param {HTMLElement} div, @returns {HTMLElement} */
525
526 buildMissingPostElement(div) {
527 let p = $tag('p.blocked-header');
528 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`;
529
530 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
531 p.append(' (', authorLink, ') ');
532
533 this.loadReferencedPostAuthor(authorLink);
534
535 div.appendChild(p);
536 div.classList.add('blocked');
537 return div;
538 }
539
540 /** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */
541
542 async loadBlockedPost(uri, div) {
543 let record = await appView.loadPostIfExists(this.post.uri);
544
545 if (!record) {
546 let post = new MissingPost({ uri: this.post.uri });
547 let postView = new PostComponent(post, 'quote').buildElement();
548 div.replaceWith(postView);
549 return;
550 }
551
552 this.post = new Post(record);
553
554 let userView = await api.getRequest('app.bsky.actor.getProfile', { actor: this.post.author.did });
555
556 if (!userView.viewer || !(userView.viewer.blockedBy || userView.viewer.blocking)) {
557 let { repo, rkey } = atURI(this.post.uri);
558
559 let a = $tag('a', {
560 href: linkToPostById(repo, rkey),
561 className: 'action',
562 title: "Load thread",
563 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>`
564 });
565
566 let header = $(div.querySelector('p.blocked-header'));
567 let separator = $tag('span.separator', { html: '•' });
568 header.append(separator, ' ', a);
569 }
570
571 let loadPost = $(div.querySelector('p.load-post'));
572 loadPost.remove();
573
574 if (this.isRoot && this.post.parentReference) {
575 let { repo, rkey } = atURI(this.post.parentReference.uri);
576 let url = linkToPostById(repo, rkey);
577
578 let handle = api.findHandleByDid(repo);
579 let link = handle ? `See parent post (@${handle})` : "See parent post";
580
581 let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` });
582 div.appendChild(p);
583 }
584
585 let p = this.buildPostBody();
586 div.appendChild(p);
587
588 if (this.post.embed) {
589 let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
590 div.appendChild(embed);
591
592 // TODO
593 Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove());
594 }
595 }
596
597 /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */
598
599 async loadSubtree(post, nodeToUpdate) {
600 try {
601 let json = await api.loadThreadByAtURI(post.uri);
602
603 let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel);
604 post.updateDataFromPost(root);
605 window.subtreeRoot = post;
606
607 let component = new PostComponent(post, 'thread');
608 component.installIntoElement(nodeToUpdate);
609 } catch (error) {
610 showError(error);
611 }
612 }
613
614
615 /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */
616
617 async loadHiddenSubtree(post, nodeToUpdate) {
618 let content = $(nodeToUpdate.querySelector('.content'));
619 let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies'));
620
621 try {
622 var expectedReplyURIs = await blueAPI.getReplies(post.uri);
623 } catch (error) {
624 hiddenRepliesDiv.remove();
625
626 if (error instanceof APIError && error.code == 404) {
627 let info = $tag('p.missing-replies-info', {
628 html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)`
629 });
630 content.append(info);
631 } else {
632 setTimeout(() => showError(error), 1);
633 }
634
635 return;
636 }
637
638 let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r));
639 let promises = missingReplyURIs.map(uri => api.loadThreadByAtURI(uri));
640
641 try {
642 // TODO
643 var responses = await Promise.allSettled(promises);
644 } catch (error) {
645 hiddenRepliesDiv.remove();
646 setTimeout(() => showError(error), 1);
647 return;
648 }
649
650 let replies = responses
651 .map(r => r.status == 'fulfilled' ? r.value : undefined)
652 .filter(v => v)
653 .map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1));
654
655 post.setReplies(replies);
656 hiddenRepliesDiv.remove();
657
658 for (let reply of post.replies) {
659 let component = new PostComponent(reply, 'thread');
660 let view = component.buildElement();
661 content.append(view);
662 }
663
664 if (replies.length < responses.length) {
665 let notFoundCount = responses.length - replies.length;
666 let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is';
667
668 let info = $tag('p.missing-replies-info', {
669 html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)`
670 });
671 content.append(info);
672 }
673 }
674
675 /** @returns {boolean} */
676 isCollapsed() {
677 return this.rootElement.classList.contains('collapsed');
678 }
679
680 toggleSectionFold() {
681 let plus = $(this.rootElement.querySelector(':scope > .margin .plus'), HTMLImageElement);
682
683 if (this.isCollapsed()) {
684 this.rootElement.classList.remove('collapsed');
685 plus.src = 'icons/subtract-square.png'
686 } else {
687 this.rootElement.classList.add('collapsed');
688 plus.src = 'icons/add-square.png'
689 }
690 }
691
692 /** @param {HTMLElement} heart, @returns {Promise<void>} */
693
694 async onHeartClick(heart) {
695 try {
696 if (!this.post.hasViewerInfo) {
697 if (accountAPI.isLoggedIn) {
698 let data = await this.loadViewerInfo();
699
700 if (data) {
701 if (this.post.liked) {
702 heart.classList.add('liked');
703 return;
704 } else {
705 // continue down
706 }
707 } else {
708 this.showPostAsBlocked();
709 return;
710 }
711 } else {
712 // not logged in
713 showDialog(loginDialog);
714 return;
715 }
716 }
717
718 let countField = $(heart.nextElementSibling);
719 let likeCount = parseInt(countField.innerText, 10);
720
721 if (!heart.classList.contains('liked')) {
722 let like = await accountAPI.likePost(this.post);
723 this.post.viewerLike = like.uri;
724 heart.classList.add('liked');
725 countField.innerText = String(likeCount + 1);
726 } else {
727 await accountAPI.removeLike(this.post.viewerLike);
728 this.post.viewerLike = undefined;
729 heart.classList.remove('liked');
730 countField.innerText = String(likeCount - 1);
731 }
732 } catch (error) {
733 showError(error);
734 }
735 }
736
737 showPostAsBlocked() {
738 let stats = $(this.rootElement.querySelector(':scope > .content > p.stats'));
739
740 if (!stats.querySelector('.blocked-info')) {
741 let span = $tag('span.blocked-info', { text: '🚫 Post unavailable' });
742 stats.append(span);
743 }
744 }
745
746 /** @returns {Promise<json | undefined>} */
747
748 async loadViewerInfo() {
749 let data = await accountAPI.loadPostIfExists(this.post.uri);
750
751 if (data) {
752 this.post.author = data.author;
753 this.post.viewerData = data.viewer;
754 this.post.viewerLike = data.viewer?.like;
755 }
756
757 return data;
758 }
759}