diff --git a/package.json b/package.json index d08449b8..a5de8272 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.17.125", + "version": "0.17.126", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/api.js b/src/js/api.js index 3fc434c0..71e7925d 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -461,8 +461,8 @@ export class Api { return posts[0]; } - async getRepost(repostUri) { - const { repo, rkey, collection } = parseUri(repostUri); + async getRecord(uri) { + const { repo, rkey, collection } = parseUri(uri); const res = await this.request(`com.atproto.repo.getRecord`, { query: { repo, @@ -473,6 +473,10 @@ export class Api { return res.data; } + async getRepost(repostUri) { + return this.getRecord(repostUri); + } + async getReposts(repostUris) { const reposts = []; // Batch to avoid rate limiting diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 5f9f48e5..e8a5e53c 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -62,6 +62,10 @@ export function isBlockingUser(blockedQuote) { return blockedQuote.author.viewer?.blockedBy; } +export function isBlockedByViewer(blockedPost) { + return !!blockedPost.author?.viewer?.blocking; +} + export function getBlockedQuote(post) { const quotedPost = getQuotedPost(post); if (!quotedPost) { diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index a2f0e247..642f082a 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -16,6 +16,7 @@ import { isPostView, getInteractionProfileDids, getLastInteractionTimestamp, + isBlockedByViewer, isGroupConvo, markBlockedQuoteNotFound, replaceBlockedQuote, @@ -611,7 +612,13 @@ export class Derived extends ReactiveStore { resolveBlockedQuote(post) { const blockedQuote = getBlockedQuote(post); - if (!blockedQuote || isBlockingUser(blockedQuote)) return post; + if (!blockedQuote) return post; + if (this.dataStore.$unavailablePosts.get(blockedQuote.uri)) { + return markBlockedQuoteNotFound(post, blockedQuote.uri); + } + if (isBlockingUser(blockedQuote) || isBlockedByViewer(blockedQuote)) { + return post; + } const fullBlockedPost = this.$hydratedPosts.get(blockedQuote.uri); if (fullBlockedPost) { const blockedQuoteEmbed = isEmptyPost(fullBlockedPost) @@ -619,7 +626,7 @@ export class Derived extends ReactiveStore { : createEmbedFromPost(fullBlockedPost); return replaceBlockedQuote(post, blockedQuoteEmbed); } - return markBlockedQuoteNotFound(post, blockedQuote.uri); + return post; } // Attach parentAuthor to a post's reply record when its parent is loaded. diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index 628996f8..ce98816a 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -4,6 +4,7 @@ import { getQuotedPost, getBlockedQuote, isBlockingUser, + isBlockedByViewer, createUnavailablePost, getPostUrisFromNotifications, buildUri, @@ -74,25 +75,23 @@ function writePageToCollection( return true; } -// Get URIs of blocked quotes from posts where the author has not blocked the viewer +// Get URIs of blocked posts and blocked quotes referenced by the given posts function getBlockedPostUris(posts) { // Blocked "top-level" posts - const blockedPosts = posts - .filter((post) => post.$type === "app.bsky.feed.defs#blockedPost") - .filter((blockedPost) => !isBlockingUser(blockedPost)); + const blockedPosts = posts.filter( + (post) => post.$type === "app.bsky.feed.defs#blockedPost", + ); // Blocked quoted posts const blockedQuotes = posts .map((post) => getBlockedQuote(post)) - .filter(Boolean) - .filter((blockedPost) => !isBlockingUser(blockedPost)); + .filter(Boolean); // Blocked nested quotes // Note - this won't load blocked quotes of blocked quotes (edge case) const blockedNestedQuotes = posts .map((post) => getQuotedPost(post)) .filter(Boolean) .map((quotedPost) => getBlockedQuote(quotedPost)) - .filter(Boolean) - .filter((blockedPost) => !isBlockingUser(blockedPost)); + .filter(Boolean); return unique([...blockedPosts, ...blockedQuotes, ...blockedNestedQuotes], { by: "uri", @@ -311,7 +310,11 @@ export class Requests { } async _loadParentChain(blockedParent, { labelers = [], rootUri } = {}) { - if (!rootUri || isBlockingUser(blockedParent)) { + if ( + !rootUri || + isBlockingUser(blockedParent) || + isBlockedByViewer(blockedParent) + ) { return await this.loadPostThread(blockedParent.uri, { depth: 0, labelers, @@ -337,7 +340,8 @@ export class Requests { while ( currentBlocked?.$type === "app.bsky.feed.defs#blockedPost" && - !isBlockingUser(currentBlocked) + !isBlockingUser(currentBlocked) && + !isBlockedByViewer(currentBlocked) ) { const authorDid = currentBlocked.author?.did; if (!authorDid || loadedAuthorDids.has(authorDid)) break; @@ -527,15 +531,24 @@ export class Requests { labelers, }); this.dataStore.setPosts(fetchedBlockedPosts); - // If any blocked posts are not found, create an unavailable post for them - const notFoundPostUris = blockedPostUris.filter( + // The appview omits posts from getPosts when a block exists in either + // direction, so a missing post may still exist. Probe the raw record + // (which block filtering doesn't apply to) and only mark posts as + // unavailable when the record is confirmed gone. + const missingPostUris = blockedPostUris.filter( (uri) => !fetchedBlockedPosts.some((post) => post.uri === uri), ); - if (notFoundPostUris.length > 0) { - for (const uri of notFoundPostUris) { + const results = await Promise.allSettled( + missingPostUris.map((uri) => this.api.getRecord(uri)), + ); + results.forEach((result, index) => { + if (result.status === "fulfilled") return; + const error = result.reason; + if (error instanceof ApiError && error.data?.error === "RecordNotFound") { + const uri = missingPostUris[index]; this.dataStore.$unavailablePosts.set(uri, createUnavailablePost(uri)); } - } + }); } async loadDetailedProfile(did) { diff --git a/src/js/feedFilters.js b/src/js/feedFilters.js index 34f62c5e..681498c7 100644 --- a/src/js/feedFilters.js +++ b/src/js/feedFilters.js @@ -1,5 +1,6 @@ import { isBlockingUser, + isBlockedByViewer, getQuotedPost, getBlockedQuote, getReplyAuthors, @@ -168,7 +169,10 @@ class FilterBlockedQuotes extends FeedFilter { filterFeedItems(feedItems) { return feedItems.filter((item) => { const blockedQuote = getBlockedQuote(item.post); - if (blockedQuote && isBlockingUser(blockedQuote)) { + if ( + blockedQuote && + (isBlockingUser(blockedQuote) || isBlockedByViewer(blockedQuote)) + ) { return false; } return true; diff --git a/src/js/templates/postEmbed.template.js b/src/js/templates/postEmbed.template.js index bc736d46..59bceb31 100644 --- a/src/js/templates/postEmbed.template.js +++ b/src/js/templates/postEmbed.template.js @@ -615,7 +615,6 @@ export function recordEmbedTemplate({ isAuthenticated, condensed, }); - // This only happens if the author is blocking the viewer case "app.bsky.embed.record#viewBlocked": return blockedQuoteTemplate(); case "app.bsky.embed.record#viewDetached": diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index b90fe7e9..ba84db26 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -620,6 +620,138 @@ t.describe("$hydratedPosts (post hydration)", (it) => { assertEquals(result.badgeLabels, ["b"]); }); + function makeBlockedQuotePost(viewerState) { + return { + uri: postURI, + record: { text: "quoting post" }, + embed: { + $type: "app.bsky.embed.record#view", + record: { + $type: "app.bsky.embed.record#viewBlocked", + uri: "at://did:blocked/app.bsky.feed.post/q", + blocked: true, + author: { did: "did:blocked", viewer: viewerState }, + }, + }, + }; + } + + it("should keep a viewer-blocked quote as blocked when the quoted post is not loaded", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + dataStore.$posts.set( + postURI, + makeBlockedQuotePost({ blocking: "at://did:me/app.bsky.graph.block/1" }), + ); + const result = derived.$hydratedPosts.get(postURI); + assertEquals( + result.embed.record.$type, + "app.bsky.embed.record#viewBlocked", + ); + }); + + it("should mark a viewer-blocked quote as deleted when the post is confirmed unavailable", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; + dataStore.$unavailablePosts.set(quotedUri, { + $type: "social.impro.feed.defs#unavailablePost", + uri: quotedUri, + }); + dataStore.$posts.set( + postURI, + makeBlockedQuotePost({ blocking: "at://did:me/app.bsky.graph.block/1" }), + ); + const result = derived.$hydratedPosts.get(postURI); + assertEquals( + result.embed.record.$type, + "app.bsky.embed.record#viewNotFound", + ); + }); + + it("should mark a blocked-by quote as deleted when the post is confirmed unavailable", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; + dataStore.$unavailablePosts.set(quotedUri, { + $type: "social.impro.feed.defs#unavailablePost", + uri: quotedUri, + }); + dataStore.$posts.set(postURI, makeBlockedQuotePost({ blockedBy: true })); + const result = derived.$hydratedPosts.get(postURI); + assertEquals( + result.embed.record.$type, + "app.bsky.embed.record#viewNotFound", + ); + }); + + it("should keep a viewer-blocked quote blocked even when the quoted post is loaded", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; + dataStore.$posts.set(quotedUri, { + uri: quotedUri, + cid: "cid-q", + author: { did: "did:blocked" }, + record: { text: "the quoted text" }, + }); + dataStore.$posts.set( + postURI, + makeBlockedQuotePost({ blocking: "at://did:me/app.bsky.graph.block/1" }), + ); + const result = derived.$hydratedPosts.get(postURI); + assertEquals( + result.embed.record.$type, + "app.bsky.embed.record#viewBlocked", + ); + }); + + it("should resolve a third-party-blocked quote when the quoted post is loaded", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; + dataStore.$posts.set(quotedUri, { + uri: quotedUri, + cid: "cid-q", + author: { did: "did:blocked" }, + record: { text: "the quoted text" }, + }); + dataStore.$posts.set(postURI, makeBlockedQuotePost({})); + const result = derived.$hydratedPosts.get(postURI); + assertEquals(result.embed.record.$type, "app.bsky.embed.record#viewRecord"); + assertEquals(result.embed.record.uri, quotedUri); + }); + + it("should keep the quote blocked when the quoted author blocks the viewer", () => { + const dataStore = new DataStore(); + const { derived } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + const quotedUri = "at://did:blocked/app.bsky.feed.post/q"; + dataStore.$posts.set(quotedUri, { + uri: quotedUri, + cid: "cid-q", + author: { did: "did:blocked" }, + record: { text: "the quoted text" }, + }); + dataStore.$posts.set(postURI, makeBlockedQuotePost({ blockedBy: true })); + const result = derived.$hydratedPosts.get(postURI); + assertEquals( + result.embed.record.$type, + "app.bsky.embed.record#viewBlocked", + ); + }); + it("should return the post unchanged when there is no blocked quote to resolve", () => { const dataStore = new DataStore(); const { derived } = makeDerived(dataStore, { diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index d703beb2..007c1ac0 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -2697,4 +2697,66 @@ t.describe("enableStatus / getStatus", (it) => { }); }); +t.describe("_loadBlockedPosts", (it) => { + const existingUri = "at://did:plc:blocked/app.bsky.feed.post/exists"; + const deletedUri = "at://did:plc:blocked/app.bsky.feed.post/gone"; + + function setup({ getPosts = async () => [], getRecord }) { + const mockApi = { getPosts, getRecord }; + const dataStore = new DataStore(); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + const requests = createRequests( + mockApi, + dataStore, + mockPreferencesProvider, + ); + return { requests, dataStore }; + } + + it("should mark a post unavailable when its record is confirmed deleted", async () => { + const { requests, dataStore } = setup({ + getRecord: async (uri) => { + if (uri === deletedUri) { + throw new ApiError({ + status: 400, + statusText: "Bad Request", + data: { error: "RecordNotFound" }, + headers: {}, + url: "", + }); + } + return { uri, value: {} }; + }, + }); + await requests._loadBlockedPosts([existingUri, deletedUri]); + assertEquals(dataStore.$unavailablePosts.get(existingUri), null); + assert(dataStore.$unavailablePosts.get(deletedUri) !== null); + assertEquals(dataStore.$unavailablePosts.get(deletedUri).uri, deletedUri); + }); + + it("should not mark a post unavailable when the record probe fails for other reasons", async () => { + const { requests, dataStore } = setup({ + getRecord: async () => { + throw new TypeError("network down"); + }, + }); + await requests._loadBlockedPosts([existingUri]); + assertEquals(dataStore.$unavailablePosts.get(existingUri), null); + }); + + it("should not probe records for posts that getPosts returned", async () => { + const { requests, dataStore } = setup({ + getPosts: async () => [{ uri: existingUri, record: { text: "hi" } }], + getRecord: async () => { + throw new Error("getRecord should not be called"); + }, + }); + await requests._loadBlockedPosts([existingUri]); + assertEquals(dataStore.$posts.get(existingUri).record.text, "hi"); + assertEquals(dataStore.$unavailablePosts.get(existingUri), null); + }); +}); + await t.run(); diff --git a/tests/unit/specs/feedFilters.test.js b/tests/unit/specs/feedFilters.test.js index 216489d7..60c91d36 100644 --- a/tests/unit/specs/feedFilters.test.js +++ b/tests/unit/specs/feedFilters.test.js @@ -250,6 +250,46 @@ t.describe("filterAlgorithmicFeed", (it) => { }); }); +t.describe("filterAlgorithmicFeed - blocked quote filtering", (it) => { + function createBlockedQuoteItem(viewerState) { + return createFeedItem({ + post: { + embed: { + $type: "app.bsky.embed.record#view", + record: { + $type: "app.bsky.embed.record#viewBlocked", + uri: "at://did:plc:quoted/app.bsky.feed.post/q", + blocked: true, + author: { did: "did:plc:quoted", viewer: viewerState }, + }, + }, + }, + }); + } + + it("should filter out posts quoting an author who blocks the viewer", () => { + const feed = createFeed([createBlockedQuoteItem({ blockedBy: true })]); + const result = filterAlgorithmicFeed(feed, true, {}); + assertEquals(result.feed.length, 0); + }); + + it("should filter out posts quoting an author the viewer blocks", () => { + const feed = createFeed([ + createBlockedQuoteItem({ + blocking: "at://did:plc:me/app.bsky.graph.block/1", + }), + ]); + const result = filterAlgorithmicFeed(feed, true, {}); + assertEquals(result.feed.length, 0); + }); + + it("should keep posts with third-party-blocked quotes", () => { + const feed = createFeed([createBlockedQuoteItem({})]); + const result = filterAlgorithmicFeed(feed, true, {}); + assertEquals(result.feed.length, 1); + }); +}); + t.describe("filterAuthorFeed", (it) => { it("should preserve cursor", () => { const feed = createFeed([], "author-cursor");