diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 62104e0c61..cbd2ee9b97 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -476,6 +476,7 @@ "description": "Description", "deselect_all": "Deselect all", "device": "Device", + "disabled_by_homeserver": "Disabled by homeserver", "edited": "edited", "email_address": "Email address", "emoji": "Emoji", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 67d0a31e14..376b690afc 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -1126,7 +1126,13 @@ export const SETTINGS: Settings = { supportedLevelsAreOrdered: true, displayName: _td("settings|inline_url_previews_default"), default: true, - controller: new UIFeatureController(UIFeature.URLPreviews), + controller: new RequiresSettingsController([UIFeature.URLPreviews], false, (c) => { + if (c["io.element.msc4452.preview_url"]?.enabled !== false) { + // If the capability is not listed, or explicitly true then do not disable. + return false; + } + return _t("common|disabled_by_homeserver"); + }), }, "urlPreviewsEnabled_e2ee": { // Can only be enabled per-device to ensure neither the homeserver nor client config diff --git a/apps/web/src/settings/controllers/RequiresSettingsController.ts b/apps/web/src/settings/controllers/RequiresSettingsController.ts index a5bbc4b26b..3729ce7fbb 100644 --- a/apps/web/src/settings/controllers/RequiresSettingsController.ts +++ b/apps/web/src/settings/controllers/RequiresSettingsController.ts @@ -5,30 +5,75 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import SettingController from "./SettingController"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; + +import type { Capabilities } from "matrix-js-sdk/src/matrix"; import SettingsStore from "../SettingsStore"; import type { BooleanSettingKey } from "../Settings.tsx"; +import MatrixClientBackedController from "./MatrixClientBackedController.ts"; +const logger = rootLogger.getChild("RequiresSettingsController"); /** * Disables a setting & forces it's value if one or more settings are not enabled + * and/or a capability on the client check does not pass. */ -export default class RequiresSettingsController extends SettingController { +export default class RequiresSettingsController extends MatrixClientBackedController { public constructor( public readonly settingNames: BooleanSettingKey[], - private forcedValue = false, + private readonly forcedValue = false, + /** + * Function to check the capabilites of the client. + * If defined this will be called when the MatrixClient is instantiated to check + * the returned capabilites. + * @returns `true` or a string if the feature is disabled by the feature, otherwise false. + */ + private readonly isCapabilityDisabled?: (caps: Capabilities) => boolean | string, ) { super(); } + protected initMatrixClient(): void { + if (this.client && this.isCapabilityDisabled) { + // Ensure we fetch capabilies at least once. + this.client.getCapabilities().catch((ex) => { + logger.warn("Failed to fetch capabilities", ex); + }); + } + } + + /** + * Checks if the `isCapabilityDisabled` function blocks the setting. + * @returns `true` or a string if the feature is disabled by the feature, otherwise false. + */ + private get isBlockedByCapabilites(): boolean | string { + if (!this.isCapabilityDisabled) { + return false; + } + // This works because the cached caps are stored forever, and we have made + // at least one call to get capaibilies. + const cachedCaps = this.client?.getCachedCapabilities(); + if (!cachedCaps) { + // If we do not have any capabilites yet, then assume the setting IS blocked. + return true; + } + return this.isCapabilityDisabled(cachedCaps); + } + + public get settingDisabled(): boolean | string { + if (this.settingNames.some((s) => !SettingsStore.getValue(s))) { + return true; + } + return this.isBlockedByCapabilites; + } + public getValueOverride(): any { if (this.settingDisabled) { // per the docs: we force a disabled state when the feature isn't active return this.forcedValue; } + if (this.isBlockedByCapabilites) { + return this.forcedValue; + } return null; // no override } - - public get settingDisabled(): boolean { - return this.settingNames.some((s) => !SettingsStore.getValue(s)); - } } diff --git a/apps/web/test/test-utils/client.ts b/apps/web/test/test-utils/client.ts index f4d35220c2..7ab88da0b7 100644 --- a/apps/web/test/test-utils/client.ts +++ b/apps/web/test/test-utils/client.ts @@ -132,6 +132,7 @@ export const mockClientMethodsServer = (): Partial Promise.resolve(), isUserIgnored: jest.fn().mockReturnValue(false), getCapabilities: jest.fn().mockResolvedValue({}), + getCachedCapabilities: jest.fn().mockReturnValue({}), supportsThreads: jest.fn().mockReturnValue(false), supportsIntentionalMentions: jest.fn().mockReturnValue(false), getRoomUpgradeHistory: jest.fn().mockReturnValue([]), diff --git a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx index c9092d7aa1..c00a3573e2 100644 --- a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx @@ -71,6 +71,7 @@ import ErrorDialog from "../../../../src/components/views/dialogs/ErrorDialog.ts import * as pinnedEventHooks from "../../../../src/hooks/usePinnedEvents"; import { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import { ModuleApi } from "../../../../src/modules/Api"; +import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController.ts"; import { type ComposerInsertPayload, ComposerType } from "../../../../src/dispatcher/payloads/ComposerInsertPayload.ts"; // Used by group calls @@ -93,6 +94,7 @@ describe("RoomView", () => { beforeEach(() => { mockPlatformPeg({ reload: () => {} }); cli = mocked(stubClient()); + MatrixClientBackedController.matrixClient = cli; const roomName = (expect.getState().currentTestName ?? "").replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); diff --git a/apps/web/test/unit-tests/components/views/settings/tabs/user/PreferencesUserSettingsTab-test.tsx b/apps/web/test/unit-tests/components/views/settings/tabs/user/PreferencesUserSettingsTab-test.tsx index b0f17fae4c..16dde05448 100644 --- a/apps/web/test/unit-tests/components/views/settings/tabs/user/PreferencesUserSettingsTab-test.tsx +++ b/apps/web/test/unit-tests/components/views/settings/tabs/user/PreferencesUserSettingsTab-test.tsx @@ -12,7 +12,13 @@ import userEvent from "@testing-library/user-event"; import PreferencesUserSettingsTab from "../../../../../../../src/components/views/settings/tabs/user/PreferencesUserSettingsTab"; import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg"; -import { mockPlatformPeg, stubClient } from "../../../../../../test-utils"; +import { + getMockClientWithEventEmitter, + mockClientMethodsServer, + mockClientMethodsUser, + mockPlatformPeg, + stubClient, +} from "../../../../../../test-utils"; import SettingsStore from "../../../../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../../../../src/settings/SettingLevel"; import MatrixClientBackedController from "../../../../../../../src/settings/controllers/MatrixClientBackedController"; @@ -29,6 +35,10 @@ describe("PreferencesUserSettingsTab", () => { }; it("should render", () => { + MatrixClientBackedController.matrixClient = getMockClientWithEventEmitter({ + ...mockClientMethodsServer(), + ...mockClientMethodsUser(), + }); const { asFragment } = renderTab(); expect(asFragment()).toMatchSnapshot(); }); diff --git a/apps/web/test/unit-tests/settings/controllers/RequiresSettingsController-test.ts b/apps/web/test/unit-tests/settings/controllers/RequiresSettingsController-test.ts index dafd7c8896..36335d9323 100644 --- a/apps/web/test/unit-tests/settings/controllers/RequiresSettingsController-test.ts +++ b/apps/web/test/unit-tests/settings/controllers/RequiresSettingsController-test.ts @@ -5,9 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import type { Capabilities } from "matrix-js-sdk/src/matrix"; import RequiresSettingsController from "../../../../src/settings/controllers/RequiresSettingsController"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; import SettingsStore from "../../../../src/settings/SettingsStore"; +import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController"; +import { getMockClientWithEventEmitter, mockClientMethodsServer } from "../../../test-utils"; describe("RequiresSettingsController", () => { afterEach(() => { @@ -30,4 +33,107 @@ describe("RequiresSettingsController", () => { expect(controller.settingDisabled).toEqual(false); expect(controller.getValueOverride()).toEqual(null); }); + + describe("with capabilites", () => { + let client: ReturnType; + beforeEach(() => { + client = getMockClientWithEventEmitter({ + ...mockClientMethodsServer(), + getCachedCapabilities: jest.fn().mockImplementation(() => {}), + getCapabilities: jest.fn().mockRejectedValue({}), + }); + MatrixClientBackedController["_matrixClient"] = client; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("will disable setting if capability check is true", async () => { + const caps = { + "m.change_password": { + enabled: false, + }, + }; + client.getCachedCapabilities.mockImplementation(() => caps); + const controller = new RequiresSettingsController([], false, (c: Capabilities) => { + expect(c).toEqual(caps); + return !c["m.change_password"]?.enabled; + }); + + // Test that we fetch caps + controller["initMatrixClient"](); + expect(client.getCapabilities).toHaveBeenCalled(); + + // Test that we check caps. + expect(controller.settingDisabled).toEqual(true); + expect(controller.getValueOverride()).toEqual(false); + expect(client.getCachedCapabilities).toHaveBeenCalled(); + }); + + it("will not disable setting if capability check is false", async () => { + const caps = { + "m.change_password": { + enabled: true, + }, + }; + client.getCachedCapabilities.mockImplementation(() => caps); + const controller = new RequiresSettingsController([], false, (c: Capabilities) => { + expect(c).toEqual(caps); + return !c["m.change_password"]?.enabled; + }); + + // Test that we fetch caps + controller["initMatrixClient"](); + expect(client.getCapabilities).toHaveBeenCalled(); + + // Test that we check caps. + expect(controller.settingDisabled).toEqual(false); + expect(controller.getValueOverride()).toEqual(null); + expect(client.getCachedCapabilities).toHaveBeenCalled(); + }); + + it("will check dependency settings before checking capabilites", async () => { + const caps = { + "m.change_password": { + enabled: false, + }, + }; + client.getCachedCapabilities.mockImplementation(() => caps); + await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, false); + const controller = new RequiresSettingsController(["useCompactLayout"], false, (c: Capabilities) => false); + + // Test that we fetch caps + controller["initMatrixClient"](); + expect(client.getCapabilities).toHaveBeenCalled(); + + // Test that we check caps. + expect(controller.settingDisabled).toEqual(true); + expect(controller.getValueOverride()).toEqual(false); + expect(client.getCachedCapabilities).not.toHaveBeenCalled(); + }); + + it("will disable setting if capability check is true and dependency settings are true", async () => { + const caps = { + "m.change_password": { + enabled: false, + }, + }; + client.getCachedCapabilities.mockImplementation(() => caps); + await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true); + const controller = new RequiresSettingsController(["useCompactLayout"], false, (c: Capabilities) => { + expect(c).toEqual(caps); + return !c["m.change_password"]?.enabled; + }); + + // Test that we fetch caps + controller["initMatrixClient"](); + expect(client.getCapabilities).toHaveBeenCalled(); + + // Test that we check caps. + expect(controller.settingDisabled).toEqual(true); + expect(controller.getValueOverride()).toEqual(false); + expect(client.getCachedCapabilities).toHaveBeenCalled(); + }); + }); });