Thread viewer for Bluesky
1function init() { 2 let document = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document)); 3 let html = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document.body.parentNode)); 4 5 window.dateLocale = localStorage.getItem('locale') || undefined; 6 window.isIncognito = !!localStorage.getItem('incognito'); 7 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null'); 8 9 window.loginDialog = document.querySelector('#login'); 10 window.accountMenu = document.querySelector('#account_menu'); 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 = /** @type {AnyElement} */ (/** @type {unknown} */ (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 = /** @type {AnyElement} */ (/** @type {unknown} */ (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 window.appView = new BlueskyAPI('api.bsky.app', false); 130 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false); 131 window.accountAPI = new BlueskyAPI(undefined, true); 132 133 if (accountAPI.isLoggedIn) { 134 accountAPI.host = accountAPI.user.pdsEndpoint; 135 hideMenuButton('login'); 136 137 if (!isIncognito) { 138 window.api = accountAPI; 139 showLoggedInStatus(true, api.user.avatar); 140 } else { 141 window.api = appView; 142 showLoggedInStatus('incognito'); 143 toggleMenuButton('incognito', true); 144 } 145 } else { 146 window.api = appView; 147 hideMenuButton('logout'); 148 hideMenuButton('incognito'); 149 } 150 151 toggleMenuButton('biohazard', window.biohazardEnabled !== false); 152 153 parseQueryParams(); 154} 155 156function parseQueryParams() { 157 let params = new URLSearchParams(location.search); 158 let query = params.get('q'); 159 let author = params.get('author'); 160 let post = params.get('post'); 161 let quotes = params.get('quotes'); 162 let hash = params.get('hash'); 163 let page = params.get('page'); 164 165 if (quotes) { 166 showLoader(); 167 loadQuotesPage(decodeURIComponent(quotes)); 168 } else if (hash) { 169 showLoader(); 170 loadHashtagPage(decodeURIComponent(hash)); 171 } else if (query) { 172 showLoader(); 173 loadThreadByURL(decodeURIComponent(query)); 174 } else if (author && post) { 175 showLoader(); 176 loadThreadById(decodeURIComponent(author), decodeURIComponent(post)); 177 } else if (page) { 178 openPage(page); 179 } else { 180 showSearch(); 181 } 182} 183 184/** @param {AnyPost} post, @returns {AnyElement} */ 185 186function buildParentLink(post) { 187 let p = $tag('p.back'); 188 189 if (post instanceof BlockedPost) { 190 let element = new PostComponent(post, 'parent').buildElement(); 191 element.className = 'back'; 192 element.querySelector('p.blocked-header span').innerText = 'Parent post blocked'; 193 return element; 194 } else if (post instanceof MissingPost) { 195 p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`; 196 } else { 197 let url = linkToPostThread(post); 198 p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`; 199 } 200 201 return p; 202} 203 204/** @returns {IntersectionObserver} */ 205 206function buildAvatarPreloader() { 207 return new IntersectionObserver((entries, observer) => { 208 for (const entry of entries) { 209 if (entry.isIntersecting) { 210 const img = entry.target; 211 img.removeAttribute('lazy'); 212 observer.unobserve(img); 213 } 214 } 215 }, { 216 rootMargin: '1000px 0px' 217 }); 218} 219 220function showLoader() { 221 $id('loader').style.display = 'block'; 222} 223 224function hideLoader() { 225 $id('loader').style.display = 'none'; 226} 227 228function showSearch() { 229 $id('search').style.visibility = 'visible'; 230 $id('search').querySelector('input[type=text]').focus(); 231} 232 233function hideSearch() { 234 $id('search').style.visibility = 'hidden'; 235} 236 237function showDialog(dialog) { 238 dialog.style.visibility = 'visible'; 239 $id('thread').classList.add('overlay'); 240 241 dialog.querySelector('input[type=text]')?.focus(); 242} 243 244function hideDialog(dialog) { 245 dialog.style.visibility = 'hidden'; 246 dialog.classList.remove('expanded'); 247 $id('thread').classList.remove('overlay'); 248 249 for (let field of dialog.querySelectorAll('input[type=text]')) { 250 field.value = ''; 251 } 252} 253 254function toggleDialog(dialog) { 255 if (dialog.style.visibility == 'visible') { 256 hideDialog(dialog); 257 } else { 258 showDialog(dialog); 259 } 260} 261 262function toggleLoginInfo(event) { 263 $id('login').classList.toggle('expanded'); 264} 265 266function toggleAccountMenu() { 267 let menu = $id('account_menu'); 268 menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible'; 269} 270 271/** @param {string} buttonName */ 272 273function showMenuButton(buttonName) { 274 let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 275 button.parentNode.style.display = 'list-item'; 276} 277 278/** @param {string} buttonName */ 279 280function hideMenuButton(buttonName) { 281 let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 282 button.parentNode.style.display = 'none'; 283} 284 285/** @param {string} buttonName, @param {boolean} state */ 286 287function toggleMenuButton(buttonName, state) { 288 let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 289 button.querySelector('.check').style.display = (state) ? 'inline' : 'none'; 290} 291 292/** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */ 293 294function showLoggedInStatus(loggedIn, avatar) { 295 let account = $id('account'); 296 297 if (loggedIn === true && avatar) { 298 let button = account.querySelector('i'); 299 300 let img = $tag('img.avatar', { src: avatar }); 301 img.style.display = 'none'; 302 img.addEventListener('load', () => { 303 button.remove(); 304 img.style.display = 'inline'; 305 }); 306 img.addEventListener('error', () => { 307 showLoggedInStatus(true, null); 308 }) 309 310 account.append(img); 311 } else if (loggedIn === false) { 312 $id('account').innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`; 313 } else if (loggedIn === 'incognito') { 314 $id('account').innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`; 315 } else { 316 account.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`; 317 } 318} 319 320function submitLogin() { 321 let handle = $id('login_handle'); 322 let password = $id('login_password'); 323 let submit = $id('login_submit'); 324 let cloudy = $id('cloudy'); 325 326 if (submit.style.display == 'none') { return } 327 328 handle.blur(); 329 password.blur(); 330 331 submit.style.display = 'none'; 332 cloudy.style.display = 'inline-block'; 333 334 logIn(handle.value, password.value).then((pds) => { 335 window.api = pds; 336 window.accountAPI = pds; 337 338 hideDialog(loginDialog); 339 submit.style.display = 'inline'; 340 cloudy.style.display = 'none'; 341 342 loadCurrentUserAvatar(); 343 showMenuButton('logout'); 344 showMenuButton('incognito'); 345 hideMenuButton('login'); 346 347 let params = new URLSearchParams(location.search); 348 let page = params.get('page'); 349 if (page) { 350 openPage(page); 351 } 352 }) 353 .catch((error) => { 354 submit.style.display = 'inline'; 355 cloudy.style.display = 'none'; 356 console.log(error); 357 358 if (error.code == 401 && error.json.error == 'AuthFactorTokenRequired') { 359 alert("Please log in using an \"app password\" if you have 2FA enabled."); 360 } else { 361 window.setTimeout(() => alert(error), 10); 362 } 363 }); 364} 365 366/** @param {string} identifier, @param {string} password, @returns {Promise<BlueskyAPI>} */ 367 368async function logIn(identifier, password) { 369 let pdsEndpoint; 370 371 if (identifier.match(/^did:/)) { 372 pdsEndpoint = await Minisky.pdsEndpointForDid(identifier); 373 } else if (identifier.match(/^[^@]+@[^@]+$/)) { 374 pdsEndpoint = 'bsky.social'; 375 } else if (identifier.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) { 376 identifier = identifier.replace(/^@/, ''); 377 let did = await appView.resolveHandle(identifier); 378 pdsEndpoint = await Minisky.pdsEndpointForDid(did); 379 } else { 380 throw 'Please enter your handle or DID.'; 381 } 382 383 let pds = new BlueskyAPI(pdsEndpoint, true); 384 await pds.logIn(identifier, password); 385 return pds; 386} 387 388function loadCurrentUserAvatar() { 389 api.loadCurrentUserAvatar().then((url) => { 390 showLoggedInStatus(true, url); 391 }).catch((error) => { 392 console.log(error); 393 showLoggedInStatus(true, null); 394 }); 395} 396 397function logOut() { 398 accountAPI.resetTokens(); 399 localStorage.removeItem('incognito'); 400 location.reload(); 401} 402 403function submitSearch() { 404 let url = $id('search').querySelector('input[name=q]').value.trim(); 405 406 if (!url) { return } 407 408 if (url.startsWith('at://')) { 409 let target = new URL(getLocation()); 410 target.searchParams.set('q', url); 411 location.assign(target.toString()); 412 return; 413 } 414 415 if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) { 416 let target = new URL(getLocation()); 417 target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, ''))); 418 location.assign(target.toString()); 419 return; 420 } 421 422 try { 423 let [handle, postId] = BlueskyAPI.parsePostURL(url); 424 425 let newURL = linkToPostById(handle, postId); 426 location.assign(newURL); 427 } catch (error) { 428 console.log(error); 429 alert(error.message || "This is not a valid URL or hashtag"); 430 } 431} 432 433function openPage(page) { 434 if (!accountAPI.isLoggedIn) { 435 toggleDialog(loginDialog); 436 return; 437 } 438 439 if (page == 'notif') { 440 showLoader(); 441 showNotificationsPage(); 442 } 443} 444 445function showNotificationsPage() { 446 document.title = `Notifications - Skythread`; 447 448 let isLoading = false; 449 let firstPageLoaded = false; 450 let finished = false; 451 let cursor; 452 453 loadInPages((next) => { 454 if (isLoading || finished) { return; } 455 isLoading = true; 456 457 accountAPI.loadMentions(cursor).then(data => { 458 let posts = data.posts.map(x => new Post(x)); 459 460 if (posts.length > 0) { 461 if (!firstPageLoaded) { 462 hideLoader(); 463 firstPageLoaded = true; 464 465 let header = $tag('header'); 466 let h2 = $tag('h2', { text: "Replies & Mentions:" }); 467 header.append(h2); 468 $id('thread').appendChild(header); 469 $id('thread').classList.add('notifications'); 470 } 471 472 for (let post of posts) { 473 if (post.parentReference) { 474 let p = $tag('p.back'); 475 p.innerHTML = `<i class="fa-solid fa-reply"></i> `; 476 477 let { repo, rkey } = atURI(post.parentReference.uri); 478 let url = linkToPostById(repo, rkey); 479 let parentLink = $tag('a', { href: url }); 480 p.append(parentLink); 481 482 if (repo == api.user.did) { 483 parentLink.innerText = 'Reply to you'; 484 } else { 485 parentLink.innerText = 'Reply'; 486 api.fetchHandleForDid(repo).then(handle => { 487 parentLink.innerText = `Reply to @${handle}`; 488 }); 489 } 490 491 $id('thread').appendChild(p); 492 } 493 494 let postView = new PostComponent(post, 'feed').buildElement(); 495 $id('thread').appendChild(postView); 496 } 497 } 498 499 isLoading = false; 500 cursor = data.cursor; 501 502 if (!cursor) { 503 finished = true; 504 } else if (posts.length == 0) { 505 next(); 506 } 507 }).catch(error => { 508 hideLoader(); 509 console.log(error); 510 isLoading = false; 511 }); 512 }); 513} 514 515/** @param {Post} post */ 516 517function setPageTitle(post) { 518 document.title = `${post.author.displayName}: "${post.text}" - Skythread`; 519} 520 521/** @param {string} hashtag */ 522 523function loadHashtagPage(hashtag) { 524 hashtag = hashtag.replace(/^\#/, ''); 525 document.title = `#${hashtag} - Skythread`; 526 527 let isLoading = false; 528 let firstPageLoaded = false; 529 let finished = false; 530 let cursor; 531 532 loadInPages(() => { 533 if (isLoading || finished) { return; } 534 isLoading = true; 535 536 api.getHashtagFeed(hashtag, cursor).then(data => { 537 let posts = data.posts.map(j => new Post(j)); 538 539 if (!firstPageLoaded) { 540 hideLoader(); 541 542 let header = $tag('header'); 543 let h2 = $tag('h2', { 544 text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.` 545 }); 546 header.append(h2); 547 548 $id('thread').appendChild(header); 549 $id('thread').classList.add('hashtag'); 550 } 551 552 for (let post of posts) { 553 let postView = new PostComponent(post, 'feed').buildElement(); 554 $id('thread').appendChild(postView); 555 } 556 557 isLoading = false; 558 firstPageLoaded = true; 559 cursor = data.cursor; 560 561 if (!cursor || posts.length == 0) { 562 finished = true; 563 } 564 }).catch(error => { 565 hideLoader(); 566 console.log(error); 567 isLoading = false; 568 }); 569 }); 570} 571 572/** @param {string} url */ 573 574function loadQuotesPage(url) { 575 let isLoading = false; 576 let firstPageLoaded = false; 577 let cursor; 578 let finished = false; 579 580 loadInPages(() => { 581 if (isLoading || finished) { return; } 582 isLoading = true; 583 584 blueAPI.getQuotes(url, cursor).then(data => { 585 api.loadPosts(data.posts).then(jsons => { 586 let posts = jsons.map(j => new Post(j)); 587 588 if (!firstPageLoaded) { 589 hideLoader(); 590 591 let header = $tag('header'); 592 let h2; 593 594 if (data.quoteCount > 1) { 595 h2 = $tag('h2', { text: `${data.quoteCount} quotes:` }); 596 } else if (data.quoteCount == 1) { 597 h2 = $tag('h2', { text: '1 quote:' }); 598 } else { 599 h2 = $tag('h2', { text: 'No quotes found.' }); 600 } 601 602 header.append(h2); 603 $id('thread').appendChild(header); 604 $id('thread').classList.add('quotes'); 605 } 606 607 for (let post of posts) { 608 let postView = new PostComponent(post, 'quotes').buildElement(); 609 $id('thread').appendChild(postView); 610 } 611 612 isLoading = false; 613 firstPageLoaded = true; 614 cursor = data.cursor; 615 616 if (!cursor || posts.length == 0) { 617 finished = true; 618 } 619 }).catch(error => { 620 hideLoader(); 621 console.log(error); 622 isLoading = false; 623 }) 624 }).catch(error => { 625 hideLoader(); 626 console.log(error); 627 isLoading = false; 628 }); 629 }); 630} 631 632/** @param {Function} callback */ 633 634function loadInPages(callback) { 635 let loadIfNeeded = () => { 636 if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) { 637 callback(loadIfNeeded); 638 } 639 }; 640 641 callback(loadIfNeeded); 642 643 document.addEventListener('scroll', loadIfNeeded); 644 const resizeObserver = new ResizeObserver(loadIfNeeded); 645 resizeObserver.observe(document.body); 646} 647 648/** @param {string} url */ 649 650function loadThreadByURL(url) { 651 let loadThread = url.startsWith('at://') ? api.loadThreadByAtURI(url) : api.loadThreadByURL(url); 652 653 loadThread.then(json => { 654 displayThread(json); 655 }).catch(error => { 656 hideLoader(); 657 showError(error); 658 }); 659} 660 661/** @param {string} author, @param {string} rkey */ 662 663function loadThreadById(author, rkey) { 664 api.loadThreadById(author, rkey).then(json => { 665 displayThread(json); 666 }).catch(error => { 667 hideLoader(); 668 showError(error); 669 }); 670} 671 672/** @param {json} json */ 673 674function displayThread(json) { 675 let root = Post.parseThreadPost(json.thread); 676 window.root = root; 677 window.subtreeRoot = root; 678 679 let loadQuoteCount; 680 681 if (root instanceof Post) { 682 setPageTitle(root); 683 loadQuoteCount = blueAPI.getQuoteCount(root.uri); 684 685 if (root.parent) { 686 let p = buildParentLink(root.parent); 687 $id('thread').appendChild(p); 688 } 689 } 690 691 let component = new PostComponent(root, 'thread'); 692 let view = component.buildElement(); 693 hideLoader(); 694 $id('thread').appendChild(view); 695 696 loadQuoteCount?.then(count => { 697 if (count > 0) { 698 component.appendQuotesIconLink(count, true); 699 } 700 }).catch(error => { 701 console.warn("Couldn't load quote count: " + error); 702 }); 703} 704 705/** @param {Post} post, @param {AnyElement} nodeToUpdate */ 706 707function loadSubtree(post, nodeToUpdate) { 708 api.loadThreadByAtURI(post.uri).then(json => { 709 let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel); 710 post.updateDataFromPost(root); 711 window.subtreeRoot = post; 712 713 let component = new PostComponent(post, 'thread'); 714 component.installIntoElement(nodeToUpdate); 715 }).catch(showError); 716} 717 718/** @param {Post} post, @param {AnyElement} nodeToUpdate */ 719 720function loadHiddenSubtree(post, nodeToUpdate) { 721 let content = nodeToUpdate.querySelector('.content'); 722 let hiddenRepliesDiv = content.querySelector(':scope > .hidden-replies'); 723 724 blueAPI.getReplies(post.uri).then(replies => { 725 let missingReplies = replies.filter(r => !post.replies.some(x => x.uri === r)); 726 727 Promise.allSettled(missingReplies.map(uri => api.loadThreadByAtURI(uri))).then(responses => { 728 let replies = responses 729 .map(r => r.status == 'fulfilled' ? r.value : undefined) 730 .filter(v => v) 731 .map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1)); 732 733 post.setReplies(replies); 734 hiddenRepliesDiv.remove(); 735 736 for (let reply of post.replies) { 737 let component = new PostComponent(reply, 'thread'); 738 let view = component.buildElement(); 739 content.append(view); 740 } 741 742 if (replies.length < responses.length) { 743 let notFoundCount = responses.length - replies.length; 744 let pluralizedCount = notFoundCount + ' ' + ((notFoundCount > 1) ? 'replies are' : 'reply is'); 745 746 let info = $tag('p.missing-replies-info', { 747 html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)` 748 }); 749 content.append(info); 750 } 751 }).catch(error => { 752 hiddenRepliesDiv.remove(); 753 setTimeout(() => showError(error), 1); 754 }); 755 }).catch(error => { 756 hiddenRepliesDiv.remove(); 757 758 if (error instanceof APIError && error.code == 404) { 759 let info = $tag('p.missing-replies-info', { 760 html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)` 761 }); 762 content.append(info); 763 } else { 764 setTimeout(() => showError(error), 1); 765 } 766 }); 767}