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