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