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