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 {AnyElement | 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 {AnyElement} 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 {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; 106 } 107 108 let div = $tag('div.post', `post-${this.context}`); 109 this._rootElement = div; 110 111 if (this.post.muted) { 112 div.classList.add('muted'); 113 } 114 115 if (this.post instanceof BlockedPost) { 116 this.buildBlockedPostElement(div); 117 return div; 118 } else if (this.post instanceof DetachedQuotePost) { 119 this.buildDetachedQuoteElement(div); 120 return div; 121 } else if (this.post instanceof MissingPost) { 122 this.buildMissingPostElement(div); 123 return div; 124 } 125 126 let header = this.buildPostHeader(); 127 div.appendChild(header); 128 129 let content = $tag('div.content'); 130 131 if (this.context == 'thread' && !this.isRoot) { 132 let edgeMargin = this.buildEdgeMargin(); 133 div.appendChild(edgeMargin); 134 } 135 136 let wrapper; 137 138 if (this.post.muted) { 139 let details = $tag('details'); 140 141 let summary = $tag('summary'); 142 summary.innerText = this.post.muteList ? `Muted (${this.post.muteList})` : 'Muted - click to show'; 143 details.appendChild(summary); 144 145 content.appendChild(details); 146 wrapper = details; 147 } else { 148 wrapper = content; 149 } 150 151 let p = this.buildPostBody(); 152 wrapper.appendChild(p); 153 154 if (this.post.tags) { 155 let tagsRow = this.buildTagsRow(this.post.tags); 156 wrapper.appendChild(tagsRow); 157 } 158 159 if (this.post.embed) { 160 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 161 wrapper.appendChild(embed); 162 } 163 164 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) { 165 let stats = this.buildStatsFooter(); 166 wrapper.appendChild(stats); 167 } 168 169 if (this.post.replyCount == 1 && this.post.replies[0]?.author?.did == this.post.author.did) { 170 let component = new PostComponent(this.post.replies[0], 'thread'); 171 let element = component.buildElement(); 172 element.classList.add('flat'); 173 content.appendChild(element); 174 } else { 175 for (let reply of this.post.replies) { 176 if (reply instanceof MissingPost) { continue } 177 if (reply instanceof BlockedPost && window.biohazardEnabled === false) { continue } 178 179 let component = new PostComponent(reply, 'thread'); 180 content.appendChild(component.buildElement()); 181 } 182 } 183 184 if (this.context == 'thread') { 185 if (this.post.hasMoreReplies) { 186 let loadMore = this.buildLoadMoreLink(); 187 content.appendChild(loadMore); 188 } else if (this.post.hasHiddenReplies && window.biohazardEnabled !== false) { 189 let loadMore = this.buildHiddenRepliesLink(); 190 content.appendChild(loadMore); 191 } 192 } 193 194 div.appendChild(content); 195 196 return div; 197 } 198 199 /** @returns {AnyElement} */ 200 201 buildPostHeader() { 202 let timeFormat = this.timeFormatForTimestamp; 203 let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat); 204 let isoTime = this.post.createdAt.toISOString(); 205 206 let h = $tag('h2'); 207 208 h.innerHTML = `${escapeHTML(this.authorName)} `; 209 210 if (this.post.isFediPost) { 211 let handle = this.post.authorFediHandle; 212 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${handle}</a> ` + 213 `<img src="icons/mastodon.svg" class="mastodon"> `; 214 } else { 215 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${this.post.author.handle}</a> `; 216 } 217 218 h.innerHTML += `<span class="separator">&bull;</span> ` + 219 `<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `; 220 221 if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(this.context)) { 222 h.innerHTML += 223 `<span class="separator">&bull;</span> ` + 224 `<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` + 225 `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a> `; 226 } 227 228 if (this.post.muted) { 229 h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x')); 230 } else if (this.post.author.avatar) { 231 h.prepend(this.buildUserAvatar(this.post.author.avatar)); 232 } else { 233 h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x')); 234 } 235 236 return h; 237 } 238 239 buildEdgeMargin() { 240 let div = $tag('div.margin'); 241 242 let edge = $tag('div.edge'); 243 let line = $tag('div.line'); 244 edge.appendChild(line); 245 div.appendChild(edge); 246 247 let plus = $tag('img.plus', { src: 'icons/subtract-square.png' }); 248 div.appendChild(plus); 249 250 [edge, plus].forEach(x => { 251 x.addEventListener('click', (e) => { 252 e.preventDefault(); 253 this.toggleSectionFold(); 254 }); 255 }); 256 257 return div; 258 } 259 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) { 273 return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) }); 274 } 275 276 let p = $tag('p.body'); 277 let richText = new RichText({ text: this.post.text, facets: this.post.facets }); 278 279 for (let seg of richText.segments()) { 280 if (seg.mention) { 281 p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text })); 282 } else if (seg.link) { 283 p.append($tag('a', { href: seg.link.uri, text: seg.text })); 284 } else if (seg.tag) { 285 let url = new URL(getLocation()); 286 url.searchParams.set('hash', seg.tag.tag); 287 p.append($tag('a', { href: url.toString(), text: seg.text })); 288 } else if (seg.text.includes("\n")) { 289 let span = $tag('span', { text: seg.text }); 290 span.innerHTML = span.innerHTML.replaceAll("\n", "<br>"); 291 p.append(span); 292 } else { 293 p.append(seg.text); 294 } 295 } 296 297 return p; 298 } 299 300 /** @param {string[]} tags, @returns {AnyElement} */ 301 302 buildTagsRow(tags) { 303 let p = $tag('p.tags'); 304 305 for (let tag of tags) { 306 let url = new URL(getLocation()); 307 url.searchParams.set('hash', tag); 308 309 let tagLink = $tag('a', { href: url.toString(), text: '# ' + tag }); 310 p.append(tagLink); 311 } 312 313 return p; 314 } 315 316 /** @returns {AnyElement} */ 317 318 buildStatsFooter() { 319 let stats = $tag('p.stats'); 320 321 let span = $tag('span'); 322 let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : '')); 323 heart.addEventListener('click', (e) => this.onHeartClick(heart)); 324 325 span.append(heart, ' ', $tag('output', { text: this.post.likeCount })); 326 stats.append(span); 327 328 if (this.post.repostCount > 0) { 329 let span = $tag('span', { html: `<i class="fa-solid fa-retweet"></i> ${this.post.repostCount}` }); 330 stats.append(span); 331 } 332 333 if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) { 334 let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, false); 335 stats.append(quotesLink); 336 } 337 338 return stats; 339 } 340 341 /** @param {number} count, @param {boolean} expanded, @returns {AnyElement} */ 342 343 buildQuotesIconLink(count, expanded) { 344 let q = new URL(getLocation()); 345 q.searchParams.set('quotes', this.linkToPost); 346 347 let url = q.toString(); 348 let icon = `<i class="fa-regular ${count > 1 ? 'fa-comments' : 'fa-comment'}"></i>`; 349 350 if (expanded) { 351 let span = $tag('span', { html: `${icon} ` }); 352 let link = $tag('a', { text: (count > 1) ? `${count} quotes` : '1 quote', href: url }); 353 span.append(link); 354 return span; 355 } else { 356 return $tag('a', { html: `${icon} ${count}`, href: url }); 357 } 358 } 359 360 /** @param {number} quoteCount, @param {boolean} expanded */ 361 362 appendQuotesIconLink(quoteCount, expanded) { 363 let stats = this.rootElement.querySelector(':scope > .content > p.stats'); 364 let quotesLink = this.buildQuotesIconLink(quoteCount, expanded); 365 stats.append(quotesLink); 366 } 367 368 /** @returns {AnyElement} */ 369 370 buildLoadMoreLink() { 371 let loadMore = $tag('p'); 372 373 let link = $tag('a', { 374 href: linkToPostThread(this.post), 375 text: "Load more replies…" 376 }); 377 378 link.addEventListener('click', (e) => { 379 e.preventDefault(); 380 loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`; 381 loadSubtree(this.post, this.rootElement); 382 }); 383 384 loadMore.appendChild(link); 385 return loadMore; 386 } 387 388 /** @returns {AnyElement} */ 389 390 buildHiddenRepliesLink() { 391 let loadMore = $tag('p.hidden-replies'); 392 393 let link = $tag('a', { 394 href: linkToPostThread(this.post), 395 text: "Load hidden replies…" 396 }); 397 398 link.addEventListener('click', (e) => { 399 e.preventDefault(); 400 401 if (window.biohazardEnabled === true) { 402 this.loadHiddenReplies(loadMore); 403 } else { 404 window.loadInfohazard = () => this.loadHiddenReplies(loadMore); 405 showDialog($id('biohazard_dialog')); 406 } 407 }); 408 409 loadMore.append("☣️ ", link); 410 return loadMore; 411 } 412 413 /** @param {HTMLLinkElement} loadMoreButton */ 414 415 loadHiddenReplies(loadMoreButton) { 416 loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; 417 loadHiddenSubtree(this.post, this.rootElement); 418 } 419 420 /** @param {HTMLLinkElement} authorLink */ 421 422 loadReferencedPostAuthor(authorLink) { 423 let did = atURI(this.post.uri).repo; 424 425 api.fetchHandleForDid(did).then(handle => { 426 if (this.post.author) { 427 this.post.author.handle = handle; 428 } else { 429 this.post.author = { did, handle }; 430 } 431 432 authorLink.href = this.linkToAuthor; 433 authorLink.innerText = `@${handle}`; 434 }); 435 } 436 437 /** @param {AnyElement} div, @returns {AnyElement} */ 438 439 buildBlockedPostElement(div) { 440 let p = $tag('p.blocked-header'); 441 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span>`; 442 443 if (window.biohazardEnabled === false) { 444 div.appendChild(p); 445 div.classList.add('blocked'); 446 return p; 447 } 448 449 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : ''; 450 blockStatus = blockStatus ? `, ${blockStatus}` : ''; 451 452 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 453 p.append(' (', authorLink, blockStatus, ') '); 454 div.appendChild(p); 455 456 this.loadReferencedPostAuthor(authorLink); 457 458 let loadPost = $tag('p.load-post'); 459 let a = $tag('a', { href: '#', text: "Load post…" }); 460 461 a.addEventListener('click', (e) => { 462 e.preventDefault(); 463 loadPost.innerHTML = '&nbsp;'; 464 this.loadBlockedPost(this.post.uri, div); 465 }); 466 467 loadPost.appendChild(a); 468 div.appendChild(loadPost); 469 div.classList.add('blocked'); 470 return div; 471 } 472 473 /** @param {AnyElement} div, @returns {AnyElement} */ 474 475 buildDetachedQuoteElement(div) { 476 let p = $tag('p.blocked-header'); 477 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Hidden quote</span>`; 478 479 if (window.biohazardEnabled === false) { 480 div.appendChild(p); 481 div.classList.add('blocked'); 482 return p; 483 } 484 485 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 486 p.append(' (', authorLink, ') '); 487 div.appendChild(p); 488 489 this.loadReferencedPostAuthor(authorLink); 490 491 let loadPost = $tag('p.load-post'); 492 let a = $tag('a', { href: '#', text: "Load post…" }); 493 494 a.addEventListener('click', (e) => { 495 e.preventDefault(); 496 loadPost.innerHTML = '&nbsp;'; 497 this.loadBlockedPost(this.post.uri, div); 498 }); 499 500 loadPost.appendChild(a); 501 div.appendChild(loadPost); 502 div.classList.add('blocked'); 503 return div; 504 } 505 506 /** @param {AnyElement} div, @returns {AnyElement} */ 507 508 buildMissingPostElement(div) { 509 let p = $tag('p.blocked-header'); 510 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`; 511 512 let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }); 513 p.append(' (', authorLink, ') '); 514 515 this.loadReferencedPostAuthor(authorLink); 516 517 div.appendChild(p); 518 div.classList.add('blocked'); 519 return div; 520 } 521 522 /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */ 523 524 async loadBlockedPost(uri, div) { 525 let record = await appView.loadPostIfExists(this.post.uri); 526 527 if (!record) { 528 let post = new MissingPost({ uri: this.post.uri }); 529 let postView = new PostComponent(post, 'quote').buildElement(); 530 div.replaceWith(postView); 531 return; 532 } 533 534 this.post = new Post(record); 535 536 let userView = await api.getRequest('app.bsky.actor.getProfile', { actor: this.post.author.did }); 537 538 if (!userView.viewer || !(userView.viewer.blockedBy || userView.viewer.blocking)) { 539 let { repo, rkey } = atURI(this.post.uri); 540 541 let a = $tag('a', { 542 href: linkToPostById(repo, rkey), 543 className: 'action', 544 title: "Load thread", 545 html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>` 546 }); 547 548 let header = div.querySelector('p.blocked-header'); 549 let separator = $tag('span.separator', { html: '&bull;' }); 550 header.append(separator, ' ', a); 551 } 552 553 div.querySelector('p.load-post').remove(); 554 555 if (this.isRoot && this.post.parentReference) { 556 let { repo, rkey } = atURI(this.post.parentReference.uri); 557 let url = linkToPostById(repo, rkey); 558 559 let handle = api.findHandleByDid(repo); 560 let link = handle ? `See parent post (@${handle})` : "See parent post"; 561 562 let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` }); 563 div.appendChild(p); 564 } 565 566 let p = this.buildPostBody(); 567 div.appendChild(p); 568 569 if (this.post.embed) { 570 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 571 div.appendChild(embed); 572 573 // TODO 574 Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove()); 575 } 576 } 577 578 /** @returns {boolean} */ 579 isCollapsed() { 580 return this.rootElement.classList.contains('collapsed'); 581 } 582 583 toggleSectionFold() { 584 let plus = this.rootElement.querySelector(':scope > .margin .plus'); 585 586 if (this.isCollapsed()) { 587 this.rootElement.classList.remove('collapsed'); 588 plus.src = 'icons/subtract-square.png' 589 } else { 590 this.rootElement.classList.add('collapsed'); 591 plus.src = 'icons/add-square.png' 592 } 593 } 594 595 /** @param {AnyElement} heart */ 596 597 onHeartClick(heart) { 598 if (!this.post.hasViewerInfo) { 599 if (accountAPI.isLoggedIn) { 600 accountAPI.loadPostIfExists(this.post.uri).then(data => { 601 if (data) { 602 this.post = new Post(data); 603 604 if (this.post.liked) { 605 heart.classList.add('liked'); 606 } else { 607 this.onHeartClick(heart); 608 } 609 } else { 610 alert("Sorry, this post is blocked or was deleted."); 611 } 612 }).catch(error => { 613 alert(error); 614 }); 615 } else { 616 showDialog(loginDialog); 617 } 618 return; 619 } 620 621 let count = heart.nextElementSibling; 622 623 if (!heart.classList.contains('liked')) { 624 accountAPI.likePost(this.post).then((like) => { 625 this.post.viewerLike = like.uri; 626 heart.classList.add('liked'); 627 count.innerText = String(parseInt(count.innerText, 10) + 1); 628 }).catch(showError); 629 } else { 630 accountAPI.removeLike(this.post.viewerLike).then(() => { 631 this.post.viewerLike = undefined; 632 heart.classList.remove('liked'); 633 count.innerText = String(parseInt(count.innerText, 10) - 1); 634 }).catch(showError); 635 } 636 } 637}