From 1c684489daa720adc985019d1b49b0c27de3aff3 Mon Sep 17 00:00:00 2001
From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Date: Thu, 27 Nov 2025 18:42:58 +0000
Subject: [PATCH] MSC4380: Invite blocking (#31268)
* Initial implementation of MSC4380
* fix lint
* Update InviteRulesAccountSetting-test
* add some docs
* `block_all` -> `default_action`
* Add a unit test for BlockInvitesConfigController
---
src/@types/invite-rules.ts | 1 +
src/@types/matrix-js-sdk.d.ts | 9 +-
.../tabs/user/InviteRulesAccountSettings.tsx | 59 +++++---
src/settings/Settings.tsx | 7 +
.../BlockInvitesConfigController.ts | 36 +++++
.../user/InviteRulesAccountSetting-test.tsx | 132 +++++++++++++-----
.../BlockInvitesConfigController-test.ts | 100 +++++++++++++
7 files changed, 294 insertions(+), 50 deletions(-)
create mode 100644 src/settings/controllers/BlockInvitesConfigController.ts
create mode 100644 test/unit-tests/settings/controllers/BlockInvitesConfigController-test.ts
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;
+ }
+ });
+}