Thread viewer for Bluesky
1function init() {
2 let html = $(document.body.parentNode);
3
4 window.dateLocale = localStorage.getItem('locale') || undefined;
5 window.isIncognito = !!localStorage.getItem('incognito');
6 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null');
7
8 window.loginDialog = $(document.querySelector('#login'));
9 window.accountMenu = $(document.querySelector('#account_menu'));
10 window.postingStatsPage = $id('posting_stats_page');
11
12 window.avatarPreloader = buildAvatarPreloader();
13
14 html.addEventListener('click', (e) => {
15 $id('account_menu').style.visibility = 'hidden';
16 });
17
18 $(document.querySelector('#search form')).addEventListener('submit', (e) => {
19 e.preventDefault();
20 submitSearch();
21 });
22
23 for (let dialog of document.querySelectorAll('.dialog')) {
24 dialog.addEventListener('click', (e) => {
25 if (e.target === e.currentTarget) {
26 hideDialog(dialog);
27 } else {
28 e.stopPropagation();
29 }
30 });
31
32 dialog.querySelector('.close')?.addEventListener('click', (e) => {
33 hideDialog(dialog);
34 });
35 }
36
37 $(document.querySelector('#login .info a')).addEventListener('click', (e) => {
38 e.preventDefault();
39 toggleLoginInfo();
40 });
41
42 $(document.querySelector('#login form')).addEventListener('submit', (e) => {
43 e.preventDefault();
44 submitLogin();
45 });
46
47 $(document.querySelector('#biohazard_show')).addEventListener('click', (e) => {
48 e.preventDefault();
49
50 window.biohazardEnabled = true;
51 localStorage.setItem('biohazard', 'true');
52
53 if (window.loadInfohazard) {
54 window.loadInfohazard();
55 window.loadInfohazard = undefined;
56 }
57
58 let target = $(e.target);
59
60 hideDialog(target.closest('.dialog'));
61 });
62
63 $(document.querySelector('#biohazard_hide')).addEventListener('click', (e) => {
64 e.preventDefault();
65
66 window.biohazardEnabled = false;
67 localStorage.setItem('biohazard', 'false');
68 toggleMenuButton('biohazard', false);
69
70 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) {
71 $(p).style.display = 'none';
72 }
73
74 let target = $(e.target);
75
76 hideDialog(target.closest('.dialog'));
77 });
78
79 $(document.querySelector('#account')).addEventListener('click', (e) => {
80 toggleAccountMenu();
81 e.stopPropagation();
82 });
83
84 accountMenu.addEventListener('click', (e) => {
85 e.stopPropagation();
86 });
87
88 $(accountMenu.querySelector('a[data-action=biohazard]')).addEventListener('click', (e) => {
89 e.preventDefault();
90
91 let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post');
92
93 if (window.biohazardEnabled === false) {
94 window.biohazardEnabled = true;
95 localStorage.setItem('biohazard', 'true');
96 toggleMenuButton('biohazard', true);
97 Array.from(hazards).forEach(p => { $(p).style.display = 'block' });
98 } else {
99 window.biohazardEnabled = false;
100 localStorage.setItem('biohazard', 'false');
101 toggleMenuButton('biohazard', false);
102 Array.from(hazards).forEach(p => { $(p).style.display = 'none' });
103 }
104 });
105
106 $(accountMenu.querySelector('a[data-action=incognito]')).addEventListener('click', (e) => {
107 e.preventDefault();
108
109 if (isIncognito) {
110 localStorage.removeItem('incognito');
111 } else {
112 localStorage.setItem('incognito', '1');
113 }
114
115 location.reload();
116 });
117
118 $(accountMenu.querySelector('a[data-action=login]')).addEventListener('click', (e) => {
119 e.preventDefault();
120 toggleDialog(loginDialog);
121 $id('account_menu').style.visibility = 'hidden';
122 });
123
124 $(accountMenu.querySelector('a[data-action=logout]')).addEventListener('click', (e) => {
125 e.preventDefault();
126 logOut();
127 });
128
129 $(postingStatsPage.querySelector('form')).addEventListener('submit', (e) => {
130 scanPostingStats();
131 });
132
133 $(postingStatsPage.querySelector('input[type="range"]')).addEventListener('input', (e) => {
134 let range = $(e.target, HTMLInputElement);
135 configurePostingStats({ days: range.value });
136 });
137
138 window.appView = new BlueskyAPI('api.bsky.app', false);
139 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false);
140 window.accountAPI = new BlueskyAPI(undefined, true);
141
142 if (accountAPI.isLoggedIn) {
143 accountAPI.host = accountAPI.user.pdsEndpoint;
144 hideMenuButton('login');
145
146 if (!isIncognito) {
147 window.api = accountAPI;
148 showLoggedInStatus(true, api.user.avatar);
149 } else {
150 window.api = appView;
151 showLoggedInStatus('incognito');
152 toggleMenuButton('incognito', true);
153 }
154 } else {
155 window.api = appView;
156 hideMenuButton('logout');
157 hideMenuButton('incognito');
158 }
159
160 toggleMenuButton('biohazard', window.biohazardEnabled !== false);
161
162 parseQueryParams();
163}
164
165function parseQueryParams() {
166 let params = new URLSearchParams(location.search);
167 let query = params.get('q');
168 let author = params.get('author');
169 let post = params.get('post');
170 let quotes = params.get('quotes');
171 let hash = params.get('hash');
172 let page = params.get('page');
173
174 if (quotes) {
175 showLoader();
176 loadQuotesPage(decodeURIComponent(quotes));
177 } else if (hash) {
178 showLoader();
179 loadHashtagPage(decodeURIComponent(hash));
180 } else if (query) {
181 showLoader();
182 loadThreadByURL(decodeURIComponent(query));
183 } else if (author && post) {
184 showLoader();
185 loadThreadById(decodeURIComponent(author), decodeURIComponent(post));
186 } else if (page) {
187 openPage(page);
188 } else {
189 showSearch();
190 }
191}
192
193/** @param {AnyPost} post, @returns {HTMLElement} */
194
195function buildParentLink(post) {
196 let p = $tag('p.back');
197
198 if (post instanceof BlockedPost) {
199 let element = new PostComponent(post, 'parent').buildElement();
200 element.className = 'back';
201 let span = $(element.querySelector('p.blocked-header span'));
202 span.innerText = 'Parent post blocked';
203 return element;
204 } else if (post instanceof MissingPost) {
205 p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`;
206 } else {
207 let url = linkToPostThread(post);
208 p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`;
209 }
210
211 return p;
212}
213
214/** @returns {IntersectionObserver} */
215
216function buildAvatarPreloader() {
217 return new IntersectionObserver((entries, observer) => {
218 for (const entry of entries) {
219 if (entry.isIntersecting) {
220 const img = entry.target;
221 img.removeAttribute('lazy');
222 observer.unobserve(img);
223 }
224 }
225 }, {
226 rootMargin: '1000px 0px'
227 });
228}
229
230function showLoader() {
231 $id('loader').style.display = 'block';
232}
233
234function hideLoader() {
235 $id('loader').style.display = 'none';
236}
237
238function showSearch() {
239 let search = $id('search');
240 let searchField = $(search.querySelector('input[type=text]'));
241
242 search.style.visibility = 'visible';
243 searchField.focus();
244}
245
246function hideSearch() {
247 $id('search').style.visibility = 'hidden';
248}
249
250function showDialog(dialog) {
251 dialog.style.visibility = 'visible';
252 $id('thread').classList.add('overlay');
253
254 dialog.querySelector('input[type=text]')?.focus();
255}
256
257function hideDialog(dialog) {
258 dialog.style.visibility = 'hidden';
259 dialog.classList.remove('expanded');
260 $id('thread').classList.remove('overlay');
261
262 for (let field of dialog.querySelectorAll('input[type=text]')) {
263 field.value = '';
264 }
265}
266
267function toggleDialog(dialog) {
268 if (dialog.style.visibility == 'visible') {
269 hideDialog(dialog);
270 } else {
271 showDialog(dialog);
272 }
273}
274
275function toggleLoginInfo(event) {
276 $id('login').classList.toggle('expanded');
277}
278
279function toggleAccountMenu() {
280 let menu = $id('account_menu');
281 menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible';
282}
283
284/** @param {string} buttonName */
285
286function showMenuButton(buttonName) {
287 let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`));
288 let item = $(button.parentNode);
289 item.style.display = 'list-item';
290}
291
292/** @param {string} buttonName */
293
294function hideMenuButton(buttonName) {
295 let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`));
296 let item = $(button.parentNode);
297 item.style.display = 'none';
298}
299
300/** @param {string} buttonName, @param {boolean} state */
301
302function toggleMenuButton(buttonName, state) {
303 let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`));
304 let check = $(button.querySelector('.check'));
305 check.style.display = (state) ? 'inline' : 'none';
306}
307
308/** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */
309
310function showLoggedInStatus(loggedIn, avatar) {
311 let account = $id('account');
312
313 if (loggedIn === true && avatar) {
314 let button = $(account.querySelector('i'));
315
316 let img = $tag('img.avatar', { src: avatar });
317 img.style.display = 'none';
318 img.addEventListener('load', () => {
319 button.remove();
320 img.style.display = 'inline';
321 });
322 img.addEventListener('error', () => {
323 showLoggedInStatus(true, null);
324 })
325
326 account.append(img);
327 } else if (loggedIn === false) {
328 $id('account').innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`;
329 } else if (loggedIn === 'incognito') {
330 $id('account').innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`;
331 } else {
332 account.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`;
333 }
334}
335
336function submitLogin() {
337 let handle = $id('login_handle', HTMLInputElement);
338 let password = $id('login_password', HTMLInputElement);
339 let submit = $id('login_submit');
340 let cloudy = $id('cloudy');
341
342 if (submit.style.display == 'none') { return }
343
344 handle.blur();
345 password.blur();
346
347 submit.style.display = 'none';
348 cloudy.style.display = 'inline-block';
349
350 logIn(handle.value, password.value).then((pds) => {
351 window.api = pds;
352 window.accountAPI = pds;
353
354 hideDialog(loginDialog);
355 submit.style.display = 'inline';
356 cloudy.style.display = 'none';
357
358 loadCurrentUserAvatar();
359 showMenuButton('logout');
360 showMenuButton('incognito');
361 hideMenuButton('login');
362
363 let params = new URLSearchParams(location.search);
364 let page = params.get('page');
365 if (page) {
366 openPage(page);
367 }
368 })
369 .catch((error) => {
370 submit.style.display = 'inline';
371 cloudy.style.display = 'none';
372 console.log(error);
373
374 if (error.code == 401 && error.json.error == 'AuthFactorTokenRequired') {
375 alert("Please log in using an \"app password\" if you have 2FA enabled.");
376 } else {
377 window.setTimeout(() => alert(error), 10);
378 }
379 });
380}
381
382/** @param {string} identifier, @param {string} password, @returns {Promise<BlueskyAPI>} */
383
384async function logIn(identifier, password) {
385 let pdsEndpoint;
386
387 if (identifier.match(/^did:/)) {
388 pdsEndpoint = await Minisky.pdsEndpointForDid(identifier);
389 } else if (identifier.match(/^[^@]+@[^@]+$/)) {
390 pdsEndpoint = 'bsky.social';
391 } else if (identifier.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) {
392 identifier = identifier.replace(/^@/, '');
393 let did = await appView.resolveHandle(identifier);
394 pdsEndpoint = await Minisky.pdsEndpointForDid(did);
395 } else {
396 throw 'Please enter your handle or DID.';
397 }
398
399 let pds = new BlueskyAPI(pdsEndpoint, true);
400 await pds.logIn(identifier, password);
401 return pds;
402}
403
404function loadCurrentUserAvatar() {
405 api.loadCurrentUserAvatar().then((url) => {
406 showLoggedInStatus(true, url);
407 }).catch((error) => {
408 console.log(error);
409 showLoggedInStatus(true, null);
410 });
411}
412
413function logOut() {
414 accountAPI.resetTokens();
415 localStorage.removeItem('incognito');
416 location.reload();
417}
418
419function submitSearch() {
420 let search = $id('search');
421 let searchField = $(search.querySelector('input[name=q]'), HTMLInputElement);
422 let url = searchField.value.trim();
423
424 if (!url) { return }
425
426 if (url.startsWith('at://')) {
427 let target = new URL(getLocation());
428 target.searchParams.set('q', url);
429 location.assign(target.toString());
430 return;
431 }
432
433 if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) {
434 let target = new URL(getLocation());
435 target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, '')));
436 location.assign(target.toString());
437 return;
438 }
439
440 try {
441 let [handle, postId] = BlueskyAPI.parsePostURL(url);
442
443 let newURL = linkToPostById(handle, postId);
444 location.assign(newURL);
445 } catch (error) {
446 console.log(error);
447 alert(error.message || "This is not a valid URL or hashtag");
448 }
449}
450
451function openPage(page) {
452 if (!accountAPI.isLoggedIn) {
453 toggleDialog(loginDialog);
454 return;
455 }
456
457 if (page == 'notif') {
458 showLoader();
459 showNotificationsPage();
460 } else if (page == 'posting_stats') {
461 showPostingStatsPage();
462 }
463}
464
465function showPostingStatsPage() {
466 $id('posting_stats_page').style.display = 'block';
467}
468
469function configurePostingStats(args) {
470 if (args.days) {
471 let label = $(postingStatsPage.querySelector('input[type=range] + label'));
472 label.innerText = (args.days == 1) ? '1 day' : `${args.days} days`;
473 }
474}
475
476function scanPostingStats() {
477 let submit = $(postingStatsPage.querySelector('input[type=submit]'), HTMLInputElement);
478 submit.disabled = true;
479
480 let range = $(postingStatsPage.querySelector('input[type=range]'), HTMLInputElement);
481 let days = parseInt(range.value, 10);
482
483 let output = $(postingStatsPage.querySelector('input[type=submit] + output'));
484 output.innerText = '';
485
486 let tbody = $(postingStatsPage.querySelector('table.scan-result tbody'));
487 tbody.innerHTML = '';
488
489 accountAPI.loadTimeline(days, {
490 onPageLoad: (d) => { output.innerText += '.' }
491 }).then(items => {
492 let users = {};
493 let total = 0;
494
495 for (let item of items) {
496 if (item.reply) { continue; }
497
498 let user = item.reason ? item.reason.by.handle : item.post.author.handle;
499 users[user] = users[user] ?? { handle: user, own: 0, reposts: 0 };
500 total += 1;
501
502 if (item.reason) {
503 users[user].reposts += 1;
504 } else {
505 users[user].own += 1;
506 }
507 }
508
509 let sorted = Object.values(users).sort((a, b) => {
510 let asum = a.own + a.reposts;
511 let bsum = b.own + b.reposts;
512
513 if (asum < bsum) {
514 return 1;
515 } else if (asum > bsum) {
516 return -1;
517 } else {
518 return 0;
519 }
520 });
521
522 for (let i = 0; i < sorted.length; i++) {
523 let user = sorted[i];
524 let tr = $tag('tr');
525
526 tr.append(
527 $tag('td', { text: i + 1 }),
528 $tag('td.handle', { text: user.handle }),
529 $tag('td', { text: ((user.own + user.reposts) / days).toFixed(1) }),
530 $tag('td', { text: (user.own / days).toFixed(1) }),
531 $tag('td', { text: (user.reposts / days).toFixed(1) }),
532 $tag('td', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) })
533 );
534
535 tbody.append(tr);
536 }
537
538 submit.disabled = false;
539 });
540}
541
542function showNotificationsPage() {
543 document.title = `Notifications - Skythread`;
544
545 let isLoading = false;
546 let firstPageLoaded = false;
547 let finished = false;
548 let cursor;
549
550 loadInPages((next) => {
551 if (isLoading || finished) { return; }
552 isLoading = true;
553
554 accountAPI.loadMentions(cursor).then(data => {
555 let posts = data.posts.map(x => new Post(x));
556
557 if (posts.length > 0) {
558 if (!firstPageLoaded) {
559 hideLoader();
560 firstPageLoaded = true;
561
562 let header = $tag('header');
563 let h2 = $tag('h2', { text: "Replies & Mentions:" });
564 header.append(h2);
565 $id('thread').appendChild(header);
566 $id('thread').classList.add('notifications');
567 }
568
569 for (let post of posts) {
570 if (post.parentReference) {
571 let p = $tag('p.back');
572 p.innerHTML = `<i class="fa-solid fa-reply"></i> `;
573
574 let { repo, rkey } = atURI(post.parentReference.uri);
575 let url = linkToPostById(repo, rkey);
576 let parentLink = $tag('a', { href: url });
577 p.append(parentLink);
578
579 if (repo == api.user.did) {
580 parentLink.innerText = 'Reply to you';
581 } else {
582 parentLink.innerText = 'Reply';
583 api.fetchHandleForDid(repo).then(handle => {
584 parentLink.innerText = `Reply to @${handle}`;
585 });
586 }
587
588 $id('thread').appendChild(p);
589 }
590
591 let postView = new PostComponent(post, 'feed').buildElement();
592 $id('thread').appendChild(postView);
593 }
594 }
595
596 isLoading = false;
597 cursor = data.cursor;
598
599 if (!cursor) {
600 finished = true;
601 } else if (posts.length == 0) {
602 next();
603 }
604 }).catch(error => {
605 hideLoader();
606 console.log(error);
607 isLoading = false;
608 });
609 });
610}
611
612/** @param {Post} post */
613
614function setPageTitle(post) {
615 document.title = `${post.author.displayName}: "${post.text}" - Skythread`;
616}
617
618/** @param {string} hashtag */
619
620function loadHashtagPage(hashtag) {
621 hashtag = hashtag.replace(/^\#/, '');
622 document.title = `#${hashtag} - Skythread`;
623
624 let isLoading = false;
625 let firstPageLoaded = false;
626 let finished = false;
627 let cursor;
628
629 loadInPages(() => {
630 if (isLoading || finished) { return; }
631 isLoading = true;
632
633 api.getHashtagFeed(hashtag, cursor).then(data => {
634 let posts = data.posts.map(j => new Post(j));
635
636 if (!firstPageLoaded) {
637 hideLoader();
638
639 let header = $tag('header');
640 let h2 = $tag('h2', {
641 text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.`
642 });
643 header.append(h2);
644
645 $id('thread').appendChild(header);
646 $id('thread').classList.add('hashtag');
647 }
648
649 for (let post of posts) {
650 let postView = new PostComponent(post, 'feed').buildElement();
651 $id('thread').appendChild(postView);
652 }
653
654 isLoading = false;
655 firstPageLoaded = true;
656 cursor = data.cursor;
657
658 if (!cursor || posts.length == 0) {
659 finished = true;
660 }
661 }).catch(error => {
662 hideLoader();
663 console.log(error);
664 isLoading = false;
665 });
666 });
667}
668
669/** @param {string} url */
670
671function loadQuotesPage(url) {
672 let isLoading = false;
673 let firstPageLoaded = false;
674 let cursor;
675 let finished = false;
676
677 loadInPages(() => {
678 if (isLoading || finished) { return; }
679 isLoading = true;
680
681 blueAPI.getQuotes(url, cursor).then(data => {
682 api.loadPosts(data.posts).then(jsons => {
683 let posts = jsons.map(j => new Post(j));
684
685 if (!firstPageLoaded) {
686 hideLoader();
687
688 let header = $tag('header');
689 let h2;
690
691 if (data.quoteCount > 1) {
692 h2 = $tag('h2', { text: `${data.quoteCount} quotes:` });
693 } else if (data.quoteCount == 1) {
694 h2 = $tag('h2', { text: '1 quote:' });
695 } else {
696 h2 = $tag('h2', { text: 'No quotes found.' });
697 }
698
699 header.append(h2);
700 $id('thread').appendChild(header);
701 $id('thread').classList.add('quotes');
702 }
703
704 for (let post of posts) {
705 let postView = new PostComponent(post, 'quotes').buildElement();
706 $id('thread').appendChild(postView);
707 }
708
709 isLoading = false;
710 firstPageLoaded = true;
711 cursor = data.cursor;
712
713 if (!cursor || posts.length == 0) {
714 finished = true;
715 }
716 }).catch(error => {
717 hideLoader();
718 console.log(error);
719 isLoading = false;
720 })
721 }).catch(error => {
722 hideLoader();
723 console.log(error);
724 isLoading = false;
725 });
726 });
727}
728
729/** @param {Function} callback */
730
731function loadInPages(callback) {
732 let loadIfNeeded = () => {
733 if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) {
734 callback(loadIfNeeded);
735 }
736 };
737
738 callback(loadIfNeeded);
739
740 document.addEventListener('scroll', loadIfNeeded);
741 const resizeObserver = new ResizeObserver(loadIfNeeded);
742 resizeObserver.observe(document.body);
743}
744
745/** @param {string} url */
746
747function loadThreadByURL(url) {
748 let loadThread = url.startsWith('at://') ? api.loadThreadByAtURI(url) : api.loadThreadByURL(url);
749
750 loadThread.then(json => {
751 displayThread(json);
752 }).catch(error => {
753 hideLoader();
754 showError(error);
755 });
756}
757
758/** @param {string} author, @param {string} rkey */
759
760function loadThreadById(author, rkey) {
761 api.loadThreadById(author, rkey).then(json => {
762 displayThread(json);
763 }).catch(error => {
764 hideLoader();
765 showError(error);
766 });
767}
768
769/** @param {json} json */
770
771function displayThread(json) {
772 let root = Post.parseThreadPost(json.thread);
773 window.root = root;
774 window.subtreeRoot = root;
775
776 let loadQuoteCount;
777
778 if (root instanceof Post) {
779 setPageTitle(root);
780 loadQuoteCount = blueAPI.getQuoteCount(root.uri);
781
782 if (root.parent) {
783 let p = buildParentLink(root.parent);
784 $id('thread').appendChild(p);
785 }
786 }
787
788 let component = new PostComponent(root, 'thread');
789 let view = component.buildElement();
790 hideLoader();
791 $id('thread').appendChild(view);
792
793 loadQuoteCount?.then(count => {
794 if (count > 0) {
795 component.appendQuotesIconLink(count, true);
796 }
797 }).catch(error => {
798 console.warn("Couldn't load quote count: " + error);
799 });
800}
801
802/** @param {Post} post, @param {HTMLElement} nodeToUpdate */
803
804function loadSubtree(post, nodeToUpdate) {
805 api.loadThreadByAtURI(post.uri).then(json => {
806 let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel);
807 post.updateDataFromPost(root);
808 window.subtreeRoot = post;
809
810 let component = new PostComponent(post, 'thread');
811 component.installIntoElement(nodeToUpdate);
812 }).catch(showError);
813}
814
815/** @param {Post} post, @param {HTMLElement} nodeToUpdate */
816
817function loadHiddenSubtree(post, nodeToUpdate) {
818 let content = $(nodeToUpdate.querySelector('.content'));
819 let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies'));
820
821 blueAPI.getReplies(post.uri).then(replies => {
822 let missingReplies = replies.filter(r => !post.replies.some(x => x.uri === r));
823
824 Promise.allSettled(missingReplies.map(uri => api.loadThreadByAtURI(uri))).then(responses => {
825 let replies = responses
826 .map(r => r.status == 'fulfilled' ? r.value : undefined)
827 .filter(v => v)
828 .map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1));
829
830 post.setReplies(replies);
831 hiddenRepliesDiv.remove();
832
833 for (let reply of post.replies) {
834 let component = new PostComponent(reply, 'thread');
835 let view = component.buildElement();
836 content.append(view);
837 }
838
839 if (replies.length < responses.length) {
840 let notFoundCount = responses.length - replies.length;
841 let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is';
842
843 let info = $tag('p.missing-replies-info', {
844 html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)`
845 });
846 content.append(info);
847 }
848 }).catch(error => {
849 hiddenRepliesDiv.remove();
850 setTimeout(() => showError(error), 1);
851 });
852 }).catch(error => {
853 hiddenRepliesDiv.remove();
854
855 if (error instanceof APIError && error.code == 404) {
856 let info = $tag('p.missing-replies-info', {
857 html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)`
858 });
859 content.append(info);
860 } else {
861 setTimeout(() => showError(error), 1);
862 }
863 });
864}