Thread viewer for Bluesky
1/** 2 * Renders a post/thread view and its subviews. 3 */ 4 5class PostComponent { 6 /** 7 * Post component's root HTML element, if built. 8 * @type {HTMLElement | undefined} 9 */ 10 _rootElement; 11 12 /** 13 Contexts: 14 - thread - a post in the thread tree 15 - parent - parent reference above the thread root 16 - quote - a quote embed 17 - quotes - a post on the quotes page 18 - feed - a post on the hashtag feed page 19 20 @typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext 21 @param {AnyPost} post, @param {PostContext} context 22 */ 23 constructor(post, context) { 24 this.post = /** @type {Post}, TODO */ (post); 25 this.context = context; 26 } 27 28 /** 29 * @returns {HTMLElement} 30 */ 31 get rootElement() { 32 if (!this._rootElement) { 33 throw new Error("rootElement not initialized"); 34 } 35 36 return this._rootElement; 37 } 38 39 /** @returns {boolean} */ 40 get isRoot() { 41 return this.post.isRoot; 42 } 43 44 /** @returns {string} */ 45 get linkToAuthor() { 46 if (this.post.author.handle != 'handle.invalid') { 47 return 'https://bsky.app/profile/' + this.post.author.handle; 48 } else { 49 return 'https://bsky.app/profile/' + this.post.author.did; 50 } 51 } 52 53 /** @returns {string} */ 54 get linkToPost() { 55 return this.linkToAuthor + '/post/' + this.post.rkey; 56 } 57 58 /** @returns {string} */ 59 get didLinkToAuthor() { 60 let { repo } = atURI(this.post.uri); 61 return `https://bsky.app/profile/${repo}`; 62 } 63 64 /** @returns {string} */ 65 get didLinkToPost() { 66 let { repo, rkey } = atURI(this.post.uri); 67 return `https://bsky.app/profile/${repo}/post/${rkey}`; 68 } 69 70 /** @returns {string} */ 71 get authorName() { 72 if (this.post.author.displayName) { 73 return this.post.author.displayName; 74 } else if (this.post.author.handle.endsWith('.bsky.social')) { 75 return this.post.author.handle.replace(/\.bsky\.social$/, ''); 76 } else { 77 return this.post.author.handle; 78 } 79 } 80 81 /** @returns {json} */ 82 get timeFormatForTimestamp() { 83 if (this.context == 'quotes' || this.context == 'feed') { 84 return { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; 85 } else if (this.isRoot || this.context != 'thread') { 86 return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; 87 } else if (this.post.pageRoot && !sameDay(this.post.createdAt, this.post.pageRoot.createdAt)) { 88 return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' }; 89 } else { 90 return { hour: 'numeric', minute: 'numeric' }; 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; 109 } 110 111 let div = $tag('div.post', `post-${this.context}`); 112 this._rootElement = div; 113 114 if (this.post.muted) { 115 div.classList.add('muted'); 116 } 117 118 if (this.post instanceof BlockedPost) { 119 this.buildBlockedPostElement(div); 120 return div; 121 } else if (this.post instanceof DetachedQuotePost) { 122 this.buildDetachedQuoteElement(div); 123 return div; 124 } else if (this.post instanceof MissingPost) { 125 this.buildMissingPostElement(div); 126 return div; 127 } 128 129 let header = this.buildPostHeader(); 130 div.appendChild(header); 131 132 let content = $tag('div.content'); 133 134 if (this.context == 'thread' && !this.isRoot) { 135 let edgeMargin = this.buildEdgeMargin(); 136 div.appendChild(edgeMargin); 137 } 138 139 let wrapper; 140 141 if (this.post.muted) { 142 let details = $tag('details'); 143 144 let summary = $tag('summary'); 145 summary.innerText = this.post.muteList ? `Muted (${this.post.muteList})` : 'Muted - click to show'; 146 details.appendChild(summary); 147 148 content.appendChild(details); 149 wrapper = details; 150 } else { 151 wrapper = content; 152 } 153 154 let p = this.buildPostBody(); 155 wrapper.appendChild(p); 156 157 if (this.post.tags) { 158 let tagsRow = this.buildTagsRow(this.post.tags); 159 wrapper.appendChild(tagsRow); 160 } 161 162 if (this.post.embed) { 163 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 164 wrapper.appendChild(embed); 165 } 166 167 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) { 168 let stats = this.buildStatsFooter(); 169 wrapper.appendChild(stats); 170 } 171 172 if (this.post.replyCount == 1 && this.post.replies[0]?.author?.did == this.post.author.did) { 173 let component = new PostComponent(this.post.replies[0], 'thread'); 174 let element = component.buildElement(); 175 element.classList.add('flat'); 176 content.appendChild(element); 177 } else { 178 for (let reply of this.post.replies) { 179 if (reply instanceof MissingPost) { continue } 180 if (reply instanceof BlockedPost && window.biohazardEnabled === false) { continue } 181 182 let component = new PostComponent(reply, 'thread'); 183 content.appendChild(component.buildElement()); 184 } 185 } 186 187 if (this.context == 'thread') { 188 if (this.post.hasMoreReplies) { 189 let loadMore = this.buildLoadMoreLink(); 190 content.appendChild(loadMore); 191 } else if (this.post.hasHiddenReplies && window.biohazardEnabled !== false) { 192 let loadMore = this.buildHiddenRepliesLink(); 193 content.appendChild(loadMore); 194 } 195 } 196 197 div.appendChild(content); 198 199 return div; 200 } 201 202 /** @returns {HTMLElement} */ 203 204 buildPostHeader() { 205 let timeFormat = this.timeFormatForTimestamp; 206 let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat); 207 let isoTime = this.post.createdAt.toISOString(); 208 209 let h = $tag('h2'); 210 211 h.innerHTML = `${escapeHTML(this.authorName)} `; 212 213 if (this.post.isFediPost) { 214 let handle = `@${this.post.authorFediHandle}`; 215 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> ` + 216 `<img src="icons/mastodon.svg" class="mastodon"> `; 217 } else { 218 let handle = (this.post.author.handle != 'handle.invalid') ? `@${this.post.author.handle}` : '[invalid handle]'; 219 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> `; 220 } 221 222 h.innerHTML += `<span class="separator">&bull;</span> ` + 223 `<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `; 224 225 if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(this.context)) { 226 h.innerHTML += 227 `<span class="separator">&bull;</span> ` + 228 `<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` + 229 `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a> `; 230 } 231 232 if (this.post.muted) { 233 h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x')); 234 } else if (this.post.author.avatar) { 235 h.prepend(this.buildUserAvatar(this.post.author.avatar)); 236 } else { 237 h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x')); 238 } 239 240 return h; 241 } 242 243 buildEdgeMargin() { 244 let div = $tag('div.margin'); 245 246 let edge = $tag('div.edge'); 247 let line = $tag('div.line'); 248 edge.appendChild(line); 249 div.appendChild(edge); 250 251 let plus = $tag('img.plus', { src: 'icons/subtract-square.png' }); 252 div.appendChild(plus); 253 254 [edge, plus].forEach(x => { 255 x.addEventListener('click', (e) => { 256 e.preventDefault(); 257 this.toggleSectionFold(); 258 }); 259 }); 260 261 return div; 262 } 263 264 /** @param {string} url, @returns {HTMLImageElement} */ 265 266 buildUserAvatar(url) { 267 let avatar = $tag('img.avatar', { loading: 'lazy' }, HTMLImageElement); // needs to be set before src! 268 avatar.src = url; 269 window.avatarPreloader.observe(avatar); 270 return avatar; 271 } 272 273 /** @returns {HTMLElement} */ 274 275 buildPostBody() { 276 if (this.post.originalFediContent) { 277 return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) }); 278 } 279 280 let p = $tag('p.body'); 281 let richText = new RichText({ text: this.post.text, facets: this.post.facets }); 282 283 for (let seg of richText.segments()) { 284 if (seg.mention) { 285 p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text })); 286 } else if (seg.link) { 287 p.append($tag('a', { href: seg.link.uri, text: seg.text })); 288 } else if (seg.tag) { 289 let url = new URL(getLocation()); 290 url.searchParams.set('hash', seg.tag.tag); 291 p.append($tag('a', { href: url.toString(), text: seg.text })); 292 } else if (seg.text.includes("\n")) { 293 let span = $tag('span', { text: seg.text }); 294 span.innerHTML = span.innerHTML.replaceAll("\n", "<br>"); 295 p.append(span); 296 } else { 297 p.append(seg.text); 298 } 299 } 300 301 return p; 302 } 303 304 /** @param {string[]} terms */ 305 306 highlightSearchResults(terms) { 307 let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi'); 308 309 let body = this._rootElement.querySelector(':scope > .content > .body'); 310 let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); 311 let textNodes = []; 312 313 while (walker.nextNode()) { 314 textNodes.push(walker.currentNode); 315 } 316 317 for (let node of textNodes) { 318 let markedText = document.createDocumentFragment(); 319 let currentPosition = 0; 320 321 for (;;) { 322 let match = regexp.exec(node.textContent); 323 if (match === null) break; 324 325 if (match.index > currentPosition) { 326 let earlierText = node.textContent.slice(currentPosition, match.index); 327 markedText.appendChild(document.createTextNode(earlierText)); 328 } 329 330 let span = $tag('span.highlight', { text: match[0] }); 331 markedText.appendChild(span); 332 333 currentPosition = match.index + match[0].length; 334 } 335 336 if (currentPosition < node.textContent.length) { 337 let remainingText = node.textContent.slice(currentPosition); 338 markedText.appendChild(document.createTextNode(remainingText)); 339 } 340 341 node.parentNode.replaceChild(markedText, node); 342 } 343 } 344 345 /** @param {string[]} tags, @returns {HTMLElement} */ 346 347 buildTagsRow(tags) { 348 let p = $tag('p.tags'); 349 350 for (let tag of tags) { 351 let url = new URL(getLocation()); 352 url.searchParams.set('hash', tag); 353 354 let tagLink = $tag('a', { href: url.toString(), text: '# ' + tag }); 355 p.append(tagLink); 356 } 357 358 return p; 359 } 360 361 /** @returns {HTMLElement} */ 362 363 buildStatsFooter() { 364 let stats = $tag('p.stats'); 365 366 let span = $tag('span'); 367 let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : '')); 368 heart.addEventListener('click', (e) => this.onHeartClick(heart)); 369 370 span.append(heart, ' ', $tag('output', { text: this.post.likeCount })); 371 stats.append(span); 372 373 if (this.post.repostCount > 0) { 374 let span = $tag('span', { html: `<i class="fa-solid fa-retweet"></i> ${this.post.repostCount}` }); 375 stats.append(span); 376 } 377 378 if (this.post.replyCount > 0 && (this.context == 'quotes' || this.context == 'feed')) { 379 let pluralizedCount = (this.post.replyCount > 1) ? `${this.post.replyCount} replies` : '1 reply'; 380 let span = $tag('span', { 381 html: `<i class="fa-regular fa-message"></i> <a href="${linkToPostThread(this.post)}">${pluralizedCount}</a>` 382 }); 383 stats.append(span); 384 } 385 386 if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) { 387 let expanded = this.context == 'quotes' || this.context == 'feed'; 388 let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, expanded); 389 stats.append(quotesLink); 390 } 391 392 if (this.context == 'thread' && this.post.isRestrictingReplies) { 393 let span = $tag('span', { html: `<i class="fa-solid fa-ban"></i> Limited replies` }); 394 stats.append(span); 395 } 396 397 return stats; 398 } 399 400 /** @param {number} count, @param {boolean} expanded, @returns {HTMLElement} */ 401 402 buildQuotesIconLink(count, expanded) { 403 let q = new URL(getLocation()); 404 q.searchParams.set('quotes', this.linkToPost); 405 406 let url = q.toString(); 407 let icon = `<i class="fa-regular fa-comments"></i>`; 408 409 if (expanded) { 410 let span = $tag('span', { html: `${icon} ` }); 411 let link = $tag('a', { text: (count > 1) ? `${count} quotes` : '1 quote', href: url }); 412 span.append(link); 413 return span; 414 } else { 415 return $tag('a', { html: `${icon} ${count}`, href: url }); 416 } 417 } 418 419 /** @param {number} quoteCount, @param {boolean} expanded */ 420 421 appendQuotesIconLink(quoteCount, expanded) { 422 let stats = $(this.rootElement.querySelector(':scope > .content > p.stats')); 423 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded); 424 stats.append(quotesLink); 425 } 426 427 /** @returns {HTMLElement} */ 428 429 buildLoadMoreLink() { 430 let loadMore = $tag('p'); 431 432 let link = $tag('a', { 433 href: linkToPostThread(this.post), 434 text: "Load more replies…" 435 }); 436 437 link.addEventListener('click', (e) => { 438 e.preventDefault(); 439 loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`; 440 this.loadSubtree(this.post, this.rootElement); 441 }); 442 443 loadMore.appendChild(link); 444 return loadMore; 445 } 446 447 /** @returns {HTMLElement} */ 448 449 buildHiddenRepliesLink() { 450 let loadMore = $tag('p.hidden-replies'); 451 452 let link = $tag('a', { 453 href: linkToPostThread(this.post), 454 text: "Load hidden replies…" 455 }); 456 457 link.addEventListener('click', (e) => { 458 e.preventDefault(); 459 460 if (window.biohazardEnabled === true) { 461 this.loadHiddenReplies(loadMore); 462 } else { 463 window.loadInfohazard = () => this.loadHiddenReplies(loadMore); 464 showDialog($id('biohazard_dialog')); 465 } 466 }); 467 468 loadMore.append("☣️ ", link); 469 return loadMore; 470 } 471 472 /** @param {HTMLElement} loadMoreButton */ 473 474 loadHiddenReplies(loadMoreButton) { 475 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; 476 this.loadHiddenSubtree(this.post, this.rootElement); 477 } 478 479 /** @param {HTMLLinkElement} authorLink */ 480 481 loadReferencedPostAuthor(authorLink) { 482 let did = atURI(this.post.uri).repo; 483 484 api.fetchHandleForDid(did).then(handle => { 485 if (this.post.author) { 486 this.post.author.handle = handle; 487 } else { 488 this.post.author = { did, handle }; 489 } 490 491 authorLink.href = this.linkToAuthor; 492 authorLink.innerText = `@${handle}`; 493 }); 494 } 495 496 /** @param {HTMLElement} div, @returns {HTMLElement} */ 497 498 buildBlockedPostElement(div) { 499 let p = $tag('p.blocked-header'); 500 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span>`; 501 502 if (window.biohazardEnabled === false) { 503 div.appendChild(p); 504 div.classList.add('blocked'); 505 return p; 506 } 507 508 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : ''; 509 blockStatus = blockStatus ? `, ${blockStatus}` : ''; 510 511 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 512 p.append(' (', authorLink, blockStatus, ') '); 513 div.appendChild(p); 514 515 this.loadReferencedPostAuthor(authorLink); 516 517 let loadPost = $tag('p.load-post'); 518 let a = $tag('a', { href: '#', text: "Load post…" }); 519 520 a.addEventListener('click', (e) => { 521 e.preventDefault(); 522 loadPost.innerHTML = '&nbsp;'; 523 this.loadBlockedPost(this.post.uri, div); 524 }); 525 526 loadPost.appendChild(a); 527 div.appendChild(loadPost); 528 div.classList.add('blocked'); 529 return div; 530 } 531 532 /** @param {HTMLElement} div, @returns {HTMLElement} */ 533 534 buildDetachedQuoteElement(div) { 535 let p = $tag('p.blocked-header'); 536 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Hidden quote</span>`; 537 538 if (window.biohazardEnabled === false) { 539 div.appendChild(p); 540 div.classList.add('blocked'); 541 return p; 542 } 543 544 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 545 p.append(' (', authorLink, ') '); 546 div.appendChild(p); 547 548 this.loadReferencedPostAuthor(authorLink); 549 550 let loadPost = $tag('p.load-post'); 551 let a = $tag('a', { href: '#', text: "Load post…" }); 552 553 a.addEventListener('click', (e) => { 554 e.preventDefault(); 555 loadPost.innerHTML = '&nbsp;'; 556 this.loadBlockedPost(this.post.uri, div); 557 }); 558 559 loadPost.appendChild(a); 560 div.appendChild(loadPost); 561 div.classList.add('blocked'); 562 return div; 563 } 564 565 /** @param {HTMLElement} div, @returns {HTMLElement} */ 566 567 buildMissingPostElement(div) { 568 let p = $tag('p.blocked-header'); 569 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`; 570 571 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement); 572 p.append(' (', authorLink, ') '); 573 574 this.loadReferencedPostAuthor(authorLink); 575 576 div.appendChild(p); 577 div.classList.add('blocked'); 578 return div; 579 } 580 581 /** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */ 582 583 async loadBlockedPost(uri, div) { 584 let record = await appView.loadPostIfExists(this.post.uri); 585 586 if (!record) { 587 let post = new MissingPost({ uri: this.post.uri }); 588 let postView = new PostComponent(post, 'quote').buildElement(); 589 div.replaceWith(postView); 590 return; 591 } 592 593 this.post = new Post(record); 594 595 let userView = await api.getRequest('app.bsky.actor.getProfile', { actor: this.post.author.did }); 596 597 if (!userView.viewer || !(userView.viewer.blockedBy || userView.viewer.blocking)) { 598 let { repo, rkey } = atURI(this.post.uri); 599 600 let a = $tag('a', { 601 href: linkToPostById(repo, rkey), 602 className: 'action', 603 title: "Load thread", 604 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>` 605 }); 606 607 let header = $(div.querySelector('p.blocked-header')); 608 let separator = $tag('span.separator', { html: '&bull;' }); 609 header.append(separator, ' ', a); 610 } 611 612 let loadPost = $(div.querySelector('p.load-post')); 613 loadPost.remove(); 614 615 if (this.isRoot && this.post.parentReference) { 616 let { repo, rkey } = atURI(this.post.parentReference.uri); 617 let url = linkToPostById(repo, rkey); 618 619 let handle = api.findHandleByDid(repo); 620 let link = handle ? `See parent post (@${handle})` : "See parent post"; 621 622 let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` }); 623 div.appendChild(p); 624 } 625 626 let p = this.buildPostBody(); 627 div.appendChild(p); 628 629 if (this.post.embed) { 630 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 631 div.appendChild(embed); 632 633 // TODO 634 Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove()); 635 } 636 } 637 638 /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */ 639 640 async loadSubtree(post, nodeToUpdate) { 641 try { 642 let json = await api.loadThreadByAtURI(post.uri); 643 644 let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel); 645 post.updateDataFromPost(root); 646 window.subtreeRoot = post; 647 648 let component = new PostComponent(post, 'thread'); 649 component.installIntoElement(nodeToUpdate); 650 } catch (error) { 651 showError(error); 652 } 653 } 654 655 656 /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */ 657 658 async loadHiddenSubtree(post, nodeToUpdate) { 659 let content = $(nodeToUpdate.querySelector('.content')); 660 let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies')); 661 662 try { 663 var expectedReplyURIs = await blueAPI.getReplies(post.uri); 664 } catch (error) { 665 hiddenRepliesDiv.remove(); 666 667 if (error instanceof APIError && error.code == 404) { 668 let info = $tag('p.missing-replies-info', { 669 html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)` 670 }); 671 content.append(info); 672 } else { 673 setTimeout(() => showError(error), 1); 674 } 675 676 return; 677 } 678 679 let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r)); 680 let promises = missingReplyURIs.map(uri => api.loadThreadByAtURI(uri)); 681 682 try { 683 // TODO 684 var responses = await Promise.allSettled(promises); 685 } catch (error) { 686 hiddenRepliesDiv.remove(); 687 setTimeout(() => showError(error), 1); 688 return; 689 } 690 691 let replies = responses 692 .map(r => r.status == 'fulfilled' ? r.value : undefined) 693 .filter(v => v) 694 .map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1)); 695 696 post.setReplies(replies); 697 hiddenRepliesDiv.remove(); 698 699 for (let reply of post.replies) { 700 let component = new PostComponent(reply, 'thread'); 701 let view = component.buildElement(); 702 content.append(view); 703 } 704 705 if (replies.length < responses.length) { 706 let notFoundCount = responses.length - replies.length; 707 let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is'; 708 709 let info = $tag('p.missing-replies-info', { 710 html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)` 711 }); 712 content.append(info); 713 } 714 } 715 716 /** @returns {boolean} */ 717 isCollapsed() { 718 return this.rootElement.classList.contains('collapsed'); 719 } 720 721 toggleSectionFold() { 722 let plus = $(this.rootElement.querySelector(':scope > .margin .plus'), HTMLImageElement); 723 724 if (this.isCollapsed()) { 725 this.rootElement.classList.remove('collapsed'); 726 plus.src = 'icons/subtract-square.png' 727 } else { 728 this.rootElement.classList.add('collapsed'); 729 plus.src = 'icons/add-square.png' 730 } 731 } 732 733 /** @param {HTMLElement} heart, @returns {Promise<void>} */ 734 735 async onHeartClick(heart) { 736 try { 737 if (!this.post.hasViewerInfo) { 738 if (accountAPI.isLoggedIn) { 739 let data = await this.loadViewerInfo(); 740 741 if (data) { 742 if (this.post.liked) { 743 heart.classList.add('liked'); 744 return; 745 } else { 746 // continue down 747 } 748 } else { 749 this.showPostAsBlocked(); 750 return; 751 } 752 } else { 753 // not logged in 754 showDialog(loginDialog); 755 return; 756 } 757 } 758 759 let countField = $(heart.nextElementSibling); 760 let likeCount = parseInt(countField.innerText, 10); 761 762 if (!heart.classList.contains('liked')) { 763 let like = await accountAPI.likePost(this.post); 764 this.post.viewerLike = like.uri; 765 heart.classList.add('liked'); 766 countField.innerText = String(likeCount + 1); 767 } else { 768 await accountAPI.removeLike(this.post.viewerLike); 769 this.post.viewerLike = undefined; 770 heart.classList.remove('liked'); 771 countField.innerText = String(likeCount - 1); 772 } 773 } catch (error) { 774 showError(error); 775 } 776 } 777 778 showPostAsBlocked() { 779 let stats = $(this.rootElement.querySelector(':scope > .content > p.stats')); 780 781 if (!stats.querySelector('.blocked-info')) { 782 let span = $tag('span.blocked-info', { text: '🚫 Post unavailable' }); 783 stats.append(span); 784 } 785 } 786 787 /** @returns {Promise<json | undefined>} */ 788 789 async loadViewerInfo() { 790 let data = await accountAPI.loadPostIfExists(this.post.uri); 791 792 if (data) { 793 this.post.author = data.author; 794 this.post.viewerData = data.viewer; 795 this.post.viewerLike = data.viewer?.like; 796 } 797 798 return data; 799 } 800}