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