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}