Thread viewer for Bluesky

converted more replies/hidden replies loading to svelte

+180 -115
+31
src/api/api.js
··· 24 24 } 25 25 } 26 26 27 + export class HiddenRepliesError extends Error { 28 + 29 + /** @param {Error} error */ 30 + constructor(error) { 31 + super(error.message); 32 + this.originalError = error; 33 + } 34 + } 35 + 27 36 28 37 /** 29 38 * Caches the mapping of handles to DIDs to avoid unnecessary API calls to resolveHandle or getProfile. ··· 289 298 let postGroups = await Promise.all(batches); 290 299 291 300 return { cursor: response.cursor, posts: postGroups.flat() }; 301 + } 302 + 303 + /** @param {Post} post, @returns {Promise<(json | undefined)[]>} */ 304 + 305 + async loadHiddenReplies(post) { 306 + let expectedReplyURIs; 307 + 308 + try { 309 + expectedReplyURIs = await blueAPI.getReplies(post.uri); 310 + } catch (error) { 311 + if (error instanceof APIError && error.code == 404) { 312 + throw new HiddenRepliesError(error); 313 + } else { 314 + throw error; 315 + } 316 + } 317 + 318 + let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r)); 319 + let promises = missingReplyURIs.map(uri => this.loadThreadByAtURI(uri)); 320 + let responses = await Promise.allSettled(promises); 321 + 322 + return responses.map(r => (r.status == 'fulfilled') ? r.value : undefined); 292 323 } 293 324 294 325 /**
+43
src/components/posts/HiddenRepliesLink.svelte
··· 1 + <script> 2 + import { showBiohazardDialog } from '../../skythread.js'; 3 + import { account } from '../../models/account.svelte.js'; 4 + import { linkToPostThread } from '../../router.js'; 5 + import { getContext } from 'svelte'; 6 + 7 + let { onLoad, onError } = $props(); 8 + let { post } = getContext('post'); 9 + let loading = $state(false); 10 + 11 + function onLinkClick(e) { 12 + e.preventDefault(); 13 + 14 + if (account.biohazardEnabled === true) { 15 + loadHiddenReplies(); 16 + } else { 17 + showBiohazardDialog(() => { 18 + loadHiddenReplies(); 19 + }); 20 + } 21 + } 22 + 23 + async function loadHiddenReplies() { 24 + loading = true; 25 + 26 + try { 27 + let replies = await api.loadHiddenReplies(post); 28 + loading = false; 29 + onLoad(replies); 30 + } catch (error) { 31 + loading = false; 32 + onError(error); 33 + } 34 + } 35 + </script> 36 + 37 + <p class="hidden-replies"> 38 + {#if !loading} 39 + ☣️ <a href={linkToPostThread(post)} onclick={onLinkClick}>Load hidden replies…</a> 40 + {:else} 41 + <img class="loader" src="icons/sunny.png" alt="Loading..."> 42 + {/if} 43 + </p>
+36
src/components/posts/LoadMoreLink.svelte
··· 1 + <script> 2 + import { parseThreadPost } from '../../models/posts.js'; 3 + import { linkToPostThread } from '../../router.js'; 4 + import { showError } from '../../utils.js'; 5 + import { getContext } from 'svelte'; 6 + 7 + let { onLoad } = $props(); 8 + let { post } = getContext('post'); 9 + let loading = $state(false); 10 + 11 + async function onLinkClick(e) { 12 + e.preventDefault(); 13 + loading = true; 14 + 15 + try { 16 + let json = await api.loadThreadByAtURI(post.uri); 17 + let root = parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel); 18 + // props.replies = root.replies; 19 + 20 + loading = false; 21 + window.subtreeRoot = root; 22 + onLoad(root); 23 + } catch (error) { 24 + loading = false; 25 + showError(error); 26 + } 27 + } 28 + </script> 29 + 30 + <p> 31 + {#if !loading} 32 + <a href={linkToPostThread(post)} onclick={onLinkClick}>Load more replies…</a> 33 + {:else} 34 + <img class="loader" src="icons/sunny.png" alt="Loading..."> 35 + {/if} 36 + </p>
+70 -115
src/post_component.js
··· 4 4 import { Post, BlockedPost, MissingPost, DetachedQuotePost, parseThreadPost } from './models/posts.js'; 5 5 import { account } from './models/account.svelte.js'; 6 6 import { InlineLinkEmbed } from './models/embeds.js'; 7 - import { APIError } from './api/api.js'; 7 + import { APIError, HiddenRepliesError } from './api/api.js'; 8 8 import { linkToPostById, linkToPostThread } from './router.js'; 9 9 import { showBiohazardDialog } from './skythread.js'; 10 10 import { PostPresenter } from './utils/post_presenter.js'; ··· 13 13 import EdgeMargin from './components/posts/EdgeMargin.svelte'; 14 14 import EmbedComponent from './components/embeds/EmbedComponent.svelte'; 15 15 import FediSourceLink from './components/posts/FediSourceLink.svelte'; 16 + import HiddenRepliesLink from './components/posts/HiddenRepliesLink.svelte'; 17 + import LoadMoreLink from './components/posts/LoadMoreLink.svelte'; 16 18 import MissingPostView from './components/posts/MissingPostView.svelte'; 17 19 import PostBody from './components/posts/PostBody.svelte'; 18 20 import PostHeader from './components/posts/PostHeader.svelte'; ··· 154 156 155 157 if (this.context == 'thread') { 156 158 if (this.post.hasMoreReplies) { 157 - let loadMore = this.buildLoadMoreLink(); 158 - content.appendChild(loadMore); 159 + this.buildLoadMoreLink(content); 159 160 } else if (this.post.hasHiddenReplies && account.biohazardEnabled !== false) { 160 161 let loadMore = this.buildHiddenRepliesLink(); 161 162 content.appendChild(loadMore); ··· 307 308 stats.append(quotesLink);*/ 308 309 } 309 310 310 - /** @returns {HTMLElement} */ 311 + /** @param {HTMLElement} element */ 311 312 312 - buildLoadMoreLink() { 313 - let loadMore = $tag('p'); 313 + buildLoadMoreLink(element) { 314 + svelte.mount(LoadMoreLink, { 315 + target: element, 316 + context: new Map(Object.entries({ 317 + post: { 318 + post: this.post, 319 + context: this.context 320 + } 321 + })), 322 + props: { 323 + onLoad: (newPost) => { 324 + this.post.updateDataFromPost(newPost); 314 325 315 - let link = $tag('a', { 316 - href: linkToPostThread(this.post), 317 - text: "Load more replies…" 318 - }); 319 - 320 - link.addEventListener('click', (e) => { 321 - e.preventDefault(); 322 - loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`; 323 - this.loadSubtree(this.post, this.rootElement); 326 + let component = new PostComponent(this.post, 'thread'); 327 + component.installIntoElement(this.rootElement); 328 + } 329 + } 324 330 }); 325 - 326 - loadMore.appendChild(link); 327 - return loadMore; 328 331 } 329 332 330 333 /** @returns {HTMLElement} */ 331 334 332 335 buildHiddenRepliesLink() { 333 - let loadMore = $tag('p.hidden-replies'); 336 + let hiddenRepliesDiv = $tag('div'); 337 + 338 + svelte.mount(HiddenRepliesLink, { 339 + target: hiddenRepliesDiv, 340 + context: new Map(Object.entries({ 341 + post: { 342 + post: this.post, 343 + context: this.context 344 + } 345 + })), 346 + props: { 347 + onLoad: (repliesData) => { 348 + let content = $(this.rootElement.querySelector('.content')); 334 349 335 - let link = $tag('a', { 336 - href: linkToPostThread(this.post), 337 - text: "Load hidden replies…" 338 - }); 350 + let replies = repliesData 351 + .filter(v => v) 352 + .map(json => parseThreadPost(json.thread, this.post.pageRoot, 1, this.post.absoluteLevel + 1)); 353 + 354 + this.post.setReplies(this.post.replies.concat(replies)); 355 + hiddenRepliesDiv.remove(); 339 356 340 - link.addEventListener('click', (e) => { 341 - e.preventDefault(); 357 + for (let reply of replies) { 358 + let component = new PostComponent(reply, 'thread'); 359 + let view = component.buildElement(); 360 + content.append(view); 361 + } 342 362 343 - if (account.biohazardEnabled === true) { 344 - this.loadHiddenReplies(loadMore); 345 - } else { 346 - showBiohazardDialog(() => this.loadHiddenReplies(loadMore)); 363 + if (replies.length < repliesData.length) { 364 + let notFoundCount = repliesData.length - replies.length; 365 + let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is'; 366 + 367 + let info = $tag('p.missing-replies-info', { 368 + html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)` 369 + }); 370 + content.append(info); 371 + } 372 + }, 373 + 374 + onError: (error) => { 375 + hiddenRepliesDiv.remove(); 376 + 377 + if (error instanceof HiddenRepliesError) { 378 + let content = $(this.rootElement.querySelector('.content')); 379 + let info = $tag('p.missing-replies-info', { 380 + html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)` 381 + }); 382 + content.append(info); 383 + } else { 384 + setTimeout(() => showError(error), 1); 385 + } 386 + } 347 387 } 348 388 }); 349 389 350 - loadMore.append("☣️ ", link); 351 - return loadMore; 352 - } 353 - 354 - /** @param {HTMLElement} loadMoreButton */ 355 - 356 - loadHiddenReplies(loadMoreButton) { 357 - loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`; 358 - this.loadHiddenSubtree(this.post, this.rootElement); 390 + return hiddenRepliesDiv; 359 391 } 360 392 361 393 /** @param {string} url, @param {HTMLElement} div */ ··· 490 522 491 523 // TODO 492 524 Array.from(div.querySelectorAll('a.link-card')).forEach(x => x.remove()); 493 - } 494 - } 495 - 496 - /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */ 497 - 498 - async loadSubtree(post, nodeToUpdate) { 499 - try { 500 - let json = await api.loadThreadByAtURI(post.uri); 501 - 502 - let root = parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel); 503 - post.updateDataFromPost(root); 504 - window.subtreeRoot = post; 505 - 506 - let component = new PostComponent(post, 'thread'); 507 - component.installIntoElement(nodeToUpdate); 508 - } catch (error) { 509 - showError(error); 510 - } 511 - } 512 - 513 - /** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */ 514 - 515 - async loadHiddenSubtree(post, nodeToUpdate) { 516 - let content = $(nodeToUpdate.querySelector('.content')); 517 - let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies')); 518 - 519 - try { 520 - var expectedReplyURIs = await blueAPI.getReplies(post.uri); 521 - } catch (error) { 522 - hiddenRepliesDiv.remove(); 523 - 524 - if (error instanceof APIError && error.code == 404) { 525 - let info = $tag('p.missing-replies-info', { 526 - html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)` 527 - }); 528 - content.append(info); 529 - } else { 530 - setTimeout(() => showError(error), 1); 531 - } 532 - 533 - return; 534 - } 535 - 536 - let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r)); 537 - let promises = missingReplyURIs.map(uri => api.loadThreadByAtURI(uri)); 538 - 539 - try { 540 - // TODO 541 - var responses = await Promise.allSettled(promises); 542 - } catch (error) { 543 - hiddenRepliesDiv.remove(); 544 - setTimeout(() => showError(error), 1); 545 - return; 546 - } 547 - 548 - let replies = responses 549 - .map(r => r.status == 'fulfilled' ? r.value : undefined) 550 - .filter(v => v) 551 - .map(json => parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1)); 552 - 553 - post.setReplies(replies); 554 - hiddenRepliesDiv.remove(); 555 - 556 - for (let reply of post.replies) { 557 - let component = new PostComponent(reply, 'thread'); 558 - let view = component.buildElement(); 559 - content.append(view); 560 - } 561 - 562 - if (replies.length < responses.length) { 563 - let notFoundCount = responses.length - replies.length; 564 - let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is'; 565 - 566 - let info = $tag('p.missing-replies-info', { 567 - html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)` 568 - }); 569 - content.append(info); 570 525 } 571 526 } 572 527 }