Thread viewer for Bluesky
1/**
2 * Renders a post/thread view and its subviews.
3 */
4
5class PostComponent {
6 /** @param {Post} post, @param {Post} [root] */
7 constructor(post, root) {
8 this.post = post;
9 this.root = root ?? post;
10 this.isRoot = (this.post === this.root);
11 }
12
13 /** @returns {string} */
14 get linkToAuthor() {
15 return 'https://bsky.app/profile/' + this.post.author.handle;
16 }
17
18 /** @returns {string} */
19 get linkToPost() {
20 return this.linkToAuthor + '/post/' + this.post.rkey;
21 }
22
23 /** @returns {string} */
24 get didLinkToAuthor() {
25 let { repo } = atURI(this.post.uri);
26 return `https://bsky.app/profile/${repo}`;
27 }
28
29 /** @returns {string} */
30 get didLinkToPost() {
31 let { repo, rkey } = atURI(this.post.uri);
32 return `https://bsky.app/profile/${repo}/post/${rkey}`;
33 }
34
35 /** @returns {string} */
36 get authorName() {
37 if (this.post.author.displayName) {
38 return this.post.author.displayName;
39 } else if (this.post.author.handle.endsWith('.bsky.social')) {
40 return this.post.author.handle.replace(/\.bsky\.social$/, '');
41 } else {
42 return this.post.author.handle;
43 }
44 }
45
46 /** @returns {json} */
47 get timeFormatForTimestamp() {
48 if (this.isRoot) {
49 return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' };
50 } else if (!sameDay(this.post.createdAt, this.root.createdAt)) {
51 return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' };
52 } else {
53 return { hour: 'numeric', minute: 'numeric' };
54 }
55 }
56
57 /**
58 Contexts:
59 - thread - a post in the thread tree
60 - parent - parent reference above the thread root
61 - quote - a quote embed
62 - quotes - a post on the quotes page
63 - feed - a post on the hashtag feed page
64
65 @typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext
66 @param {PostContext} context
67 @returns {AnyElement}
68 */
69
70 buildElement(context) {
71 let div = $tag('div.post');
72
73 if (this.post.muted) {
74 div.classList.add('muted');
75 }
76
77 if (this.post instanceof BlockedPost) {
78 this.buildBlockedPostElement(div);
79 return div;
80 } else if (this.post instanceof MissingPost) {
81 this.buildMissingPostElement(div);
82 return div;
83 }
84
85 let header = this.buildPostHeader(context);
86 div.appendChild(header);
87
88 let content = $tag('div.content');
89
90 if (!this.isRoot) {
91 let edge = $tag('div.edge');
92 let line = $tag('div.line');
93 edge.appendChild(line);
94 div.appendChild(edge);
95
96 let plus = $tag('img.plus', { src: 'icons/subtract-square.png' });
97 div.appendChild(plus);
98
99 [edge, plus].forEach(x => {
100 x.addEventListener('click', (e) => {
101 e.preventDefault();
102 this.toggleSectionFold(div);
103 });
104 });
105 }
106
107 let wrapper;
108
109 if (this.post.muted) {
110 let details = $tag('details');
111
112 let summary = $tag('summary');
113 summary.innerText = this.post.muteList ? `Muted (${this.post.muteList})` : 'Muted - click to show';
114 details.appendChild(summary);
115
116 content.appendChild(details);
117 wrapper = details;
118 } else {
119 wrapper = content;
120 }
121
122 let p = this.buildPostBody();
123 wrapper.appendChild(p);
124
125 if (this.post.embed) {
126 let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
127 wrapper.appendChild(embed);
128 }
129
130 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
131 let stats = this.buildStatsFooter();
132 wrapper.appendChild(stats);
133 }
134
135 if (this.post.replies.length == 1 && this.post.replies[0].author?.did == this.post.author.did) {
136 let component = new PostComponent(this.post.replies[0], this.root);
137 let element = component.buildElement('thread');
138 element.classList.add('flat');
139 content.appendChild(element);
140 } else {
141 for (let reply of this.post.replies) {
142 if (reply instanceof MissingPost) { continue }
143
144 let component = new PostComponent(reply, this.root);
145 content.appendChild(component.buildElement('thread'));
146 }
147 }
148
149 if (context == 'thread' && this.post.hasMoreReplies) {
150 let loadMore = this.buildLoadMoreLink()
151 content.appendChild(loadMore);
152 }
153
154 div.appendChild(content);
155
156 return div;
157 }
158
159 /** @param {PostContext} context, @returns {AnyElement} */
160
161 buildPostHeader(context) {
162 let timeFormat = this.timeFormatForTimestamp;
163 let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat);
164 let isoTime = this.post.createdAt.toISOString();
165
166 let h = $tag('h2');
167
168 h.innerHTML = `${escapeHTML(this.authorName)} `;
169
170 if (this.post.isFediPost) {
171 let handle = this.post.authorFediHandle;
172 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${handle}</a> ` +
173 `<img src="icons/mastodon.svg" class="mastodon"> `;
174 } else {
175 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${this.post.author.handle}</a> `;
176 }
177
178 h.innerHTML += `<span class="separator">•</span> ` +
179 `<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `;
180
181 if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(context)) {
182 h.innerHTML +=
183 `<span class="separator">•</span> ` +
184 `<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` +
185 `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a> `;
186 }
187
188 if (this.post.muted) {
189 h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x'));
190 } else if (this.post.author.avatar) {
191 h.prepend(this.buildUserAvatar(this.post.author.avatar));
192 } else {
193 h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x'));
194 }
195
196 return h;
197 }
198
199 /** @param {string} url, @returns {HTMLImageElement} */
200
201 buildUserAvatar(url) {
202 let avatar = $tag('img.avatar', { src: url });
203 let tries = 0;
204
205 let errorHandler = function(e) {
206 if (tries < 3) {
207 tries++;
208 setTimeout(() => { avatar.src = url }, Math.random() * 5 * tries);
209 } else {
210 avatar.removeEventListener('error', errorHandler);
211 }
212 };
213
214 avatar.addEventListener('error', errorHandler);
215 return avatar;
216 }
217
218 /** @returns {AnyElement} */
219
220 buildPostBody() {
221 if (this.post.originalFediContent) {
222 return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) });
223 }
224
225 let p = $tag('p.body');
226 let richText = new RichText({ text: this.post.text, facets: this.post.facets });
227
228 for (let seg of richText.segments()) {
229 if (seg.mention) {
230 p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text }));
231 } else if (seg.link) {
232 p.append($tag('a', { href: seg.link.uri, text: seg.text }));
233 } else if (seg.tag) {
234 let url = new URL(getLocation());
235 url.searchParams.set('hash', seg.tag.tag);
236 p.append($tag('a', { href: url.toString(), text: seg.text }));
237 } else if (seg.text.includes("\n")) {
238 let span = $tag('span', { text: seg.text });
239 span.innerHTML = span.innerHTML.replaceAll("\n", "<br>");
240 p.append(span);
241 } else {
242 p.append(seg.text);
243 }
244 }
245
246 if (this.post.isTruncatedFediPost) {
247 if (this.post.embed && ('url' in this.post.embed) && typeof this.post.embed.url == 'string') {
248 let link = this.buildLoadFediPostLink(this.post.embed.url, p);
249 p.append(' ', link);
250 }
251 }
252
253 return p;
254 }
255
256 /** @returns {AnyElement} */
257
258 buildStatsFooter() {
259 let stats = $tag('p.stats');
260
261 let span = $tag('span');
262 let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : ''));
263 heart.addEventListener('click', (e) => this.onHeartClick(heart));
264
265 span.append(heart, ' ', $tag('output', { text: this.post.likeCount }));
266 stats.append(span);
267
268 if (this.post.repostCount > 0) {
269 let span = $tag('span', { html: `<i class="fa-solid fa-retweet"></i> ${this.post.repostCount}` });
270 stats.append(span);
271 }
272
273 return stats;
274 }
275
276 /** @param {string} originalURL, @param {HTMLElement} p, @returns {AnyElement} */
277
278 buildLoadFediPostLink(originalURL, p) {
279 let link = $tag('a', {
280 href: originalURL,
281 text: "(Load full post)"
282 });
283
284 link.addEventListener('click', (e) => {
285 e.preventDefault();
286 link.remove();
287
288 this.loadFediPost(originalURL, p);
289 });
290
291 return link;
292 }
293
294 /** @returns {AnyElement} */
295
296 buildLoadMoreLink() {
297 let loadMore = $tag('p');
298
299 let link = $tag('a', {
300 href: linkToPostThread(this.post),
301 text: "Load more replies…"
302 });
303
304 link.addEventListener('click', (e) => {
305 e.preventDefault();
306 link.innerHTML = `<img class="loader" src="icons/sunny.png">`;
307
308 if (this.post.mastodonURL) {
309 loadMastodonThread(this.post.mastodonURL, loadMore.parentNode.parentNode);
310 } else {
311 loadThread(this.post.author.handle, this.post.rkey, loadMore.parentNode.parentNode);
312 }
313 });
314
315 loadMore.appendChild(link);
316 return loadMore;
317 }
318
319 /** @param {AnyElement} div, @returns {AnyElement} */
320
321 buildBlockedPostElement(div) {
322 let p = $tag('p.blocked-header');
323 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span> ` +
324 `(<a href="${this.didLinkToAuthor}" target="_blank">see author</a>) `;
325 div.appendChild(p);
326
327 let authorLink = p.querySelector('a');
328 let did = atURI(this.post.uri).repo;
329 let cachedHandle = api.findHandleByDid(did);
330 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
331
332 if (cachedHandle) {
333 this.post.author.handle = cachedHandle;
334 authorLink.href = this.linkToAuthor;
335 authorLink.innerText = `@${cachedHandle}`;
336 if (blockStatus) {
337 authorLink.after(`, ${blockStatus}`);
338 }
339 } else {
340 api.loadUserProfile(did).then((author) => {
341 this.post.author = author;
342 authorLink.href = this.linkToAuthor;
343 authorLink.innerText = `@${author.handle}`;
344 if (blockStatus) {
345 authorLink.after(`, ${blockStatus}`);
346 }
347 });
348 }
349
350 let loadPost = $tag('p.load-post');
351 let a = $tag('a', { href: '#', text: "Load post…" });
352
353 a.addEventListener('click', (e) => {
354 e.preventDefault();
355 loadPost.innerHTML = ' ';
356 this.loadBlockedPost(this.post.uri, div);
357 });
358
359 loadPost.appendChild(a);
360 div.appendChild(loadPost);
361 div.classList.add('blocked');
362 return div;
363 }
364
365 /** @param {AnyElement} div, @returns {AnyElement} */
366
367 buildMissingPostElement(div) {
368 let p = $tag('p.blocked-header');
369 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`;
370 div.appendChild(p);
371 div.classList.add('blocked');
372 return div;
373 }
374
375 /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */
376
377 async loadBlockedPost(uri, div) {
378 let record = await appView.loadPost(this.post.uri);
379 this.post = new Post(record);
380
381 div.querySelector('p.load-post').remove();
382
383 if (this.isRoot && this.post.parentReference) {
384 let { repo, rkey } = atURI(this.post.parentReference.uri);
385 let url = linkToPostById(repo, rkey);
386
387 let handle = api.findHandleByDid(repo);
388 let link = handle ? `See parent post (@${handle})` : "See parent post";
389
390 let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` });
391 div.appendChild(p);
392 }
393
394 let p = this.buildPostBody();
395 div.appendChild(p);
396
397 if (this.post.embed) {
398 let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
399 div.appendChild(embed);
400 }
401 }
402
403 /** @param {string} url, @param {HTMLElement} p, @returns Promise<void> */
404
405 async loadFediPost(url, p) {
406 let host = new URL(url).host;
407 let postId = url.replace(/\/$/, '').split('/').reverse()[0];
408 let statusURL = `https://${host}/api/v1/statuses/${postId}`;
409
410 let response = await fetch(statusURL);
411 let json = await response.json();
412
413 if (json.content) {
414 let div = $tag('div.body', { html: sanitizeHTML(json.content) });
415 p.replaceWith(div);
416 }
417 }
418
419 /** @param {AnyElement} div */
420
421 toggleSectionFold(div) {
422 let plus = div.querySelector('.plus');
423
424 if (div.classList.contains('collapsed')) {
425 div.classList.remove('collapsed');
426 plus.src = 'icons/subtract-square.png'
427 } else {
428 div.classList.add('collapsed');
429 plus.src = 'icons/add-square.png'
430 }
431 }
432
433 /** @param {AnyElement} heart */
434
435 onHeartClick(heart) {
436 if (!this.post.hasViewerInfo) {
437 if (accountAPI.isLoggedIn) {
438 accountAPI.loadPost(this.post.uri).then(data => {
439 this.post = new Post(data);
440
441 if (this.post.liked) {
442 heart.classList.add('liked');
443 } else {
444 this.onHeartClick(heart);
445 }
446 }).catch(error => {
447 console.log(error);
448 alert("Sorry, this post is blocked.");
449 });
450 } else {
451 showLogin();
452 }
453 return;
454 }
455
456 let count = heart.nextElementSibling;
457
458 if (!heart.classList.contains('liked')) {
459 accountAPI.likePost(this.post).then((like) => {
460 this.post.viewerLike = like.uri;
461 heart.classList.add('liked');
462 count.innerText = String(parseInt(count.innerText, 10) + 1);
463 }).catch((error) => {
464 console.log(error);
465 alert(error);
466 });
467 } else {
468 accountAPI.removeLike(this.post.viewerLike).then(() => {
469 this.post.viewerLike = undefined;
470 heart.classList.remove('liked');
471 count.innerText = String(parseInt(count.innerText, 10) - 1);
472 }).catch((error) => {
473 console.log(error);
474 alert(error);
475 });
476 }
477 }
478}