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