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
This commit is contained in:
Richard van der Hoff 2025-11-27 18:42:58 +00:00 committed by GitHub
parent 5869c519ed
commit 1c684489da
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 294 additions and 50 deletions

View File

@ -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[];

View File

@ -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

View File

@ -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 (
<Root className="mx_MediaPreviewAccountSetting_Form">
<LabelledToggleSwitch
className="mx_MediaPreviewAccountSetting_ToggleSwitch"
label={_t("settings|invite_controls|default_label")}
value={!rules.allBlocked}
value={!invitesBlocked}
onChange={onChange}
tooltip={settingsDisabled}
disabled={!!settingsDisabled || busy}
tooltip={disabledMessage}
disabled={!!disabledMessage || busy}
/>
</Root>
);

View File

@ -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<boolean>;
"mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>;
"inviteRules": IBaseSetting<ComputedInviteConfig>;
"blockInvites": IBaseSetting<boolean>;
"Developer.elementCallUrl": IBaseSetting<string>;
}
@ -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,

View File

@ -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<boolean> {
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;
}
}

View File

@ -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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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(<InviteRulesAccountSetting />);
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);
});
});
});

View File

@ -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;
}
});
}