From 7cce3729bf067385b9626fbf9e3178cada693bf5 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Wed, 7 Oct 2026 20:29:18 -0500 Subject: [PATCH] Enable thread muting --- src/js/api.js | 22 +++ src/js/dataLayer/derived.js | 11 ++ src/js/dataLayer/mutations.js | 30 ++++ src/js/dataLayer/patchStore.js | 46 ++++++ src/js/dataLayer/requests.js | 5 + src/js/postInteractionHandler.js | 16 ++ src/js/templates/largePost.template.js | 2 + src/js/templates/postActionBar.template.js | 15 ++ src/js/templates/smallPost.template.js | 2 + tests/e2e/mockServer.js | 41 +++++ tests/e2e/specs/flows/muteThread.test.js | 151 ++++++++++++++++++ tests/unit/specs/api.test.js | 30 ++++ tests/unit/specs/dataLayer/derived.test.js | 132 +++++++++++++++ tests/unit/specs/dataLayer/mutations.test.js | 79 ++++++++- tests/unit/specs/dataLayer/patchStore.test.js | 72 +++++++++ tests/unit/specs/dataLayer/requests.test.js | 24 +++ .../templates/postActionBar.template.test.js | 66 ++++++++ 17 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/specs/flows/muteThread.test.js diff --git a/src/js/api.js b/src/js/api.js index fdf8955a..fb930ae2 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -1129,6 +1129,28 @@ export class Api { return res; } + async muteThread(rootUri) { + const res = await this.appViewRequest("app.bsky.graph.muteThread", { + method: "POST", + body: { + root: rootUri, + }, + parseJson: false, + }); + return res; + } + + async unmuteThread(rootUri) { + const res = await this.appViewRequest("app.bsky.graph.unmuteThread", { + method: "POST", + body: { + root: rootUri, + }, + parseJson: false, + }); + return res; + } + async blockActor(profile) { const res = await this.request("com.atproto.repo.createRecord", { method: "POST", diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 00ca5172..2380231f 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -26,6 +26,7 @@ import { getJoinLinkCodeFromEmbed, isFollowingFeedUri, isStatusValid, + getReplyRootFromPost, } from "/js/dataHelpers.js"; import { sortBy, KeyedScheduler } from "/js/utils.js"; import { FOLLOWING_FEED_URI } from "/js/config.js"; @@ -911,6 +912,16 @@ export class Derived extends ReactiveStore { result = this.attachJoinLinkPreview(result); result = applyMutedWords(result, preferences); result = applyIsHidden(result, preferences); + if (result.viewer) { + const rootUri = getReplyRootFromPost(result).uri; + const threadMutePatches = this.patchStore.$threadMutePatches.get(rootUri); + if (threadMutePatches?.length) { + result = this.patchStore.applyThreadMutePatches( + result, + threadMutePatches, + ); + } + } return applyLabels(result, preferences); } diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index d11d8493..bfac6e28 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -15,6 +15,7 @@ import { unpinPostInFeed, valueForPinnedItem, buildCdnUrl, + getReplyRootFromPost, } from "/js/dataHelpers.js"; import { batch, @@ -2172,6 +2173,27 @@ export class Mutations { } } + async setThreadMuted(post, muted) { + const rootUri = getReplyRootFromPost(post).uri; + const patchId = this.patchStore.addThreadMutePatch(rootUri, { + type: "setThreadMuted", + muted, + }); + try { + if (muted) { + await this.api.muteThread(rootUri); + } else { + await this.api.unmuteThread(rootUri); + } + this._updatePostsByThreadRoot(rootUri, (stored) => ({ + ...stored, + viewer: { ...stored.viewer, threadMuted: muted }, + })); + } finally { + this.patchStore.removeThreadMutePatch(rootUri, patchId); + } + } + async markConvoAsRead(convoId) { await this.api.markConvoAsRead(convoId); const latest = this.dataStore.$convos.get(convoId); @@ -2314,4 +2336,12 @@ export class Mutations { } } } + + _updatePostsByThreadRoot(rootUri, updateFunc) { + for (const post of this.dataStore.$posts.values()) { + if (post?.viewer && getReplyRootFromPost(post).uri === rootUri) { + this.dataStore.$posts.set(post.uri, updateFunc(post)); + } + } + } } diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js index 4561e1b4..40ba075e 100644 --- a/src/js/dataLayer/patchStore.js +++ b/src/js/dataLayer/patchStore.js @@ -12,6 +12,7 @@ export class PatchStore extends ReactiveStore { this.$profilePatches = new SignalMap(); this.$messagePatches = new SignalMap(); this.$convoPatches = new SignalMap(); + this.$threadMutePatches = new SignalMap(); this.$preferencePatches = new Signal.State([]); this.$currentUserPatches = new Signal.State([]); this.$authorFeedPatches = new SignalMap(); @@ -357,6 +358,51 @@ export class PatchStore extends ReactiveStore { } } + /* Thread Mute Patches */ + + _getThreadMutePatches(rootUri) { + return this.$threadMutePatches.get(rootUri) || []; + } + + addThreadMutePatch(rootUri, patchBody) { + const patchId = this.uuid.create(); + this.$threadMutePatches.set(rootUri, [ + ...this._getThreadMutePatches(rootUri), + { id: patchId, body: patchBody }, + ]); + return patchId; + } + + removeThreadMutePatch(rootUri, patchId) { + this.$threadMutePatches.set( + rootUri, + this._getThreadMutePatches(rootUri).filter(({ id }) => id !== patchId), + ); + } + + applyThreadMutePatches(post, patches) { + let patchedPost = post; + for (const patch of patches) { + patchedPost = this.applyThreadMutePatch(patchedPost, patch.body); + } + return patchedPost; + } + + applyThreadMutePatch(post, patchBody) { + switch (patchBody.type) { + case "setThreadMuted": + if ((post.viewer.threadMuted ?? false) === patchBody.muted) { + return post; + } + return { + ...post, + viewer: { ...post.viewer, threadMuted: patchBody.muted }, + }; + default: + throw new Error(`Unknown patch type: ${patchBody.type}`); + } + } + /* Preference Patches */ addPreferencePatch(patchBody) { diff --git a/src/js/dataLayer/requests.js b/src/js/dataLayer/requests.js index 461967c1..5d4310ce 100644 --- a/src/js/dataLayer/requests.js +++ b/src/js/dataLayer/requests.js @@ -539,6 +539,11 @@ export class Requests { default: throw new Error(`Unknown pinned item type: ${type}`); } + // Apply thread mutes on load so muting in the UI doesn't hide threads from feeds + feed = { + ...feed, + feed: feed.feed.filter((item) => item.post.viewer?.threadMuted !== true), + }; const postsToSave = getPostsFromFeed(feed); await this._loadPostDependencies(postsToSave); this.dataStore.setPosts(postsToSave); diff --git a/src/js/postInteractionHandler.js b/src/js/postInteractionHandler.js index e26e2a72..b5a02d2b 100644 --- a/src/js/postInteractionHandler.js +++ b/src/js/postInteractionHandler.js @@ -214,6 +214,22 @@ export class PostInteractionHandler { } } + async handleMuteThread(post, doMute) { + try { + await this.dataLayer.mutations.setThreadMuted(post, doMute); + showToast( + doMute + ? "You will no longer receive notifications for this thread" + : "You will now receive notifications for this thread", + ); + } catch (error) { + console.error(error); + showToast(doMute ? "Failed to mute thread" : "Failed to unmute thread", { + style: "error", + }); + } + } + async handleBlockAuthor(profile, doBlock) { if (doBlock) { const confirmed = await confirmModal( diff --git a/src/js/templates/largePost.template.js b/src/js/templates/largePost.template.js index f459a280..93ac476c 100644 --- a/src/js/templates/largePost.template.js +++ b/src/js/templates/largePost.template.js @@ -293,6 +293,8 @@ export function largePostTemplate({ }, onClickMute: (profile, doMute) => postInteractionHandler.handleMuteAuthor(profile, doMute), + onClickMuteThread: (post, doMute) => + postInteractionHandler.handleMuteThread(post, doMute), onClickBlock: async (profile, doBlock) => { const success = await postInteractionHandler.handleBlockAuthor( diff --git a/src/js/templates/postActionBar.template.js b/src/js/templates/postActionBar.template.js index c19b1d57..6eccbf9e 100644 --- a/src/js/templates/postActionBar.template.js +++ b/src/js/templates/postActionBar.template.js @@ -61,6 +61,7 @@ function postContextMenuTemplate({ onClickShowLess, onClickHidePost, onClickMute, + onClickMuteThread, onClickBlock, onClickReport, onClickDelete, @@ -139,6 +140,18 @@ function postContextMenuTemplate({ ` : null} + + onClickMuteThread(post, !post.viewer?.threadMuted)} + > + ${post.viewer?.threadMuted ? "Unmute thread" : "Mute thread"} + + ${!isUserPost ? html` ${!post.viewer?.isHidden @@ -383,6 +396,7 @@ export function postActionBarTemplate({ onClickShowMore = noop, onClickHidePost = noop, onClickMute = noop, + onClickMuteThread = noop, onClickBlock = noop, onClickDelete = noop, onClickReport = noop, @@ -548,6 +562,7 @@ export function postActionBarTemplate({ onClickShowLess, onClickHidePost, onClickMute, + onClickMuteThread, onClickBlock, onClickReport, onClickDelete, diff --git a/src/js/templates/smallPost.template.js b/src/js/templates/smallPost.template.js index 361c098d..1c680b7f 100644 --- a/src/js/templates/smallPost.template.js +++ b/src/js/templates/smallPost.template.js @@ -245,6 +245,8 @@ export function smallPostTemplate({ postInteractionHandler.handleHidePost(post), onClickMute: (profile, doMute) => postInteractionHandler.handleMuteAuthor(profile, doMute), + onClickMuteThread: (post, doMute) => + postInteractionHandler.handleMuteThread(post, doMute), onClickBlock: (profile, doBlock) => postInteractionHandler.handleBlockAuthor(profile, doBlock), onClickDelete: (post) => { diff --git a/tests/e2e/mockServer.js b/tests/e2e/mockServer.js index 2d9ee189..52165e66 100644 --- a/tests/e2e/mockServer.js +++ b/tests/e2e/mockServer.js @@ -49,6 +49,8 @@ export class MockServer { this.createGroupError = null; this.createGroupRequests = []; this.leaveConvoError = null; + this.threadMuteError = null; + this.threadMuteRequests = []; this.typeaheadProfiles = []; this.typeaheadDelayMs = 0; this.externalLinkCards = new Map(); @@ -493,6 +495,10 @@ export class MockServer { this.createGroupError = errorName; } + setThreadMuteError(errorName) { + this.threadMuteError = errorName; + } + setExternalLinkCard(url, meta) { this.externalLinkCards.set(url, meta); } @@ -1812,6 +1818,41 @@ export class MockServer { }); }); + const handleThreadMute = (route, threadMuted) => { + const body = route.request().postDataJSON(); + this.threadMuteRequests.push({ root: body?.root, threadMuted }); + if (this.threadMuteError) { + return route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ + error: this.threadMuteError, + message: this.threadMuteError, + }), + }); + } + [...this.timelinePosts, ...this.posts, ...this.searchPosts] + .filter( + (post) => (post.record?.reply?.root?.uri ?? post.uri) === body?.root, + ) + .forEach((post) => { + post.viewer = { ...post.viewer, threadMuted }; + }); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: "{}", + }); + }; + + await page.route("**/xrpc/app.bsky.graph.muteThread*", (route) => + handleThreadMute(route, true), + ); + + await page.route("**/xrpc/app.bsky.graph.unmuteThread*", (route) => + handleThreadMute(route, false), + ); + await page.route("**/xrpc/app.bsky.graph.muteActorList*", (route) => { const body = route.request().postDataJSON(); const listUri = body?.list; diff --git a/tests/e2e/specs/flows/muteThread.test.js b/tests/e2e/specs/flows/muteThread.test.js new file mode 100644 index 00000000..221f3f70 --- /dev/null +++ b/tests/e2e/specs/flows/muteThread.test.js @@ -0,0 +1,151 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { MockServer } from "../../mockServer.js"; +import { createPost, createThreadViewPost } from "../../../shared/factories.js"; + +const rootUri = "at://did:plc:author1/app.bsky.feed.post/root1"; + +// The mock server updates post viewer state in place, so each test builds +// its own posts. +function createRootPost() { + return createPost({ + uri: rootUri, + text: "Root post", + authorHandle: "author1.bsky.social", + authorDisplayName: "Author One", + replyCount: 2, + }); +} + +function createReply(rootPost, rkey, text) { + return createPost({ + uri: `at://did:plc:replier1/app.bsky.feed.post/${rkey}`, + text, + authorHandle: "replier1.bsky.social", + authorDisplayName: "Replier One", + reply: { + parent: { uri: rootPost.uri, cid: rootPost.cid }, + root: { uri: rootPost.uri, cid: rootPost.cid }, + }, + }); +} + +function setupThread(mockServer) { + const rootPost = createRootPost(); + const firstReply = createReply(rootPost, "reply1", "First reply"); + const secondReply = createReply(rootPost, "reply2", "Second reply"); + mockServer.addPosts([rootPost, firstReply, secondReply]); + mockServer.setPostThread( + rootPost.uri, + createThreadViewPost({ + post: rootPost, + replies: [ + createThreadViewPost({ post: firstReply, replies: [] }), + createThreadViewPost({ post: secondReply, replies: [] }), + ], + }), + ); +} + +const muteThreadItem = '[data-testid="menu-action-post-mute-thread"]'; + +test.describe("Mute thread flow", () => { + test("should mute a thread from a reply and reflect it on sibling replies", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupThread(mockServer); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/root1"); + + const view = page.locator("#post-detail-view"); + const replies = view.locator('[data-testid="small-post"]'); + await expect(replies).toHaveCount(2, { timeout: 10000 }); + + await replies.nth(0).locator('[data-testid="post-action-more"]').click(); + await page.locator(`${muteThreadItem}[data-teststate="unmuted"]`).click(); + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + expect(mockServer.threadMuteRequests).toEqual([ + { root: rootUri, threadMuted: true }, + ]); + + await replies.nth(1).locator('[data-testid="post-action-more"]').click(); + await page.locator(`${muteThreadItem}[data-teststate="muted"]`).click(); + await expect.poll(() => mockServer.threadMuteRequests.length).toBe(2); + expect(mockServer.threadMuteRequests[1]).toEqual({ + root: rootUri, + threadMuted: false, + }); + + await view + .locator('[data-testid="large-post"] [data-testid="post-action-more"]') + .click(); + await expect( + page.locator(`${muteThreadItem}[data-teststate="unmuted"]`), + ).toBeVisible(); + }); + + test("should roll back and show an error toast when muting fails", async ({ + page, + }) => { + const mockServer = new MockServer(); + setupThread(mockServer); + mockServer.setThreadMuteError("InternalServerError"); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/author1.bsky.social/post/root1"); + + const view = page.locator("#post-detail-view"); + const replies = view.locator('[data-testid="small-post"]'); + await expect(replies).toHaveCount(2, { timeout: 10000 }); + + await replies.nth(0).locator('[data-testid="post-action-more"]').click(); + await page.locator(muteThreadItem).click(); + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + await expect.poll(() => mockServer.threadMuteRequests.length).toBe(1); + + await replies.nth(1).locator('[data-testid="post-action-more"]').click(); + await expect( + page.locator(`${muteThreadItem}[data-teststate="unmuted"]`), + ).toBeVisible(); + }); + + test("should keep a muted thread in the home feed until it reloads", async ({ + page, + }) => { + const mockServer = new MockServer(); + const otherPost = createPost({ + uri: "at://did:plc:author2/app.bsky.feed.post/other1", + text: "Unrelated post", + authorHandle: "author2.bsky.social", + authorDisplayName: "Author Two", + }); + mockServer.addTimelinePosts([createRootPost(), otherPost]); + await mockServer.setup(page); + + await login(page); + await page.goto("/"); + + const homeView = page.locator("#home-view"); + const feedItems = homeView.locator('[data-testid="feed-item"]'); + await expect(feedItems).toHaveCount(2, { timeout: 10000 }); + + await feedItems + .filter({ hasText: "Root post" }) + .locator('[data-testid="post-action-more"]') + .click(); + await page.locator(muteThreadItem).click(); + await expect(page.locator('[data-testid="toast"]')).toBeVisible(); + expect(mockServer.threadMuteRequests).toEqual([ + { root: rootUri, threadMuted: true }, + ]); + await expect(feedItems).toHaveCount(2); + + await page.reload(); + await expect(feedItems).toHaveCount(1, { timeout: 10000 }); + await expect(feedItems).toContainText("Unrelated post"); + }); +}); diff --git a/tests/unit/specs/api.test.js b/tests/unit/specs/api.test.js index 0274b275..1707f11d 100644 --- a/tests/unit/specs/api.test.js +++ b/tests/unit/specs/api.test.js @@ -1558,6 +1558,36 @@ describe("unmuteActor", () => { }); }); +describe("muteThread", () => { + it("should mute thread", async () => { + const session = createMockSession({}); + const api = new Api(session); + + await api.muteThread("at://did:plc:target/app.bsky.feed.post/root"); + + const { url, options } = session.getLastFetchOptions(); + assert(url.includes("app.bsky.graph.muteThread")); + assert.equal(options.method, "POST"); + const body = JSON.parse(options.body); + assert.equal(body.root, "at://did:plc:target/app.bsky.feed.post/root"); + }); +}); + +describe("unmuteThread", () => { + it("should unmute thread", async () => { + const session = createMockSession({}); + const api = new Api(session); + + await api.unmuteThread("at://did:plc:target/app.bsky.feed.post/root"); + + const { url, options } = session.getLastFetchOptions(); + assert(url.includes("app.bsky.graph.unmuteThread")); + assert.equal(options.method, "POST"); + const body = JSON.parse(options.body); + assert.equal(body.root, "at://did:plc:target/app.bsky.feed.post/root"); + }); +}); + describe("blockActor", () => { it("should create block record", async () => { const session = createMockSession({ diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index 8ec90517..2603d39b 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -2939,3 +2939,135 @@ describe("patched overlays ($patchedPosts / $patchedProfiles / $patchedConvos / assert.deepEqual(derived.$patchedMessages.get("missing"), null); }); }); + +describe("thread mute hydration", () => { + const rootUri = "at://did:plc:alice/app.bsky.feed.post/root"; + const rootRef = { uri: rootUri, cid: "bafyreitestroot" }; + const root = createPost({ uri: rootUri, text: "root" }); + const reply = createPost({ + uri: "at://did:plc:bob/app.bsky.feed.post/reply", + text: "reply", + reply: { root: rootRef, parent: rootRef }, + }); + const deepReply = createPost({ + uri: "at://did:plc:carol/app.bsky.feed.post/deep", + text: "deep", + reply: { + root: rootRef, + parent: { uri: reply.uri, cid: reply.cid }, + }, + }); + const otherThreadPost = createPost({ + uri: "at://did:plc:dave/app.bsky.feed.post/other", + text: "other", + }); + const quoteOfRoot = createPost({ + uri: "at://did:plc:erin/app.bsky.feed.post/quote", + text: "quote", + recordEmbed: { $type: "app.bsky.embed.record", record: rootRef }, + }); + + function setup(posts) { + const dataStore = new DataStore(createSessionState(null)); + const { derived, patchStore } = makeDerived(dataStore, { + preferences: fakePreferences(), + }); + for (const post of posts) { + dataStore.$posts.set(post.uri, post); + } + return { dataStore, derived, patchStore }; + } + + function addThreadMutePatch(patchStore, muted) { + patchStore.addThreadMutePatch(rootUri, { type: "setThreadMuted", muted }); + } + + function isThreadMuted(derived, uri) { + return derived.$hydratedPosts.get(uri).viewer.threadMuted; + } + + it("should follow the server value with no override", () => { + const mutedRoot = createPost({ + uri: rootUri, + text: "root", + viewer: { threadMuted: true }, + }); + const { derived } = setup([mutedRoot, otherThreadPost]); + assert.equal(isThreadMuted(derived, rootUri), true); + assert.equal(isThreadMuted(derived, otherThreadPost.uri), undefined); + }); + + it("should not change post identity when the value is unchanged", () => { + const { derived } = setup([otherThreadPost]); + const hydrated = derived.$hydratedPosts.get(otherThreadPost.uri); + assert.deepEqual(hydrated.viewer, otherThreadPost.viewer); + }); + + it("should let a pending patch beat the server value", () => { + const mutedRoot = createPost({ + uri: rootUri, + text: "root", + viewer: { threadMuted: true }, + }); + const { derived, patchStore } = setup([mutedRoot]); + addThreadMutePatch(patchStore, false); + assert.equal(isThreadMuted(derived, rootUri), false); + }); + + it("should let the last pending patch win", () => { + const { derived, patchStore } = setup([root]); + addThreadMutePatch(patchStore, true); + assert.equal(isThreadMuted(derived, rootUri), true); + addThreadMutePatch(patchStore, false); + assert(!isThreadMuted(derived, rootUri)); + }); + + it("should apply a patch to every post in the thread", () => { + const { derived, patchStore } = setup([ + root, + reply, + deepReply, + otherThreadPost, + quoteOfRoot, + ]); + addThreadMutePatch(patchStore, true); + assert.equal(isThreadMuted(derived, rootUri), true); + assert.equal(isThreadMuted(derived, reply.uri), true); + assert.equal(isThreadMuted(derived, deepReply.uri), true); + assert.equal(isThreadMuted(derived, otherThreadPost.uri), undefined); + assert.equal(isThreadMuted(derived, quoteOfRoot.uri), undefined); + }); + + it("should keep a loaded feed item when its thread is muted", () => { + const feedURI = "at://did:test/app.bsky.feed.generator/test"; + const { derived, dataStore, patchStore } = setup([reply]); + dataStore.$feeds.set(feedURI, { + feed: [ + createFeedItem({ + post: reply, + reason: { + $type: "app.bsky.feed.defs#reasonRepost", + by: createProfile({ did: "did:plc:frank", handle: "frank.test" }), + indexedAt: "2025-01-01T00:00:00.000Z", + }, + }), + ], + cursor: null, + }); + addThreadMutePatch(patchStore, true); + const { feed } = derived.$hydratedFeeds.get(feedURI); + assert.equal(feed.length, 1); + assert.equal(feed[0].post.viewer.threadMuted, true); + }); + + it("should leave posts without a viewer untouched", () => { + const loggedOutRoot = createPost({ + uri: rootUri, + text: "root", + loggedOut: true, + }); + const { derived, patchStore } = setup([loggedOutRoot]); + addThreadMutePatch(patchStore, true); + assert.equal(derived.$hydratedPosts.get(rootUri).viewer, undefined); + }); +}); diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 1cd62d33..d1132da8 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -1,4 +1,4 @@ -import { describe, it, beforeEach, afterEach } from "node:test"; +import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert/strict"; import { Mutations } from "/js/dataLayer/mutations.js"; import { DataStore } from "/js/dataLayer/dataStore.js"; @@ -12,6 +12,7 @@ import { HiddenFeedItemsStore } from "/js/dataLayer/hiddenFeedItemsStore.js"; import { CDN_URL } from "/js/config.js"; import { ApiError } from "/js/api.js"; import { trackDisposable } from "../../testHelpers.js"; +import { createPost } from "../../../shared/factories.js"; function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -6046,3 +6047,79 @@ describe("updatePostInteractionSettings", () => { }); }); }); + +describe("setThreadMuted", () => { + const rootUri = "at://did:plc:alice/app.bsky.feed.post/root"; + const root = createPost({ uri: rootUri, text: "root" }); + const rootRef = { uri: root.uri, cid: root.cid }; + const reply = createPost({ + uri: "at://did:plc:bob/app.bsky.feed.post/reply", + text: "reply", + reply: { root: rootRef, parent: rootRef }, + }); + const otherPost = createPost({ + uri: "at://did:plc:carol/app.bsky.feed.post/other", + text: "other", + }); + + function storedThreadMuted(dataStore, uri) { + return dataStore.$posts.get(uri).viewer.threadMuted; + } + + function setup(mockApi) { + const dataStore = new DataStore(createSessionState(null)); + const patchStore = new PatchStore(); + const mockPreferencesProvider = { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }; + const mutations = makeMutations( + mockApi, + dataStore, + patchStore, + mockPreferencesProvider, + ); + for (const post of [root, reply, otherPost]) { + dataStore.$posts.set(post.uri, post); + } + return { mutations, dataStore, patchStore }; + } + + it("should mute the record root and update stored thread posts", async () => { + const muteThread = mock.fn(async () => ({})); + const { mutations, dataStore, patchStore } = setup({ muteThread }); + + const promise = mutations.setThreadMuted(reply, true); + assert.deepEqual( + patchStore.$threadMutePatches.get(rootUri).map((patch) => patch.body), + [{ type: "setThreadMuted", muted: true }], + ); + await promise; + + assert.equal(muteThread.mock.callCount(), 1); + assert.deepEqual(muteThread.mock.calls[0].arguments, [rootUri]); + assert.equal(storedThreadMuted(dataStore, rootUri), true); + assert.equal(storedThreadMuted(dataStore, reply.uri), true); + assert.equal(storedThreadMuted(dataStore, otherPost.uri), undefined); + assert.deepEqual(patchStore.$threadMutePatches.get(rootUri), []); + }); + + it("should use the post's own uri when it is not a reply", async () => { + const unmuteThread = mock.fn(async () => ({})); + const { mutations, dataStore } = setup({ unmuteThread }); + await mutations.setThreadMuted(root, false); + assert.deepEqual(unmuteThread.mock.calls[0].arguments, [rootUri]); + assert.equal(storedThreadMuted(dataStore, reply.uri), false); + }); + + it("should roll back on failure without updating stored posts", async () => { + const error = new Error("boom"); + const { mutations, dataStore, patchStore } = setup({ + muteThread: async () => { + throw error; + }, + }); + await assert.rejects(mutations.setThreadMuted(reply, true), error); + assert.equal(storedThreadMuted(dataStore, reply.uri), undefined); + assert.deepEqual(patchStore.$threadMutePatches.get(rootUri), []); + }); +}); diff --git a/tests/unit/specs/dataLayer/patchStore.test.js b/tests/unit/specs/dataLayer/patchStore.test.js index 1d99c75d..b6f37eb4 100644 --- a/tests/unit/specs/dataLayer/patchStore.test.js +++ b/tests/unit/specs/dataLayer/patchStore.test.js @@ -1425,3 +1425,75 @@ describe("Message Patches - convergence", () => { assert.deepEqual(patched.reactions.length, 3); }); }); + +describe("thread mute patches", () => { + const rootUri = "at://did:plc:test/app.bsky.feed.post/root"; + + it("should add patches in order", () => { + const patchStore = new PatchStore(); + patchStore.addThreadMutePatch(rootUri, { + type: "setThreadMuted", + muted: true, + }); + patchStore.addThreadMutePatch(rootUri, { + type: "setThreadMuted", + muted: false, + }); + const patches = patchStore.$threadMutePatches.get(rootUri); + assert.deepEqual( + patches.map((patch) => patch.body.muted), + [true, false], + ); + }); + + it("should remove a patch by id", () => { + const patchStore = new PatchStore(); + const firstId = patchStore.addThreadMutePatch(rootUri, { + type: "setThreadMuted", + muted: true, + }); + patchStore.addThreadMutePatch(rootUri, { + type: "setThreadMuted", + muted: false, + }); + patchStore.removeThreadMutePatch(rootUri, firstId); + const patches = patchStore.$threadMutePatches.get(rootUri); + assert.deepEqual( + patches.map((patch) => patch.body.muted), + [false], + ); + }); + + it("should apply patches in order so the last one wins", () => { + const patchStore = new PatchStore(); + const post = { uri: rootUri, viewer: {} }; + const patches = [ + { id: "1", body: { type: "setThreadMuted", muted: true } }, + { id: "2", body: { type: "setThreadMuted", muted: false } }, + ]; + const patched = patchStore.applyThreadMutePatches(post, patches); + assert.equal(patched.viewer.threadMuted ?? false, false); + assert.deepEqual( + patchStore.applyThreadMutePatches(post, patches.slice(0, 1)).viewer + .threadMuted, + true, + ); + }); + + it("should return the same post when the value is unchanged", () => { + const patchStore = new PatchStore(); + const post = { uri: rootUri, viewer: { threadMuted: true } }; + const patched = patchStore.applyThreadMutePatch(post, { + type: "setThreadMuted", + muted: true, + }); + assert.equal(patched, post); + }); + + it("should throw on an unknown patch type", () => { + const patchStore = new PatchStore(); + assert.throws(() => + patchStore.applyThreadMutePatch({ viewer: {} }, { type: "bogus" }), + ); + }); +}); diff --git a/tests/unit/specs/dataLayer/requests.test.js b/tests/unit/specs/dataLayer/requests.test.js index 6cccb0b3..179de031 100644 --- a/tests/unit/specs/dataLayer/requests.test.js +++ b/tests/unit/specs/dataLayer/requests.test.js @@ -175,6 +175,30 @@ describe("loadNextFeedPage", () => { assert.equal(dataStore.$postNumbering.get("post3"), null); }); + it("should drop items from muted threads", async () => { + const mutedItem = { + post: { uri: "post1", viewer: { threadMuted: true } }, + }; + const unmutedItem = { + post: { uri: "post2", viewer: { threadMuted: false } }, + }; + const mockApi = { + getFeed: async () => ({ + feed: [mutedItem, unmutedItem], + cursor: "cursor123", + }), + }; + const dataStore = new DataStore(createSessionState(null)); + const requests = makeRequests(mockApi, dataStore); + + await requests.loadNextFeedPage({ type: "feed", uri: feedURI }); + + assert.deepEqual(dataStore.$feeds.get(feedURI), { + feed: [unmutedItem], + cursor: "cursor123", + }); + }); + it("should load initial feed page", async () => { const mockFeed = { feed: [{ post: { uri: "post1" } }, { post: { uri: "post2" } }], diff --git a/tests/unit/specs/templates/postActionBar.template.test.js b/tests/unit/specs/templates/postActionBar.template.test.js index 065a30e9..29841ab0 100644 --- a/tests/unit/specs/templates/postActionBar.template.test.js +++ b/tests/unit/specs/templates/postActionBar.template.test.js @@ -461,6 +461,72 @@ describe("postActionBarTemplate - plugin context menu items", () => { container.remove(); }); + async function renderAndOpenMenu(props) { + const container = document.createElement("div"); + document.body.appendChild(container); + render( + postActionBarTemplate({ + currentUser: { did: "did:plc:test" }, + pluginService: { getPostContextMenuItems: async () => [] }, + ...props, + }), + container, + ); + const menu = await openPostContextMenu(container); + container.remove(); + return menu; + } + + it("should show mute thread on others' posts", async () => { + const menu = await renderAndOpenMenu({ post, isAuthenticated: true }); + const item = menu.querySelector( + '[data-testid="menu-action-post-mute-thread"]', + ); + assert.equal(item.dataset.teststate, "unmuted"); + }); + + it("should show mute thread on the user's own posts", async () => { + const menu = await renderAndOpenMenu({ + post, + isAuthenticated: true, + isUserPost: true, + }); + assert( + menu.querySelector('[data-testid="menu-action-post-mute-thread"]') !== + null, + ); + }); + + it("should hide mute thread when logged out", async () => { + const menu = await renderAndOpenMenu({ post, isAuthenticated: false }); + assert( + menu.querySelector('[data-testid="menu-action-post-mute-thread"]') === + null, + ); + }); + + it("should call onClickMuteThread with the toggled value", async () => { + const onClickMuteThread = mock.fn(); + const mutedPost = { + ...post, + viewer: { ...post.viewer, threadMuted: true }, + }; + const menu = await renderAndOpenMenu({ + post: mutedPost, + isAuthenticated: true, + onClickMuteThread, + }); + const item = menu.querySelector( + '[data-testid="menu-action-post-mute-thread"]', + ); + assert.equal(item.dataset.teststate, "muted"); + item.click(); + assert.deepEqual(onClickMuteThread.mock.calls[0].arguments, [ + mutedPost, + false, + ]); + }); + it("should pass null feed meta when the post is not in a feed", async () => { const menuCalls = []; const pluginService = { -- 2.51.2