diff --git a/package.json b/package.json index 898de4e4..f8a3b825 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.100", + "version": "0.20.101", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/css/style.css b/src/css/style.css index 3dac0e62..ef9d77e3 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -2312,6 +2312,20 @@ container-link { width: 13px; } +.small-post .hidden-reply-label { + font-size: 14px; + margin-top: 4px; + color: var(--text-color-muted); + display: flex; + align-items: center; + gap: 4px; +} + +.hidden-reply-label app-icon { + height: 15px; + width: 15px; +} + .small-post .repost-label { font-size: 14px; margin-bottom: 5px; diff --git a/src/js/dataHelpers.js b/src/js/dataHelpers.js index 8f528a9f..3c454eec 100644 --- a/src/js/dataHelpers.js +++ b/src/js/dataHelpers.js @@ -1114,6 +1114,13 @@ export function buildPostgateEmbeddingRules(quotesEnabled) { export const MAX_DETACHED_EMBEDDING_URIS = 50; +export const MAX_HIDDEN_REPLIES = 300; + +export function isThreadRootAuthor(post, did) { + const rootUri = post.record?.reply?.root?.uri; + return !!rootUri && parseUri(rootUri).repo === did; +} + export function createDetachedQuote(uri) { return { $type: "app.bsky.embed.record#viewDetached", uri, detached: true }; } diff --git a/src/js/dataLayer/derived.js b/src/js/dataLayer/derived.js index 92dc561f..eee391ee 100644 --- a/src/js/dataLayer/derived.js +++ b/src/js/dataLayer/derived.js @@ -335,7 +335,25 @@ export class Derived extends ReactiveStore { if (isEmptyPost(postThread)) { return postThread; } - const hiddenReplyUris = new Set(postThreadOther.map((item) => item.uri)); + const rootPost = this.$hydratedPosts.get( + getReplyRootFromPost(postThread.post).uri, + ); + const isOtherHiddenByThreadgate = (item) => + !!item.value?.hiddenByThreadgate; + const hiddenReplyUris = { + server: new Set( + postThreadOther + .filter((item) => !isOtherHiddenByThreadgate(item)) + .map((item) => item.uri), + ), + threadgate: new Set( + rootPost + ? (rootPost.threadgate?.record?.hiddenReplies ?? []) + : postThreadOther + .filter(isOtherHiddenByThreadgate) + .map((item) => item.uri), + ), + }; const hydrated = this.hydratePostThreadNode(postThread, hiddenReplyUris); if (!hydrated) { return null; @@ -1124,9 +1142,17 @@ export class Derived extends ReactiveStore { post, postNumbering: this.dataStore.$postNumbering.get(node.post.uri) ?? null, }; - if (hiddenReplyUris.has(node.post.uri)) { + if (post.record?.reply) { + const isHiddenByThreadgate = hiddenReplyUris.threadgate.has( + node.post.uri, + ); // NOTE: LEXICON DEVIATION - hydrated.post = { ...post, isHidden: true }; + hydrated.post = { + ...post, + isHidden: + isHiddenByThreadgate || hiddenReplyUris.server.has(node.post.uri), + isHiddenByThreadgate, + }; } if (node.replies) { hydrated.replies = node.replies.map((reply) => { diff --git a/src/js/dataLayer/mutations.js b/src/js/dataLayer/mutations.js index 8173d016..84b23ebc 100644 --- a/src/js/dataLayer/mutations.js +++ b/src/js/dataLayer/mutations.js @@ -21,6 +21,7 @@ import { createDetachedQuote, createEmbedFromPost, replaceQuotedPost, + MAX_HIDDEN_REPLIES, } from "/js/dataHelpers.js"; import { addPostLanguagesToHistory, @@ -45,6 +46,13 @@ export class DetachedQuoteLimitError extends Error { } } +export class HiddenReplyLimitError extends Error { + constructor() { + super(`Threads can have at most ${MAX_HIDDEN_REPLIES} hidden replies`); + this.name = "HiddenReplyLimitError"; + } +} + // Handles mutations to the data, making optimistic updates if needed. export class Mutations { constructor( @@ -2030,6 +2038,72 @@ export class Mutations { return post ? createEmbedFromPost(post) : null; } + async updateThreadgateHiddenReply(reply, hide) { + const currentUser = this.dataStore.$currentUser.get(); + if (!currentUser) throw new Error("No current user"); + const rootUri = reply.record?.reply?.root?.uri; + if (!rootUri) throw new Error("Post is not a reply"); + const { repo, rkey } = parseUri(rootUri); + if (currentUser.did !== repo) { + throw new Error("Only the thread author can hide replies"); + } + if (reply.author?.did === currentUser.did) { + throw new Error("Can't hide your own reply"); + } + + const patchId = this.patchStore.addPostPatch(rootUri, { + type: "setThreadgateHiddenReply", + replyUri: reply.uri, + hidden: hide, + }); + try { + let priorThreadgateRecord = null; + try { + priorThreadgateRecord = await this.api.getThreadgateRecord(rkey); + } catch (error) { + if (!isRecordNotFoundError(error)) throw error; + } + if (!priorThreadgateRecord && !hide) return; + const priorUris = priorThreadgateRecord?.value?.hiddenReplies ?? []; + const otherUris = priorUris.filter((uri) => uri !== reply.uri); + if (hide && otherUris.length >= MAX_HIDDEN_REPLIES) { + throw new HiddenReplyLimitError(); + } + const record = { + ...priorThreadgateRecord?.value, + $type: "app.bsky.feed.threadgate", + post: rootUri, + createdAt: getCurrentTimestamp(), + hiddenReplies: hide ? [...otherUris, reply.uri] : otherUris, + }; + const result = await this.api.putThreadgateRecord( + rkey, + record, + priorThreadgateRecord?.cid ?? null, + ); + + const latestRootPost = this.dataStore.$posts.get(rootUri); + if (latestRootPost) { + this.dataStore.$posts.set(rootUri, { + ...latestRootPost, + threadgate: { + uri: buildUri({ + repo, + collection: "app.bsky.feed.threadgate", + rkey, + }), + lists: [], + ...latestRootPost.threadgate, + cid: result.cid, + record, + }, + }); + } + } finally { + this.patchStore.removePostPatch(rootUri, patchId); + } + } + async updateThreadgateAllow(post, threadgateAllow) { const currentUser = this.dataStore.$currentUser.get(); if (!currentUser) throw new Error("No current user"); diff --git a/src/js/dataLayer/patchStore.js b/src/js/dataLayer/patchStore.js index 3b2323b7..18722a6e 100644 --- a/src/js/dataLayer/patchStore.js +++ b/src/js/dataLayer/patchStore.js @@ -153,6 +153,27 @@ export class PatchStore extends ReactiveStore { }, }; } + case "setThreadgateHiddenReply": { + const record = { + $type: "app.bsky.feed.threadgate", + post: post.uri, + ...post.threadgate?.record, + }; + const otherUris = (record.hiddenReplies ?? []).filter( + (uri) => uri !== patchBody.replyUri, + ); + record.hiddenReplies = patchBody.hidden + ? [...otherUris, patchBody.replyUri] + : otherUris; + return { + ...post, + threadgate: { + lists: [], + ...post.threadgate, + record, + }, + }; + } default: throw new Error("Unknown patch type", patchBody.type); } diff --git a/src/js/postInteractionHandler.js b/src/js/postInteractionHandler.js index 8431c64d..1b481890 100644 --- a/src/js/postInteractionHandler.js +++ b/src/js/postInteractionHandler.js @@ -5,8 +5,12 @@ import { html } from "/js/lib/lit-html.js"; import { buildPostgateEmbeddingRules, MAX_DETACHED_EMBEDDING_URIS, + MAX_HIDDEN_REPLIES, } from "/js/dataHelpers.js"; -import { DetachedQuoteLimitError } from "/js/dataLayer/mutations.js"; +import { + DetachedQuoteLimitError, + HiddenReplyLimitError, +} from "/js/dataLayer/mutations.js"; import "/js/components/post-interaction-settings-dialog.js"; import "/js/components/app-icon.js"; @@ -219,6 +223,41 @@ export class PostInteractionHandler { } } + async handleToggleReplyHiddenForEveryone(post, doHide) { + if ( + doHide && + !(await confirmModal( + "This reply will be hidden for everyone in your thread, and you won't be notified of any further replies to it.", + { + title: "Hide reply for everyone?", + confirmButtonText: "Hide", + }, + )) + ) { + return; + } + try { + await this.dataLayer.mutations.updateThreadgateHiddenReply(post, doHide); + showToast( + doHide ? "Reply hidden for everyone" : "Reply shown for everyone", + { style: "success" }, + ); + } catch (error) { + if (error instanceof HiddenReplyLimitError) { + console.warn(error); + showToast( + `You can hide at most ${MAX_HIDDEN_REPLIES} replies in a thread`, + { style: "error" }, + ); + return; + } + console.error(error); + showToast(doHide ? "Failed to hide reply" : "Failed to show reply", { + style: "error", + }); + } + } + async handleHidePost(post) { if ( !(await confirmModal("This post will be hidden from feeds and threads.", { diff --git a/src/js/templates/largePost.template.js b/src/js/templates/largePost.template.js index c52e551a..588660eb 100644 --- a/src/js/templates/largePost.template.js +++ b/src/js/templates/largePost.template.js @@ -323,6 +323,11 @@ export function largePostTemplate({ quotedPostUri, doDetach, ), + onClickToggleReplyHiddenForEveryone: (post, doHide) => + postInteractionHandler.handleToggleReplyHiddenForEveryone( + post, + doHide, + ), onClickReport: (post) => postInteractionHandler.handleReport(post), pluginService, diff --git a/src/js/templates/postActionBar.template.js b/src/js/templates/postActionBar.template.js index f2e724c5..141f9852 100644 --- a/src/js/templates/postActionBar.template.js +++ b/src/js/templates/postActionBar.template.js @@ -9,6 +9,7 @@ import { classnames, isAndroid, isFirefox, + isNil, } from "/js/utils.js"; import { fillableIconTemplate } from "/js/templates/fillableIcon.template.js"; import "/js/components/app-icon.js"; @@ -17,6 +18,7 @@ import { canReplyToPost, getFeedGeneratorProxyUrl, getOwnQuoteEmbed, + isThreadRootAuthor, } from "/js/dataHelpers.js"; import { richTextToString } from "/js/facetHelpers.js"; import { SignInModal } from "/js/modals/signIn.modal.js"; @@ -56,6 +58,7 @@ function postContextMenuTemplate({ isUserPost, isPinnedToProfile, ownQuoteEmbed, + canHideReplyForEveryone, enableFeedFeedback, feedContext, pluginItems, @@ -70,6 +73,7 @@ function postContextMenuTemplate({ onClickPin, onClickEditInteractionSettings, onClickToggleQuoteDetachment, + onClickToggleReplyHiddenForEveryone, }) { const canPin = isUserPost && !post.record?.reply; const canEditInteractionSettings = isUserPost && !post.record?.reply; @@ -158,7 +162,7 @@ function postContextMenuTemplate({ ${!isUserPost ? html` - ${canHidePostForMe || ownQuoteEmbed + ${canHidePostForMe || ownQuoteEmbed || canHideReplyForEveryone ? html` ${canHidePostForMe @@ -173,6 +177,28 @@ function postContextMenuTemplate({ ` : null} + ${canHideReplyForEveryone + ? html` + + onClickToggleReplyHiddenForEveryone( + post, + !post.isHiddenByThreadgate, + )} + > + ${post.isHiddenByThreadgate + ? "Show reply for everyone" + : "Hide reply for everyone"} + + ` + : null} ${ownQuoteEmbed ? html`
@@ -592,6 +624,7 @@ export function postActionBarTemplate({ isUserPost, isPinnedToProfile, ownQuoteEmbed, + canHideReplyForEveryone, enableFeedFeedback, feedContext, feedGenerator, @@ -607,6 +640,7 @@ export function postActionBarTemplate({ onClickPin, onClickEditInteractionSettings, onClickToggleQuoteDetachment, + onClickToggleReplyHiddenForEveryone, }); }} > diff --git a/src/js/templates/smallPost.template.js b/src/js/templates/smallPost.template.js index 7bf33612..8cc02636 100644 --- a/src/js/templates/smallPost.template.js +++ b/src/js/templates/smallPost.template.js @@ -5,6 +5,7 @@ import { isUnavailablePost, getDisplayName, doHideAuthorOnUnauthenticated, + isThreadRootAuthor, } from "/js/dataHelpers.js"; import { noop } from "/js/utils.js"; import { linkToPost, linkToProfile } from "/js/navigation.js"; @@ -120,6 +121,10 @@ export function smallPostTemplate({ !isAuthenticated && post.author && doHideAuthorOnUnauthenticated(post.author); + const isHiddenByYou = + !!post.isHiddenByThreadgate && + !!currentUser && + isThreadRootAuthor(post, currentUser.did); const postText = post.record.text || ""; const content = html` + Reply hidden by you +
` + : ""} ${showReplyToLabel ? replyToLabelTemplate({ currentUser, @@ -264,6 +277,11 @@ export function smallPostTemplate({ quotedPostUri, doDetach, ), + onClickToggleReplyHiddenForEveryone: (post, doHide) => + postInteractionHandler.handleToggleReplyHiddenForEveryone( + post, + doHide, + ), onClickReport: (post) => postInteractionHandler.handleReport(post), enableFeedFeedback, pluginService, diff --git a/tests/e2e/specs/flows/hiddenReplyAuthoring.test.js b/tests/e2e/specs/flows/hiddenReplyAuthoring.test.js new file mode 100644 index 00000000..58b3da8d --- /dev/null +++ b/tests/e2e/specs/flows/hiddenReplyAuthoring.test.js @@ -0,0 +1,167 @@ +import { test, expect } from "../../base.js"; +import { login } from "../../helpers.js"; +import { userProfile } from "../../testData.js"; +import { MockServer } from "../../mockServer.js"; +import { createPost } from "../../../shared/factories.js"; + +test.describe("Hidden reply authoring flow", () => { + const ownPostUri = `at://${userProfile.did}/app.bsky.feed.post/own1`; + const replyUri = "at://did:plc:replier/app.bsky.feed.post/reply1"; + const allow = [{ $type: "app.bsky.feed.threadgate#mentionRule" }]; + + function makeOwnPost(threadgateRecord) { + return createPost({ + uri: ownPostUri, + text: "My own post", + authorHandle: userProfile.handle, + authorDisplayName: userProfile.displayName, + replyCount: 1, + threadgate: { + uri: `at://${userProfile.did}/app.bsky.feed.threadgate/own1`, + cid: "bafypriorgate", + record: threadgateRecord, + lists: [], + }, + }); + } + const ownPost = makeOwnPost(null); + const reply = createPost({ + uri: replyUri, + text: "A reply to hide", + authorHandle: "replier.bsky.social", + authorDisplayName: "Replier", + reply: { + root: { uri: ownPost.uri, cid: ownPost.cid }, + parent: { uri: ownPost.uri, cid: ownPost.cid }, + }, + }); + + function setupThread(mockServer, threadgateRecord, { serverHidden }) { + const rootPost = makeOwnPost(threadgateRecord); + mockServer.threadgateRecords.set("own1", { + cid: "bafypriorgate", + value: threadgateRecord, + }); + mockServer.addPosts([rootPost, reply]); + mockServer.setPostThread(ownPostUri, { + $type: "app.bsky.feed.defs#threadViewPost", + post: rootPost, + parent: null, + replies: [ + { + $type: "app.bsky.feed.defs#threadViewPost", + post: reply, + replies: [], + }, + ], + }); + mockServer.setPostThreadOther( + ownPostUri, + serverHidden + ? [{ uri: replyUri, value: { hiddenByThreadgate: true } }] + : [], + ); + } + + test("hides a reply for everyone, preserving allow", async ({ page }) => { + const mockServer = new MockServer(); + const priorHiddenUri = "at://did:plc:other/app.bsky.feed.post/old"; + setupThread( + mockServer, + { + $type: "app.bsky.feed.threadgate", + post: ownPostUri, + allow, + hiddenReplies: [priorHiddenUri], + createdAt: "2024-01-01T00:00:00.000Z", + }, + { serverHidden: false }, + ); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.handle}/post/own1`); + + const view = page.locator("#post-detail-view"); + const replyPost = view + .locator('[data-testid="small-post"]') + .filter({ hasText: "A reply to hide" }); + await expect(replyPost).toBeVisible({ timeout: 10000 }); + await expect(view.locator("hidden-replies-section")).not.toBeAttached(); + + await replyPost.locator('[data-testid="post-action-more"]').click(); + const menuItem = page.locator( + '[data-testid="menu-action-post-toggle-reply-hidden"]', + ); + await expect(menuItem).toHaveAttribute("data-teststate", "shown"); + await menuItem.click(); + await page.locator('[data-testid="modal-confirm-button"]').click(); + + const hiddenSection = view.locator("hidden-replies-section"); + await expect(hiddenSection).toBeAttached(); + await expect( + view.locator(".post-thread-reply-chains [data-testid='small-post']"), + ).toHaveCount(0); + + await expect.poll(() => mockServer.putThreadgateCalls.length).toBe(1); + const body = mockServer.putThreadgateCalls[0]; + expect(body.rkey).toBe("own1"); + expect(body.swapRecord).toBe("bafypriorgate"); + expect(body.record.allow).toEqual(allow); + expect(body.record.hiddenReplies).toEqual([priorHiddenUri, replyUri]); + + await hiddenSection + .locator('[data-testid="hidden-replies-button"]') + .click(); + await expect( + hiddenSection.locator('[data-testid="hidden-reply-label"]'), + ).toBeVisible(); + }); + + test("shows a hidden reply for everyone", async ({ page }) => { + const mockServer = new MockServer(); + setupThread( + mockServer, + { + $type: "app.bsky.feed.threadgate", + post: ownPostUri, + allow, + hiddenReplies: [replyUri], + createdAt: "2024-01-01T00:00:00.000Z", + }, + { serverHidden: true }, + ); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.handle}/post/own1`); + + const view = page.locator("#post-detail-view"); + const hiddenSection = view.locator("hidden-replies-section"); + await expect(hiddenSection).toBeAttached({ timeout: 10000 }); + await hiddenSection + .locator('[data-testid="hidden-replies-button"]') + .click(); + + const replyPost = hiddenSection + .locator('[data-testid="small-post"]') + .filter({ hasText: "A reply to hide" }); + await replyPost.locator('[data-testid="post-action-more"]').click(); + const menuItem = page.locator( + '[data-testid="menu-action-post-toggle-reply-hidden"]', + ); + await expect(menuItem).toHaveAttribute("data-teststate", "hidden"); + await menuItem.click(); + + await expect(hiddenSection).not.toBeAttached(); + await expect( + view.locator(".post-thread-reply-chains [data-testid='small-post']"), + ).toHaveCount(1); + + await expect.poll(() => mockServer.putThreadgateCalls.length).toBe(1); + const body = mockServer.putThreadgateCalls[0]; + expect(body.swapRecord).toBe("bafypriorgate"); + expect(body.record.allow).toEqual(allow); + expect(body.record.hiddenReplies).toEqual([]); + }); +}); diff --git a/tests/unit/specs/dataLayer/derived.test.js b/tests/unit/specs/dataLayer/derived.test.js index 54531d32..edfbe3f8 100644 --- a/tests/unit/specs/dataLayer/derived.test.js +++ b/tests/unit/specs/dataLayer/derived.test.js @@ -2047,7 +2047,13 @@ describe("$hydratedPostThreads", () => { uri: threadUri, record: { text: "root" }, }); - dataStore.$posts.set("reply1", { uri: "reply1", record: { text: "r1" } }); + dataStore.$posts.set("reply1", { + uri: "reply1", + record: { + text: "r1", + reply: { root: { uri: threadUri }, parent: { uri: threadUri } }, + }, + }); dataStore.setPostThread(threadUri, { $type: "app.bsky.feed.defs#threadViewPost", post: { uri: threadUri }, @@ -2067,6 +2073,160 @@ describe("$hydratedPostThreads", () => { assert.deepEqual(result.replies[1], blockedReply); }); + function seedThreadWithReply( + dataStore, + { hiddenReplies, serverHidden, serverHiddenByThreadgate = false }, + ) { + dataStore.$posts.set(threadUri, { + uri: threadUri, + record: { text: "root" }, + threadgate: { record: { hiddenReplies } }, + }); + dataStore.$posts.set("reply1", { + uri: "reply1", + record: { + text: "r1", + reply: { root: { uri: threadUri }, parent: { uri: threadUri } }, + }, + }); + dataStore.setPostThread(threadUri, { + $type: "app.bsky.feed.defs#threadViewPost", + post: { uri: threadUri }, + replies: [ + { + $type: "app.bsky.feed.defs#threadViewPost", + post: { uri: "reply1" }, + }, + ], + }); + dataStore.$postThreadOthers.set( + threadUri, + serverHidden + ? [ + { + uri: "reply1", + value: { hiddenByThreadgate: serverHiddenByThreadgate }, + }, + ] + : [], + ); + } + + it("should mark replies in the root threadgate's hiddenReplies as hidden by the threadgate", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + seedThreadWithReply(dataStore, { + hiddenReplies: ["reply1"], + serverHidden: false, + }); + const reply = derived.$hydratedPostThreads.get(threadUri).replies[0]; + assert.deepEqual(reply.post.isHidden, true); + assert.deepEqual(reply.post.isHiddenByThreadgate, true); + }); + + it("should not attribute a server-only hide to the threadgate", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + seedThreadWithReply(dataStore, { hiddenReplies: [], serverHidden: true }); + const reply = derived.$hydratedPostThreads.get(threadUri).replies[0]; + assert.deepEqual(reply.post.isHidden, true); + assert.deepEqual(reply.post.isHiddenByThreadgate, false); + }); + + it("should mark unhidden replies as not hidden by the threadgate", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + seedThreadWithReply(dataStore, { hiddenReplies: [], serverHidden: false }); + const reply = derived.$hydratedPostThreads.get(threadUri).replies[0]; + assert.deepEqual(reply.post.isHidden, false); + assert.deepEqual(reply.post.isHiddenByThreadgate, false); + }); + + it("should hide a reply from a pending threadgate patch before the appview catches up", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived, patchStore } = makeDerived(dataStore); + seedThreadWithReply(dataStore, { hiddenReplies: [], serverHidden: false }); + assert.deepEqual( + derived.$hydratedPostThreads.get(threadUri).replies[0].post.isHidden, + false, + ); + patchStore.addPostPatch(threadUri, { + type: "setThreadgateHiddenReply", + replyUri: "reply1", + hidden: true, + }); + const reply = derived.$hydratedPostThreads.get(threadUri).replies[0]; + assert.deepEqual(reply.post.isHidden, true); + assert.deepEqual(reply.post.isHiddenByThreadgate, true); + }); + + it("should show a reply the root threadgate no longer hides despite a stale server verdict", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + seedThreadWithReply(dataStore, { + hiddenReplies: [], + serverHidden: true, + serverHiddenByThreadgate: true, + }); + const reply = derived.$hydratedPostThreads.get(threadUri).replies[0]; + assert.deepEqual(reply.post.isHidden, false); + assert.deepEqual(reply.post.isHiddenByThreadgate, false); + }); + + it("should keep a reply hidden for other reasons after its threadgate hide is removed", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived, patchStore } = makeDerived(dataStore); + seedThreadWithReply(dataStore, { + hiddenReplies: ["reply1"], + serverHidden: true, + }); + patchStore.addPostPatch(threadUri, { + type: "setThreadgateHiddenReply", + replyUri: "reply1", + hidden: false, + }); + const reply = derived.$hydratedPostThreads.get(threadUri).replies[0]; + assert.deepEqual(reply.post.isHidden, true); + assert.deepEqual(reply.post.isHiddenByThreadgate, false); + }); + + it("should fall back to the appview's threadgate verdict when the root post isn't loaded", () => { + const dataStore = new DataStore(createSessionState(null)); + const { derived } = makeDerived(dataStore); + const rootUri = "missing-root"; + const anchor = { + uri: "anchor", + record: { + text: "anchor", + reply: { root: { uri: rootUri }, parent: { uri: rootUri } }, + }, + }; + dataStore.$posts.set("anchor", anchor); + dataStore.$posts.set("reply1", { + uri: "reply1", + record: { + text: "r1", + reply: { root: { uri: rootUri }, parent: { uri: "anchor" } }, + }, + }); + dataStore.setPostThread("anchor", { + $type: "app.bsky.feed.defs#threadViewPost", + post: anchor, + replies: [ + { + $type: "app.bsky.feed.defs#threadViewPost", + post: { uri: "reply1" }, + }, + ], + }); + dataStore.$postThreadOthers.set("anchor", [ + { uri: "reply1", value: { hiddenByThreadgate: true } }, + ]); + const reply = derived.$hydratedPostThreads.get("anchor").replies[0]; + assert.deepEqual(reply.post.isHidden, true); + assert.deepEqual(reply.post.isHiddenByThreadgate, true); + }); + function seedThreadWithParent(dataStore, parent) { dataStore.$posts.set(threadUri, { uri: threadUri, diff --git a/tests/unit/specs/dataLayer/mutations.test.js b/tests/unit/specs/dataLayer/mutations.test.js index 209b150e..c41c9d09 100644 --- a/tests/unit/specs/dataLayer/mutations.test.js +++ b/tests/unit/specs/dataLayer/mutations.test.js @@ -1,6 +1,10 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert/strict"; -import { Mutations, DetachedQuoteLimitError } from "/js/dataLayer/mutations.js"; +import { + Mutations, + DetachedQuoteLimitError, + HiddenReplyLimitError, +} from "/js/dataLayer/mutations.js"; import { DataStore } from "/js/dataLayer/dataStore.js"; import { createSessionState } from "/js/dataLayer/sessionState.js"; import { DraftMediaStore } from "/js/drafts.js"; @@ -12,7 +16,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 { isBlockedPost } from "/js/dataHelpers.js"; +import { isBlockedPost, MAX_HIDDEN_REPLIES } from "/js/dataHelpers.js"; import { createPost } from "../../../shared/factories.js"; function escapeRegExp(value) { @@ -6654,3 +6658,233 @@ describe("setThreadMuted", () => { assert.deepEqual(patchStore.$threadMutePatches.get(rootUri), []); }); }); + +describe("updateThreadgateHiddenReply", () => { + const userDid = "did:plc:me"; + const rootUri = `at://${userDid}/app.bsky.feed.post/root`; + const replyUri = "at://did:plc:other/app.bsky.feed.post/reply"; + const rootPost = { + uri: rootUri, + cid: "cid-root", + author: { did: userDid, viewer: {} }, + record: { text: "root" }, + }; + const reply = { + uri: replyUri, + cid: "cid-reply", + author: { did: "did:plc:other", viewer: {} }, + record: { + text: "reply", + reply: { + root: { uri: rootUri, cid: "cid-root" }, + parent: { uri: rootUri, cid: "cid-root" }, + }, + }, + }; + const allow = [{ $type: "app.bsky.feed.threadgate#mentionRule" }]; + + function recordNotFoundError() { + return new ApiError({ + status: 400, + statusText: "Bad Request", + data: { error: "RecordNotFound" }, + headers: {}, + url: "", + }); + } + + function priorRecord(value) { + return { + cid: "cid-prior", + value: { + $type: "app.bsky.feed.threadgate", + post: rootUri, + createdAt: "2024-01-01T00:00:00.000Z", + ...value, + }, + }; + } + + function setup(mockApi) { + const dataStore = new DataStore(createSessionState(null)); + dataStore.$currentUser.set({ did: userDid, handle: "me.test" }); + dataStore.$posts.set(rootUri, rootPost); + const patchStore = new PatchStore(); + const mutations = makeMutations(mockApi, dataStore, patchStore, { + requirePreferences: () => Preferences.createLoggedOutPreferences(), + }); + return { mutations, dataStore, patchStore }; + } + + it("appends the reply to hiddenReplies, preserving allow and swapping on the prior cid", async () => { + let putArgs = null; + const otherHidden = "at://did:plc:x/app.bsky.feed.post/old"; + const { mutations, dataStore } = setup({ + getThreadgateRecord: async () => + priorRecord({ allow, hiddenReplies: [otherHidden] }), + putThreadgateRecord: async (rkey, record, swapRecord) => { + putArgs = { rkey, record, swapRecord }; + return { cid: "cid-next" }; + }, + }); + + await mutations.updateThreadgateHiddenReply(reply, true); + + assert.deepEqual(putArgs.rkey, "root"); + assert.deepEqual(putArgs.swapRecord, "cid-prior"); + assert.deepEqual(putArgs.record.allow, allow); + assert.deepEqual(putArgs.record.hiddenReplies, [otherHidden, replyUri]); + const updatedRoot = dataStore.$posts.get(rootUri); + assert.deepEqual(updatedRoot.threadgate.cid, "cid-next"); + assert.deepEqual(updatedRoot.threadgate.record.hiddenReplies, [ + otherHidden, + replyUri, + ]); + }); + + it("creates a threadgate without allow when none exists", async () => { + let putArgs = null; + const { mutations } = setup({ + getThreadgateRecord: async () => { + throw recordNotFoundError(); + }, + putThreadgateRecord: async (rkey, record, swapRecord) => { + putArgs = { rkey, record, swapRecord }; + return { cid: "cid-new" }; + }, + }); + + await mutations.updateThreadgateHiddenReply(reply, true); + + assert.deepEqual(putArgs.swapRecord, null); + assert.deepEqual("allow" in putArgs.record, false); + assert.deepEqual(putArgs.record.post, rootUri); + assert.deepEqual(putArgs.record.hiddenReplies, [replyUri]); + }); + + it("removes the reply from hiddenReplies when showing", async () => { + let putArgs = null; + const { mutations } = setup({ + getThreadgateRecord: async () => + priorRecord({ allow, hiddenReplies: [replyUri] }), + putThreadgateRecord: async (rkey, record, swapRecord) => { + putArgs = { rkey, record, swapRecord }; + return { cid: "cid-next" }; + }, + }); + + await mutations.updateThreadgateHiddenReply(reply, false); + + assert.deepEqual(putArgs.record.hiddenReplies, []); + assert.deepEqual(putArgs.record.allow, allow); + }); + + it("doesn't write when showing and no threadgate exists", async () => { + const putThreadgateRecord = mock.fn(); + const { mutations } = setup({ + getThreadgateRecord: async () => { + throw recordNotFoundError(); + }, + putThreadgateRecord, + }); + + await mutations.updateThreadgateHiddenReply(reply, false); + + assert.deepEqual(putThreadgateRecord.mock.callCount(), 0); + }); + + it("throws HiddenReplyLimitError at the cap", async () => { + const putThreadgateRecord = mock.fn(); + const hiddenReplies = Array.from( + { length: MAX_HIDDEN_REPLIES }, + (_, i) => `at://did:plc:x/app.bsky.feed.post/${i}`, + ); + const { mutations } = setup({ + getThreadgateRecord: async () => priorRecord({ hiddenReplies }), + putThreadgateRecord, + }); + + await assert.rejects( + () => mutations.updateThreadgateHiddenReply(reply, true), + HiddenReplyLimitError, + ); + assert.deepEqual(putThreadgateRecord.mock.callCount(), 0); + }); + + function deferredGet() { + let resolveGet; + const getThreadgateRecord = () => + new Promise((resolve) => { + resolveGet = resolve; + }); + return { getThreadgateRecord, resolve: (value) => resolveGet(value) }; + } + + it("patches the root's hiddenReplies while in flight and removes the patch after", async () => { + const get = deferredGet(); + const { mutations, dataStore, patchStore } = setup({ + getThreadgateRecord: get.getThreadgateRecord, + putThreadgateRecord: async () => ({ cid: "cid-next" }), + }); + + const promise = mutations.updateThreadgateHiddenReply(reply, true); + assert.deepEqual( + applyPostPatches(patchStore, rootPost).threadgate.record.hiddenReplies, + [replyUri], + ); + get.resolve(priorRecord({ allow })); + await promise; + assert.deepEqual(patchStore.$postPatches.get(rootUri) ?? [], []); + assert.deepEqual( + dataStore.$posts.get(rootUri).threadgate.record.hiddenReplies, + [replyUri], + ); + }); + + it("patches the reply shown optimistically and removes the patch on failure", async () => { + const get = deferredGet(); + const { mutations, dataStore, patchStore } = setup({ + getThreadgateRecord: get.getThreadgateRecord, + putThreadgateRecord: async () => { + throw new Error("conflict"); + }, + }); + const hiddenRootPost = { + ...rootPost, + threadgate: { record: { hiddenReplies: [replyUri] } }, + }; + dataStore.$posts.set(rootUri, hiddenRootPost); + + const promise = mutations.updateThreadgateHiddenReply(reply, false); + assert.deepEqual( + applyPostPatches(patchStore, hiddenRootPost).threadgate.record + .hiddenReplies, + [], + ); + get.resolve(priorRecord({ hiddenReplies: [replyUri] })); + await assert.rejects(promise, /conflict/); + assert.deepEqual(patchStore.$postPatches.get(rootUri) ?? [], []); + assert.deepEqual( + dataStore.$posts.get(rootUri).threadgate.record.hiddenReplies, + [replyUri], + ); + }); + + it("rejects when the viewer doesn't own the thread root", async () => { + const { mutations } = setup({}); + const foreignReply = { + ...reply, + record: { + ...reply.record, + reply: { + root: { uri: "at://did:plc:else/app.bsky.feed.post/r", cid: "c" }, + parent: { uri: "at://did:plc:else/app.bsky.feed.post/r", cid: "c" }, + }, + }, + }; + await assert.rejects( + () => mutations.updateThreadgateHiddenReply(foreignReply, true), + /thread author/, + ); + }); +}); diff --git a/tests/unit/specs/templates/postActionBar.template.test.js b/tests/unit/specs/templates/postActionBar.template.test.js index 05249edb..9f74389b 100644 --- a/tests/unit/specs/templates/postActionBar.template.test.js +++ b/tests/unit/specs/templates/postActionBar.template.test.js @@ -527,6 +527,104 @@ describe("postActionBarTemplate - plugin context menu items", () => { ]); }); + function replyToThreadOf(rootDid, overrides = {}) { + const rootUri = `at://${rootDid}/app.bsky.feed.post/root`; + return { + ...post, + record: { + ...post.record, + reply: { + root: { uri: rootUri, cid: "cid-root" }, + parent: { uri: rootUri, cid: "cid-root" }, + }, + }, + isHiddenByThreadgate: false, + ...overrides, + }; + } + + it("should offer hiding a reply for everyone in the viewer's own thread", async () => { + const onClickToggleReplyHiddenForEveryone = mock.fn(); + const reply = replyToThreadOf("did:plc:test"); + const menu = await renderAndOpenMenu({ + post: reply, + isAuthenticated: true, + onClickToggleReplyHiddenForEveryone, + }); + const item = menu.querySelector( + '[data-testid="menu-action-post-toggle-reply-hidden"]', + ); + assert.equal(item.dataset.teststate, "shown"); + item.click(); + assert.deepEqual( + onClickToggleReplyHiddenForEveryone.mock.calls[0].arguments, + [reply, true], + ); + }); + + it("should offer showing a reply hidden by the threadgate", async () => { + const onClickToggleReplyHiddenForEveryone = mock.fn(); + const reply = replyToThreadOf("did:plc:test", { + isHiddenByThreadgate: true, + }); + const menu = await renderAndOpenMenu({ + post: reply, + isAuthenticated: true, + onClickToggleReplyHiddenForEveryone, + }); + const item = menu.querySelector( + '[data-testid="menu-action-post-toggle-reply-hidden"]', + ); + assert.equal(item.dataset.teststate, "hidden"); + item.click(); + assert.deepEqual( + onClickToggleReplyHiddenForEveryone.mock.calls[0].arguments, + [reply, false], + ); + }); + + it("should not offer hiding a reply for everyone in someone else's thread", async () => { + const menu = await renderAndOpenMenu({ + post: replyToThreadOf("did:plc:someone-else"), + isAuthenticated: true, + }); + assert.equal( + menu.querySelector( + '[data-testid="menu-action-post-toggle-reply-hidden"]', + ), + null, + ); + }); + + it("should not offer hiding a reply for everyone outside a thread", async () => { + const menu = await renderAndOpenMenu({ + post: replyToThreadOf("did:plc:test", { + isHiddenByThreadgate: undefined, + }), + isAuthenticated: true, + }); + assert.equal( + menu.querySelector( + '[data-testid="menu-action-post-toggle-reply-hidden"]', + ), + null, + ); + }); + + it("should not offer hiding the viewer's own reply for everyone", async () => { + const menu = await renderAndOpenMenu({ + post: replyToThreadOf("did:plc:test"), + isAuthenticated: true, + isUserPost: true, + }); + assert.equal( + menu.querySelector( + '[data-testid="menu-action-post-toggle-reply-hidden"]', + ), + null, + ); + }); + it("should pass null feed meta when the post is not in a feed", async () => { const menuCalls = []; const pluginService = { diff --git a/tests/unit/specs/templates/smallPost.template.test.js b/tests/unit/specs/templates/smallPost.template.test.js index 347021e8..29755308 100644 --- a/tests/unit/specs/templates/smallPost.template.test.js +++ b/tests/unit/specs/templates/smallPost.template.test.js @@ -332,6 +332,45 @@ describe("smallPostTemplate - reply-to label", () => { }); }); +describe("smallPostTemplate - reply hidden by you label", () => { + function hiddenReplyInThreadOf(rootDid) { + const rootUri = `at://${rootDid}/app.bsky.feed.post/root`; + return { + ...post, + isHidden: true, + isHiddenByThreadgate: true, + record: { + ...post.record, + reply: { + root: { uri: rootUri, cid: "cid-root" }, + parent: { uri: rootUri, cid: "cid-root" }, + }, + }, + }; + } + + function renderPost(post) { + const container = document.createElement("div"); + render(smallPostTemplate({ post, ...baseProps }), container); + return container; + } + + it("should show the label to the thread author", () => { + const container = renderPost(hiddenReplyInThreadOf(currentUser.did)); + assert( + container.querySelector("[data-testid='hidden-reply-label']") !== null, + ); + }); + + it("should not show the label to other viewers", () => { + const container = renderPost(hiddenReplyInThreadOf("did:plc:someone")); + assert.equal( + container.querySelector("[data-testid='hidden-reply-label']"), + null, + ); + }); +}); + describe("smallPostTemplate - blocked/unavailable posts", () => { it("should render blocked post template for blocked post", () => { const blockedPost = {