mirror of
https://github.com/vector-im/element-web.git
synced 2025-11-29 14:31:22 +01:00
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:
parent
5869c519ed
commit
1c684489da
@ -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[];
|
||||
|
||||
9
src/@types/matrix-js-sdk.d.ts
vendored
9
src/@types/matrix-js-sdk.d.ts
vendored
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
36
src/settings/controllers/BlockInvitesConfigController.ts
Normal file
36
src/settings/controllers/BlockInvitesConfigController.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user