Thread viewer for Bluesky
at mastodon 478 lines 14 kB view raw
1/** 2 * Renders a post/thread view and its subviews. 3 */ 4 5class PostComponent { 6 /** @param {Post} post, @param {Post} [root] */ 7 constructor(post, root) { 8 this.post = post; 9 this.root = root ?? post; 10 this.isRoot = (this.post === this.root); 11 } 12 13 /** @returns {string} */ 14 get linkToAuthor() { 15 return 'https://bsky.app/profile/' + this.post.author.handle; 16 } 17 18 /** @returns {string} */ 19 get linkToPost() { 20 return this.linkToAuthor + '/post/' + this.post.rkey; 21 } 22 23 /** @returns {string} */ 24 get didLinkToAuthor() { 25 let { repo } = atURI(this.post.uri); 26 return `https://bsky.app/profile/${repo}`; 27 } 28 29 /** @returns {string} */ 30 get didLinkToPost() { 31 let { repo, rkey } = atURI(this.post.uri); 32 return `https://bsky.app/profile/${repo}/post/${rkey}`; 33 } 34 35 /** @returns {string} */ 36 get authorName() { 37 if (this.post.author.displayName) { 38 return this.post.author.displayName; 39 } else if (this.post.author.handle.endsWith('.bsky.social')) { 40 return this.post.author.handle.replace(/\.bsky\.social$/, ''); 41 } else { 42 return this.post.author.handle; 43 } 44 } 45 46 /** @returns {json} */ 47 get timeFormatForTimestamp() { 48 if (this.isRoot) { 49 return { day: 'numeric', month: 'short', year: 'numeric', hour: 'numeric', minute: 'numeric' }; 50 } else if (!sameDay(this.post.createdAt, this.root.createdAt)) { 51 return { day: 'numeric', month: 'short', hour: 'numeric', minute: 'numeric' }; 52 } else { 53 return { hour: 'numeric', minute: 'numeric' }; 54 } 55 } 56 57 /** 58 Contexts: 59 - thread - a post in the thread tree 60 - parent - parent reference above the thread root 61 - quote - a quote embed 62 - quotes - a post on the quotes page 63 - feed - a post on the hashtag feed page 64 65 @typedef {'thread' | 'parent' | 'quote' | 'quotes' | 'feed'} PostContext 66 @param {PostContext} context 67 @returns {AnyElement} 68 */ 69 70 buildElement(context) { 71 let div = $tag('div.post'); 72 73 if (this.post.muted) { 74 div.classList.add('muted'); 75 } 76 77 if (this.post instanceof BlockedPost) { 78 this.buildBlockedPostElement(div); 79 return div; 80 } else if (this.post instanceof MissingPost) { 81 this.buildMissingPostElement(div); 82 return div; 83 } 84 85 let header = this.buildPostHeader(context); 86 div.appendChild(header); 87 88 let content = $tag('div.content'); 89 90 if (!this.isRoot) { 91 let edge = $tag('div.edge'); 92 let line = $tag('div.line'); 93 edge.appendChild(line); 94 div.appendChild(edge); 95 96 let plus = $tag('img.plus', { src: 'icons/subtract-square.png' }); 97 div.appendChild(plus); 98 99 [edge, plus].forEach(x => { 100 x.addEventListener('click', (e) => { 101 e.preventDefault(); 102 this.toggleSectionFold(div); 103 }); 104 }); 105 } 106 107 let wrapper; 108 109 if (this.post.muted) { 110 let details = $tag('details'); 111 112 let summary = $tag('summary'); 113 summary.innerText = this.post.muteList ? `Muted (${this.post.muteList})` : 'Muted - click to show'; 114 details.appendChild(summary); 115 116 content.appendChild(details); 117 wrapper = details; 118 } else { 119 wrapper = content; 120 } 121 122 let p = this.buildPostBody(); 123 wrapper.appendChild(p); 124 125 if (this.post.embed) { 126 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 127 wrapper.appendChild(embed); 128 } 129 130 if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) { 131 let stats = this.buildStatsFooter(); 132 wrapper.appendChild(stats); 133 } 134 135 if (this.post.replies.length == 1 && this.post.replies[0].author?.did == this.post.author.did) { 136 let component = new PostComponent(this.post.replies[0], this.root); 137 let element = component.buildElement('thread'); 138 element.classList.add('flat'); 139 content.appendChild(element); 140 } else { 141 for (let reply of this.post.replies) { 142 if (reply instanceof MissingPost) { continue } 143 144 let component = new PostComponent(reply, this.root); 145 content.appendChild(component.buildElement('thread')); 146 } 147 } 148 149 if (context == 'thread' && this.post.hasMoreReplies) { 150 let loadMore = this.buildLoadMoreLink() 151 content.appendChild(loadMore); 152 } 153 154 div.appendChild(content); 155 156 return div; 157 } 158 159 /** @param {PostContext} context, @returns {AnyElement} */ 160 161 buildPostHeader(context) { 162 let timeFormat = this.timeFormatForTimestamp; 163 let formattedTime = this.post.createdAt.toLocaleString(window.dateLocale, timeFormat); 164 let isoTime = this.post.createdAt.toISOString(); 165 166 let h = $tag('h2'); 167 168 h.innerHTML = `${escapeHTML(this.authorName)} `; 169 170 if (this.post.isFediPost) { 171 let handle = this.post.authorFediHandle; 172 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${handle}</a> ` + 173 `<img src="icons/mastodon.svg" class="mastodon"> `; 174 } else { 175 h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${this.post.author.handle}</a> `; 176 } 177 178 h.innerHTML += `<span class="separator">&bull;</span> ` + 179 `<a class="time" href="${this.linkToPost}" target="_blank" title="${isoTime}">${formattedTime}</a> `; 180 181 if (this.post.replyCount > 0 && !this.isRoot || ['quote', 'quotes', 'feed'].includes(context)) { 182 h.innerHTML += 183 `<span class="separator">&bull;</span> ` + 184 `<a href="${linkToPostThread(this.post)}" class="action" title="Load this subtree">` + 185 `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i></a> `; 186 } 187 188 if (this.post.muted) { 189 h.prepend($tag('i', 'missing fa-regular fa-circle-user fa-2x')); 190 } else if (this.post.author.avatar) { 191 h.prepend(this.buildUserAvatar(this.post.author.avatar)); 192 } else { 193 h.prepend($tag('i', 'missing fa-regular fa-face-smile fa-2x')); 194 } 195 196 return h; 197 } 198 199 /** @param {string} url, @returns {HTMLImageElement} */ 200 201 buildUserAvatar(url) { 202 let avatar = $tag('img.avatar', { src: url }); 203 let tries = 0; 204 205 let errorHandler = function(e) { 206 if (tries < 3) { 207 tries++; 208 setTimeout(() => { avatar.src = url }, Math.random() * 5 * tries); 209 } else { 210 avatar.removeEventListener('error', errorHandler); 211 } 212 }; 213 214 avatar.addEventListener('error', errorHandler); 215 return avatar; 216 } 217 218 /** @returns {AnyElement} */ 219 220 buildPostBody() { 221 if (this.post.originalFediContent) { 222 return $tag('div.body', { html: sanitizeHTML(this.post.originalFediContent) }); 223 } 224 225 let p = $tag('p.body'); 226 let richText = new RichText({ text: this.post.text, facets: this.post.facets }); 227 228 for (let seg of richText.segments()) { 229 if (seg.mention) { 230 p.append($tag('a', { href: `https://bsky.app/profile/${seg.mention.did}`, text: seg.text })); 231 } else if (seg.link) { 232 p.append($tag('a', { href: seg.link.uri, text: seg.text })); 233 } else if (seg.tag) { 234 let url = new URL(getLocation()); 235 url.searchParams.set('hash', seg.tag.tag); 236 p.append($tag('a', { href: url.toString(), text: seg.text })); 237 } else if (seg.text.includes("\n")) { 238 let span = $tag('span', { text: seg.text }); 239 span.innerHTML = span.innerHTML.replaceAll("\n", "<br>"); 240 p.append(span); 241 } else { 242 p.append(seg.text); 243 } 244 } 245 246 if (this.post.isTruncatedFediPost) { 247 if (this.post.embed && ('url' in this.post.embed) && typeof this.post.embed.url == 'string') { 248 let link = this.buildLoadFediPostLink(this.post.embed.url, p); 249 p.append(' ', link); 250 } 251 } 252 253 return p; 254 } 255 256 /** @returns {AnyElement} */ 257 258 buildStatsFooter() { 259 let stats = $tag('p.stats'); 260 261 let span = $tag('span'); 262 let heart = $tag('i', 'fa-solid fa-heart ' + (this.post.liked ? 'liked' : '')); 263 heart.addEventListener('click', (e) => this.onHeartClick(heart)); 264 265 span.append(heart, ' ', $tag('output', { text: this.post.likeCount })); 266 stats.append(span); 267 268 if (this.post.repostCount > 0) { 269 let span = $tag('span', { html: `<i class="fa-solid fa-retweet"></i> ${this.post.repostCount}` }); 270 stats.append(span); 271 } 272 273 return stats; 274 } 275 276 /** @param {string} originalURL, @param {HTMLElement} p, @returns {AnyElement} */ 277 278 buildLoadFediPostLink(originalURL, p) { 279 let link = $tag('a', { 280 href: originalURL, 281 text: "(Load full post)" 282 }); 283 284 link.addEventListener('click', (e) => { 285 e.preventDefault(); 286 link.remove(); 287 288 this.loadFediPost(originalURL, p); 289 }); 290 291 return link; 292 } 293 294 /** @returns {AnyElement} */ 295 296 buildLoadMoreLink() { 297 let loadMore = $tag('p'); 298 299 let link = $tag('a', { 300 href: linkToPostThread(this.post), 301 text: "Load more replies…" 302 }); 303 304 link.addEventListener('click', (e) => { 305 e.preventDefault(); 306 link.innerHTML = `<img class="loader" src="icons/sunny.png">`; 307 308 if (this.post.mastodonURL) { 309 loadMastodonThread(this.post.mastodonURL, loadMore.parentNode.parentNode); 310 } else { 311 loadThread(this.post.author.handle, this.post.rkey, loadMore.parentNode.parentNode); 312 } 313 }); 314 315 loadMore.appendChild(link); 316 return loadMore; 317 } 318 319 /** @param {AnyElement} div, @returns {AnyElement} */ 320 321 buildBlockedPostElement(div) { 322 let p = $tag('p.blocked-header'); 323 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Blocked post</span> ` + 324 `(<a href="${this.didLinkToAuthor}" target="_blank">see author</a>) `; 325 div.appendChild(p); 326 327 let authorLink = p.querySelector('a'); 328 let did = atURI(this.post.uri).repo; 329 let cachedHandle = api.findHandleByDid(did); 330 let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : ''; 331 332 if (cachedHandle) { 333 this.post.author.handle = cachedHandle; 334 authorLink.href = this.linkToAuthor; 335 authorLink.innerText = `@${cachedHandle}`; 336 if (blockStatus) { 337 authorLink.after(`, ${blockStatus}`); 338 } 339 } else { 340 api.loadUserProfile(did).then((author) => { 341 this.post.author = author; 342 authorLink.href = this.linkToAuthor; 343 authorLink.innerText = `@${author.handle}`; 344 if (blockStatus) { 345 authorLink.after(`, ${blockStatus}`); 346 } 347 }); 348 } 349 350 let loadPost = $tag('p.load-post'); 351 let a = $tag('a', { href: '#', text: "Load post…" }); 352 353 a.addEventListener('click', (e) => { 354 e.preventDefault(); 355 loadPost.innerHTML = '&nbsp;'; 356 this.loadBlockedPost(this.post.uri, div); 357 }); 358 359 loadPost.appendChild(a); 360 div.appendChild(loadPost); 361 div.classList.add('blocked'); 362 return div; 363 } 364 365 /** @param {AnyElement} div, @returns {AnyElement} */ 366 367 buildMissingPostElement(div) { 368 let p = $tag('p.blocked-header'); 369 p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`; 370 div.appendChild(p); 371 div.classList.add('blocked'); 372 return div; 373 } 374 375 /** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */ 376 377 async loadBlockedPost(uri, div) { 378 let record = await appView.loadPost(this.post.uri); 379 this.post = new Post(record); 380 381 div.querySelector('p.load-post').remove(); 382 383 if (this.isRoot && this.post.parentReference) { 384 let { repo, rkey } = atURI(this.post.parentReference.uri); 385 let url = linkToPostById(repo, rkey); 386 387 let handle = api.findHandleByDid(repo); 388 let link = handle ? `See parent post (@${handle})` : "See parent post"; 389 390 let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` }); 391 div.appendChild(p); 392 } 393 394 let p = this.buildPostBody(); 395 div.appendChild(p); 396 397 if (this.post.embed) { 398 let embed = new EmbedComponent(this.post, this.post.embed).buildElement(); 399 div.appendChild(embed); 400 } 401 } 402 403 /** @param {string} url, @param {HTMLElement} p, @returns Promise<void> */ 404 405 async loadFediPost(url, p) { 406 let host = new URL(url).host; 407 let postId = url.replace(/\/$/, '').split('/').reverse()[0]; 408 let statusURL = `https://${host}/api/v1/statuses/${postId}`; 409 410 let response = await fetch(statusURL); 411 let json = await response.json(); 412 413 if (json.content) { 414 let div = $tag('div.body', { html: sanitizeHTML(json.content) }); 415 p.replaceWith(div); 416 } 417 } 418 419 /** @param {AnyElement} div */ 420 421 toggleSectionFold(div) { 422 let plus = div.querySelector('.plus'); 423 424 if (div.classList.contains('collapsed')) { 425 div.classList.remove('collapsed'); 426 plus.src = 'icons/subtract-square.png' 427 } else { 428 div.classList.add('collapsed'); 429 plus.src = 'icons/add-square.png' 430 } 431 } 432 433 /** @param {AnyElement} heart */ 434 435 onHeartClick(heart) { 436 if (!this.post.hasViewerInfo) { 437 if (accountAPI.isLoggedIn) { 438 accountAPI.loadPost(this.post.uri).then(data => { 439 this.post = new Post(data); 440 441 if (this.post.liked) { 442 heart.classList.add('liked'); 443 } else { 444 this.onHeartClick(heart); 445 } 446 }).catch(error => { 447 console.log(error); 448 alert("Sorry, this post is blocked."); 449 }); 450 } else { 451 showLogin(); 452 } 453 return; 454 } 455 456 let count = heart.nextElementSibling; 457 458 if (!heart.classList.contains('liked')) { 459 accountAPI.likePost(this.post).then((like) => { 460 this.post.viewerLike = like.uri; 461 heart.classList.add('liked'); 462 count.innerText = String(parseInt(count.innerText, 10) + 1); 463 }).catch((error) => { 464 console.log(error); 465 alert(error); 466 }); 467 } else { 468 accountAPI.removeLike(this.post.viewerLike).then(() => { 469 this.post.viewerLike = undefined; 470 heart.classList.remove('liked'); 471 count.innerText = String(parseInt(count.innerText, 10) - 1); 472 }).catch((error) => { 473 console.log(error); 474 alert(error); 475 }); 476 } 477 } 478}