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