[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.

Update behavior for current user blocks

Grace Kind (Jul 10, 2026, 7:48 PM -0500) 36147d02 b1f06bf5

+287 -22
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.17.125", 3 + "version": "0.17.126", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+6 -2
src/js/api.js
··· 461 461 return posts[0]; 462 462 } 463 463 464 - async getRepost(repostUri) { 465 - const { repo, rkey, collection } = parseUri(repostUri); 464 + async getRecord(uri) { 465 + const { repo, rkey, collection } = parseUri(uri); 466 466 const res = await this.request(`com.atproto.repo.getRecord`, { 467 467 query: { 468 468 repo, ··· 471 471 }, 472 472 }); 473 473 return res.data; 474 + } 475 + 476 + async getRepost(repostUri) { 477 + return this.getRecord(repostUri); 474 478 } 475 479 476 480 async getReposts(repostUris) {
+4
src/js/dataHelpers.js
··· 62 62 return blockedQuote.author.viewer?.blockedBy; 63 63 } 64 64 65 + export function isBlockedByViewer(blockedPost) { 66 + return !!blockedPost.author?.viewer?.blocking; 67 + } 68 + 65 69 export function getBlockedQuote(post) { 66 70 const quotedPost = getQuotedPost(post); 67 71 if (!quotedPost) {
+9 -2
src/js/dataLayer/derived.js
··· 16 16 isPostView, 17 17 getInteractionProfileDids, 18 18 getLastInteractionTimestamp, 19 + isBlockedByViewer, 19 20 isGroupConvo, 20 21 markBlockedQuoteNotFound, 21 22 replaceBlockedQuote, ··· 611 612 612 613 resolveBlockedQuote(post) { 613 614 const blockedQuote = getBlockedQuote(post); 614 - if (!blockedQuote || isBlockingUser(blockedQuote)) return post; 615 + if (!blockedQuote) return post; 616 + if (this.dataStore.$unavailablePosts.get(blockedQuote.uri)) { 617 + return markBlockedQuoteNotFound(post, blockedQuote.uri); 618 + } 619 + if (isBlockingUser(blockedQuote) || isBlockedByViewer(blockedQuote)) { 620 + return post; 621 + } 615 622 const fullBlockedPost = this.$hydratedPosts.get(blockedQuote.uri); 616 623 if (fullBlockedPost) { 617 624 const blockedQuoteEmbed = isEmptyPost(fullBlockedPost) ··· 619 626 : createEmbedFromPost(fullBlockedPost); 620 627 return replaceBlockedQuote(post, blockedQuoteEmbed); 621 628 } 622 - return markBlockedQuoteNotFound(post, blockedQuote.uri); 629 + return post; 623 630 } 624 631 625 632 // Attach parentAuthor to a post's reply record when its parent is loaded.
+28 -15
src/js/dataLayer/requests.js
··· 4 4 getQuotedPost, 5 5 getBlockedQuote, 6 6 isBlockingUser, 7 + isBlockedByViewer, 7 8 createUnavailablePost, 8 9 getPostUrisFromNotifications, 9 10 buildUri, ··· 74 75 return true; 75 76 } 76 77 77 - // Get URIs of blocked quotes from posts where the author has not blocked the viewer 78 + // Get URIs of blocked posts and blocked quotes referenced by the given posts 78 79 function getBlockedPostUris(posts) { 79 80 // Blocked "top-level" posts 80 - const blockedPosts = posts 81 - .filter((post) => post.$type === "app.bsky.feed.defs#blockedPost") 82 - .filter((blockedPost) => !isBlockingUser(blockedPost)); 81 + const blockedPosts = posts.filter( 82 + (post) => post.$type === "app.bsky.feed.defs#blockedPost", 83 + ); 83 84 // Blocked quoted posts 84 85 const blockedQuotes = posts 85 86 .map((post) => getBlockedQuote(post)) 86 - .filter(Boolean) 87 - .filter((blockedPost) => !isBlockingUser(blockedPost)); 87 + .filter(Boolean); 88 88 // Blocked nested quotes 89 89 // Note - this won't load blocked quotes of blocked quotes (edge case) 90 90 const blockedNestedQuotes = posts 91 91 .map((post) => getQuotedPost(post)) 92 92 .filter(Boolean) 93 93 .map((quotedPost) => getBlockedQuote(quotedPost)) 94 - .filter(Boolean) 95 - .filter((blockedPost) => !isBlockingUser(blockedPost)); 94 + .filter(Boolean); 96 95 97 96 return unique([...blockedPosts, ...blockedQuotes, ...blockedNestedQuotes], { 98 97 by: "uri", ··· 311 310 } 312 311 313 312 async _loadParentChain(blockedParent, { labelers = [], rootUri } = {}) { 314 - if (!rootUri || isBlockingUser(blockedParent)) { 313 + if ( 314 + !rootUri || 315 + isBlockingUser(blockedParent) || 316 + isBlockedByViewer(blockedParent) 317 + ) { 315 318 return await this.loadPostThread(blockedParent.uri, { 316 319 depth: 0, 317 320 labelers, ··· 337 340 338 341 while ( 339 342 currentBlocked?.$type === "app.bsky.feed.defs#blockedPost" && 340 - !isBlockingUser(currentBlocked) 343 + !isBlockingUser(currentBlocked) && 344 + !isBlockedByViewer(currentBlocked) 341 345 ) { 342 346 const authorDid = currentBlocked.author?.did; 343 347 if (!authorDid || loadedAuthorDids.has(authorDid)) break; ··· 527 531 labelers, 528 532 }); 529 533 this.dataStore.setPosts(fetchedBlockedPosts); 530 - // If any blocked posts are not found, create an unavailable post for them 531 - const notFoundPostUris = blockedPostUris.filter( 534 + // The appview omits posts from getPosts when a block exists in either 535 + // direction, so a missing post may still exist. Probe the raw record 536 + // (which block filtering doesn't apply to) and only mark posts as 537 + // unavailable when the record is confirmed gone. 538 + const missingPostUris = blockedPostUris.filter( 532 539 (uri) => !fetchedBlockedPosts.some((post) => post.uri === uri), 533 540 ); 534 - if (notFoundPostUris.length > 0) { 535 - for (const uri of notFoundPostUris) { 541 + const results = await Promise.allSettled( 542 + missingPostUris.map((uri) => this.api.getRecord(uri)), 543 + ); 544 + results.forEach((result, index) => { 545 + if (result.status === "fulfilled") return; 546 + const error = result.reason; 547 + if (error instanceof ApiError && error.data?.error === "RecordNotFound") { 548 + const uri = missingPostUris[index]; 536 549 this.dataStore.$unavailablePosts.set(uri, createUnavailablePost(uri)); 537 550 } 538 - } 551 + }); 539 552 } 540 553 541 554 async loadDetailedProfile(did) {
+5 -1
src/js/feedFilters.js
··· 1 1 import { 2 2 isBlockingUser, 3 + isBlockedByViewer, 3 4 getQuotedPost, 4 5 getBlockedQuote, 5 6 getReplyAuthors, ··· 168 169 filterFeedItems(feedItems) { 169 170 return feedItems.filter((item) => { 170 171 const blockedQuote = getBlockedQuote(item.post); 171 - if (blockedQuote && isBlockingUser(blockedQuote)) { 172 + if ( 173 + blockedQuote && 174 + (isBlockingUser(blockedQuote) || isBlockedByViewer(blockedQuote)) 175 + ) { 172 176 return false; 173 177 } 174 178 return true;
-1
src/js/templates/postEmbed.template.js
··· 615 615 isAuthenticated, 616 616 condensed, 617 617 }); 618 - // This only happens if the author is blocking the viewer 619 618 case "app.bsky.embed.record#viewBlocked": 620 619 return blockedQuoteTemplate(); 621 620 case "app.bsky.embed.record#viewDetached":
+132
tests/unit/specs/dataLayer/derived.test.js
··· 620 620 assertEquals(result.badgeLabels, ["b"]); 621 621 }); 622 622 623 + function makeBlockedQuotePost(viewerState) { 624 + return { 625 + uri: postURI, 626 + record: { text: "quoting post" }, 627 + embed: { 628 + $type: "app.bsky.embed.record#view", 629 + record: { 630 + $type: "app.bsky.embed.record#viewBlocked", 631 + uri: "at://did:blocked/app.bsky.feed.post/q", 632 + blocked: true, 633 + author: { did: "did:blocked", viewer: viewerState }, 634 + }, 635 + }, 636 + }; 637 + } 638 + 639 + it("should keep a viewer-blocked quote as blocked when the quoted post is not loaded", () => { 640 + const dataStore = new DataStore(); 641 + const { derived } = makeDerived(dataStore, { 642 + preferences: fakePreferences(), 643 + }); 644 + dataStore.$posts.set( 645 + postURI, 646 + makeBlockedQuotePost({ blocking: "at://did:me/app.bsky.graph.block/1" }), 647 + ); 648 + const result = derived.$hydratedPosts.get(postURI); 649 + assertEquals( 650 + result.embed.record.$type, 651 + "app.bsky.embed.record#viewBlocked", 652 + ); 653 + }); 654 + 655 + it("should mark a viewer-blocked quote as deleted when the post is confirmed unavailable", () => { 656 + const dataStore = new DataStore(); 657 + const { derived } = makeDerived(dataStore, { 658 + preferences: fakePreferences(), 659 + }); 660 + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; 661 + dataStore.$unavailablePosts.set(quotedUri, { 662 + $type: "social.impro.feed.defs#unavailablePost", 663 + uri: quotedUri, 664 + }); 665 + dataStore.$posts.set( 666 + postURI, 667 + makeBlockedQuotePost({ blocking: "at://did:me/app.bsky.graph.block/1" }), 668 + ); 669 + const result = derived.$hydratedPosts.get(postURI); 670 + assertEquals( 671 + result.embed.record.$type, 672 + "app.bsky.embed.record#viewNotFound", 673 + ); 674 + }); 675 + 676 + it("should mark a blocked-by quote as deleted when the post is confirmed unavailable", () => { 677 + const dataStore = new DataStore(); 678 + const { derived } = makeDerived(dataStore, { 679 + preferences: fakePreferences(), 680 + }); 681 + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; 682 + dataStore.$unavailablePosts.set(quotedUri, { 683 + $type: "social.impro.feed.defs#unavailablePost", 684 + uri: quotedUri, 685 + }); 686 + dataStore.$posts.set(postURI, makeBlockedQuotePost({ blockedBy: true })); 687 + const result = derived.$hydratedPosts.get(postURI); 688 + assertEquals( 689 + result.embed.record.$type, 690 + "app.bsky.embed.record#viewNotFound", 691 + ); 692 + }); 693 + 694 + it("should keep a viewer-blocked quote blocked even when the quoted post is loaded", () => { 695 + const dataStore = new DataStore(); 696 + const { derived } = makeDerived(dataStore, { 697 + preferences: fakePreferences(), 698 + }); 699 + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; 700 + dataStore.$posts.set(quotedUri, { 701 + uri: quotedUri, 702 + cid: "cid-q", 703 + author: { did: "did:blocked" }, 704 + record: { text: "the quoted text" }, 705 + }); 706 + dataStore.$posts.set( 707 + postURI, 708 + makeBlockedQuotePost({ blocking: "at://did:me/app.bsky.graph.block/1" }), 709 + ); 710 + const result = derived.$hydratedPosts.get(postURI); 711 + assertEquals( 712 + result.embed.record.$type, 713 + "app.bsky.embed.record#viewBlocked", 714 + ); 715 + }); 716 + 717 + it("should resolve a third-party-blocked quote when the quoted post is loaded", () => { 718 + const dataStore = new DataStore(); 719 + const { derived } = makeDerived(dataStore, { 720 + preferences: fakePreferences(), 721 + }); 722 + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; 723 + dataStore.$posts.set(quotedUri, { 724 + uri: quotedUri, 725 + cid: "cid-q", 726 + author: { did: "did:blocked" }, 727 + record: { text: "the quoted text" }, 728 + }); 729 + dataStore.$posts.set(postURI, makeBlockedQuotePost({})); 730 + const result = derived.$hydratedPosts.get(postURI); 731 + assertEquals(result.embed.record.$type, "app.bsky.embed.record#viewRecord"); 732 + assertEquals(result.embed.record.uri, quotedUri); 733 + }); 734 + 735 + it("should keep the quote blocked when the quoted author blocks the viewer", () => { 736 + const dataStore = new DataStore(); 737 + const { derived } = makeDerived(dataStore, { 738 + preferences: fakePreferences(), 739 + }); 740 + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; 741 + dataStore.$posts.set(quotedUri, { 742 + uri: quotedUri, 743 + cid: "cid-q", 744 + author: { did: "did:blocked" }, 745 + record: { text: "the quoted text" }, 746 + }); 747 + dataStore.$posts.set(postURI, makeBlockedQuotePost({ blockedBy: true })); 748 + const result = derived.$hydratedPosts.get(postURI); 749 + assertEquals( 750 + result.embed.record.$type, 751 + "app.bsky.embed.record#viewBlocked", 752 + ); 753 + }); 754 + 623 755 it("should return the post unchanged when there is no blocked quote to resolve", () => { 624 756 const dataStore = new DataStore(); 625 757 const { derived } = makeDerived(dataStore, {
+62
tests/unit/specs/dataLayer/requests.test.js
··· 2697 2697 }); 2698 2698 }); 2699 2699 2700 + t.describe("_loadBlockedPosts", (it) => { 2701 + const existingUri = "at://did:plc:blocked/app.bsky.feed.post/exists"; 2702 + const deletedUri = "at://did:plc:blocked/app.bsky.feed.post/gone"; 2703 + 2704 + function setup({ getPosts = async () => [], getRecord }) { 2705 + const mockApi = { getPosts, getRecord }; 2706 + const dataStore = new DataStore(); 2707 + const mockPreferencesProvider = { 2708 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2709 + }; 2710 + const requests = createRequests( 2711 + mockApi, 2712 + dataStore, 2713 + mockPreferencesProvider, 2714 + ); 2715 + return { requests, dataStore }; 2716 + } 2717 + 2718 + it("should mark a post unavailable when its record is confirmed deleted", async () => { 2719 + const { requests, dataStore } = setup({ 2720 + getRecord: async (uri) => { 2721 + if (uri === deletedUri) { 2722 + throw new ApiError({ 2723 + status: 400, 2724 + statusText: "Bad Request", 2725 + data: { error: "RecordNotFound" }, 2726 + headers: {}, 2727 + url: "", 2728 + }); 2729 + } 2730 + return { uri, value: {} }; 2731 + }, 2732 + }); 2733 + await requests._loadBlockedPosts([existingUri, deletedUri]); 2734 + assertEquals(dataStore.$unavailablePosts.get(existingUri), null); 2735 + assert(dataStore.$unavailablePosts.get(deletedUri) !== null); 2736 + assertEquals(dataStore.$unavailablePosts.get(deletedUri).uri, deletedUri); 2737 + }); 2738 + 2739 + it("should not mark a post unavailable when the record probe fails for other reasons", async () => { 2740 + const { requests, dataStore } = setup({ 2741 + getRecord: async () => { 2742 + throw new TypeError("network down"); 2743 + }, 2744 + }); 2745 + await requests._loadBlockedPosts([existingUri]); 2746 + assertEquals(dataStore.$unavailablePosts.get(existingUri), null); 2747 + }); 2748 + 2749 + it("should not probe records for posts that getPosts returned", async () => { 2750 + const { requests, dataStore } = setup({ 2751 + getPosts: async () => [{ uri: existingUri, record: { text: "hi" } }], 2752 + getRecord: async () => { 2753 + throw new Error("getRecord should not be called"); 2754 + }, 2755 + }); 2756 + await requests._loadBlockedPosts([existingUri]); 2757 + assertEquals(dataStore.$posts.get(existingUri).record.text, "hi"); 2758 + assertEquals(dataStore.$unavailablePosts.get(existingUri), null); 2759 + }); 2760 + }); 2761 + 2700 2762 await t.run();
+40
tests/unit/specs/feedFilters.test.js
··· 250 250 }); 251 251 }); 252 252 253 + t.describe("filterAlgorithmicFeed - blocked quote filtering", (it) => { 254 + function createBlockedQuoteItem(viewerState) { 255 + return createFeedItem({ 256 + post: { 257 + embed: { 258 + $type: "app.bsky.embed.record#view", 259 + record: { 260 + $type: "app.bsky.embed.record#viewBlocked", 261 + uri: "at://did:plc:quoted/app.bsky.feed.post/q", 262 + blocked: true, 263 + author: { did: "did:plc:quoted", viewer: viewerState }, 264 + }, 265 + }, 266 + }, 267 + }); 268 + } 269 + 270 + it("should filter out posts quoting an author who blocks the viewer", () => { 271 + const feed = createFeed([createBlockedQuoteItem({ blockedBy: true })]); 272 + const result = filterAlgorithmicFeed(feed, true, {}); 273 + assertEquals(result.feed.length, 0); 274 + }); 275 + 276 + it("should filter out posts quoting an author the viewer blocks", () => { 277 + const feed = createFeed([ 278 + createBlockedQuoteItem({ 279 + blocking: "at://did:plc:me/app.bsky.graph.block/1", 280 + }), 281 + ]); 282 + const result = filterAlgorithmicFeed(feed, true, {}); 283 + assertEquals(result.feed.length, 0); 284 + }); 285 + 286 + it("should keep posts with third-party-blocked quotes", () => { 287 + const feed = createFeed([createBlockedQuoteItem({})]); 288 + const result = filterAlgorithmicFeed(feed, true, {}); 289 + assertEquals(result.feed.length, 1); 290 + }); 291 + }); 292 + 253 293 t.describe("filterAuthorFeed", (it) => { 254 294 it("should preserve cursor", () => { 255 295 const feed = createFeed([], "author-cursor");