Thread viewer for Bluesky

redesigned how DOM types are handled for TS/JSDoc

+14 -14
embed_component.js
··· 10 this.embed = embed; 11 } 12 13 - /** @returns {AnyElement} */ 14 15 buildElement() { 16 if (this.embed instanceof RawRecordEmbed) { ··· 54 } 55 } 56 57 - /** @returns {AnyElement} */ 58 59 quotedPostPlaceholder() { 60 return $tag('div.quote-embed', { ··· 62 }); 63 } 64 65 - /** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {AnyElement} */ 66 67 buildQuotedPostElement(embed) { 68 let div = $tag('div.quote-embed'); ··· 88 return div; 89 } 90 91 - /** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {AnyElement} */ 92 93 buildLinkComponent(embed) { 94 let hostname; ··· 128 return a; 129 } 130 131 - /** @param {FeedGeneratorRecord} feedgen, @returns {AnyElement} */ 132 133 buildFeedGeneratorView(feedgen) { 134 let link = this.linkToFeedGenerator(feedgen); ··· 137 let box = $tag('div'); 138 139 if (feedgen.avatar) { 140 - let avatar = $tag('img.avatar'); 141 avatar.src = feedgen.avatar; 142 box.append(avatar); 143 } ··· 167 return `https://bsky.app/profile/${repo}/feed/${rkey}`; 168 } 169 170 - /** @param {UserListRecord} list, @returns {AnyElement} */ 171 172 buildUserListView(list) { 173 let link = this.linkToUserList(list); ··· 176 let box = $tag('div'); 177 178 if (list.avatar) { 179 - let avatar = $tag('img.avatar'); 180 avatar.src = list.avatar; 181 box.append(avatar); 182 } ··· 207 return a; 208 } 209 210 - /** @param {StarterPackRecord} pack, @returns {AnyElement} */ 211 212 buildStarterPackView(pack) { 213 let { repo, rkey } = atURI(pack.uri); ··· 236 return `https://bsky.app/profile/${repo}/lists/${rkey}`; 237 } 238 239 - /** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {AnyElement} */ 240 241 buildImagesComponent(embed) { 242 let wrapper = $tag('div'); ··· 246 p.append('['); 247 248 // TODO: load image 249 - let a = $tag('a', { text: "Image" }); 250 251 if (image.fullsize) { 252 a.href = image.fullsize; ··· 272 return wrapper; 273 } 274 275 - /** @params {RawVideoEmbed | InlineVideoEmbed} embed, @returns {AnyElement} */ 276 277 buildVideoComponent(embed) { 278 let wrapper = $tag('div'); 279 280 // TODO: load thumbnail 281 - let a = $tag('a', { text: "Video" }); 282 283 if (embed.playlistURL) { 284 a.href = embed.playlistURL; ··· 303 return wrapper; 304 } 305 306 - /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */ 307 308 async loadQuotedPost(uri, div) { 309 let record = await api.loadPostIfExists(uri);
··· 10 this.embed = embed; 11 } 12 13 + /** @returns {HTMLElement} */ 14 15 buildElement() { 16 if (this.embed instanceof RawRecordEmbed) { ··· 54 } 55 } 56 57 + /** @returns {HTMLElement} */ 58 59 quotedPostPlaceholder() { 60 return $tag('div.quote-embed', { ··· 62 }); 63 } 64 65 + /** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {HTMLElement} */ 66 67 buildQuotedPostElement(embed) { 68 let div = $tag('div.quote-embed'); ··· 88 return div; 89 } 90 91 + /** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {HTMLElement} */ 92 93 buildLinkComponent(embed) { 94 let hostname; ··· 128 return a; 129 } 130 131 + /** @param {FeedGeneratorRecord} feedgen, @returns {HTMLElement} */ 132 133 buildFeedGeneratorView(feedgen) { 134 let link = this.linkToFeedGenerator(feedgen); ··· 137 let box = $tag('div'); 138 139 if (feedgen.avatar) { 140 + let avatar = $tag('img.avatar', HTMLImageElement); 141 avatar.src = feedgen.avatar; 142 box.append(avatar); 143 } ··· 167 return `https://bsky.app/profile/${repo}/feed/${rkey}`; 168 } 169 170 + /** @param {UserListRecord} list, @returns {HTMLElement} */ 171 172 buildUserListView(list) { 173 let link = this.linkToUserList(list); ··· 176 let box = $tag('div'); 177 178 if (list.avatar) { 179 + let avatar = $tag('img.avatar', HTMLImageElement); 180 avatar.src = list.avatar; 181 box.append(avatar); 182 } ··· 207 return a; 208 } 209 210 + /** @param {StarterPackRecord} pack, @returns {HTMLElement} */ 211 212 buildStarterPackView(pack) { 213 let { repo, rkey } = atURI(pack.uri); ··· 236 return `https://bsky.app/profile/${repo}/lists/${rkey}`; 237 } 238 239 + /** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {HTMLElement} */ 240 241 buildImagesComponent(embed) { 242 let wrapper = $tag('div'); ··· 246 p.append('['); 247 248 // TODO: load image 249 + let a = $tag('a', { text: "Image" }, HTMLLinkElement); 250 251 if (image.fullsize) { 252 a.href = image.fullsize; ··· 272 return wrapper; 273 } 274 275 + /** @params {RawVideoEmbed | InlineVideoEmbed} embed, @returns {HTMLElement} */ 276 277 buildVideoComponent(embed) { 278 let wrapper = $tag('div'); 279 280 // TODO: load thumbnail 281 + let a = $tag('a', { text: "Video" }, HTMLLinkElement); 282 283 if (embed.playlistURL) { 284 a.href = embed.playlistURL; ··· 303 return wrapper; 304 } 305 306 + /** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */ 307 308 async loadQuotedPost(uri, div) { 309 let record = await api.loadPostIfExists(uri);
+31 -27
post_component.js
··· 5 class PostComponent { 6 /** 7 * Post component's root HTML element, if built. 8 - * @type {AnyElement | undefined} 9 */ 10 _rootElement; 11 ··· 26 } 27 28 /** 29 - * @returns {AnyElement} 30 */ 31 get rootElement() { 32 if (!this._rootElement) { ··· 91 } 92 } 93 94 - /** @param {AnyElement} nodeToUpdate */ 95 installIntoElement(nodeToUpdate) { 96 let view = this.buildElement(); 97 98 - nodeToUpdate.querySelector('.content').replaceWith(view.querySelector('.content')); 99 this._rootElement = nodeToUpdate; 100 } 101 102 - /** @returns {AnyElement} */ 103 buildElement() { 104 if (this._rootElement) { 105 return this._rootElement; ··· 196 return div; 197 } 198 199 - /** @returns {AnyElement} */ 200 201 buildPostHeader() { 202 let timeFormat = this.timeFormatForTimestamp; ··· 260 /** @param {string} url, @returns {HTMLImageElement} */ 261 262 buildUserAvatar(url) { 263 - let avatar = $tag('img.avatar', { loading: 'lazy' }); // needs to be set before src! 264 avatar.src = url; 265 window.avatarPreloader.observe(avatar); 266 return avatar; 267 } 268 269 - /** @returns {AnyElement} */ 270 271 buildPostBody() { 272 if (this.post.originalFediContent) { ··· 297 return p; 298 } 299 300 - /** @param {string[]} tags, @returns {AnyElement} */ 301 302 buildTagsRow(tags) { 303 let p = $tag('p.tags'); ··· 313 return p; 314 } 315 316 - /** @returns {AnyElement} */ 317 318 buildStatsFooter() { 319 let stats = $tag('p.stats'); ··· 347 return stats; 348 } 349 350 - /** @param {number} count, @param {boolean} expanded, @returns {AnyElement} */ 351 352 buildQuotesIconLink(count, expanded) { 353 let q = new URL(getLocation()); ··· 369 /** @param {number} quoteCount, @param {boolean} expanded */ 370 371 appendQuotesIconLink(quoteCount, expanded) { 372 - let stats = this.rootElement.querySelector(':scope > .content > p.stats'); 373 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded); 374 stats.append(quotesLink); 375 } 376 377 - /** @returns {AnyElement} */ 378 379 buildLoadMoreLink() { 380 let loadMore = $tag('p'); ··· 394 return loadMore; 395 } 396 397 - /** @returns {AnyElement} */ 398 399 buildHiddenRepliesLink() { 400 let loadMore = $tag('p.hidden-replies'); ··· 419 return loadMore; 420 } 421 422 - /** @param {HTMLLinkElement} loadMoreButton */ 423 424 loadHiddenReplies(loadMoreButton) { 425 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; ··· 443 }); 444 } 445 446 - /** @param {AnyElement} div, @returns {AnyElement} */ 447 448 buildBlockedPostElement(div) { 449 let p = $tag('p.blocked-header'); ··· 458 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : ''; 459 blockStatus = blockStatus ? `, ${blockStatus}` : ''; 460 461 - let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 462 p.append(' (', authorLink, blockStatus, ') '); 463 div.appendChild(p); 464 ··· 479 return div; 480 } 481 482 - /** @param {AnyElement} div, @returns {AnyElement} */ 483 484 buildDetachedQuoteElement(div) { 485 let p = $tag('p.blocked-header'); ··· 491 return p; 492 } 493 494 - let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 495 p.append(' (', authorLink, ') '); 496 div.appendChild(p); 497 ··· 512 return div; 513 } 514 515 - /** @param {AnyElement} div, @returns {AnyElement} */ 516 517 buildMissingPostElement(div) { 518 let p = $tag('p.blocked-header'); 519 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`; 520 521 - let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 522 p.append(' (', authorLink, ') '); 523 524 this.loadReferencedPostAuthor(authorLink); ··· 528 return div; 529 } 530 531 - /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */ 532 533 async loadBlockedPost(uri, div) { 534 let record = await appView.loadPostIfExists(this.post.uri); ··· 554 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>` 555 }); 556 557 - let header = div.querySelector('p.blocked-header'); 558 let separator = $tag('span.separator', { html: '&bull;' }); 559 header.append(separator, ' ', a); 560 } 561 562 - div.querySelector('p.load-post').remove(); 563 564 if (this.isRoot && this.post.parentReference) { 565 let { repo, rkey } = atURI(this.post.parentReference.uri); ··· 590 } 591 592 toggleSectionFold() { 593 - let plus = this.rootElement.querySelector(':scope > .margin .plus'); 594 595 if (this.isCollapsed()) { 596 this.rootElement.classList.remove('collapsed'); ··· 601 } 602 } 603 604 - /** @param {AnyElement} heart */ 605 606 onHeartClick(heart) { 607 if (!this.post.hasViewerInfo) { ··· 627 return; 628 } 629 630 - let count = heart.nextElementSibling; 631 632 if (!heart.classList.contains('liked')) { 633 accountAPI.likePost(this.post).then((like) => {
··· 5 class PostComponent { 6 /** 7 * Post component's root HTML element, if built. 8 + * @type {HTMLElement | undefined} 9 */ 10 _rootElement; 11 ··· 26 } 27 28 /** 29 + * @returns {HTMLElement} 30 */ 31 get rootElement() { 32 if (!this._rootElement) { ··· 91 } 92 } 93 94 + /** @param {HTMLElement} nodeToUpdate */ 95 installIntoElement(nodeToUpdate) { 96 let view = this.buildElement(); 97 98 + let oldContent = $(nodeToUpdate.querySelector('.content')); 99 + let newContent = $(view.querySelector('.content')); 100 + oldContent.replaceWith(newContent); 101 + 102 this._rootElement = nodeToUpdate; 103 } 104 105 + /** @returns {HTMLElement} */ 106 buildElement() { 107 if (this._rootElement) { 108 return this._rootElement; ··· 199 return div; 200 } 201 202 + /** @returns {HTMLElement} */ 203 204 buildPostHeader() { 205 let timeFormat = this.timeFormatForTimestamp; ··· 263 /** @param {string} url, @returns {HTMLImageElement} */ 264 265 buildUserAvatar(url) { 266 + let avatar = $tag('img.avatar', { loading: 'lazy' }, HTMLImageElement); // needs to be set before src! 267 avatar.src = url; 268 window.avatarPreloader.observe(avatar); 269 return avatar; 270 } 271 272 + /** @returns {HTMLElement} */ 273 274 buildPostBody() { 275 if (this.post.originalFediContent) { ··· 300 return p; 301 } 302 303 + /** @param {string[]} tags, @returns {HTMLElement} */ 304 305 buildTagsRow(tags) { 306 let p = $tag('p.tags'); ··· 316 return p; 317 } 318 319 + /** @returns {HTMLElement} */ 320 321 buildStatsFooter() { 322 let stats = $tag('p.stats'); ··· 350 return stats; 351 } 352 353 + /** @param {number} count, @param {boolean} expanded, @returns {HTMLElement} */ 354 355 buildQuotesIconLink(count, expanded) { 356 let q = new URL(getLocation()); ··· 372 /** @param {number} quoteCount, @param {boolean} expanded */ 373 374 appendQuotesIconLink(quoteCount, expanded) { 375 + let stats = $(this.rootElement.querySelector(':scope > .content > p.stats')); 376 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded); 377 stats.append(quotesLink); 378 } 379 380 + /** @returns {HTMLElement} */ 381 382 buildLoadMoreLink() { 383 let loadMore = $tag('p'); ··· 397 return loadMore; 398 } 399 400 + /** @returns {HTMLElement} */ 401 402 buildHiddenRepliesLink() { 403 let loadMore = $tag('p.hidden-replies'); ··· 422 return loadMore; 423 } 424 425 + /** @param {HTMLElement} loadMoreButton */ 426 427 loadHiddenReplies(loadMoreButton) { 428 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; ··· 446 }); 447 } 448 449 + /** @param {HTMLElement} div, @returns {HTMLElement} */ 450 451 buildBlockedPostElement(div) { 452 let p = $tag('p.blocked-header'); ··· 461 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : ''; 462 blockStatus = blockStatus ? `, ${blockStatus}` : ''; 463 464 + let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 465 p.append(' (', authorLink, blockStatus, ') '); 466 div.appendChild(p); 467 ··· 482 return div; 483 } 484 485 + /** @param {HTMLElement} div, @returns {HTMLElement} */ 486 487 buildDetachedQuoteElement(div) { 488 let p = $tag('p.blocked-header'); ··· 494 return p; 495 } 496 497 + let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 498 p.append(' (', authorLink, ') '); 499 div.appendChild(p); 500 ··· 515 return div; 516 } 517 518 + /** @param {HTMLElement} div, @returns {HTMLElement} */ 519 520 buildMissingPostElement(div) { 521 let p = $tag('p.blocked-header'); 522 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`; 523 524 + let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 525 p.append(' (', authorLink, ') '); 526 527 this.loadReferencedPostAuthor(authorLink); ··· 531 return div; 532 } 533 534 + /** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */ 535 536 async loadBlockedPost(uri, div) { 537 let record = await appView.loadPostIfExists(this.post.uri); ··· 557 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>` 558 }); 559 560 + let header = $(div.querySelector('p.blocked-header')); 561 let separator = $tag('span.separator', { html: '&bull;' }); 562 header.append(separator, ' ', a); 563 } 564 565 + let loadPost = $(div.querySelector('p.load-post')); 566 + loadPost.remove(); 567 568 if (this.isRoot && this.post.parentReference) { 569 let { repo, rkey } = atURI(this.post.parentReference.uri); ··· 594 } 595 596 toggleSectionFold() { 597 + let plus = $(this.rootElement.querySelector(':scope > .margin .plus'), HTMLImageElement); 598 599 if (this.isCollapsed()) { 600 this.rootElement.classList.remove('collapsed'); ··· 605 } 606 } 607 608 + /** @param {HTMLElement} heart */ 609 610 onHeartClick(heart) { 611 if (!this.post.hasViewerInfo) { ··· 631 return; 632 } 633 634 + let count = $(heart.nextElementSibling); 635 636 if (!heart.classList.contains('liked')) { 637 accountAPI.likePost(this.post).then((like) => {
+45 -37
skythread.js
··· 1 function 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 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null'); 8 9 - window.loginDialog = document.querySelector('#login'); 10 - window.accountMenu = document.querySelector('#account_menu'); 11 12 window.avatarPreloader = buildAvatarPreloader(); 13 ··· 15 $id('account_menu').style.visibility = 'hidden'; 16 }); 17 18 - document.querySelector('#search form').addEventListener('submit', (e) => { 19 e.preventDefault(); 20 submitSearch(); 21 }); ··· 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; ··· 55 window.loadInfohazard = undefined; 56 } 57 58 - let target = /** @type {AnyElement} */ (/** @type {unknown} */ (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; ··· 68 toggleMenuButton('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 = /** @type {AnyElement} */ (/** @type {unknown} */ (e.target)); 75 76 hideDialog(target.closest('.dialog')); 77 }); 78 79 - document.querySelector('#account').addEventListener('click', (e) => { 80 toggleAccountMenu(); 81 e.stopPropagation(); 82 }); ··· 85 e.stopPropagation(); 86 }); 87 88 - accountMenu.querySelector('a[data-action=biohazard]').addEventListener('click', (e) => { 89 e.preventDefault(); 90 91 let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post'); ··· 94 window.biohazardEnabled = true; 95 localStorage.setItem('biohazard', 'true'); 96 toggleMenuButton('biohazard', true); 97 - Array.from(hazards).forEach(p => { p.style.display = 'block' }); 98 } else { 99 window.biohazardEnabled = false; 100 localStorage.setItem('biohazard', 'false'); 101 toggleMenuButton('biohazard', false); 102 - Array.from(hazards).forEach(p => { p.style.display = 'none' }); 103 } 104 }); 105 106 - accountMenu.querySelector('a[data-action=incognito]').addEventListener('click', (e) => { 107 e.preventDefault(); 108 109 if (isIncognito) { ··· 115 location.reload(); 116 }); 117 118 - accountMenu.querySelector('a[data-action=login]').addEventListener('click', (e) => { 119 e.preventDefault(); 120 toggleDialog(loginDialog); 121 $id('account_menu').style.visibility = 'hidden'; 122 }); 123 124 - accountMenu.querySelector('a[data-action=logout]').addEventListener('click', (e) => { 125 e.preventDefault(); 126 logOut(); 127 }); ··· 181 } 182 } 183 184 - /** @param {AnyPost} post, @returns {AnyElement} */ 185 186 function buildParentLink(post) { 187 let p = $tag('p.back'); ··· 189 if (post instanceof BlockedPost) { 190 let element = new PostComponent(post, 'parent').buildElement(); 191 element.className = 'back'; 192 - element.querySelector('p.blocked-header span').innerText = 'Parent post blocked'; 193 return element; 194 } else if (post instanceof MissingPost) { 195 p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`; ··· 226 } 227 228 function showSearch() { 229 - $id('search').style.visibility = 'visible'; 230 - $id('search').querySelector('input[type=text]').focus(); 231 } 232 233 function hideSearch() { ··· 271 /** @param {string} buttonName */ 272 273 function showMenuButton(buttonName) { 274 - let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 275 - button.parentNode.style.display = 'list-item'; 276 } 277 278 /** @param {string} buttonName */ 279 280 function hideMenuButton(buttonName) { 281 - let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 282 - button.parentNode.style.display = 'none'; 283 } 284 285 /** @param {string} buttonName, @param {boolean} state */ 286 287 function toggleMenuButton(buttonName, state) { 288 - let button = accountMenu.querySelector(`a[data-action=${buttonName}]`); 289 - button.querySelector('.check').style.display = (state) ? 'inline' : 'none'; 290 } 291 292 /** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */ ··· 295 let account = $id('account'); 296 297 if (loggedIn === true && avatar) { 298 - let button = account.querySelector('i'); 299 300 let img = $tag('img.avatar', { src: avatar }); 301 img.style.display = 'none'; ··· 318 } 319 320 function submitLogin() { 321 - let handle = $id('login_handle'); 322 - let password = $id('login_password'); 323 let submit = $id('login_submit'); 324 let cloudy = $id('cloudy'); 325 ··· 401 } 402 403 function submitSearch() { 404 - let url = $id('search').querySelector('input[name=q]').value.trim(); 405 406 if (!url) { return } 407 ··· 702 }); 703 } 704 705 - /** @param {Post} post, @param {AnyElement} nodeToUpdate */ 706 707 function loadSubtree(post, nodeToUpdate) { 708 api.loadThreadByAtURI(post.uri).then(json => { ··· 715 }).catch(showError); 716 } 717 718 - /** @param {Post} post, @param {AnyElement} nodeToUpdate */ 719 720 function loadHiddenSubtree(post, nodeToUpdate) { 721 - let content = nodeToUpdate.querySelector('.content'); 722 - let hiddenRepliesDiv = content.querySelector(':scope > .hidden-replies'); 723 724 blueAPI.getReplies(post.uri).then(replies => { 725 let missingReplies = replies.filter(r => !post.replies.some(x => x.uri === r));
··· 1 function init() { 2 + let html = $(document.body.parentNode); 3 4 window.dateLocale = localStorage.getItem('locale') || undefined; 5 window.isIncognito = !!localStorage.getItem('incognito'); 6 window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null'); 7 8 + window.loginDialog = $(document.querySelector('#login')); 9 + window.accountMenu = $(document.querySelector('#account_menu')); 10 11 window.avatarPreloader = buildAvatarPreloader(); 12 ··· 14 $id('account_menu').style.visibility = 'hidden'; 15 }); 16 17 + $(document.querySelector('#search form')).addEventListener('submit', (e) => { 18 e.preventDefault(); 19 submitSearch(); 20 }); ··· 33 }); 34 } 35 36 + $(document.querySelector('#login .info a')).addEventListener('click', (e) => { 37 e.preventDefault(); 38 toggleLoginInfo(); 39 }); 40 41 + $(document.querySelector('#login form')).addEventListener('submit', (e) => { 42 e.preventDefault(); 43 submitLogin(); 44 }); 45 46 + $(document.querySelector('#biohazard_show')).addEventListener('click', (e) => { 47 e.preventDefault(); 48 49 window.biohazardEnabled = true; ··· 54 window.loadInfohazard = undefined; 55 } 56 57 + let target = $(e.target); 58 59 hideDialog(target.closest('.dialog')); 60 }); 61 62 + $(document.querySelector('#biohazard_hide')).addEventListener('click', (e) => { 63 e.preventDefault(); 64 65 window.biohazardEnabled = false; ··· 67 toggleMenuButton('biohazard', false); 68 69 for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) { 70 + $(p).style.display = 'none'; 71 } 72 73 + let target = $(e.target); 74 75 hideDialog(target.closest('.dialog')); 76 }); 77 78 + $(document.querySelector('#account')).addEventListener('click', (e) => { 79 toggleAccountMenu(); 80 e.stopPropagation(); 81 }); ··· 84 e.stopPropagation(); 85 }); 86 87 + $(accountMenu.querySelector('a[data-action=biohazard]')).addEventListener('click', (e) => { 88 e.preventDefault(); 89 90 let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post'); ··· 93 window.biohazardEnabled = true; 94 localStorage.setItem('biohazard', 'true'); 95 toggleMenuButton('biohazard', true); 96 + Array.from(hazards).forEach(p => { $(p).style.display = 'block' }); 97 } else { 98 window.biohazardEnabled = false; 99 localStorage.setItem('biohazard', 'false'); 100 toggleMenuButton('biohazard', false); 101 + Array.from(hazards).forEach(p => { $(p).style.display = 'none' }); 102 } 103 }); 104 105 + $(accountMenu.querySelector('a[data-action=incognito]')).addEventListener('click', (e) => { 106 e.preventDefault(); 107 108 if (isIncognito) { ··· 114 location.reload(); 115 }); 116 117 + $(accountMenu.querySelector('a[data-action=login]')).addEventListener('click', (e) => { 118 e.preventDefault(); 119 toggleDialog(loginDialog); 120 $id('account_menu').style.visibility = 'hidden'; 121 }); 122 123 + $(accountMenu.querySelector('a[data-action=logout]')).addEventListener('click', (e) => { 124 e.preventDefault(); 125 logOut(); 126 }); ··· 180 } 181 } 182 183 + /** @param {AnyPost} post, @returns {HTMLElement} */ 184 185 function buildParentLink(post) { 186 let p = $tag('p.back'); ··· 188 if (post instanceof BlockedPost) { 189 let element = new PostComponent(post, 'parent').buildElement(); 190 element.className = 'back'; 191 + let span = $(element.querySelector('p.blocked-header span')); 192 + span.innerText = 'Parent post blocked'; 193 return element; 194 } else if (post instanceof MissingPost) { 195 p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`; ··· 226 } 227 228 function showSearch() { 229 + let search = $id('search'); 230 + let searchField = $(search.querySelector('input[type=text]')); 231 + 232 + search.style.visibility = 'visible'; 233 + searchField.focus(); 234 } 235 236 function hideSearch() { ··· 274 /** @param {string} buttonName */ 275 276 function showMenuButton(buttonName) { 277 + let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`)); 278 + let item = $(button.parentNode); 279 + item.style.display = 'list-item'; 280 } 281 282 /** @param {string} buttonName */ 283 284 function hideMenuButton(buttonName) { 285 + let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`)); 286 + let item = $(button.parentNode); 287 + item.style.display = 'none'; 288 } 289 290 /** @param {string} buttonName, @param {boolean} state */ 291 292 function toggleMenuButton(buttonName, state) { 293 + let button = $(accountMenu.querySelector(`a[data-action=${buttonName}]`)); 294 + let check = $(button.querySelector('.check')); 295 + check.style.display = (state) ? 'inline' : 'none'; 296 } 297 298 /** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */ ··· 301 let account = $id('account'); 302 303 if (loggedIn === true && avatar) { 304 + let button = $(account.querySelector('i')); 305 306 let img = $tag('img.avatar', { src: avatar }); 307 img.style.display = 'none'; ··· 324 } 325 326 function submitLogin() { 327 + let handle = $id('login_handle', HTMLInputElement); 328 + let password = $id('login_password', HTMLInputElement); 329 let submit = $id('login_submit'); 330 let cloudy = $id('cloudy'); 331 ··· 407 } 408 409 function submitSearch() { 410 + let search = $id('search'); 411 + let searchField = $(search.querySelector('input[name=q]'), HTMLInputElement); 412 + let url = searchField.value.trim(); 413 414 if (!url) { return } 415 ··· 710 }); 711 } 712 713 + /** @param {Post} post, @param {HTMLElement} nodeToUpdate */ 714 715 function loadSubtree(post, nodeToUpdate) { 716 api.loadThreadByAtURI(post.uri).then(json => { ··· 723 }).catch(showError); 724 } 725 726 + /** @param {Post} post, @param {HTMLElement} nodeToUpdate */ 727 728 function loadHiddenSubtree(post, nodeToUpdate) { 729 + let content = $(nodeToUpdate.querySelector('.content')); 730 + let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies')); 731 732 blueAPI.getReplies(post.uri).then(replies => { 733 let missingReplies = replies.filter(r => !post.replies.some(x => x.uri === r));
+10 -25
types.d.ts
··· 11 declare var api: BlueskyAPI; 12 declare var isIncognito: boolean; 13 declare var biohazardEnabled: boolean; 14 - declare var loginDialog: AnyElement; 15 - declare var accountMenu: AnyElement; 16 declare var avatarPreloader: IntersectionObserver; 17 18 - type SomeElement = Element | HTMLElement | AnyElement; 19 type json = Record<string, any>; 20 21 - interface AnyElement { 22 - classList: CSSClassList; 23 - className: string; 24 - innerText: string; 25 - innerHTML: string; 26 - nextElementSibling: AnyElement; 27 - parentNode: AnyElement; 28 - src: string; 29 - style: CSSStyleDeclaration; 30 31 - addEventListener<K extends keyof DocumentEventMap>( 32 - type: K, listener: EventListenerOrEventListenerObject 33 - ): void; 34 35 - append(...e: Array<string | SomeElement>): void; 36 - appendChild(e: SomeElement): void; 37 - closest(q: string): AnyElement; 38 - querySelector(q: string): AnyElement; 39 - querySelectorAll(q: string): AnyElement[]; 40 - prepend(...e: Array<string | SomeElement>): void; 41 - remove(): void; 42 - replaceChildren(e: SomeElement): void; 43 - replaceWith(e: SomeElement): void; 44 - }
··· 11 declare var api: BlueskyAPI; 12 declare var isIncognito: boolean; 13 declare var biohazardEnabled: boolean; 14 + declare var loginDialog: HTMLElement; 15 + declare var accountMenu: HTMLElement; 16 declare var avatarPreloader: IntersectionObserver; 17 18 type json = Record<string, any>; 19 20 + function $tag(tag: string): HTMLElement; 21 + function $tag<T>(tag: string, type: new (...args: any[]) => T): T; 22 + function $tag(tag: string, params: string | object): HTMLElement; 23 + function $tag<T>(tag: string, params: string | object, type: new (...args: any[]) => T): T; 24 25 + function $id(id: string): HTMLElement; 26 + function $id<T>(id: string, type: new (...args: any[]) => T): T; 27 28 + function $(element: Node | EventTarget | null): HTMLElement; 29 + function $<T>(element: Node | EventTarget | null, type: new (...args: any[]) => T): T;
+21 -6
utils.js
··· 17 } 18 } 19 20 - /** @param {string} tag, @param {string | object} [params], @returns {any} */ 21 22 - function $tag(tag, params) { 23 let element; 24 let parts = tag.split('.'); 25 ··· 45 } 46 } 47 48 - return element; 49 } 50 51 - /** @param {string} name, @returns {any} */ 52 53 - function $id(name) { 54 - return document.getElementById(name); 55 } 56 57 /** @param {string} uri, @returns {AtURI} */
··· 17 } 18 } 19 20 + /** 21 + * @template T 22 + * @param {string} tag 23 + * @param {string | object} params 24 + * @param {new (...args: any[]) => T} type 25 + * @returns {T} 26 + */ 27 28 + function $tag(tag, params, type) { 29 let element; 30 let parts = tag.split('.'); 31 ··· 51 } 52 } 53 54 + return /** @type {T} */ (element); 55 + } 56 + 57 + function $id(name, type) { 58 + return (document.getElementById(name)); 59 } 60 61 + /** 62 + * @template T 63 + * @param {Node | EventTarget | null} element 64 + * @param {new (...args: any[]) => T} type 65 + * @returns {T} 66 + */ 67 68 + function $(element, type) { 69 + return /** @type {T} */ (element); 70 } 71 72 /** @param {string} uri, @returns {AtURI} */