[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Handle blocked post threads

Grace Kind (May 24, 2026, 3:28 PM -0500) 159bd2b2 07bd61a9

+83 -21
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.14.97", 3 + "version": "0.14.98", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf build && NODE_ENV=development eleventy --serve",
+3 -2
src/js/dataLayer/selectors.js
··· 12 12 replaceBlockedQuote, 13 13 createEmbedFromPost, 14 14 isBlockedPost, 15 + isEmptyPost, 15 16 isPostView, 16 17 getQuotedPost, 17 18 getLastInteractionTimestamp, ··· 104 105 if (!postThread || !postThreadOther) { 105 106 return null; 106 107 } 107 - if (isBlockedPost(postThread) && isBlockingUser(postThread)) { 108 + if (isEmptyPost(postThread)) { 108 109 return postThread; 109 110 } 110 111 const hiddenReplyUris = postThreadOther.map((item) => item.uri); ··· 121 122 } 122 123 123 124 hydratePostThread(postThread, hiddenReplyUris) { 124 - if (isBlockedPost(postThread) && isBlockingUser(postThread)) { 125 + if (isEmptyPost(postThread)) { 125 126 return postThread; 126 127 } 127 128 const hydratedPostThread = {
+1 -1
src/js/templates/blockedPost.template.js
··· 2 2 import { infoIconTemplate } from "/js/templates/icons/infoIcon.template.js"; 3 3 4 4 export function blockedPostTemplate() { 5 - return html`<div class="post small-post"> 5 + return html`<div class="post small-post" data-testid="post-tombstone-blocked"> 6 6 <div class="missing-post-indicator">${infoIconTemplate()} Blocked</div> 7 7 </div> `; 8 8 }
+4 -1
src/js/templates/notFoundPost.template.js
··· 2 2 import { trashCanIconTemplate } from "/js/templates/icons/trashCanIcon.template.js"; 3 3 4 4 export function notFoundPostTemplate() { 5 - return html`<div class="post small-post"> 5 + return html`<div 6 + class="post small-post" 7 + data-testid="post-tombstone-not-found" 8 + > 6 9 <div class="missing-post-indicator"> 7 10 ${trashCanIconTemplate()} Post not found 8 11 </div>
+4 -1
src/js/templates/unavailablePost.template.js
··· 2 2 import { infoIconTemplate } from "/js/templates/icons/infoIcon.template.js"; 3 3 4 4 export function unavailablePostTemplate() { 5 - return html`<div class="post small-post"> 5 + return html`<div 6 + class="post small-post" 7 + data-testid="post-tombstone-unavailable" 8 + > 6 9 <div class="missing-post-indicator"> 7 10 ${infoIconTemplate()} Post unavailable 8 11 </div>
+13 -15
src/js/views/postThread.view.js
··· 12 12 isBlockedPost, 13 13 isNotFoundPost, 14 14 isUnavailablePost, 15 + isEmptyPost, 15 16 isMutedPost, 16 17 getReplyRootFromPost, 17 18 doHideAuthorOnUnauthenticated, ··· 353 354 354 355 function threadTemplate({ postThread, currentUser }) { 355 356 try { 357 + const mainPost = isEmptyPost(postThread) ? postThread : postThread.post; 356 358 const parents = flattenParents(postThread); 357 359 // A post might still have a parent even if it isn't loaded by the appview - 358 360 // this happens if the client has malformed reply refs. 359 - const replyParent = postThread.post?.record?.reply?.parent; 361 + const replyParent = mainPost?.record?.reply?.parent; 360 362 const hasParent = !!replyParent; 361 363 // Don't set this to true unless the full post thread has loaded 362 364 const hasBrokenReplyRef = 363 365 hasParent && !postThread.__isPrefill && parents.length === 0; 364 - const root = getReplyRootFromPost(postThread.post); 366 + const root = getReplyRootFromPost(mainPost); 365 367 const replies = postThread.replies; 366 - const postAuthor = postThread.post?.author; 368 + const postAuthor = mainPost?.author; 367 369 const hiddenUnauthenticated = 368 370 !isAuthenticated && 369 - postThread.post?.author && 370 - doHideAuthorOnUnauthenticated(postThread.post.author); 371 + mainPost?.author && 372 + doHideAuthorOnUnauthenticated(mainPost.author); 371 373 return html` 372 374 <div class="post-thread"> 373 375 <plugin-slot ··· 417 419 ${hiddenUnauthenticated 418 420 ? noUnauthenticatedLargePostTemplate() 419 421 : largePostTemplate({ 420 - post: postThread.post, 422 + post: mainPost, 421 423 currentUser, 422 424 isAuthenticated, 423 425 pluginService, 424 - isUserPost: currentUser?.did === postThread.post?.author?.did, 426 + isUserPost: currentUser?.did === mainPost?.author?.did, 425 427 postInteractionHandler, 426 428 afterHide: () => { 427 429 // if the main post is hidden, go back to the previous page ··· 432 434 router.back(); 433 435 }, 434 436 onClickReply: async () => { 435 - await handleClickReply(postThread.post, root, currentUser); 437 + await handleClickReply(mainPost, root, currentUser); 436 438 }, 437 439 replyContext: hasParent ? "reply" : null, 438 440 })} ··· 441 443 context-uri=${postUri} 442 444 .pluginService=${pluginService} 443 445 ></plugin-slot> 444 - ${isAuthenticated && currentUser && canReplyToPost(postThread.post) 446 + ${isAuthenticated && currentUser && canReplyToPost(mainPost) 445 447 ? html` 446 448 <div 447 449 class="post-thread-reply-prompt" 448 450 @click=${async () => { 449 - await handleClickReply( 450 - postThread.post, 451 - root, 452 - currentUser, 453 - ); 451 + await handleClickReply(mainPost, root, currentUser); 454 452 }} 455 453 > 456 454 <div class="post-thread-reply-prompt-inner"> ··· 476 474 currentUser, 477 475 }); 478 476 } 479 - const numReplies = postThread.post.replyCount; 477 + const numReplies = mainPost?.replyCount; 480 478 if (numReplies > 0) { 481 479 return repliesSkeletonTemplate({ numReplies }); 482 480 }
+57
tests/e2e/specs/views/postThread.view.test.js
··· 1735 1735 ); 1736 1736 }); 1737 1737 }); 1738 + 1739 + test.describe("Tombstone thread root", () => { 1740 + test("should render without crashing when thread root is a notFoundPost", async ({ 1741 + page, 1742 + }) => { 1743 + const mockServer = new MockServer(); 1744 + mockServer.setPostThread(postUri, { 1745 + $type: "app.bsky.feed.defs#notFoundPost", 1746 + uri: postUri, 1747 + }); 1748 + await mockServer.setup(page); 1749 + 1750 + await login(page); 1751 + await page.goto("/profile/did:plc:author1/post/abc123"); 1752 + 1753 + const view = page.locator("#post-detail-view"); 1754 + await expect( 1755 + view.locator('[data-testid="post-tombstone-not-found"]'), 1756 + ).toBeVisible({ timeout: 10000 }); 1757 + // The view should NOT fall into the generic thread-error template 1758 + await expect( 1759 + view.locator('[data-testid="thread-error"]'), 1760 + ).not.toBeAttached(); 1761 + // No reply prompt should be shown for a tombstone root 1762 + await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible(); 1763 + }); 1764 + 1765 + test("should render without crashing when thread root is a blockedPost", async ({ 1766 + page, 1767 + }) => { 1768 + const mockServer = new MockServer(); 1769 + mockServer.setPostThread(postUri, { 1770 + $type: "app.bsky.feed.defs#blockedPost", 1771 + uri: postUri, 1772 + author: { 1773 + did: "did:plc:blockedauthor", 1774 + viewer: { 1775 + blocking: 1776 + "at://did:plc:testuser123/app.bsky.graph.block/existing-block", 1777 + }, 1778 + }, 1779 + }); 1780 + await mockServer.setup(page); 1781 + 1782 + await login(page); 1783 + await page.goto("/profile/did:plc:author1/post/abc123"); 1784 + 1785 + const view = page.locator("#post-detail-view"); 1786 + await expect( 1787 + view.locator('[data-testid="post-tombstone-blocked"]'), 1788 + ).toBeVisible({ timeout: 10000 }); 1789 + await expect( 1790 + view.locator('[data-testid="thread-error"]'), 1791 + ).not.toBeAttached(); 1792 + await expect(view.locator(".post-thread-reply-prompt")).not.toBeVisible(); 1793 + }); 1794 + }); 1738 1795 });