Thread viewer for Bluesky
1function init() { 2 window.dateLocale = localStorage.getItem('locale') || undefined; 3 window.isIncognito = !!localStorage.getItem('incognito'); 4 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null'); 5 6 window.loginDialog = $(document.querySelector('#login')); 7 8 window.avatarPreloader = buildAvatarPreloader(); 9 10 window.accountMenu = new Menu(); 11 window.threadPage = new ThreadPage(); 12 window.postingStatsPage = new PostingStatsPage(); 13 window.likeStatsPage = new LikeStatsPage(); 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 let target = $(e.target); 56 57 hideDialog(target.closest('.dialog')); 58 }); 59 60 $(document.querySelector('#biohazard_hide')).addEventListener('click', (e) => { 61 e.preventDefault(); 62 63 window.biohazardEnabled = false; 64 localStorage.setItem('biohazard', 'false'); 65 accountMenu.toggleMenuButtonCheck('biohazard', false); 66 67 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) { 68 $(p).style.display = 'none'; 69 } 70 71 let target = $(e.target); 72 73 hideDialog(target.closest('.dialog')); 74 }); 75 76 window.appView = new BlueskyAPI('api.bsky.app', false); 77 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false); 78 window.accountAPI = new BlueskyAPI(undefined, true); 79 80 if (accountAPI.isLoggedIn) { 81 accountAPI.host = accountAPI.user.pdsEndpoint; 82 accountMenu.hideMenuButton('login'); 83 84 if (!isIncognito) { 85 window.api = accountAPI; 86 accountMenu.showLoggedInStatus(true, api.user.avatar); 87 } else { 88 window.api = appView; 89 accountMenu.showLoggedInStatus('incognito'); 90 accountMenu.toggleMenuButtonCheck('incognito', true); 91 } 92 } else { 93 window.api = appView; 94 accountMenu.hideMenuButton('logout'); 95 accountMenu.hideMenuButton('incognito'); 96 } 97 98 accountMenu.toggleMenuButtonCheck('biohazard', window.biohazardEnabled !== false); 99 100 parseQueryParams(); 101} 102 103function parseQueryParams() { 104 let params = new URLSearchParams(location.search); 105 let query = params.get('q'); 106 let author = params.get('author'); 107 let post = params.get('post'); 108 let quotes = params.get('quotes'); 109 let hash = params.get('hash'); 110 let page = params.get('page'); 111 112 if (quotes) { 113 showLoader(); 114 loadQuotesPage(decodeURIComponent(quotes)); 115 } else if (hash) { 116 showLoader(); 117 loadHashtagPage(decodeURIComponent(hash)); 118 } else if (query) { 119 showLoader(); 120 threadPage.loadThreadByURL(decodeURIComponent(query)); 121 } else if (author && post) { 122 showLoader(); 123 threadPage.loadThreadById(decodeURIComponent(author), decodeURIComponent(post)); 124 } else if (page) { 125 openPage(page); 126 } else { 127 showSearch(); 128 } 129} 130 131/** @returns {IntersectionObserver} */ 132 133function buildAvatarPreloader() { 134 return new IntersectionObserver((entries, observer) => { 135 for (const entry of entries) { 136 if (entry.isIntersecting) { 137 const img = entry.target; 138 img.removeAttribute('lazy'); 139 observer.unobserve(img); 140 } 141 } 142 }, { 143 rootMargin: '1000px 0px' 144 }); 145} 146 147function showLoader() { 148 $id('loader').style.display = 'block'; 149} 150 151function hideLoader() { 152 $id('loader').style.display = 'none'; 153} 154 155function showSearch() { 156 let search = $id('search'); 157 let searchField = $(search.querySelector('input[type=text]')); 158 159 search.style.visibility = 'visible'; 160 searchField.focus(); 161} 162 163function hideSearch() { 164 $id('search').style.visibility = 'hidden'; 165} 166 167function showDialog(dialog) { 168 dialog.style.visibility = 'visible'; 169 $id('thread').classList.add('overlay'); 170 171 dialog.querySelector('input[type=text]')?.focus(); 172} 173 174function hideDialog(dialog) { 175 dialog.style.visibility = 'hidden'; 176 dialog.classList.remove('expanded'); 177 $id('thread').classList.remove('overlay'); 178 179 for (let field of dialog.querySelectorAll('input[type=text]')) { 180 field.value = ''; 181 } 182} 183 184function toggleDialog(dialog) { 185 if (dialog.style.visibility == 'visible') { 186 hideDialog(dialog); 187 } else { 188 showDialog(dialog); 189 } 190} 191 192function toggleLoginInfo(event) { 193 $id('login').classList.toggle('expanded'); 194} 195 196function submitLogin() { 197 let handle = $id('login_handle', HTMLInputElement); 198 let password = $id('login_password', HTMLInputElement); 199 let submit = $id('login_submit'); 200 let cloudy = $id('cloudy'); 201 202 if (submit.style.display == 'none') { return } 203 204 handle.blur(); 205 password.blur(); 206 207 submit.style.display = 'none'; 208 cloudy.style.display = 'inline-block'; 209 210 logIn(handle.value, password.value).then((pds) => { 211 window.api = pds; 212 window.accountAPI = pds; 213 214 hideDialog(loginDialog); 215 submit.style.display = 'inline'; 216 cloudy.style.display = 'none'; 217 218 accountMenu.loadCurrentUserAvatar(); 219 220 accountMenu.showMenuButton('logout'); 221 accountMenu.showMenuButton('incognito'); 222 accountMenu.hideMenuButton('login'); 223 224 let params = new URLSearchParams(location.search); 225 let page = params.get('page'); 226 if (page) { 227 openPage(page); 228 } 229 }) 230 .catch((error) => { 231 submit.style.display = 'inline'; 232 cloudy.style.display = 'none'; 233 console.log(error); 234 235 if (error.code == 401 && error.json.error == 'AuthFactorTokenRequired') { 236 alert("Please log in using an \"app password\" if you have 2FA enabled."); 237 } else { 238 window.setTimeout(() => alert(error), 10); 239 } 240 }); 241} 242 243/** @param {string} identifier, @param {string} password, @returns {Promise<BlueskyAPI>} */ 244 245async function logIn(identifier, password) { 246 let pdsEndpoint; 247 248 if (identifier.match(/^did:/)) { 249 pdsEndpoint = await Minisky.pdsEndpointForDid(identifier); 250 } else if (identifier.match(/^[^@]+@[^@]+$/)) { 251 pdsEndpoint = 'bsky.social'; 252 } else if (identifier.match(/^@?[\w\-]+(\.[\w\-]+)+$/)) { 253 identifier = identifier.replace(/^@/, ''); 254 let did = await appView.resolveHandle(identifier); 255 pdsEndpoint = await Minisky.pdsEndpointForDid(did); 256 } else { 257 throw 'Please enter your handle or DID.'; 258 } 259 260 let pds = new BlueskyAPI(pdsEndpoint, true); 261 await pds.logIn(identifier, password); 262 return pds; 263} 264 265function logOut() { 266 accountAPI.resetTokens(); 267 localStorage.removeItem('incognito'); 268 location.reload(); 269} 270 271function submitSearch() { 272 let search = $id('search'); 273 let searchField = $(search.querySelector('input[name=q]'), HTMLInputElement); 274 let url = searchField.value.trim(); 275 276 if (!url) { return } 277 278 if (url.startsWith('at://')) { 279 let target = new URL(getLocation()); 280 target.searchParams.set('q', url); 281 location.assign(target.toString()); 282 return; 283 } 284 285 if (url.match(/^#?((\p{Letter}|\p{Number})+)$/u)) { 286 let target = new URL(getLocation()); 287 target.searchParams.set('hash', encodeURIComponent(url.replace(/^#/, ''))); 288 location.assign(target.toString()); 289 return; 290 } 291 292 try { 293 let [handle, postId] = BlueskyAPI.parsePostURL(url); 294 295 let newURL = linkToPostById(handle, postId); 296 location.assign(newURL); 297 } catch (error) { 298 console.log(error); 299 alert(error.message || "This is not a valid URL or hashtag"); 300 } 301} 302 303function openPage(page) { 304 if (!accountAPI.isLoggedIn) { 305 toggleDialog(loginDialog); 306 return; 307 } 308 309 if (page == 'notif') { 310 showLoader(); 311 showNotificationsPage(); 312 } else if (page == 'posting_stats') { 313 window.postingStatsPage.show(); 314 } else if (page == 'like_stats') { 315 window.likeStatsPage.show(); 316 } 317} 318 319function showNotificationsPage() { 320 document.title = `Notifications - Skythread`; 321 322 let isLoading = false; 323 let firstPageLoaded = false; 324 let finished = false; 325 let cursor; 326 327 loadInPages((next) => { 328 if (isLoading || finished) { return; } 329 isLoading = true; 330 331 accountAPI.loadMentions(cursor).then(data => { 332 let posts = data.posts.map(x => new Post(x)); 333 334 if (posts.length > 0) { 335 if (!firstPageLoaded) { 336 hideLoader(); 337 firstPageLoaded = true; 338 339 let header = $tag('header'); 340 let h2 = $tag('h2', { text: "Replies & Mentions:" }); 341 header.append(h2); 342 $id('thread').appendChild(header); 343 $id('thread').classList.add('notifications'); 344 } 345 346 for (let post of posts) { 347 if (post.parentReference) { 348 let p = $tag('p.back'); 349 p.innerHTML = `<i class="fa-solid fa-reply"></i> `; 350 351 let { repo, rkey } = atURI(post.parentReference.uri); 352 let url = linkToPostById(repo, rkey); 353 let parentLink = $tag('a', { href: url }); 354 p.append(parentLink); 355 356 if (repo == api.user.did) { 357 parentLink.innerText = 'Reply to you'; 358 } else { 359 parentLink.innerText = 'Reply'; 360 api.fetchHandleForDid(repo).then(handle => { 361 parentLink.innerText = `Reply to @${handle}`; 362 }); 363 } 364 365 $id('thread').appendChild(p); 366 } 367 368 let postView = new PostComponent(post, 'feed').buildElement(); 369 $id('thread').appendChild(postView); 370 } 371 } 372 373 isLoading = false; 374 cursor = data.cursor; 375 376 if (!cursor) { 377 finished = true; 378 } else if (posts.length == 0) { 379 next(); 380 } 381 }).catch(error => { 382 hideLoader(); 383 console.log(error); 384 isLoading = false; 385 }); 386 }); 387} 388 389/** @param {Post} post */ 390 391function setPageTitle(post) { 392 document.title = `${post.author.displayName}: "${post.text}" - Skythread`; 393} 394 395/** @param {string} hashtag */ 396 397function loadHashtagPage(hashtag) { 398 hashtag = hashtag.replace(/^\#/, ''); 399 document.title = `#${hashtag} - Skythread`; 400 401 let isLoading = false; 402 let firstPageLoaded = false; 403 let finished = false; 404 let cursor; 405 406 loadInPages(() => { 407 if (isLoading || finished) { return; } 408 isLoading = true; 409 410 api.getHashtagFeed(hashtag, cursor).then(data => { 411 let posts = data.posts.map(j => new Post(j)); 412 413 if (!firstPageLoaded) { 414 hideLoader(); 415 416 let header = $tag('header'); 417 let h2 = $tag('h2', { 418 text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.` 419 }); 420 header.append(h2); 421 422 $id('thread').appendChild(header); 423 $id('thread').classList.add('hashtag'); 424 } 425 426 for (let post of posts) { 427 let postView = new PostComponent(post, 'feed').buildElement(); 428 $id('thread').appendChild(postView); 429 } 430 431 isLoading = false; 432 firstPageLoaded = true; 433 cursor = data.cursor; 434 435 if (!cursor || posts.length == 0) { 436 finished = true; 437 } 438 }).catch(error => { 439 hideLoader(); 440 console.log(error); 441 isLoading = false; 442 }); 443 }); 444} 445 446/** @param {string} url */ 447 448function loadQuotesPage(url) { 449 let isLoading = false; 450 let firstPageLoaded = false; 451 let cursor; 452 let finished = false; 453 454 loadInPages(() => { 455 if (isLoading || finished) { return; } 456 isLoading = true; 457 458 blueAPI.getQuotes(url, cursor).then(data => { 459 api.loadPosts(data.posts).then(jsons => { 460 let posts = jsons.map(j => new Post(j)); 461 462 if (!firstPageLoaded) { 463 hideLoader(); 464 465 let header = $tag('header'); 466 let h2; 467 468 if (data.quoteCount > 1) { 469 h2 = $tag('h2', { text: `${data.quoteCount} quotes:` }); 470 } else if (data.quoteCount == 1) { 471 h2 = $tag('h2', { text: '1 quote:' }); 472 } else { 473 h2 = $tag('h2', { text: 'No quotes found.' }); 474 } 475 476 header.append(h2); 477 $id('thread').appendChild(header); 478 $id('thread').classList.add('quotes'); 479 } 480 481 for (let post of posts) { 482 let postView = new PostComponent(post, 'quotes').buildElement(); 483 $id('thread').appendChild(postView); 484 } 485 486 isLoading = false; 487 firstPageLoaded = true; 488 cursor = data.cursor; 489 490 if (!cursor || posts.length == 0) { 491 finished = true; 492 } 493 }).catch(error => { 494 hideLoader(); 495 console.log(error); 496 isLoading = false; 497 }) 498 }).catch(error => { 499 hideLoader(); 500 console.log(error); 501 isLoading = false; 502 }); 503 }); 504} 505 506/** @param {Function} callback */ 507 508function loadInPages(callback) { 509 let loadIfNeeded = () => { 510 if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) { 511 callback(loadIfNeeded); 512 } 513 }; 514 515 callback(loadIfNeeded); 516 517 document.addEventListener('scroll', loadIfNeeded); 518 const resizeObserver = new ResizeObserver(loadIfNeeded); 519 resizeObserver.observe(document.body); 520}