1
0
mirror of https://github.com/element-hq/element-web.git synced 2025-08-08 03:42:14 +03:00

Implement MSC4155: Invite filtering (#29603)

* Add settings for MSC4155

* copyright

* Tweak to not use js-sdk

* Update for latest MSC

* Various tidyups

* Move tab

* i18n

* update .snap

* mvvm

* lint

* add header

* Remove capability check

* fix

* Rewrite to use Settings

* lint

* lint

* fix test

* Tweaks

* lint

* revert copyright

* update screenshot

* cleanup
This commit is contained in:
Will Hunt
2025-06-10 11:47:33 +01:00
committed by GitHub
parent d638691fbd
commit a333856c50
14 changed files with 450 additions and 2 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 272 KiB

View File

@@ -0,0 +1,29 @@
/*
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.
*/
export const INVITE_RULES_ACCOUNT_DATA_TYPE = "org.matrix.msc4155.invite_permission_config";
export interface InviteConfigAccountData {
allowed_users?: string[];
blocked_users?: string[];
ignored_users?: string[];
allowed_servers?: string[];
blocked_servers?: string[];
ignored_servers?: string[];
}
/**
* Computed values based on MSC4155. Currently Element Web only supports
* blocking all invites.
*/
export interface ComputedInviteConfig extends Record<string, unknown> {
/**
* Are all invites blocked. This is only about blocking all invites,
* but this being false may still block invites through other rules.
*/
allBlocked: boolean;
}

View File

@@ -15,6 +15,7 @@ import type { EmptyObject } from "matrix-js-sdk/src/matrix";
import type { DeviceClientInformation } from "../utils/device/types.ts"; import type { DeviceClientInformation } from "../utils/device/types.ts";
import type { UserWidget } from "../utils/WidgetUtils-types.ts"; import type { UserWidget } from "../utils/WidgetUtils-types.ts";
import { type MediaPreviewConfig } from "./media_preview.ts"; import { type MediaPreviewConfig } from "./media_preview.ts";
import { type INVITE_RULES_ACCOUNT_DATA_TYPE, type InviteConfigAccountData } from "./invite-rules.ts";
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
declare module "matrix-js-sdk/src/types" { declare module "matrix-js-sdk/src/types" {
@@ -60,7 +61,6 @@ declare module "matrix-js-sdk/src/types" {
}; };
}; };
} }
export interface AccountDataEvents { export interface AccountDataEvents {
// Analytics account data event // Analytics account data event
"im.vector.analytics": { "im.vector.analytics": {
@@ -89,6 +89,8 @@ declare module "matrix-js-sdk/src/types" {
accepted: string[]; accepted: string[];
}; };
// MSC4155: Invite filtering
[INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData;
"io.element.msc4278.media_preview_config": MediaPreviewConfig; "io.element.msc4278.media_preview_config": MediaPreviewConfig;
} }

View File

@@ -0,0 +1,47 @@
/*
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 React, { type FC, useCallback, useState } from "react";
import { Root } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../../../languageHandler";
import { useSettingValue } from "../../../../../hooks/useSettings";
import SettingsStore from "../../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
export const InviteRulesAccountSetting: FC = () => {
const rules = useSettingValue("inviteRules");
const settingsDisabled = SettingsStore.disabledMessage("inviteRules");
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);
}
}, []);
return (
<Root className="mx_MediaPreviewAccountSetting_Form">
<LabelledToggleSwitch
className="mx_MediaPreviewAccountSetting_ToggleSwitch"
label={_t("settings|invite_controls|default_label")}
value={!rules.allBlocked}
onChange={onChange}
tooltip={settingsDisabled}
disabled={!!settingsDisabled || busy}
/>
</Root>
);
};

View File

@@ -33,6 +33,7 @@ import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import * as TimezoneHandler from "../../../../../TimezoneHandler"; import * as TimezoneHandler from "../../../../../TimezoneHandler";
import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx"; import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx";
import { MediaPreviewAccountSettings } from "./MediaPreviewAccountSettings.tsx"; import { MediaPreviewAccountSettings } from "./MediaPreviewAccountSettings.tsx";
import { InviteRulesAccountSetting } from "./InviteRulesAccountSettings.tsx";
interface IProps { interface IProps {
closeSettingsFn(success: boolean): void; closeSettingsFn(success: boolean): void;
@@ -339,6 +340,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
<SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}> <SettingsSubsection heading={_t("common|moderation_and_safety")} legacy={false}>
<MediaPreviewAccountSettings /> <MediaPreviewAccountSettings />
<InviteRulesAccountSetting />
</SettingsSubsection> </SettingsSubsection>
<SettingsSubsection heading={_t("settings|preferences|room_directory_heading")}> <SettingsSubsection heading={_t("settings|preferences|room_directory_heading")}>

View File

@@ -2686,6 +2686,9 @@
"inline_url_previews_room": "Enable URL previews by default for participants in this room", "inline_url_previews_room": "Enable URL previews by default for participants in this room",
"inline_url_previews_room_account": "Enable URL previews for this room (only affects you)", "inline_url_previews_room_account": "Enable URL previews for this room (only affects you)",
"insert_trailing_colon_mentions": "Insert a trailing colon after user mentions at the start of a message", "insert_trailing_colon_mentions": "Insert a trailing colon after user mentions at the start of a message",
"invite_controls": {
"default_label": "Allow users to invite you to rooms"
},
"jump_to_bottom_on_send": "Jump to the bottom of the timeline when you send a message", "jump_to_bottom_on_send": "Jump to the bottom of the timeline when you send a message",
"key_backup": { "key_backup": {
"backup_in_progress": "Your keys are being backed up (the first backup could take a few minutes).", "backup_in_progress": "Your keys are being backed up (the first backup could take a few minutes).",
@@ -2752,6 +2755,7 @@
"show_in_private": "In private rooms", "show_in_private": "In private rooms",
"show_media": "Always show" "show_media": "Always show"
}, },
"not_supported": "Your server does not implement this feature.",
"notifications": { "notifications": {
"default_setting_description": "This setting will be applied by default to all your rooms.", "default_setting_description": "This setting will be applied by default to all your rooms.",
"default_setting_section": "I want to be notified for (Default Setting)", "default_setting_section": "I want to be notified for (Default Setting)",

View File

@@ -47,6 +47,8 @@ import { type RecentEmojiData } from "../emojipicker/recent.ts";
import { type Assignable } from "../@types/common.ts"; import { type Assignable } from "../@types/common.ts";
import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.ts"; import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.ts";
import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts"; import MediaPreviewConfigController from "./controllers/MediaPreviewConfigController.ts";
import InviteRulesConfigController from "./controllers/InviteRulesConfigController.ts";
import { type ComputedInviteConfig } from "../@types/invite-rules.ts";
export const defaultWatchManager = new WatchManager(); export const defaultWatchManager = new WatchManager();
@@ -351,6 +353,7 @@ export interface Settings {
"Electron.enableHardwareAcceleration": IBaseSetting<boolean>; "Electron.enableHardwareAcceleration": IBaseSetting<boolean>;
"Electron.enableContentProtection": IBaseSetting<boolean>; "Electron.enableContentProtection": IBaseSetting<boolean>;
"mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>; "mediaPreviewConfig": IBaseSetting<MediaPreviewConfig>;
"inviteRules": IBaseSetting<ComputedInviteConfig>;
"Developer.elementCallUrl": IBaseSetting<string>; "Developer.elementCallUrl": IBaseSetting<string>;
} }
@@ -434,6 +437,11 @@ export const SETTINGS: Settings = {
supportedLevels: LEVELS_ROOM_SETTINGS, supportedLevels: LEVELS_ROOM_SETTINGS,
default: MediaPreviewConfigController.default, default: MediaPreviewConfigController.default,
}, },
"inviteRules": {
controller: new InviteRulesConfigController(),
supportedLevels: [SettingLevel.ACCOUNT],
default: InviteRulesConfigController.default,
},
"feature_report_to_moderators": { "feature_report_to_moderators": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Moderation, labsGroup: LabGroup.Moderation,

View File

@@ -0,0 +1,88 @@
/*
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 MatrixClient, type IContent } from "matrix-js-sdk/src/matrix";
import { type SettingLevel } from "../SettingLevel.ts";
import MatrixClientBackedController from "./MatrixClientBackedController.ts";
import {
type ComputedInviteConfig as ComputedInviteRules,
INVITE_RULES_ACCOUNT_DATA_TYPE,
type InviteConfigAccountData,
} from "../../@types/invite-rules.ts";
import { _t } from "../../languageHandler.tsx";
/**
* Handles invite filtering rules provided by MSC4155.
* This handler does not make use of the roomId parameter.
*/
export default class InviteRulesConfigController extends MatrixClientBackedController {
public static readonly default: ComputedInviteRules = {
allBlocked: false,
};
private static getValidSettingData(content: IContent): ComputedInviteRules {
const expectedConfig = content as InviteConfigAccountData;
return {
allBlocked: !!expectedConfig.blocked_users?.includes("*"),
};
}
public initMatrixClient(newClient: MatrixClient): void {
newClient.doesServerSupportUnstableFeature("org.matrix.msc4155").then((result) => {
this.featureSupported = result;
});
}
public featureSupported?: boolean;
public constructor() {
super();
this.featureSupported = false;
}
private getValue = (): ComputedInviteRules => {
const accountData =
this.client?.getAccountData(INVITE_RULES_ACCOUNT_DATA_TYPE)?.getContent<InviteConfigAccountData>() ?? {};
return InviteRulesConfigController.getValidSettingData(accountData);
};
public getValueOverride(_level: SettingLevel): ComputedInviteRules {
return this.getValue();
}
public get settingDisabled(): true | string {
return this.featureSupported ? true : _t("settings|not_supported");
}
public async beforeChange(
_level: SettingLevel,
_roomId: string | null,
newValue: ComputedInviteRules,
): Promise<boolean> {
if (!this.client) {
return false;
}
const existingContent = this.client
.getAccountData(INVITE_RULES_ACCOUNT_DATA_TYPE)
?.getContent<InviteConfigAccountData>();
const newContent: InviteConfigAccountData = {
...existingContent,
blocked_users: [...(existingContent?.blocked_users ?? [])],
};
if (newValue.allBlocked && !newContent.blocked_users!.includes("*")) {
newContent.blocked_users!.push("*");
} else if (!newValue.allBlocked && newContent.blocked_users?.includes("*")) {
newContent.blocked_users = newContent.blocked_users.filter((u) => u !== "*");
} else {
// No changes required.
return false;
}
await this.client.setAccountData(INVITE_RULES_ACCOUNT_DATA_TYPE, newContent);
return true;
}
}

View File

@@ -204,6 +204,9 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa
case "mediaPreviewConfig": case "mediaPreviewConfig":
// Handled in MediaPreviewConfigController. // Handled in MediaPreviewConfigController.
return; return;
case "inviteRules":
// Handled in InviteRulesConfigController.
return;
default: default:
return this.setAccountData(DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue); return this.setAccountData(DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue);
} }

