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 {AnyElement | 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 {AnyElement}
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 {AnyElement} nodeToUpdate */
95 installIntoElement(nodeToUpdate) {
96 let view = this.buildElement();
97
98 nodeToUpdate.querySelector('.content').replaceWith(view.querySelector('.content'));
99 this._rootElement = nodeToUpdate;
100 }
101
102 /** @returns {AnyElement} */
103 buildElement() {
104 if (this._rootElement) {
105 return this._rootElement;
106 }
107
108 let div = $tag('div.post', `post-${this.context}`);
109 this._rootElement = div;
110
111 if (this.post.muted) {
112 div.classList.add('muted');
113 }
114
115 if (this.post instanceof BlockedPost) {
116 this.buildBlockedPostElement(div);
117 return div;
118 } else if (this.post instanceof DetachedQuotePost) {
119 this.buildDetachedQuoteElement(div);
120 return div;
121 } else if (this.post instanceof MissingPost) {
122 this.buildMissingPostElement(div);
123 return div;
124 }
125
126 let header = this.buildPostHeader();
127 div.appendChild(header);
128
129 let content = $tag('div.content');
130
131 if (this.context == 'thread' && !this.isRoot) {
132 let edgeMargin = this.buildEdgeMargin();
133 div.appendChild(edgeMargin);
134 }
135
136 let wrapper;
137
138 if (this.post.muted) {
139 let details = $tag('details');
140
141 let summary = $tag('summary');
142 summary.innerText = this.post.muteList ? `Muted (${this.post.muteList})` : 'Muted - click to show';
143 details.appendChild(summary);
144
145 content.appendChild(details);
146 wrapper = details;
147 } else {
148 wrapper = content;
149 }
150
151 let p = this.buildPostBody();
152 wrapper.appendChild(p);
153
154 if (this.post.tags) {
155 let tagsRow = this.buildTagsRow(this.post.tags);
156 wrapper.appendChild(tagsRow);
157 }
158
159 if (this.post.embed) {
160 let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
161 wrapper.appendChild(embed);
162 }
163
164 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
165 let stats = this.buildStatsFooter();
166 wrapper.appendChild(stats);
167 }
168
169 if (this.post.replyCount == 1 && this.post.replies[0]?.author?.did == this.post.author.did) {
170 let component = new PostComponent(this.post.replies[0], 'thread');
171 let element = component.buildElement();
172 element.classList.add('flat');
173 content.appendChild(element);
174 } else {
175 for (let reply of this.post.replies) {
176 if (reply instanceof MissingPost) { continue }
177 if (reply instanceof BlockedPost && window.biohazardEnabled === false) { continue }
178
179 let component = new PostComponent(reply, 'thread');
180 content.appendChild(component.buildElement());
181 }
182 }
183
184 if (this.context == 'thread') {
185 if (this.post.hasMoreReplies) {
186 let loadMore = this.buildLoadMoreLink();
187 content.appendChild(loadMore);
188 } else if (this.post.hasHiddenReplies && window.biohazardEnabled !== false) {
189 let loadMore = this.buildHiddenRepliesLink();
190 content.appendChild(loadMore);
191 }
192 }
193
194 div.appendChild(content);
195
196 return div;
197 }
198
199 /** @returns {AnyElement} */
200
201 buildPostHeader() {
202 let timeFormat = this.timeFormatForTimestamp;
203 let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat);
204 let isoTime = this.post.createdAt.toISOString();
205
206 let h = $tag('h2');
207
208 h.innerHTML = `${escapeHTML(this.authorName)} `;
209
210 if (this.post.isFediPost) {
211 let handle = this.post.authorFediHandle;
212 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${handle}</a> ` +
213 `<img src="icons/mastodon.svg" class="mastodon"> `;
214 } else {
215 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${this.post.author.handle}</a> `;
216 }
217
218 h.innerHTML += `<span class="separator">•</span> ` +
219 `<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `;
220
221 if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(this.context)) {
222 h.innerHTML +=
223 `<span class="separator">•</span> ` +
224 `<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` +
225 `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a> `;
226 }
227
228 if (this.post.muted) {
229 h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x'));
230 } else if (this.post.author.avatar) {
231 h.prepend(this.buildUserAvatar(this.post.author.avatar));
232 } else {
233 h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x'));
234 }
235
236 return h;
237 }
238
239 buildEdgeMargin() {
240 let div = $tag('div.margin');
241
242 let edge = $tag('div.edge');
243 let line = $tag('div.line');
244 edge.appendChild(line);
245 div.appendChild(edge);
246
247 let plus = $tag('img.plus', { src: 'icons/subtract-square.png' });
248 div.appendChild(plus);
249
250 [edge, plus].forEach(x => {
251 x.addEventListener('click', (e) => {
252 e.preventDefault();
253 this.toggleSectionFold();
254 });
255 });
256
257 return div;
258 }
259
260 /** @param {string} url, @returns {HTMLImageElement} */
261
262 buildUserAvatar(url) {
263 let avatar = $tag('img.avatar', { loading: 'lazy' }); // needs to be set before src!
264 avatar.src = url;
265 window.avatarPreloader.observe(avatar);
266 return avatar;
267 }
268
269 /** @returns {AnyElement} */
270
271 buildPostBody() {
272 if (this.post.originalFediContent) {
273 return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) });
274 }
275
276 let p = $tag('p.body');
277 let richText = new RichText({ text: this.post.text, facets: this.post.facets });
278
279 for (let seg of richText.segments()) {
280 if (seg.mention) {
281 p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text }));
282 } else if (seg.link) {
283 p.append($tag('a', { href: seg.link.uri, text: seg.text }));
284 } else if (seg.tag) {
285 let url = new URL(getLocation());
286 url.searchParams.set('hash', seg.tag.tag);
287 p.append($tag('a', { href: url.toString(), text: seg.text }));
288 } else if (seg.text.includes("\n")) {
289 let span = $tag('span', { text: seg.text });
290 span.innerHTML = span.innerHTML.replaceAll("\n", "<br>");
291 p.append(span);
292 } else {
293 p.append(seg.text);
294 }
295 }
296
297 return p;
298 }
299
300 /** @param {string[]} tags, @returns {AnyElement} */
301
302 buildTagsRow(tags) {
303 let p = $tag('p.tags');
304
305 for (let tag of tags) {
306 let url = new URL(getLocation());
307 url.searchParams.set('hash', tag);
308
309 let tagLink = $tag('a', { href: url.toString(), text: '# ' + tag });
310 p.append(tagLink);
311 }
312
313 return p;
314 }
315
316 /** @returns {AnyElement} */
317
318 buildStatsFooter() {
319 let stats = $tag('p.stats');
320
321 let span = $tag('span');
322 let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : ''));
323 heart.addEventListener('click', (e) => this.onHeartClick(heart));
324
325 span.append(heart, ' ', $tag('output', { text: this.post.likeCount }));
326 stats.append(span);
327
328 if (this.post.repostCount > 0) {
329 let span = $tag('span', { html: `<i class="fa-solid fa-retweet"></i> ${this.post.repostCount}` });
330 stats.append(span);
331 }
332
333 if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) {
334 let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, false);
335 stats.append(quotesLink);
336 }
337
338 return stats;
339 }
340
341 /** @param {number} count, @param {boolean} expanded, @returns {AnyElement} */
342
343 buildQuotesIconLink(count, expanded) {
344 let q = new URL(getLocation());
345 q.searchParams.set('quotes', this.linkToPost);
346
347 let url = q.toString();
348 let icon = `<i class="fa-regular ${count > 1 ? 'fa-comments' : 'fa-comment'}"></i>`;
349
350 if (expanded) {
351 let span = $tag('span', { html: `${icon} ` });
352 let link = $tag('a', { text: (count > 1) ? `${count} quotes` : '1 quote', href: url });
353 span.append(link);
354 return span;
355 } else {
356 return $tag('a', { html: `${icon} ${count}`, href: url });
357 }
358 }
359
360 /** @param {number} quoteCount, @param {boolean} expanded */
361
362 appendQuotesIconLink(quoteCount, expanded) {
363 let stats = this.rootElement.querySelector(':scope > .content > p.stats');
364 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded);
365 stats.append(quotesLink);
366 }
367
368 /** @returns {AnyElement} */
369
370 buildLoadMoreLink() {
371 let loadMore = $tag('p');
372
373 let link = $tag('a', {
374 href: linkToPostThread(this.post),
375 text: "Load more replies…"
376 });
377
378 link.addEventListener('click', (e) => {
379 e.preventDefault();
380 loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`;
381 loadSubtree(this.post, this.rootElement);
382 });
383
384 loadMore.appendChild(link);
385 return loadMore;
386 }
387
388 /** @returns {AnyElement} */
389
390 buildHiddenRepliesLink() {
391 let loadMore = $tag('p.hidden-replies');
392
393 let link = $tag('a', {
394 href: linkToPostThread(this.post),
395 text: "Load hidden replies…"
396 });
397
398 link.addEventListener('click', (e) => {
399 e.preventDefault();
400
401 if (window.biohazardEnabled === true) {
402 this.loadHiddenReplies(loadMore);
403 } else {
404 window.loadInfohazard = () => this.loadHiddenReplies(loadMore);
405 showDialog($id('biohazard_dialog'));
406 }
407 });
408
409 loadMore.append("☣️ ", link);
410 return loadMore;
411 }
412
413 /** @param {HTMLLinkElement} loadMoreButton */
414
415 loadHiddenReplies(loadMoreButton) {
416 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`;
417 loadHiddenSubtree(this.post, this.rootElement);
418 }
419
420 /** @param {HTMLLinkElement} authorLink */
421
422 loadReferencedPostAuthor(authorLink) {
423 let did = atURI(this.post.uri).repo;
424
425 api.fetchHandleForDid(did).then(handle => {
426 if (this.post.author) {
427 this.post.author.handle = handle;
428 } else {
429 this.post.author = { did, handle };
430 }
431
432 authorLink.href = this.linkToAuthor;
433 authorLink.innerText = `@${handle}`;
434 });
435 }
436
437 /** @param {AnyElement} div, @returns {AnyElement} */
438
439 buildBlockedPostElement(div) {
440 let p = $tag('p.blocked-header');
441 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span>`;
442
443 if (window.biohazardEnabled === false) {
444 div.appendChild(p);
445 div.classList.add('blocked');
446 return p;
447 }
448
449 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
450 blockStatus = blockStatus ? `, ${blockStatus}` : '';
451
452 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' });
453 p.append(' (', authorLink, blockStatus, ') ');
454 div.appendChild(p);
455
456 this.loadReferencedPostAuthor(authorLink);
457
458 let loadPost = $tag('p.load-post');
459 let a = $tag('a', { href: '#', text: "Load post…" });
460
461 a.addEventListener('click', (e) => {
462 e.preventDefault();
463 loadPost.innerHTML = ' ';
464 this.loadBlockedPost(this.post.uri, div);
465 });
466
467 loadPost.appendChild(a);
468 div.appendChild(loadPost);
469 div.classList.add('blocked');
470 return div;
471 }
472
473 /** @param {AnyElement} div, @returns {AnyElement} */
474
475 buildDetachedQuoteElement(div) {
476 let p = $tag('p.blocked-header');
477 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Hidden quote</span>`;
478
479 if (window.biohazardEnabled === false) {
480 div.appendChild(p);
481 div.classList.add('blocked');
482 return p;
483 }
484
485 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' });
486 p.append(' (', authorLink, ') ');
487 div.appendChild(p);
488
489 this.loadReferencedPostAuthor(authorLink);
490
491 let loadPost = $tag('p.load-post');
492 let a = $tag('a', { href: '#', text: "Load post…" });
493
494 a.addEventListener('click', (e) => {
495 e.preventDefault();
496 loadPost.innerHTML = ' ';
497 this.loadBlockedPost(this.post.uri, div);
498 });
499
500 loadPost.appendChild(a);
501 div.appendChild(loadPost);
502 div.classList.add('blocked');
503 return div;
504 }
505
506 /** @param {AnyElement} div, @returns {AnyElement} */
507
508 buildMissingPostElement(div) {
509 let p = $tag('p.blocked-header');
510 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`;
511
512 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' });
513 p.append(' (', authorLink, ') ');
514
515 this.loadReferencedPostAuthor(authorLink);
516
517 div.appendChild(p);
518 div.classList.add('blocked');
519 return div;
520 }
521
522 /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */
523
524 async loadBlockedPost(uri, div) {
525 let record = await appView.loadPostIfExists(this.post.uri);
526
527 if (!record) {
528 let post = new MissingPost({ uri: this.post.uri });
529 let postView = new PostComponent(post, 'quote').buildElement();
530 div.replaceWith(postView);
531 return;
532 }
533
534 this.post = new Post(record);
535
536 let userView = await api.getRequest('app.bsky.actor.getProfile', { actor: this.post.author.did });
537
538 if (!userView.viewer || !(userView.viewer.blockedBy || userView.viewer.blocking)) {
539 let { repo, rkey } = atURI(this.post.uri);
540
541 let a = $tag('a', {
542 href: linkToPostById(repo, rkey),
543 className: 'action',
544 title: "Load thread",
545 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>`
546 });
547
548 let header = div.querySelector('p.blocked-header');
549 let separator = $tag('span.separator', { html: '•' });
550 header.append(separator, ' ', a);
551 }
552
553 div.querySelector('p.load-post').remove();
554
555 if (this.isRoot && this.post.parentReference) {
556 let { repo, rkey } = atURI(this.post.parentReference.uri);
557 let url = linkToPostById(repo, rkey);
558
559 let handle = api.findHandleByDid(repo);
560 let link = handle ? `See parent post (@${handle})` : "See parent post";
561
562 let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` });
563 div.appendChild(p);
564 }
565
566 let p = this.buildPostBody();
567 div.appendChild(p);
568
569 if (this.post.embed) {
570 let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
571 div.appendChild(embed);
572
573 // TODO
574 Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove());
575 }
576 }
577
578 /** @returns {boolean} */
579 isCollapsed() {
580 return this.rootElement.classList.contains('collapsed');
581 }
582
583 toggleSectionFold() {
584 let plus = this.rootElement.querySelector(':scope > .margin .plus');
585
586 if (this.isCollapsed()) {
587 this.rootElement.classList.remove('collapsed');
588 plus.src = 'icons/subtract-square.png'
589 } else {
590 this.rootElement.classList.add('collapsed');
591 plus.src = 'icons/add-square.png'
592 }
593 }
594
595 /** @param {AnyElement} heart */
596
597 onHeartClick(heart) {
598 if (!this.post.hasViewerInfo) {
599 if (accountAPI.isLoggedIn) {
600 accountAPI.loadPostIfExists(this.post.uri).then(data => {
601 if (data) {
602 this.post = new Post(data);
603
604 if (this.post.liked) {
605 heart.classList.add('liked');
606 } else {
607 this.onHeartClick(heart);
608 }
609 } else {
610 alert("Sorry, this post is blocked or was deleted.");
611 }
612 }).catch(error => {
613 alert(error);
614 });
615 } else {
616 showDialog(loginDialog);
617 }
618 return;
619 }
620
621 let count = heart.nextElementSibling;
622
623 if (!heart.classList.contains('liked')) {
624 accountAPI.likePost(this.post).then((like) => {
625 this.post.viewerLike = like.uri;
626 heart.classList.add('liked');
627 count.innerText = String(parseInt(count.innerText, 10) + 1);
628 }).catch(showError);
629 } else {
630 accountAPI.removeLike(this.post.viewerLike).then(() => {
631 this.post.viewerLike = undefined;
632 heart.classList.remove('liked');
633 count.innerText = String(parseInt(count.innerText, 10) - 1);
634 }).catch(showError);
635 }
636 }
637}