From 2e6ae41556c25ea66f379f47f2945e88c1609767 Mon Sep 17 00:00:00 2001 From: Grace Kind Date: Sat, 10 Oct 2026 21:55:54 -0500 Subject: [PATCH] Interaction settings for replies --- package.json | 2 +- src/css/style.css | 26 +++ .../post-interaction-settings-dialog.js | 107 +++++++-- src/js/postInteractionHandler.js | 6 +- src/js/templates/postActionBar.template.js | 19 +- tests/e2e/specs/flows/editThreadgate.test.js | 216 ++++++++++++++++++ .../post-interaction-settings-dialog.test.js | 170 +++++++++++++- 7 files changed, 518 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index f8a3b825..0507f906 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.20.101", + "version": "0.20.102", "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 ef9d77e3..51cdf2f7 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -10901,6 +10901,32 @@ label:has(toggle-switch[disabled]) { cursor: not-allowed; } +.interaction-settings-section-disabled { + opacity: 0.4; +} + +.interaction-settings-section-disabled .interaction-settings-row-disabled { + opacity: 1; +} + +.interaction-settings-notice { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + border-radius: var(--card-border-radius); + background: var(--post-hover-color); + color: var(--text-color-secondary); + font-size: 14px; +} + +.interaction-settings-notice app-icon { + display: flex; + width: 18px; + height: 18px; + flex-shrink: 0; +} + .interaction-settings-list-avatar { width: 24px; height: 24px; diff --git a/src/js/components/post-interaction-settings-dialog.js b/src/js/components/post-interaction-settings-dialog.js index 368731a5..062f627d 100644 --- a/src/js/components/post-interaction-settings-dialog.js +++ b/src/js/components/post-interaction-settings-dialog.js @@ -77,7 +77,7 @@ function listRowTemplate({ list, checked, disabled, onToggle }) { // Rules that must survive a save but can't be shown as a normal row: unknown // rule types and list rules for lists the user doesn't have access to. They // stay checked-but-opaque and can only be explicitly removed. -function preservedRowTemplate({ setting, hydratedLists, onRemove }) { +function preservedRowTemplate({ setting, hydratedLists, disabled, onRemove }) { let label; if (setting.type === "unknown") { label = "An unknown rule (added by another app)"; @@ -100,6 +100,7 @@ function preservedRowTemplate({ setting, hydratedLists, onRemove }) { type="button" class="text-pill-button" data-testid="interaction-settings-remove-rule" + .disabled=${disabled} @click=${onRemove} > Remove @@ -115,6 +116,7 @@ function listsSectionTemplate({ listsError, atRuleCap, isSaving, + disabled, listsOpen, onToggleListsOpen, onToggleList, @@ -160,7 +162,7 @@ function listsSectionTemplate({ return listRowTemplate({ list, checked, - disabled: isSaving || (!checked && atRuleCap), + disabled: disabled || isSaving || (!checked && atRuleCap), onToggle: () => onToggleList(list), }); })} @@ -185,6 +187,27 @@ function quotePostsRowTemplate({ quotesEnabled, isSaving, onToggleQuotes }) { `; } +function replySettingsNoticeTemplate({ replySettingsStatus }) { + let content; + if (replySettingsStatus === "loading") { + content = html`
+ Loading the thread's reply settings...`; + } else if (replySettingsStatus === "unavailable") { + content = html` Couldn't load + the thread's reply settings.`; + } else { + content = html` Reply settings + are chosen by the author of the thread.`; + } + return html`
+ ${content} +
`; +} + function interactionSettingsFormTemplate({ settings, curateLists, @@ -193,6 +216,7 @@ function interactionSettingsFormTemplate({ hydratedLists, quotesEnabled, isSaving, + replySettingsStatus, saveError, defaultsRowState, saveAsDefault, @@ -207,9 +231,11 @@ function interactionSettingsFormTemplate({ onRemoveSetting, onSave, }) { + const replySettingsDisabled = replySettingsStatus !== "editable"; const isEverybody = settings.some((setting) => setting.type === "everybody"); const isNobody = settings.some((setting) => setting.type === "nobody"); const atRuleCap = countRules(settings) >= MAX_ALLOW_RULES; + const replyInputsDisabled = isSaving || replySettingsDisabled; const curateListUris = new Set(curateLists.map((list) => list.uri)); const preservedSettings = settings.filter( (setting) => @@ -219,7 +245,16 @@ function interactionSettingsFormTemplate({ !curateListUris.has(setting.list)), ); return html` -
+ ${replySettingsDisabled + ? replySettingsNoticeTemplate({ replySettingsStatus }) + : null} +

Who can reply

${interactionSettingsFormTemplate({ - settings, + settings: replySettingsKnown ? settings : [], curateLists: curateLists ?? [], listsLoaded: curateLists !== null, listsError, hydratedLists: this.hydratedLists ?? [], quotesEnabled, isSaving, + replySettingsStatus, saveError, defaultsRowState, saveAsDefault, diff --git a/src/js/postInteractionHandler.js b/src/js/postInteractionHandler.js index 1b481890..d99170fc 100644 --- a/src/js/postInteractionHandler.js +++ b/src/js/postInteractionHandler.js @@ -28,7 +28,11 @@ export class PostInteractionHandler { } const dialog = document.createElement("post-interaction-settings-dialog"); this._interactionSettingsDialog = dialog; + const rootUri = post.record?.reply?.root?.uri ?? post.uri; dialog.dataLayer = this.dataLayer; + if (rootUri !== post.uri) { + dialog.threadRootUri = rootUri; + } dialog.threadgateAllow = post.threadgate?.record?.allow ?? null; dialog.postgateEmbeddingRules = buildPostgateEmbeddingRules( !post.viewer?.embeddingDisabled, @@ -48,7 +52,7 @@ export class PostInteractionHandler { if (threadgateDirty) { writes.push( this.dataLayer.mutations.updateThreadgateAllow( - post, + { uri: rootUri }, threadgateAllow, ), ); diff --git a/src/js/templates/postActionBar.template.js b/src/js/templates/postActionBar.template.js index 141f9852..6d0bb86d 100644 --- a/src/js/templates/postActionBar.template.js +++ b/src/js/templates/postActionBar.template.js @@ -76,7 +76,6 @@ function postContextMenuTemplate({ onClickToggleReplyHiddenForEveryone, }) { const canPin = isUserPost && !post.record?.reply; - const canEditInteractionSettings = isUserPost && !post.record?.reply; const canHidePostForMe = !post.viewer?.isHidden; const pluginGroups = [...groupBy(pluginItems, "pluginId").values()]; return html` @@ -272,17 +271,13 @@ function postContextMenuTemplate({ ${isUserPost ? html` - ${canEditInteractionSettings - ? html` - onClickEditInteractionSettings(post)} - > - Edit interaction settings - - ` - : null} + onClickEditInteractionSettings(post)} + > + Edit interaction settings + { ).not.toBeAttached(); }); + test("edits quote settings on an own reply in another user's thread", async ({ + page, + }) => { + const otherDid = "did:plc:other"; + const rootUri = `at://${otherDid}/app.bsky.feed.post/root1`; + const allow = [{ $type: "app.bsky.feed.threadgate#mentionRule" }]; + const rootPost = createPost({ + uri: rootUri, + text: "Someone else's thread", + authorHandle: "other.test", + authorDisplayName: "Other User", + replyCount: 1, + threadgate: { + uri: `at://${otherDid}/app.bsky.feed.threadgate/root1`, + cid: "bafyothergate", + record: { $type: "app.bsky.feed.threadgate", post: rootUri, allow }, + lists: [], + }, + }); + const ownReply = makeOwnPost({ + text: "My reply", + reply: { + root: { uri: rootUri, cid: rootPost.cid }, + parent: { uri: rootUri, cid: rootPost.cid }, + }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([rootPost, ownReply]); + mockServer.setPostThread(rootUri, { + $type: "app.bsky.feed.defs#threadViewPost", + post: rootPost, + parent: null, + replies: [ + { + $type: "app.bsky.feed.defs#threadViewPost", + post: ownReply, + replies: [], + }, + ], + }); + await mockServer.setup(page); + + await login(page); + await page.goto("/profile/other.test/post/root1"); + + const replyPost = page + .locator("#post-detail-view [data-testid='small-post']") + .filter({ hasText: "My reply" }); + await expect(replyPost).toBeVisible({ timeout: 10000 }); + await replyPost.locator('[data-testid="post-action-more"]').click(); + await page + .locator('[data-testid="menu-action-interaction-settings"]') + .click(); + + const dialog = page.locator( + '[data-testid="post-interaction-settings-dialog"]', + ); + await expect(dialog).toBeVisible(); + await expect( + dialog.locator( + '[data-testid="interaction-settings-reply-disabled-notice"]', + ), + ).toHaveAttribute("data-teststate", "readonly"); + await expect( + dialog.locator('[data-testid="interaction-settings-mention"]'), + ).toBeChecked(); + await expect( + dialog.locator('[data-testid="interaction-settings-reply-nobody"]'), + ).toBeDisabled(); + + await dialog + .locator('[data-testid="interaction-settings-quote-posts"]') + .click(); + await dialog.locator('[data-testid="interaction-settings-save"]').click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + + expect(mockServer.putThreadgateCalls.length).toBe(0); + expect(mockServer.putPostgateCalls.length).toBe(1); + const body = mockServer.putPostgateCalls[0]; + expect(body.rkey).toBe("own1"); + expect(body.record.post).toBe(ownPostUri); + expect(body.record.embeddingRules).toEqual([ + { $type: "app.bsky.feed.postgate#disableRule" }, + ]); + }); + + test("still edits quote settings on an own reply whose thread root can't load", async ({ + page, + }) => { + const rootUri = "at://did:plc:other/app.bsky.feed.post/deleted1"; + const ownReply = makeOwnPost({ + text: "My reply", + reply: { + root: { uri: rootUri, cid: "bafydeletedroot" }, + parent: { uri: rootUri, cid: "bafydeletedroot" }, + }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([ownReply]); + mockServer.setPostThread(ownPostUri, { + $type: "app.bsky.feed.defs#threadViewPost", + post: ownReply, + parent: { $type: "app.bsky.feed.defs#notFoundPost", uri: rootUri }, + replies: [], + }); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.handle}/post/own1`); + + const largePost = page.locator('[data-testid="large-post"]'); + await expect(largePost).toBeVisible({ timeout: 10000 }); + await largePost.locator('[data-testid="post-action-more"]').click(); + await page + .locator('[data-testid="menu-action-interaction-settings"]') + .click(); + + const dialog = page.locator( + '[data-testid="post-interaction-settings-dialog"]', + ); + await expect(dialog).toBeVisible(); + await expect( + dialog.locator( + '[data-testid="interaction-settings-reply-disabled-notice"]', + ), + ).toHaveAttribute("data-teststate", "unavailable"); + await expect( + dialog.locator('[data-testid="interaction-settings-reply-anyone"]'), + ).not.toBeChecked(); + await expect( + dialog.locator('[data-testid="interaction-settings-reply-anyone"]'), + ).toBeDisabled(); + + await dialog + .locator('[data-testid="interaction-settings-quote-posts"]') + .click(); + await dialog.locator('[data-testid="interaction-settings-save"]').click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + + expect(mockServer.putThreadgateCalls.length).toBe(0); + expect(mockServer.putPostgateCalls.length).toBe(1); + expect(mockServer.putPostgateCalls[0].rkey).toBe("own1"); + }); + + test("edits the thread root's gate from an own reply in an own thread", async ({ + page, + }) => { + const rootUri = `at://${userProfile.did}/app.bsky.feed.post/root1`; + const rootPost = createPost({ + uri: rootUri, + text: "My thread", + authorHandle: userProfile.handle, + authorDisplayName: userProfile.displayName, + replyCount: 1, + }); + const ownReply = makeOwnPost({ + text: "My reply", + reply: { + root: { uri: rootUri, cid: rootPost.cid }, + parent: { uri: rootUri, cid: rootPost.cid }, + }, + }); + const mockServer = new MockServer(); + mockServer.addPosts([rootPost, ownReply]); + mockServer.setPostThread(rootUri, { + $type: "app.bsky.feed.defs#threadViewPost", + post: rootPost, + parent: null, + replies: [ + { + $type: "app.bsky.feed.defs#threadViewPost", + post: ownReply, + replies: [], + }, + ], + }); + await mockServer.setup(page); + + await login(page); + await page.goto(`/profile/${userProfile.handle}/post/root1`); + + const replyPost = page + .locator("#post-detail-view [data-testid='small-post']") + .filter({ hasText: "My reply" }); + await expect(replyPost).toBeVisible({ timeout: 10000 }); + await replyPost.locator('[data-testid="post-action-more"]').click(); + await page + .locator('[data-testid="menu-action-interaction-settings"]') + .click(); + + const dialog = page.locator( + '[data-testid="post-interaction-settings-dialog"]', + ); + await expect(dialog).toBeVisible(); + const nobody = dialog.locator( + '[data-testid="interaction-settings-reply-nobody"]', + ); + await expect(nobody).toBeEnabled(); + await expect( + dialog.locator( + '[data-testid="interaction-settings-reply-disabled-notice"]', + ), + ).not.toBeAttached(); + + await nobody.click(); + await dialog.locator('[data-testid="interaction-settings-save"]').click(); + await expect(dialog).not.toBeVisible({ timeout: 5000 }); + + expect(mockServer.putPostgateCalls.length).toBe(0); + expect(mockServer.putThreadgateCalls.length).toBe(1); + const body = mockServer.putThreadgateCalls[0]; + expect(body.rkey).toBe("root1"); + expect(body.record.post).toBe(rootUri); + expect(body.record.allow).toEqual([]); + }); + test("composes a post with a threadgate written in the same batch", async ({ page, }) => { diff --git a/tests/unit/specs/components/post-interaction-settings-dialog.test.js b/tests/unit/specs/components/post-interaction-settings-dialog.test.js index b7eb4824..e7e6cb4d 100644 --- a/tests/unit/specs/components/post-interaction-settings-dialog.test.js +++ b/tests/unit/specs/components/post-interaction-settings-dialog.test.js @@ -1,4 +1,4 @@ -import { describe, it, beforeEach } from "node:test"; +import { describe, it, beforeEach, mock } from "node:test"; import assert from "node:assert/strict"; import { makeTestDataLayer } from "../../testHelpers.js"; import "/js/components/post-interaction-settings-dialog.js"; @@ -379,6 +379,174 @@ describe("post-interaction-settings-dialog", () => { ]); }); + describe("thread root reply settings", () => { + const ownRootUri = `at://${userDid}/app.bsky.feed.post/root`; + const otherRootUri = "at://did:plc:other/app.bsky.feed.post/root"; + const mentionAllow = [{ $type: "app.bsky.feed.threadgate#mentionRule" }]; + + function makeRootPost(uri, allow) { + return { + uri, + threadgate: { + uri: uri.replace("app.bsky.feed.post", "app.bsky.feed.threadgate"), + lists: [], + record: { $type: "app.bsky.feed.threadgate", post: uri, allow }, + }, + }; + } + + async function createReplyDialog({ threadRootUri, ensurePost }) { + const element = document.createElement( + "post-interaction-settings-dialog", + ); + element.dataLayer = makeDataLayer(); + element.dataLayer.declarative.ensurePost = ensurePost; + element.threadRootUri = threadRootUri; + element.threadgateAllow = null; + element.postgateEmbeddingRules = null; + document.body.appendChild(element); + // One flush for the initial render, one for the re-render once the + // root has resolved. + await flushRender(); + await flushRender(); + return element; + } + + function notice(element) { + return input(element, "interaction-settings-reply-disabled-notice"); + } + + function replySection(element) { + return input(element, "interaction-settings-reply-section"); + } + + function captureSave(element) { + const captured = { detail: null }; + element.addEventListener("save-interaction-settings", (event) => { + captured.detail = event.detail; + }); + return captured; + } + + it("shows a disabled section with nothing selected while the root loads", async () => { + const element = await createReplyDialog({ + threadRootUri: ownRootUri, + ensurePost: mock.fn(() => new Promise(() => {})), + }); + assert.equal(notice(element).dataset.teststate, "loading"); + assert(replySection(element).hasAttribute("inert")); + assert(!input(element, "interaction-settings-reply-anyone").checked); + assert(input(element, "interaction-settings-reply-anyone").disabled); + assert(!quoteToggle(element).hasAttribute("disabled")); + }); + + it("makes the root's gate editable for the thread author", async () => { + const ensurePost = mock.fn(async () => + makeRootPost(ownRootUri, mentionAllow), + ); + const element = await createReplyDialog({ + threadRootUri: ownRootUri, + ensurePost, + }); + assert.deepEqual(ensurePost.mock.calls[0].arguments, [ownRootUri]); + assert(!notice(element)); + assert(!replySection(element).hasAttribute("inert")); + assert(input(element, "interaction-settings-mention").checked); + + const captured = captureSave(element); + input(element, "interaction-settings-following").click(); + await flushRender(); + input(element, "interaction-settings-save").click(); + assert.deepEqual(captured.detail.threadgateDirty, true); + assert.deepEqual(captured.detail.threadgateAllow, [ + ...mentionAllow, + { $type: "app.bsky.feed.threadgate#followingRule" }, + ]); + }); + + it("does not treat the loaded root gate as a change", async () => { + const element = await createReplyDialog({ + threadRootUri: ownRootUri, + ensurePost: mock.fn(async () => makeRootPost(ownRootUri, mentionAllow)), + }); + const captured = captureSave(element); + clickQuoteToggle(element); + await flushRender(); + input(element, "interaction-settings-save").click(); + assert.deepEqual(captured.detail.threadgateDirty, false); + }); + + it("shows another author's root gate read-only", async () => { + const element = await createReplyDialog({ + threadRootUri: otherRootUri, + ensurePost: mock.fn(async () => + makeRootPost(otherRootUri, mentionAllow), + ), + }); + assert.equal(notice(element).dataset.teststate, "readonly"); + assert(replySection(element).hasAttribute("inert")); + assert(input(element, "interaction-settings-mention").checked); + for (const testid of [ + "interaction-settings-reply-anyone", + "interaction-settings-reply-nobody", + "interaction-settings-mention", + "interaction-settings-following", + "interaction-settings-followers", + ]) { + assert(input(element, testid).disabled, testid); + } + assert(!quoteToggle(element).hasAttribute("disabled")); + + const captured = captureSave(element); + clickQuoteToggle(element); + await flushRender(); + input(element, "interaction-settings-save").click(); + assert.deepEqual(captured.detail.threadgateDirty, false); + assert.deepEqual(captured.detail.postgateDirty, true); + }); + + it("disables list rows and preserved-rule remove buttons when read-only", async () => { + const element = await createReplyDialog({ + threadRootUri: otherRootUri, + ensurePost: mock.fn(async () => + makeRootPost(otherRootUri, [{ $type: "com.example.unknownRule" }]), + ), + }); + expandLists(element); + await flushRender(); + const listRow = element.querySelector( + '[data-testid="interaction-settings-list-row"] input', + ); + assert(listRow.disabled); + assert(input(element, "interaction-settings-remove-rule").disabled); + }); + + it("stays locked with nothing selected when the root can't be loaded", async (t) => { + t.mock.method(console, "warn", () => {}); + const element = await createReplyDialog({ + threadRootUri: ownRootUri, + ensurePost: mock.fn(async () => { + throw new Error("Post not found"); + }), + }); + assert.equal(notice(element).dataset.teststate, "unavailable"); + assert(replySection(element).hasAttribute("inert")); + assert(!input(element, "interaction-settings-reply-anyone").checked); + + const captured = captureSave(element); + clickQuoteToggle(element); + await flushRender(); + input(element, "interaction-settings-save").click(); + assert.deepEqual(captured.detail.threadgateDirty, false); + }); + + it("omits the notice when no thread root is given", async () => { + const element = await createDialog({}); + assert(!notice(element)); + assert(!replySection(element).hasAttribute("inert")); + }); + }); + describe("composer mode default settings row", () => { async function createComposerDialog({ threadgateAllow = null, -- 2.51.2