diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index f10ac53fe4..c484a47fc9 100644 Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/src/@types/invite-rules.ts b/src/@types/invite-rules.ts new file mode 100644 index 0000000000..bc72a5e922 --- /dev/null +++ b/src/@types/invite-rules.ts @@ -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 { + /** + * Are all invites blocked. This is only about blocking all invites, + * but this being false may still block invites through other rules. + */ + allBlocked: boolean; +} diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts index c81c5377bf..e1f8c4562e 100644 --- a/src/@types/matrix-js-sdk.d.ts +++ b/src/@types/matrix-js-sdk.d.ts @@ -15,6 +15,7 @@ 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"; // Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types declare module "matrix-js-sdk/src/types" { @@ -60,7 +61,6 @@ declare module "matrix-js-sdk/src/types" { }; }; } - export interface AccountDataEvents { // Analytics account data event "im.vector.analytics": { @@ -89,6 +89,8 @@ declare module "matrix-js-sdk/src/types" { accepted: string[]; }; + // MSC4155: Invite filtering + [INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData; "io.element.msc4278.media_preview_config": MediaPreviewConfig; } diff --git a/src/components/views/settings/tabs/user/InviteRulesAccountSettings.tsx b/src/components/views/settings/tabs/user/InviteRulesAccountSettings.tsx new file mode 100644 index 0000000000..5ceeffa4ef --- /dev/null +++ b/src/components/views/settings/tabs/user/InviteRulesAccountSettings.tsx @@ -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 ( + + + + ); +}; diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 1fb14f96f8..ecf2766b20 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -33,6 +33,7 @@ import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import * as TimezoneHandler from "../../../../../TimezoneHandler"; import { type BooleanSettingKey } from "../../../../../settings/Settings.tsx"; import { MediaPreviewAccountSettings } from "./MediaPreviewAccountSettings.tsx"; +import { InviteRulesAccountSetting } from "./InviteRulesAccountSettings.tsx"; interface IProps { closeSettingsFn(success: boolean): void; @@ -339,6 +340,7 @@ export default class PreferencesUserSettingsTab extends React.Component + diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3a55fdb75f..0caa0fb07f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2686,6 +2686,9 @@ "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)", "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", "key_backup": { "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_media": "Always show" }, + "not_supported": "Your server does not implement this feature.", "notifications": { "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)", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 6d3af7c502..3ca7503cd1 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -47,6 +47,8 @@ import { type RecentEmojiData } from "../emojipicker/recent.ts"; import { type Assignable } from "../@types/common.ts"; import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.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(); @@ -351,6 +353,7 @@ export interface Settings { "Electron.enableHardwareAcceleration": IBaseSetting; "Electron.enableContentProtection": IBaseSetting; "mediaPreviewConfig": IBaseSetting; + "inviteRules": IBaseSetting; "Developer.elementCallUrl": IBaseSetting; } @@ -434,6 +437,11 @@ export const SETTINGS: Settings = { supportedLevels: LEVELS_ROOM_SETTINGS, default: MediaPreviewConfigController.default, }, + "inviteRules": { + controller: new InviteRulesConfigController(), + supportedLevels: [SettingLevel.ACCOUNT], + default: InviteRulesConfigController.default, + }, "feature_report_to_moderators": { isFeature: true, labsGroup: LabGroup.Moderation, diff --git a/src/settings/controllers/InviteRulesConfigController.ts b/src/settings/controllers/InviteRulesConfigController.ts new file mode 100644 index 0000000000..61c7323009 --- /dev/null +++ b/src/settings/controllers/InviteRulesConfigController.ts @@ -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() ?? {}; + 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 { + if (!this.client) { + return false; + } + const existingContent = this.client + .getAccountData(INVITE_RULES_ACCOUNT_DATA_TYPE) + ?.getContent(); + 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; + } +} diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index 3ea9db158e..29117778d6 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -204,6 +204,9 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa case "mediaPreviewConfig": // Handled in MediaPreviewConfigController. return; + case "inviteRules": + // Handled in InviteRulesConfigController. + return; default: return this.setAccountData(DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue); } diff --git a/src/settings/handlers/RoomAccountSettingsHandler.ts b/src/settings/handlers/RoomAccountSettingsHandler.ts index 8fdf412adf..6ed09a9801 100644 --- a/src/settings/handlers/RoomAccountSettingsHandler.ts +++ b/src/settings/handlers/RoomAccountSettingsHandler.ts @@ -122,6 +122,9 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin case "mediaPreviewConfig": // Handled in MediaPreviewConfigController. return; + case "inviteRules": + // Handled in InviteRulesConfigController. + return; default: return this.setRoomAccountData(roomId, DEFAULT_SETTINGS_EVENT_TYPE, settingName, newValue); } diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx index 62fcf40f4d..94fc9a925a 100644 --- a/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -127,7 +127,7 @@ describe("", () => { setGuest: jest.fn(), setNotifTimelineSet: jest.fn(), getAccountData: jest.fn(), - doesServerSupportUnstableFeature: jest.fn(), + doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false), getDevices: jest.fn().mockResolvedValue({ devices: [] }), getProfileInfo: jest.fn().mockResolvedValue({ displayname: "Ernie", 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 new file mode 100644 index 0000000000..2753bd39c9 --- /dev/null +++ b/test/unit-tests/components/views/settings/tabs/user/InviteRulesAccountSetting-test.tsx @@ -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(); + 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, + }); + }); + 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, + }); + }); +}); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap index 3e3662bd68..7fe1f1d49b 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap @@ -1297,6 +1297,36 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` +
+
+ +
+ Allow users to invite you to rooms +
+
+
+
+
+
+
{ + 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([{ 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([ + { 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(); + }); +});