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