diff --git a/src/@types/invite-rules.ts b/src/@types/invite-rules.ts index bc72a5e922..b51b9d377e 100644 --- a/src/@types/invite-rules.ts +++ b/src/@types/invite-rules.ts @@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details. */ export const INVITE_RULES_ACCOUNT_DATA_TYPE = "org.matrix.msc4155.invite_permission_config"; +export const MSC4380_INVITE_RULES_ACCOUNT_DATA_TYPE = "org.matrix.msc4380.invite_permission_config"; export interface InviteConfigAccountData { allowed_users?: string[]; diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index ad75ca95f0..802ae0da21 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -15,7 +15,11 @@ import type { EmptyObject } from "matrix-js-sdk/src/matrix"; import type { DeviceClientInformation } from "../utils/device/types.ts"; import type { UserWidget } from "../utils/WidgetUtils-types.ts"; import { type MediaPreviewConfig } from "./media_preview.ts"; -import { type INVITE_RULES_ACCOUNT_DATA_TYPE, type InviteConfigAccountData } from "./invite-rules.ts"; +import { + type INVITE_RULES_ACCOUNT_DATA_TYPE, + type InviteConfigAccountData, + type MSC4380_INVITE_RULES_ACCOUNT_DATA_TYPE, +} from "./invite-rules.ts"; // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types declare module "matrix-js-sdk/src/types" { @@ -91,6 +95,9 @@ declare module "matrix-js-sdk/src/types" { // MSC4155: Invite filtering [INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData; + + [MSC4380_INVITE_RULES_ACCOUNT_DATA_TYPE]: { default_action?: "allow" | "block" }; + "io.element.msc4278.media_preview_config": MediaPreviewConfig; // Indicate whether recovery is enabled or disabled diff --git a/src/components/views/settings/tabs/user/InviteRulesAccountSettings.tsx b/src/components/views/settings/tabs/user/InviteRulesAccountSettings.tsx index 5ceeffa4ef..7c66e88046 100644 --- a/src/components/views/settings/tabs/user/InviteRulesAccountSettings.tsx +++ b/src/components/views/settings/tabs/user/InviteRulesAccountSettings.tsx @@ -15,32 +15,57 @@ import SettingsStore from "../../../../../settings/SettingsStore"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; +/** + * A settings component which allows the user to enable/disable invite blocking. + * + * Uses whichever of MSC4155 and MSC4380 is available on the server; if neither is available, the toggle is disabled. If + * both are available, the toggle will use MSC4380 to block invites. + */ export const InviteRulesAccountSetting: FC = () => { - const rules = useSettingValue("inviteRules"); - const settingsDisabled = SettingsStore.disabledMessage("inviteRules"); + const msc4155Rules = useSettingValue("inviteRules"); + const msc4380BlockInvites = useSettingValue("blockInvites"); + + const msc4155Disabled = SettingsStore.disabledMessage("inviteRules"); + const msc4380Disabled = SettingsStore.disabledMessage("blockInvites"); + const [busy, setBusy] = useState(false); - const onChange = useCallback(async (checked: boolean) => { - try { - setBusy(true); - await SettingsStore.setValue("inviteRules", null, SettingLevel.ACCOUNT, { - allBlocked: !checked, - }); - } catch (ex) { - logger.error(`Unable to set invite rules`, ex); - } finally { - setBusy(false); - } - }, []); + const onChange = useCallback( + async (allowInvites: boolean) => { + try { + setBusy(true); + if (allowInvites) { + // When allowing invites, clear the block setting on both bits of account data. + await SettingsStore.setValue("blockInvites", null, SettingLevel.ACCOUNT, false); + await SettingsStore.setValue("inviteRules", null, SettingLevel.ACCOUNT, { allBlocked: false }); + } else { + // When blocking invites, prefer MSC4380 over MSC4155. + if (!msc4380Disabled) { + await SettingsStore.setValue("blockInvites", null, SettingLevel.ACCOUNT, true); + } else if (!msc4155Disabled) { + await SettingsStore.setValue("inviteRules", null, SettingLevel.ACCOUNT, { allBlocked: true }); + } + } + } catch (ex) { + logger.error(`Unable to set invite rules`, ex); + } finally { + setBusy(false); + } + }, + [msc4155Disabled, msc4380Disabled, setBusy], + ); + + const disabledMessage = msc4155Disabled && msc4380Disabled; + const invitesBlocked = (!msc4155Disabled && msc4155Rules.allBlocked) || (!msc4380Disabled && msc4380BlockInvites); return ( ); diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 2b2a57c2c5..3042d3a4c1 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -50,6 +50,7 @@ import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts"; import InviteRulesConfigController from "./controllers/InviteRulesConfigController.ts"; import { type ComputedInviteConfig } from "../@types/invite-rules.ts"; +import BlockInvitesConfigController from "./controllers/BlockInvitesConfigController.ts"; export const defaultWatchManager = new WatchManager(); @@ -368,6 +369,7 @@ export interface Settings { "Electron.enableContentProtection": IBaseSetting; "mediaPreviewConfig": IBaseSetting; "inviteRules": IBaseSetting; + "blockInvites": IBaseSetting; "Developer.elementCallUrl": IBaseSetting; } @@ -458,6 +460,11 @@ export const SETTINGS: Settings = { // Contains server names shouldExportToRageshake: false, }, + "blockInvites": { + controller: new BlockInvitesConfigController("blockInvites"), + supportedLevels: [SettingLevel.ACCOUNT], + default: false, + }, "feature_report_to_moderators": { isFeature: true, labsGroup: LabGroup.Moderation, diff --git a/src/settings/controllers/BlockInvitesConfigController.ts b/src/settings/controllers/BlockInvitesConfigController.ts new file mode 100644 index 0000000000..dffcb0852b --- /dev/null +++ b/src/settings/controllers/BlockInvitesConfigController.ts @@ -0,0 +1,36 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type SettingLevel } from "../SettingLevel.ts"; +import { MSC4380_INVITE_RULES_ACCOUNT_DATA_TYPE } from "../../@types/invite-rules.ts"; +import { _td } from "../../languageHandler.tsx"; +import ServerSupportUnstableFeatureController from "./ServerSupportUnstableFeatureController.ts"; +import { defaultWatchManager, type SettingKey } from "../Settings.tsx"; + +/** + * Handles invite filtering rules provided by MSC4380. + * This handler does not make use of the roomId parameter. + */ +export default class BlockInvitesConfigController extends ServerSupportUnstableFeatureController { + public constructor(settingName: SettingKey) { + super(settingName, defaultWatchManager, [["org.matrix.msc4380"]], undefined, _td("settings|not_supported")); + } + + public getValueOverride(_level: SettingLevel): boolean { + const accountData = this.client?.getAccountData(MSC4380_INVITE_RULES_ACCOUNT_DATA_TYPE)?.getContent(); + return accountData?.default_action == "block"; + } + + public async beforeChange(_level: SettingLevel, _roomId: string | null, newValue: boolean): Promise { + if (!this.client) { + return false; + } + const newDefault = newValue ? "block" : "allow"; + await this.client.setAccountData(MSC4380_INVITE_RULES_ACCOUNT_DATA_TYPE, { default_action: newDefault }); + return true; + } +} diff --git a/test/unit-tests/components/views/settings/tabs/user/InviteRulesAccountSetting-test.tsx b/test/unit-tests/components/views/settings/tabs/user/InviteRulesAccountSetting-test.tsx index 2753bd39c9..3c766138e5 100644 --- a/test/unit-tests/components/views/settings/tabs/user/InviteRulesAccountSetting-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/InviteRulesAccountSetting-test.tsx @@ -13,16 +13,25 @@ import SettingsStore from "../../../../../../../src/settings/SettingsStore"; import { type ComputedInviteConfig } from "../../../../../../../src/@types/invite-rules"; import { SettingLevel } from "../../../../../../../src/settings/SettingLevel"; -function mockSetting(mediaPreviews: ComputedInviteConfig, supported = true) { +function mockSetting(inviteRules: ComputedInviteConfig, blockInvites: boolean) { jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => { if (settingName === "inviteRules") { - return mediaPreviews; + return inviteRules; + } + if (settingName === "blockInvites") { + return blockInvites; } throw Error(`Unexpected setting ${settingName}`); }); +} + +function mockDisabledMessage(msc4155supported: boolean, msc4380Supported: boolean) { jest.spyOn(SettingsStore, "disabledMessage").mockImplementation((settingName) => { if (settingName === "inviteRules") { - return supported ? undefined : "test-not-supported"; + return msc4155supported ? undefined : "test-not-supported"; + } + if (settingName === "blockInvites") { + return msc4380Supported ? undefined : "test-not-supported"; } throw Error(`Unexpected setting ${settingName}`); }); @@ -33,44 +42,103 @@ describe("InviteRulesAccountSetting", () => { jest.restoreAllMocks(); }); - it("does not render if not supported", async () => { + it("does not render if neither MSC4155 nor MSC4380 are supported", async () => { mockSetting({ allBlocked: false }, false); + mockDisabledMessage(false, false); const { findByText, findByLabelText } = render(); const input = await findByLabelText("Allow users to invite you to rooms"); await userEvent.hover(input); const result = await findByText("test-not-supported"); expect(result).toBeInTheDocument(); }); - it("renders correct state when invites are not blocked", async () => { - mockSetting({ allBlocked: false }, true); - const { findByLabelText } = render(); - const result = await findByLabelText("Allow users to invite you to rooms"); - expect(result).toBeChecked(); - }); - it("renders correct state when invites are blocked", async () => { - mockSetting({ allBlocked: true }, true); - const { findByLabelText } = render(); - const result = await findByLabelText("Allow users to invite you to rooms"); - expect(result).not.toBeChecked(); - }); - it("handles disabling all invites", async () => { - mockSetting({ allBlocked: false }, true); - jest.spyOn(SettingsStore, "setValue").mockImplementation(); - const { findByLabelText } = render(); - const result = await findByLabelText("Allow users to invite you to rooms"); - await userEvent.click(result); - expect(SettingsStore.setValue).toHaveBeenCalledWith("inviteRules", null, SettingLevel.ACCOUNT, { - allBlocked: true, + + describe("when MSC4155 is supported", () => { + beforeEach(() => { + mockDisabledMessage(true, false); + }); + + it("renders correct state when invites are not blocked", async () => { + mockSetting({ allBlocked: false }, false); + const { findByLabelText } = render(); + const result = await findByLabelText("Allow users to invite you to rooms"); + expect(result).toBeChecked(); + }); + + it("renders correct state when invites are blocked", async () => { + mockSetting({ allBlocked: true }, false); + const { findByLabelText } = render(); + const result = await findByLabelText("Allow users to invite you to rooms"); + expect(result).not.toBeChecked(); + }); + + it("handles disabling all invites", async () => { + mockSetting({ allBlocked: false }, false); + jest.spyOn(SettingsStore, "setValue").mockImplementation(); + const { findByLabelText } = render(); + const result = await findByLabelText("Allow users to invite you to rooms"); + await userEvent.click(result); + expect(SettingsStore.setValue).toHaveBeenCalledTimes(1); + expect(SettingsStore.setValue).toHaveBeenCalledWith("inviteRules", null, SettingLevel.ACCOUNT, { + allBlocked: true, + }); + }); + + it("handles enabling all invites", async () => { + mockSetting({ allBlocked: true }, true); + jest.spyOn(SettingsStore, "setValue").mockImplementation(); + const { findByLabelText } = render(); + const result = await findByLabelText("Allow users to invite you to rooms"); + await userEvent.click(result); + // Should clear both MSC4155 and MSC4380 settings + expect(SettingsStore.setValue).toHaveBeenCalledTimes(2); + expect(SettingsStore.setValue).toHaveBeenCalledWith("inviteRules", null, SettingLevel.ACCOUNT, { + allBlocked: false, + }); + expect(SettingsStore.setValue).toHaveBeenCalledWith("blockInvites", null, SettingLevel.ACCOUNT, false); }); }); - it("handles enabling all invites", async () => { - mockSetting({ allBlocked: true }, true); - jest.spyOn(SettingsStore, "setValue").mockImplementation(); - const { findByLabelText } = render(); - const result = await findByLabelText("Allow users to invite you to rooms"); - await userEvent.click(result); - expect(SettingsStore.setValue).toHaveBeenCalledWith("inviteRules", null, SettingLevel.ACCOUNT, { - allBlocked: false, + + describe("when MSC4380 is supported", () => { + beforeEach(() => { + mockDisabledMessage(false, true); + }); + + it("renders correct state when invites are not blocked", async () => { + mockSetting({ allBlocked: false }, false); + const { findByLabelText } = render(); + const result = await findByLabelText("Allow users to invite you to rooms"); + expect(result).toBeChecked(); + }); + + it("renders correct state when invites are blocked", async () => { + mockSetting({ allBlocked: false }, true); + const { findByLabelText } = render(); + const result = await findByLabelText("Allow users to invite you to rooms"); + expect(result).not.toBeChecked(); + }); + + it("handles disabling all invites", async () => { + mockSetting({ allBlocked: false }, false); + jest.spyOn(SettingsStore, "setValue").mockImplementation(); + const { findByLabelText } = render(); + const result = await findByLabelText("Allow users to invite you to rooms"); + await userEvent.click(result); + expect(SettingsStore.setValue).toHaveBeenCalledTimes(1); + expect(SettingsStore.setValue).toHaveBeenCalledWith("blockInvites", null, SettingLevel.ACCOUNT, true); + }); + + it("handles enabling all invites", async () => { + mockSetting({ allBlocked: true }, true); + jest.spyOn(SettingsStore, "setValue").mockImplementation(); + const { findByLabelText } = render(); + const result = await findByLabelText("Allow users to invite you to rooms"); + await userEvent.click(result); + // Should clear both MSC4155 and MSC4380 settings + expect(SettingsStore.setValue).toHaveBeenCalledTimes(2); + expect(SettingsStore.setValue).toHaveBeenCalledWith("inviteRules", null, SettingLevel.ACCOUNT, { + allBlocked: false, + }); + expect(SettingsStore.setValue).toHaveBeenCalledWith("blockInvites", null, SettingLevel.ACCOUNT, false); }); }); }); diff --git a/test/unit-tests/settings/controllers/BlockInvitesConfigController-test.ts b/test/unit-tests/settings/controllers/BlockInvitesConfigController-test.ts new file mode 100644 index 0000000000..368d4a3032 --- /dev/null +++ b/test/unit-tests/settings/controllers/BlockInvitesConfigController-test.ts @@ -0,0 +1,100 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2023 The Matrix.org Foundation C.I.C. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { mocked } from "jest-mock"; + +import { SETTINGS } from "../../../../src/settings/Settings"; +import { stubClient } from "../../../test-utils"; +import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController"; +import { SettingLevel } from "../../../../src/settings/SettingLevel.ts"; + +describe("BlockInvitesConfigController", () => { + describe("When server does not support MSC4380", () => { + let cli: MatrixClient; + beforeEach(() => { + cli = stubClient(); + cli.doesServerSupportUnstableFeature = jest.fn(async () => false); + MatrixClientBackedController.matrixClient = cli; + }); + + test("settingDisabled() should give a message", () => { + const controller = SETTINGS.blockInvites.controller!; + expect(controller.settingDisabled).toEqual("Your server does not implement this feature."); + }); + }); + + describe("When server supports MSC4380", () => { + let cli: MatrixClient; + beforeEach(async () => { + cli = stubClient(); + cli.doesServerSupportUnstableFeature = jest.fn(async (feature) => { + return feature == "org.matrix.msc4380"; + }); + MatrixClientBackedController.matrixClient = cli; + }); + + test("settingDisabled() should be false", () => { + const controller = SETTINGS.blockInvites.controller!; + expect(controller.settingDisabled).toEqual(false); + }); + + describe("getValueOverride()", () => { + it("should return true when invites are blocked", async () => { + const controller = SETTINGS.blockInvites.controller!; + + mockAccountData(cli, { default_action: "block" }); + expect(controller.getValueOverride(SettingLevel.DEVICE, null, null, null)).toEqual(true); + }); + + it("should return false when invites are not blocked", async () => { + const controller = SETTINGS.blockInvites.controller!; + + mockAccountData(cli, { default_action: {} }); + expect(controller.getValueOverride(SettingLevel.DEVICE, null, null, null)).toEqual(false); + }); + }); + + describe("beforeChange()", () => { + it("should set the account data when the value is enabled", async () => { + const controller = SETTINGS.blockInvites.controller!; + await controller.beforeChange(SettingLevel.DEVICE, null, true); + expect(cli.setAccountData).toHaveBeenCalledTimes(1); + expect(cli.setAccountData).toHaveBeenCalledWith("org.matrix.msc4380.invite_permission_config", { + default_action: "block", + }); + }); + + it("should set the account data when the value is disabled", async () => { + const controller = SETTINGS.blockInvites.controller!; + await controller.beforeChange(SettingLevel.DEVICE, null, false); + expect(cli.setAccountData).toHaveBeenCalledTimes(1); + expect(cli.setAccountData).toHaveBeenCalledWith("org.matrix.msc4380.invite_permission_config", { + default_action: "allow", + }); + }); + }); + }); +}); + +/** + * Add a mock implementation for {@link MatrixClient.getAccountData} which will return the given data + * in respomsnse to any request for `org.matrix.msc4380.invite_permission_config`. + */ +function mockAccountData(cli: MatrixClient, mockAccountData: object) { + mocked(cli.getAccountData).mockImplementation((eventType) => { + if (eventType == "org.matrix.msc4380.invite_permission_config") { + return new MatrixEvent({ + type: "org.matrix.msc4380.invite_permission_config", + content: mockAccountData, + }); + } else { + return undefined; + } + }); +}