diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index f9070fb5985..13c75f0ba3c 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -40,6 +40,7 @@ import { SlidingSyncManager } from "./SlidingSyncManager"; import CryptoStoreTooNewDialog from "./components/views/dialogs/CryptoStoreTooNewDialog"; import { _t } from "./languageHandler"; import { SettingLevel } from "./settings/SettingLevel"; +import MatrixClientBackedController from "./settings/controllers/MatrixClientBackedController"; export interface IMatrixClientCreds { homeserverUrl: string; @@ -237,6 +238,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { // Connect the matrix client to the dispatcher and setting handlers MatrixActionCreators.start(this.matrixClient); MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient; + MatrixClientBackedController.matrixClient = this.matrixClient; return opts; } diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx index e4380f2aa62..889b1e43872 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx @@ -23,38 +23,41 @@ import { SettingLevel } from "../../../../../settings/SettingLevel"; import SdkConfig from "../../../../../SdkConfig"; import BetaCard from "../../../beta/BetaCard"; import SettingsFlag from "../../../elements/SettingsFlag"; -import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; -import { LabGroup, labGroupNames } from "../../../../../settings/Settings"; +import { defaultWatchManager, LabGroup, labGroupNames } from "../../../../../settings/Settings"; import { EnhancedMap } from "../../../../../utils/maps"; +import { arrayHasDiff } from "../../../../../utils/arrays"; -interface IState { - showJumpToDate: boolean; - showExploringPublicSpaces: boolean; +interface State { + labs: string[]; + betas: string[]; } -export default class LabsUserSettingsTab extends React.Component<{}, IState> { +export default class LabsUserSettingsTab extends React.Component<{}, State> { + private readonly features = SettingsStore.getFeatureSettingNames(); + public constructor(props: {}) { super(props); - const cli = MatrixClientPeg.get(); - - cli.doesServerSupportUnstableFeature("org.matrix.msc3030").then((showJumpToDate) => { - this.setState({ showJumpToDate }); - }); + this.state = { + betas: [], + labs: [], + }; + } - cli.doesServerSupportUnstableFeature("org.matrix.msc3827.stable").then((showExploringPublicSpaces) => { - this.setState({ showExploringPublicSpaces }); + public componentDidMount(): void { + this.features.forEach((feature) => { + defaultWatchManager.watchSetting(feature, null, this.onChange); }); + this.onChange(); + } - this.state = { - showJumpToDate: false, - showExploringPublicSpaces: false, - }; + public componentWillUnmount(): void { + defaultWatchManager.unwatchSetting(this.onChange); } - public render(): React.ReactNode { - const features = SettingsStore.getFeatureSettingNames(); - const [labs, betas] = features.reduce( + private onChange = (): void => { + const features = SettingsStore.getFeatureSettingNames().filter((f) => SettingsStore.isEnabled(f)); + const [_labs, betas] = features.reduce( (arr, f) => { arr[SettingsStore.getBetaInfo(f) ? 1 : 0].push(f); return arr; @@ -62,21 +65,28 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { [[], []] as [string[], string[]], ); - let betaSection; - if (betas.length) { + const labs = SdkConfig.get("show_labs_settings") ? _labs : []; + if (arrayHasDiff(labs, this.state.labs) || arrayHasDiff(betas, this.state.betas)) { + this.setState({ labs, betas }); + } + }; + + public render(): React.ReactNode { + let betaSection: JSX.Element | undefined; + if (this.state.betas.length) { betaSection = (
- {betas.map((f) => ( + {this.state.betas.map((f) => ( ))}
); } - let labsSections; - if (SdkConfig.get("show_labs_settings")) { + let labsSections: JSX.Element | undefined; + if (this.state.labs.length) { const groups = new EnhancedMap(); - labs.forEach((f) => { + this.state.labs.forEach((f) => { groups .getOrCreate(SettingsStore.getLabGroup(f), []) .push(); @@ -101,30 +111,6 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { />, ); - if (this.state.showJumpToDate) { - groups - .getOrCreate(LabGroup.Messaging, []) - .push( - , - ); - } - - if (this.state.showExploringPublicSpaces) { - groups - .getOrCreate(LabGroup.Spaces, []) - .push( - , - ); - } - labsSections = ( <> {sortBy(Array.from(groups.entries()), "0").map(([group, flags]) => ( diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 83a9cfcc99f..b8d69f6c262 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -44,6 +44,10 @@ import SdkConfig from "../SdkConfig"; import SlidingSyncController from "./controllers/SlidingSyncController"; import { FontWatcher } from "./watchers/FontWatcher"; import RustCryptoSdkController from "./controllers/RustCryptoSdkController"; +import ServerSupportUnstableFeatureController from "./controllers/ServerSupportUnstableFeatureController"; +import { WatchManager } from "./WatchManager"; + +export const defaultWatchManager = new WatchManager(); // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -218,9 +222,14 @@ export const SETTINGS: { [setting: string]: ISetting } = { }, }, "feature_exploring_public_spaces": { + isFeature: true, + labsGroup: LabGroup.Spaces, displayName: _td("Explore public spaces in the new search dialog"), supportedLevels: LEVELS_FEATURE, default: false, + controller: new ServerSupportUnstableFeatureController("feature_exploring_public_spaces", defaultWatchManager, [ + "org.matrix.msc3827.stable", + ]), }, "feature_msc3531_hide_messages_pending_moderation": { isFeature: true, @@ -359,13 +368,14 @@ export const SETTINGS: { [setting: string]: ISetting } = { default: false, }, "feature_jump_to_date": { - // We purposely leave out `isFeature: true` so it doesn't show in Labs - // by default. We will conditionally show it depending on whether we can - // detect MSC3030 support (see LabUserSettingsTab.tsx). - // labsGroup: LabGroup.Messaging, + isFeature: true, + labsGroup: LabGroup.Messaging, displayName: _td("Jump to date (adds /jumptodate and jump to date headers)"), supportedLevels: LEVELS_FEATURE, default: false, + controller: new ServerSupportUnstableFeatureController("feature_jump_to_date", defaultWatchManager, [ + "org.matrix.msc3030", + ]), }, "RoomList.backgroundImage": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, @@ -387,6 +397,7 @@ export const SETTINGS: { [setting: string]: ISetting } = { controller: new SlidingSyncController(), }, "feature_sliding_sync_proxy_url": { + // This is not a distinct feature, it is a setting for feature_sliding_sync above supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, default: "", }, diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 962078ec7e4..d03f45bcc5d 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -27,9 +27,9 @@ import RoomSettingsHandler from "./handlers/RoomSettingsHandler"; import ConfigSettingsHandler from "./handlers/ConfigSettingsHandler"; import { _t } from "../languageHandler"; import dis from "../dispatcher/dispatcher"; -import { IFeature, ISetting, LabGroup, SETTINGS } from "./Settings"; +import { IFeature, ISetting, LabGroup, SETTINGS, defaultWatchManager } from "./Settings"; import LocalEchoWrapper from "./handlers/LocalEchoWrapper"; -import { CallbackFn as WatchCallbackFn, WatchManager } from "./WatchManager"; +import { CallbackFn as WatchCallbackFn } from "./WatchManager"; import { SettingLevel } from "./SettingLevel"; import SettingsHandler from "./handlers/SettingsHandler"; import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload"; @@ -39,8 +39,6 @@ import dispatcher from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; import { MatrixClientPeg } from "../MatrixClientPeg"; -const defaultWatchManager = new WatchManager(); - // Convert the settings to easier to manage objects for the handlers const defaultSettings: Record = {}; const invertedDefaultSettings: Record = {}; diff --git a/src/settings/WatchManager.ts b/src/settings/WatchManager.ts index 0e3cae84358..a917d513b56 100644 --- a/src/settings/WatchManager.ts +++ b/src/settings/WatchManager.ts @@ -39,7 +39,7 @@ export class WatchManager { public unwatchSetting(cb: CallbackFn): void { this.watchers.forEach((map) => { map.forEach((callbacks) => { - let idx; + let idx: number; while ((idx = callbacks.indexOf(cb)) !== -1) { callbacks.splice(idx, 1); } diff --git a/src/settings/controllers/MatrixClientBackedController.ts b/src/settings/controllers/MatrixClientBackedController.ts new file mode 100644 index 00000000000..6a68e729d0a --- /dev/null +++ b/src/settings/controllers/MatrixClientBackedController.ts @@ -0,0 +1,52 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import SettingController from "./SettingController"; + +// Dev note: This whole class exists in the event someone logs out and back in - we want +// to make sure the right MatrixClient is listening for changes. + +/** + * Represents the base class for settings controllers which need access to a MatrixClient. + * This class performs no logic and should be overridden. + */ +export default abstract class MatrixClientBackedController extends SettingController { + private static _matrixClient: MatrixClient; + private static instances: MatrixClientBackedController[] = []; + + public static set matrixClient(client: MatrixClient) { + const oldClient = MatrixClientBackedController._matrixClient; + MatrixClientBackedController._matrixClient = client; + + for (const instance of MatrixClientBackedController.instances) { + instance.initMatrixClient(oldClient, client); + } + } + + protected constructor() { + super(); + + MatrixClientBackedController.instances.push(this); + } + + public get client(): MatrixClient { + return MatrixClientBackedController._matrixClient; + } + + protected abstract initMatrixClient(oldClient: MatrixClient, newClient: MatrixClient): void; +} diff --git a/src/settings/controllers/ServerSupportUnstableFeatureController.ts b/src/settings/controllers/ServerSupportUnstableFeatureController.ts new file mode 100644 index 00000000000..e50b673d82f --- /dev/null +++ b/src/settings/controllers/ServerSupportUnstableFeatureController.ts @@ -0,0 +1,77 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { SettingLevel } from "../SettingLevel"; +import MatrixClientBackedController from "./MatrixClientBackedController"; +import { WatchManager } from "../WatchManager"; +import SettingsStore from "../SettingsStore"; + +/** + * Disables a given setting if the server unstable feature it requires is not supported + * When a setting gets disabled or enabled from this controller it notifies the given WatchManager + */ +export default class ServerSupportUnstableFeatureController extends MatrixClientBackedController { + private enabled: boolean | undefined; + + public constructor( + private readonly settingName: string, + private readonly watchers: WatchManager, + private readonly unstableFeatures: string[], + private readonly forcedValue: any = false, + ) { + super(); + } + + public get disabled(): boolean { + return !this.enabled; + } + + public set disabled(v: boolean) { + if (!v === this.enabled) return; + this.enabled = !v; + const level = SettingsStore.firstSupportedLevel(this.settingName); + const settingValue = SettingsStore.getValue(this.settingName, null); + this.watchers.notifyUpdate(this.settingName, null, level, settingValue); + } + + protected async initMatrixClient(oldClient: MatrixClient, newClient: MatrixClient): Promise { + this.disabled = true; + let supported = true; + for (const feature of this.unstableFeatures) { + supported = await this.client.doesServerSupportUnstableFeature(feature); + if (!supported) break; + } + this.disabled = !supported; + } + + public getValueOverride( + level: SettingLevel, + roomId: string, + calculatedValue: any, + calculatedAtLevel: SettingLevel | null, + ): any { + if (this.settingDisabled) { + return this.forcedValue; + } + return null; // no override + } + + public get settingDisabled(): boolean { + return this.disabled; + } +} diff --git a/test/MatrixClientPeg-test.ts b/test/MatrixClientPeg-test.ts index 46b9757ad94..5608b803d65 100644 --- a/test/MatrixClientPeg-test.ts +++ b/test/MatrixClientPeg-test.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { logger } from "matrix-js-sdk/src/logger"; +import fetchMockJest from "fetch-mock-jest"; import { advanceDateAndTime, stubClient } from "./test-utils"; import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg"; @@ -68,6 +69,7 @@ describe("MatrixClientPeg", () => { // instantiate a MatrixClientPegClass instance, with a new MatrixClient const PegClass = Object.getPrototypeOf(peg).constructor; testPeg = new PegClass(); + fetchMockJest.get("http://example.com/_matrix/client/versions", {}); testPeg.replaceUsingCreds({ accessToken: "SEKRET", homeserverUrl: "http://example.com", diff --git a/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx b/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx index 7fa008d4a32..7bd5062cc4a 100644 --- a/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx +++ b/test/components/views/settings/tabs/user/LabsUserSettingsTab-test.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import { render } from "@testing-library/react"; +import { defer } from "matrix-js-sdk/src/utils"; import LabsUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/LabsUserSettingsTab"; import SettingsStore from "../../../../../../src/settings/SettingsStore"; @@ -25,6 +26,7 @@ import { mockClientMethodsUser, } from "../../../../../test-utils"; import SdkConfig from "../../../../../../src/SdkConfig"; +import MatrixClientBackedController from "../../../../../../src/settings/controllers/MatrixClientBackedController"; describe("", () => { const sdkConfigSpy = jest.spyOn(SdkConfig, "get"); @@ -35,7 +37,7 @@ describe("", () => { const getComponent = () => ; const userId = "@alice:server.org"; - getMockClientWithEventEmitter({ + const cli = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), ...mockClientMethodsServer(), }); @@ -70,4 +72,21 @@ describe("", () => { const labsSections = container.getElementsByClassName("mx_SettingsTab_section"); expect(labsSections.length).toEqual(11); }); + + it("renders a labs flag which requires unstable support once support is confirmed", async () => { + // enable labs + sdkConfigSpy.mockImplementation((configName) => configName === "show_labs_settings"); + + const deferred = defer(); + cli.doesServerSupportUnstableFeature.mockImplementation(async (featureName) => { + return featureName === "org.matrix.msc3827.stable" ? deferred.promise : false; + }); + MatrixClientBackedController.matrixClient = cli; + + const { queryByText, findByText } = render(getComponent()); + + expect(queryByText("Explore public spaces in the new search dialog")).toBeFalsy(); + deferred.resolve(true); + await expect(findByText("Explore public spaces in the new search dialog")).resolves.toBeDefined(); + }); }); diff --git a/test/settings/controllers/ServerSupportUnstableFeatureController-test.ts b/test/settings/controllers/ServerSupportUnstableFeatureController-test.ts new file mode 100644 index 00000000000..91f6083da6e --- /dev/null +++ b/test/settings/controllers/ServerSupportUnstableFeatureController-test.ts @@ -0,0 +1,119 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { defer } from "matrix-js-sdk/src/utils"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import ServerSupportUnstableFeatureController from "../../../src/settings/controllers/ServerSupportUnstableFeatureController"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { LabGroup, SETTINGS } from "../../../src/settings/Settings"; +import { stubClient } from "../../test-utils"; +import { WatchManager } from "../../../src/settings/WatchManager"; +import MatrixClientBackedController from "../../../src/settings/controllers/MatrixClientBackedController"; + +describe("ServerSupportUnstableFeatureController", () => { + const watchers = new WatchManager(); + const setting = "setting_name"; + + async function prepareSetting( + cli: MatrixClient, + controller: ServerSupportUnstableFeatureController, + ): Promise { + SETTINGS[setting] = { + isFeature: true, + labsGroup: LabGroup.Messaging, + displayName: "name of some kind", + supportedLevels: [SettingLevel.DEVICE, SettingLevel.CONFIG], + default: false, + controller, + }; + + const deferred = defer(); + watchers.watchSetting(setting, null, deferred.resolve); + MatrixClientBackedController.matrixClient = cli; + await deferred.promise; + } + + describe("getValueOverride()", () => { + it("should return forced value is setting is disabled", async () => { + const cli = stubClient(); + cli.doesServerSupportUnstableFeature = jest.fn(async () => false); + + const controller = new ServerSupportUnstableFeatureController( + setting, + watchers, + ["feature"], + "other_value", + ); + await prepareSetting(cli, controller); + + expect(controller.getValueOverride(SettingLevel.DEVICE, null, true, SettingLevel.ACCOUNT)).toEqual( + "other_value", + ); + }); + + it("should pass through to the handler if setting is not disabled", async () => { + const cli = stubClient(); + cli.doesServerSupportUnstableFeature = jest.fn(async () => true); + + const controller = new ServerSupportUnstableFeatureController( + setting, + watchers, + ["feature"], + "other_value", + ); + await prepareSetting(cli, controller); + + expect(controller.getValueOverride(SettingLevel.DEVICE, null, true, SettingLevel.ACCOUNT)).toEqual(null); + }); + }); + + describe("settingDisabled()", () => { + it("returns true if there is no matrix client", () => { + const controller = new ServerSupportUnstableFeatureController(setting, watchers, ["org.matrix.msc3030"]); + expect(controller.settingDisabled).toEqual(true); + }); + + it("returns true if not all required features are supported", async () => { + const cli = stubClient(); + cli.doesServerSupportUnstableFeature = jest.fn(async (featureName) => { + return featureName === "org.matrix.msc3827.stable"; + }); + + const controller = new ServerSupportUnstableFeatureController(setting, watchers, [ + "org.matrix.msc3827.stable", + "org.matrix.msc3030", + ]); + await prepareSetting(cli, controller); + + expect(controller.settingDisabled).toEqual(true); + }); + + it("returns false if all required features are supported", async () => { + const cli = stubClient(); + cli.doesServerSupportUnstableFeature = jest.fn(async (featureName) => { + return featureName === "org.matrix.msc3827.stable" || featureName === "org.matrix.msc3030"; + }); + const controller = new ServerSupportUnstableFeatureController(setting, watchers, [ + "org.matrix.msc3827.stable", + "org.matrix.msc3030", + ]); + await prepareSetting(cli, controller); + + expect(controller.settingDisabled).toEqual(false); + }); + }); +}); diff --git a/test/setup/setupManualMocks.ts b/test/setup/setupManualMocks.ts index b0cac01cbd5..2d4d4532566 100644 --- a/test/setup/setupManualMocks.ts +++ b/test/setup/setupManualMocks.ts @@ -85,6 +85,7 @@ window.HTMLElement.prototype.scrollIntoView = jest.fn(); fetchMock.config.overwriteRoutes = false; fetchMock.catch(""); fetchMock.get("/image-file-stub", "image file stub"); +fetchMock.get("/_matrix/client/versions", {}); // @ts-ignore window.fetch = fetchMock.sandbox(); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 342949e7684..f7f0fe9c7c7 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -509,6 +509,7 @@ export function mkStubRoom( return { canInvite: jest.fn(), client, + findThreadForEvent: jest.fn(), createThreadsTimelineSets: jest.fn().mockReturnValue(new Promise(() => {})), currentState: { getStateEvents: jest.fn((_type, key) => (key === undefined ? [] : null)),