+ ${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,