View File

@@ -122,6 +122,9 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin
case "mediaPreviewConfig": case "mediaPreviewConfig":
// Handled in MediaPreviewConfigController. // Handled in MediaPreviewConfigController.
return; return;
case "inviteRules":
// Handled in InviteRulesConfigController.
return;
default: default:
return this.setRoomAccountData(roomId, DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue); return this.setRoomAccountData(roomId, DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue);
} }

View File

@@ -127,7 +127,7 @@ describe("<MatrixChat />", () => {
setGuest: jest.fn(), setGuest: jest.fn(),
setNotifTimelineSet: jest.fn(), setNotifTimelineSet: jest.fn(),
getAccountData: jest.fn(), getAccountData: jest.fn(),
doesServerSupportUnstableFeature: jest.fn(), doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
getDevices: jest.fn().mockResolvedValue({ devices: [] }), getDevices: jest.fn().mockResolvedValue({ devices: [] }),
getProfileInfo: jest.fn().mockResolvedValue({ getProfileInfo: jest.fn().mockResolvedValue({
displayname: "Ernie", displayname: "Ernie",

View File

@@ -0,0 +1,76 @@
/*
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 React from "react";
import { render } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { InviteRulesAccountSetting } from "../../../../../../../src/components/views/settings/tabs/user/InviteRulesAccountSettings";
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) {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => {
if (settingName === "inviteRules") {
return mediaPreviews;
}
throw Error(`Unexpected setting ${settingName}`);
});
jest.spyOn(SettingsStore, "disabledMessage").mockImplementation((settingName) => {
if (settingName === "inviteRules") {
return supported ? undefined : "test-not-supported";
}
throw Error(`Unexpected setting ${settingName}`);
});
}
describe("InviteRulesAccountSetting", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("does not render if not supported", async () => {
mockSetting({ allBlocked: 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,
});
});
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,
});
});
});

View File

@@ -1297,6 +1297,36 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
</div> </div>
</div> </div>
</form> </form>
<form
class="_root_19upo_16 mx_MediaPreviewAccountSetting_Form"
>
<div
class="mx_SettingsFlag mx_MediaPreviewAccountSetting_ToggleSwitch"
>
<span
class="mx_SettingsFlag_label"
>
<div
id="mx_LabelledToggleSwitch_«re»"
>
Allow users to invite you to rooms
</div>
</span>
<div
aria-checked="true"
aria-disabled="true"
aria-label="Your server does not implement this feature."
aria-labelledby="mx_LabelledToggleSwitch_«re»"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_on"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
</form>
</div> </div>
<div <div
class="_separator_7ckbw_8" class="_separator_7ckbw_8"

View File

@@ -0,0 +1,156 @@
/*
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 { MatrixEvent } from "matrix-js-sdk/src/matrix";
import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController";
import InviteRulesConfigController from "../../../../src/settings/controllers/InviteRulesConfigController";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import { getMockClientWithEventEmitter, mockClientMethodsServer } from "../../../test-utils";
import { INVITE_RULES_ACCOUNT_DATA_TYPE, type InviteConfigAccountData } from "../../../../src/@types/invite-rules";
describe("InviteRulesConfigController", () => {
afterEach(() => {
jest.restoreAllMocks();
});
it("gets the default settings when none are specified.", () => {
const controller = new InviteRulesConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(null),
});
const value = controller.getValueOverride(SettingLevel.ACCOUNT);
expect(value).toEqual(InviteRulesConfigController.default);
});
it("gets the default settings when the setting is empty.", () => {
const controller = new InviteRulesConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest
.fn()
.mockReturnValue(new MatrixEvent({ type: INVITE_RULES_ACCOUNT_DATA_TYPE, content: {} })),
});
const value = controller.getValueOverride(SettingLevel.ACCOUNT);
expect(value).toEqual(InviteRulesConfigController.default);
});
it.each<InviteConfigAccountData>([{ blocked_users: ["foo_bar"] }, { blocked_users: [] }, {}])(
"calculates blockAll to be false",
(content: InviteConfigAccountData) => {
const controller = new InviteRulesConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: INVITE_RULES_ACCOUNT_DATA_TYPE,
content,
}),
),
});
const globalValue = controller.getValueOverride(SettingLevel.ACCOUNT);
expect(globalValue.allBlocked).toEqual(false);
},
);
it.each<InviteConfigAccountData>([
{ blocked_users: ["*"] },
{ blocked_users: ["*", "bob"] },
{ allowed_users: ["*"], blocked_users: ["*"] },
])("calculates blockAll to be true", (content: InviteConfigAccountData) => {
const controller = new InviteRulesConfigController();
MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: INVITE_RULES_ACCOUNT_DATA_TYPE,
content,
}),
),
});
const globalValue = controller.getValueOverride(SettingLevel.ACCOUNT);
expect(globalValue.allBlocked).toEqual(true);
});
it("sets the account data correctly for blockAll = true", async () => {
const controller = new InviteRulesConfigController();
const client = (MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: INVITE_RULES_ACCOUNT_DATA_TYPE,
content: {
existing_content: {},
allowed_servers: ["*"],
},
}),
),
setAccountData: jest.fn(),
}));
expect(await controller.beforeChange(SettingLevel.ACCOUNT, null, { allBlocked: true })).toBe(true);
expect(client.setAccountData).toHaveBeenCalledWith(INVITE_RULES_ACCOUNT_DATA_TYPE, {
existing_content: {},
allowed_servers: ["*"],
blocked_users: ["*"],
});
});
it("sets the account data correctly for blockAll = false", async () => {
const controller = new InviteRulesConfigController();
const client = (MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: INVITE_RULES_ACCOUNT_DATA_TYPE,
content: {
existing_content: {},
allowed_servers: ["*"],
blocked_users: ["extra_user", "*"],
},
}),
),
setAccountData: jest.fn(),
}));
expect(await controller.beforeChange(SettingLevel.ACCOUNT, null, { allBlocked: false })).toBe(true);
expect(client.setAccountData).toHaveBeenCalledWith(INVITE_RULES_ACCOUNT_DATA_TYPE, {
existing_content: {},
allowed_servers: ["*"],
blocked_users: ["extra_user"],
});
});
it.each([true, false])("ignores a no-op when allBlocked = %s", async (allBlocked) => {
const controller = new InviteRulesConfigController();
const client = (MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({
...mockClientMethodsServer(),
getAccountData: jest.fn().mockReturnValue(
new MatrixEvent({
type: INVITE_RULES_ACCOUNT_DATA_TYPE,
content: {
existing_content: {},
allowed_servers: ["*"],
blocked_users: allBlocked ? ["*"] : [],
},
}),
),
setAccountData: jest.fn(),
}));
expect(await controller.beforeChange(SettingLevel.ACCOUNT, null, { allBlocked })).toBe(false);
expect(client.setAccountData).not.toHaveBeenCalled();
});
});