Thread viewer for Bluesky
at mastodon 530 lines 14 kB view raw
1function init() { 2 let document = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document)); 3 let html = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document.body.parentNode)); 4 5 window.dateLocale = localStorage.getItem('locale') || undefined; 6 window.isIncognito = !!localStorage.getItem('incognito'); 7 8 document.addEventListener('click', (e) => { 9 $id('account_menu').style.visibility = 'hidden'; 10 }); 11 12 document.querySelector('#search form').addEventListener('submit', (e) => { 13 e.preventDefault(); 14 submitSearch(); 15 }); 16 17 document.querySelector('#login').addEventListener('click', (e) => { 18 if (e.target === e.currentTarget) { 19 hideLogin(); 20 } else { 21 e.stopPropagation(); 22 } 23 }); 24 25 document.querySelector('#login .info a').addEventListener('click', (e) => { 26 e.preventDefault(); 27 toggleLoginInfo(); 28 }); 29 30 document.querySelector('#login form').addEventListener('submit', (e) => { 31 e.preventDefault(); 32 submitLogin(); 33 }); 34 35 document.querySelector('#login .close').addEventListener('click', (e) => { 36 hideLogin(); 37 }); 38 39 document.querySelector('#account').addEventListener('click', (e) => { 40 if (accountAPI.isLoggedIn) { 41 toggleAccount(); 42 } else { 43 toggleLogin(); 44 } 45 e.stopPropagation(); 46 }); 47 48 document.querySelector('#account_menu').addEventListener('click', (e) => { 49 e.stopPropagation(); 50 }); 51 52 document.querySelector('#account_menu a[data-action=incognito]').addEventListener('click', (e) => { 53 e.preventDefault(); 54 55 if (isIncognito) { 56 localStorage.removeItem('incognito'); 57 } else { 58 localStorage.setItem('incognito', '1'); 59 } 60 61 location.reload(); 62 }); 63 64 document.querySelector('#account_menu a[data-action=logout]').addEventListener('click', (e) => { 65 e.preventDefault(); 66 logOut(); 67 }); 68 69 window.appView = new BlueskyAPI('api.bsky.app', false); 70 window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false); 71 window.accountAPI = new BlueskyAPI(undefined, true); 72 73 if (accountAPI.isLoggedIn && !isIncognito) { 74 window.api = accountAPI; 75 accountAPI.host = accountAPI.user.pdsEndpoint; 76 showLoggedInStatus(true, api.user.avatar); 77 } else if (accountAPI.isLoggedIn && isIncognito) { 78 window.api = appView; 79 accountAPI.host = accountAPI.user.pdsEndpoint; 80 showLoggedInStatus('incognito'); 81 document.querySelector('#account_menu a[data-action=incognito]').innerText = '✓ Incognito mode'; 82 } else { 83 window.api = appView; 84 } 85 86 parseQueryParams(); 87} 88 89function parseQueryParams() { 90 let params = new URLSearchParams(location.search); 91 let query = params.get('q'); 92 let author = params.get('author'); 93 let post = params.get('post'); 94 let quotes = params.get('quotes'); 95 let hash = params.get('hash'); 96 let mastodon = params.get('masto'); 97 98 if (quotes) { 99 showLoader(); 100 loadQuotesPage(decodeURIComponent(quotes)); 101 } else if (hash) { 102 showLoader(); 103 loadHashtagPage(decodeURIComponent(hash)); 104 } else if (query) { 105 showLoader(); 106 loadThread(decodeURIComponent(query)); 107 } else if (author && post) { 108 showLoader(); 109 loadThread(decodeURIComponent(author), decodeURIComponent(post)); 110 } else if (mastodon) { 111 showLoader(); 112 loadMastodonThread(decodeURIComponent(mastodon)); 113 } else { 114 showSearch(); 115 } 116} 117 118/** @param {AnyPost} post, @returns {AnyElement} */ 119 120function buildParentLink(post) { 121 let p = $tag('p.back'); 122 123 if (post instanceof BlockedPost) { 124 let element = new PostComponent(post).buildElement('parent'); 125 element.className = 'back'; 126 element.querySelector('p.blocked-header span').innerText = 'Parent post blocked'; 127 return element; 128 } else if (post instanceof MissingPost) { 129 p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`; 130 } else { 131 let url = linkToPostThread(post); 132 p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`; 133 } 134 135 return p; 136} 137 138function showLoader() { 139 $id('loader').style.display = 'block'; 140} 141 142function hideLoader() { 143 $id('loader').style.display = 'none'; 144} 145 146function showSearch() { 147 $id('search').style.visibility = 'visible'; 148 $id('search').querySelector('input[type=text]').focus(); 149} 150 151function hideSearch() { 152 $id('search').style.visibility = 'hidden'; 153} 154 155function showLogin() { 156 $id('login').style.visibility = 'visible'; 157 $id('thread').classList.add('overlay'); 158 $id('login_handle').focus(); 159} 160 161function hideLogin() { 162 $id('login').style.visibility = 'hidden'; 163 $id('login').classList.remove('expanded'); 164 $id('thread').classList.remove('overlay'); 165 $id('login_handle').value = ''; 166 $id('login_password').value = ''; 167} 168 169function toggleLogin() { 170 if ($id('login').style.visibility == 'visible') { 171 hideLogin(); 172 } else { 173 showLogin(); 174 } 175} 176 177function toggleLoginInfo(event) { 178 $id('login').classList.toggle('expanded'); 179} 180 181function toggleAccount() { 182 let menu = $id('account_menu'); 183 menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible'; 184} 185 186/** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */ 187 188function showLoggedInStatus(loggedIn, avatar) { 189 let account = $id('account'); 190 191 if (loggedIn === true && avatar) { 192 let button = account.querySelector('i'); 193 194 let img = $tag('img.avatar', { src: avatar }); 195 img.style.display = 'none'; 196 img.addEventListener('load', () => { 197 button.remove(); 198 img.style.display = 'inline'; 199 }); 200 img.addEventListener('error', () => { 201 showLoggedInStatus(true, null); 202 }) 203 204 account.append(img); 205 } else if (loggedIn === false) { 206 $id('account').innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`; 207 } else if (loggedIn === 'incognito') { 208 $id('account').innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`; 209 } else { 210 account.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`; 211 } 212} 213 214function submitLogin() { 215 let handle = $id('login_handle'); 216 let password = $id('login_password'); 217 let submit = $id('login_submit'); 218 let cloudy = $id('cloudy'); 219 220 if (submit.style.display == 'none') { return } 221 222 handle.blur(); 223 password.blur(); 224 225 submit.style.display = 'none'; 226 cloudy.style.display = 'inline-block'; 227 228 logIn(handle.value, password.value).then((pds) => { 229 window.api = pds; 230 window.accountAPI = pds; 231 232 hideLogin(); 233 submit.style.display = 'inline'; 234 cloudy.style.display = 'none'; 235 236 loadCurrentUserAvatar(); 237 }) 238 .catch((error) => { 239 submit.style.display = 'inline'; 240 cloudy.style.display = 'none'; 241 console.log(error); 242 243 window.setTimeout(() => alert(error), 10); 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 loadCurrentUserAvatar() { 270 api.loadCurrentUserAvatar().then((url) => { 271 showLoggedInStatus(true, url); 272 }).catch((error) => { 273 console.log(error); 274 showLoggedInStatus(true, null); 275 }); 276} 277 278function logOut() { 279 accountAPI.resetTokens(); 280 location.reload(); 281} 282 283function submitSearch() { 284 let url = $id('search').querySelector('input[name=q]').value.trim(); 285 286 if (!url) { return } 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 306/** @param {Post} post */ 307 308function setPageTitle(post) { 309 document.title = `${post.author.displayName}: "${post.text}" - Skythread`; 310} 311 312/** @param {string} hashtag */ 313 314function loadHashtagPage(hashtag) { 315 hashtag = hashtag.replace(/^\#/, ''); 316 document.title = `#${hashtag} - Skythread`; 317 318 let isLoading = false; 319 let firstPageLoaded = false; 320 let finished = false; 321 let cursor; 322 323 loadInPages(() => { 324 if (isLoading || finished) { return; } 325 isLoading = true; 326 327 api.getHashtagFeed(hashtag, cursor).then(data => { 328 let posts = data.posts.map(j => new Post(j)); 329 330 if (!firstPageLoaded) { 331 hideLoader(); 332 333 let header = $tag('header'); 334 let h2 = $tag('h2', { 335 text: (posts.length > 0) ? `Posts tagged: #${hashtag}` : `No posts tagged #${hashtag}.` 336 }); 337 header.append(h2); 338 339 $id('thread').appendChild(header); 340 $id('thread').classList.add('hashtag'); 341 } 342 343 for (let post of posts) { 344 let postView = new PostComponent(post).buildElement('feed'); 345 $id('thread').appendChild(postView); 346 } 347 348 isLoading = false; 349 firstPageLoaded = true; 350 cursor = data.cursor; 351 352 if (!cursor || posts.length == 0) { 353 finished = true; 354 } 355 }).catch(error => { 356 hideLoader(); 357 console.log(error); 358 isLoading = false; 359 }); 360 }); 361} 362 363/** @param {string} url */ 364 365function loadQuotesPage(url) { 366 let isLoading = false; 367 let firstPageLoaded = false; 368 let cursor; 369 let finished = false; 370 371 loadInPages(() => { 372 if (isLoading || finished) { return; } 373 isLoading = true; 374 375 blueAPI.getQuotes(url, cursor).then(data => { 376 api.loadPosts(data.posts).then(jsons => { 377 let posts = jsons.map(j => new Post(j)); 378 379 if (!firstPageLoaded) { 380 hideLoader(); 381 382 let header = $tag('header'); 383 let h2; 384 385 if (data.quoteCount > 1) { 386 h2 = $tag('h2', { text: `${data.quoteCount} quotes:` }); 387 } else if (data.quoteCount == 1) { 388 h2 = $tag('h2', { text: '1 quote:' }); 389 } else { 390 h2 = $tag('h2', { text: 'No quotes found.' }); 391 } 392 393 header.append(h2); 394 $id('thread').appendChild(header); 395 $id('thread').classList.add('quotes'); 396 } 397 398 for (let post of posts) { 399 let postView = new PostComponent(post).buildElement('quotes'); 400 $id('thread').appendChild(postView); 401 } 402 403 isLoading = false; 404 firstPageLoaded = true; 405 cursor = data.cursor; 406 407 if (!cursor || posts.length == 0) { 408 finished = true; 409 } 410 }).catch(error => { 411 hideLoader(); 412 console.log(error); 413 isLoading = false; 414 }) 415 }).catch(error => { 416 hideLoader(); 417 console.log(error); 418 isLoading = false; 419 }); 420 }); 421} 422 423/** @param {Function} callback */ 424 425function loadInPages(callback) { 426 callback(); 427 428 document.addEventListener('scroll', (e) => { 429 if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 200) { 430 callback(); 431 } 432 }); 433} 434 435/** @param {string} url, @param {string} [postId], @param {AnyElement} [nodeToUpdate] */ 436 437function loadThread(url, postId, nodeToUpdate) { 438 let load = postId ? api.loadThreadById(url, postId) : api.loadThreadByURL(url); 439 440 load.then(json => { 441 let root = Post.parseThreadPost(json.thread); 442 window.root = root; 443 444 let loadQuoteCount; 445 446 if (!nodeToUpdate && root instanceof Post) { 447 setPageTitle(root); 448 loadQuoteCount = blueAPI.getQuoteCount(root.uri); 449 450 if (root.parent) { 451 let p = buildParentLink(root.parent); 452 $id('thread').appendChild(p); 453 } 454 } 455 456 let component = new PostComponent(root); 457 let list = component.buildElement('thread'); 458 hideLoader(); 459 460 if (nodeToUpdate) { 461 nodeToUpdate.querySelector('.content').replaceWith(list.querySelector('.content')); 462 } else { 463 $id('thread').appendChild(list); 464 } 465 466 loadQuoteCount?.then(count => { 467 if (count > 0) { 468 let stats = list.querySelector(':scope > .content > p.stats'); 469 let q = new URL(getLocation()); 470 q.searchParams.set('quotes', component.linkToPost); 471 stats.append($tag('i', { className: count > 1 ? 'fa-regular fa-comments' : 'fa-regular fa-comment' })); 472 stats.append(" "); 473 let quotes = $tag('a', { 474 html: count > 1 ? `${count} quotes` : '1 quote', 475 href: q.toString() 476 }); 477 stats.append(quotes); 478 } 479 }).catch(error => { 480 console.warn("Couldn't load quote count: " + error); 481 }); 482 }).catch(error => { 483 hideLoader(); 484 console.log(error); 485 alert(error); 486 }); 487} 488 489function loadMastodonThread(url, nodeToUpdate) { 490 let host = new URL(url).host; 491 let postId = url.replace(/\/$/, '').split('/').reverse()[0]; 492 let statusURL = `https://${host}/api/v1/statuses/${postId}`; 493 let contextURL = `https://${host}/api/v1/statuses/${postId}/context`; 494 495 let load = async function() { 496 let post = await fetch(statusURL).then(x => x.json()); 497 let context = await fetch(contextURL).then(x => x.json()); 498 return [post, context]; 499 } 500 501 load().then(json => { 502 console.log(json); 503 504 let root = Post.parseMastodonThread(json[0], json[1]); 505 window.root = root; 506 507 if (!nodeToUpdate && root instanceof Post) { 508 setPageTitle(root); 509 510 if (root.parent) { 511 let p = buildParentLink(root.parent); 512 $id('thread').appendChild(p); 513 } 514 } 515 516 let component = new PostComponent(root); 517 let list = component.buildElement('thread'); 518 hideLoader(); 519 520 if (nodeToUpdate) { 521 nodeToUpdate.querySelector('.content').replaceWith(list.querySelector('.content')); 522 } else { 523 $id('thread').appendChild(list); 524 } 525 }).catch(error => { 526 hideLoader(); 527 console.log(error); 528 alert(error); 529 }); 530}