diff --git a/src/@types/common.ts b/src/@types/common.ts index 3281ad6872..e6e69ab1ec 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -54,3 +54,25 @@ export type KeysStartingWith = { }[keyof Input]; export type NonEmptyArray = [T, ...T[]]; + +export type Defaultize = P extends any + ? string extends keyof P + ? P + : Pick> & + Partial>> & + Partial>> + : never; + +export type DeepReadonly = T extends (infer R)[] + ? DeepReadonlyArray + : T extends Function + ? T + : T extends object + ? DeepReadonlyObject + : T; + +interface DeepReadonlyArray extends ReadonlyArray> {} + +type DeepReadonlyObject = { + readonly [P in keyof T]: DeepReadonly; +}; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 587dc99dc7..d8f01cd4be 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -49,6 +49,7 @@ import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import AutoRageshakeStore from "../stores/AutoRageshakeStore"; import { IConfigOptions } from "../IConfigOptions"; import { MatrixDispatcher } from "../dispatcher/dispatcher"; +import { DeepReadonly } from "./common"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -59,7 +60,7 @@ declare global { Olm: { init: () => Promise; }; - mxReactSdkConfig: IConfigOptions; + mxReactSdkConfig: DeepReadonly; // Needed for Safari, unknown to TypeScript webkitAudioContext: typeof AudioContext; diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index b2e44f23ce..c6913d2eb2 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -186,6 +186,11 @@ export interface IConfigOptions { description: string; show_once?: boolean; }; + + feedback: { + existing_issues_url: string; + new_issue_url: string; + }; } export interface ISsoRedirectOptions { diff --git a/src/Modal.tsx b/src/Modal.tsx index c92741cfc6..e8c514b801 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -23,6 +23,7 @@ import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter" import dis from "./dispatcher/dispatcher"; import AsyncWrapper from "./AsyncWrapper"; +import { Defaultize } from "./@types/common"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; @@ -32,14 +33,6 @@ export type ComponentType = React.ComponentType<{ onFinished?(...args: any): void; }>; -type Defaultize = P extends any - ? string extends keyof P - ? P - : Pick> & - Partial>> & - Partial>> - : never; - // Generic type which returns the props of the Modal component with the onFinished being optional. export type ComponentProps = Defaultize< Omit, "onFinished">, diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 70032cdabb..be94bb5b08 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -16,12 +16,15 @@ limitations under the License. */ import { Optional } from "matrix-events-sdk"; +import { mergeWith } from "lodash"; import { SnakedObject } from "./utils/SnakedObject"; import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions"; +import { isObject, objectClone } from "./utils/objects"; +import { DeepReadonly, Defaultize } from "./@types/common"; // see element-web config.md for docs, or the IConfigOptions interface for dev docs -export const DEFAULTS: IConfigOptions = { +export const DEFAULTS: DeepReadonly = { brand: "Element", integrations_ui_url: "https://scalar.vector.im/", integrations_rest_url: "https://scalar.vector.im/api", @@ -50,13 +53,43 @@ export const DEFAULTS: IConfigOptions = { chunk_length: 2 * 60, // two minutes max_length: 4 * 60 * 60, // four hours }, + + feedback: { + existing_issues_url: + "https://github.com/vector-im/element-web/issues?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc", + new_issue_url: "https://github.com/vector-im/element-web/issues/new/choose", + }, }; -export default class SdkConfig { - private static instance: IConfigOptions; - private static fallback: SnakedObject; +export type ConfigOptions = Defaultize; - private static setInstance(i: IConfigOptions): void { +function mergeConfig( + config: DeepReadonly, + changes: DeepReadonly>, +): DeepReadonly { + // return { ...config, ...changes }; + return mergeWith(objectClone(config), changes, (objValue, srcValue) => { + // Don't merge arrays, prefer values from newer object + if (Array.isArray(objValue)) { + return srcValue; + } + + // Don't allow objects to get nulled out, this will break our types + if (isObject(objValue) && !isObject(srcValue)) { + return objValue; + } + }); +} + +type ObjectType = IConfigOptions[K] extends object + ? SnakedObject> + : Optional>>; + +export default class SdkConfig { + private static instance: DeepReadonly; + private static fallback: SnakedObject>; + + private static setInstance(i: DeepReadonly): void { SdkConfig.instance = i; SdkConfig.fallback = new SnakedObject(i); @@ -69,7 +102,7 @@ export default class SdkConfig { public static get( key?: K, altCaseName?: string, - ): IConfigOptions | IConfigOptions[K] { + ): DeepReadonly | DeepReadonly[K] { if (key === undefined) { // safe to cast as a fallback - we want to break the runtime contract in this case return SdkConfig.instance || {}; @@ -77,32 +110,29 @@ export default class SdkConfig { return SdkConfig.fallback.get(key, altCaseName); } - public static getObject( - key: K, - altCaseName?: string, - ): Optional>> { + public static getObject(key: K, altCaseName?: string): ObjectType { const val = SdkConfig.get(key, altCaseName); - if (val !== null && val !== undefined) { + if (isObject(val)) { return new SnakedObject(val); } // return the same type for sensitive callers (some want `undefined` specifically) - return val === undefined ? undefined : null; + return (val === undefined ? undefined : null) as ObjectType; } - public static put(cfg: Partial): void { - SdkConfig.setInstance({ ...DEFAULTS, ...cfg }); + public static put(cfg: DeepReadonly): void { + SdkConfig.setInstance(mergeConfig(DEFAULTS, cfg)); } /** - * Resets the config to be completely empty. + * Resets the config. */ - public static unset(): void { - SdkConfig.setInstance({}); // safe to cast - defaults will be applied + public static reset(): void { + SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied } - public static add(cfg: Partial): void { - SdkConfig.put({ ...SdkConfig.get(), ...cfg }); + public static add(cfg: Partial): void { + SdkConfig.put(mergeConfig(SdkConfig.get(), cfg)); } } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 9c72b269f1..afc35508ac 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -66,11 +66,11 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { TimelineRenderingType } from "../../contexts/RoomContext"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; -import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from "../views/beacon/LeftPanelLiveShareWarning"; import { UserOnboardingPage } from "../views/user-onboarding/UserOnboardingPage"; import { PipContainer } from "./PipContainer"; import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules"; +import { ConfigOptions } from "../../SdkConfig"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -98,7 +98,7 @@ interface IProps { roomOobData?: IOOBData; currentRoomId: string; collapseLhs: boolean; - config: IConfigOptions; + config: ConfigOptions; currentUserId?: string; justRegistered?: boolean; roomJustCreatedOpts?: IOpts; diff --git a/src/components/views/dialogs/FeedbackDialog.tsx b/src/components/views/dialogs/FeedbackDialog.tsx index 7ee24e05a4..6b204d68e1 100644 --- a/src/components/views/dialogs/FeedbackDialog.tsx +++ b/src/components/views/dialogs/FeedbackDialog.tsx @@ -28,10 +28,6 @@ import { submitFeedback } from "../../../rageshake/submit-rageshake"; import { useStateToggle } from "../../../hooks/useStateToggle"; import StyledCheckbox from "../elements/StyledCheckbox"; -const existingIssuesUrl = - "https://github.com/vector-im/element-web/issues" + "?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc"; -const newIssueUrl = "https://github.com/vector-im/element-web/issues/new/choose"; - interface IProps { feature?: string; onFinished(): void; @@ -117,6 +113,9 @@ const FeedbackDialog: React.FC = (props: IProps) => { ); } + const existingIssuesUrl = SdkConfig.getObject("feedback").get("existing_issues_url"); + const newIssueUrl = SdkConfig.getObject("feedback").get("new_issue_url"); + return ( = ({ room, busy, setBusy, behavi let menu: JSX.Element | null = null; if (menuOpen) { const buttonRect = buttonRef.current!.getBoundingClientRect(); - const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; + const brand = SdkConfig.get("element_call").brand; menu = ( @@ -250,7 +250,7 @@ const CallButtons: FC = ({ room }) => { const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]); const useElementCallExclusively = useMemo(() => { - return SdkConfig.get("element_call").use_exclusively ?? DEFAULTS.element_call.use_exclusively; + return SdkConfig.get("element_call").use_exclusively; }, []); const hasLegacyCall = useEventEmitterState( diff --git a/src/utils/device/clientInformation.ts b/src/utils/device/clientInformation.ts index 86ab7faa2d..4b7d8e60e0 100644 --- a/src/utils/device/clientInformation.ts +++ b/src/utils/device/clientInformation.ts @@ -18,6 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import BasePlatform from "../../BasePlatform"; import { IConfigOptions } from "../../IConfigOptions"; +import { DeepReadonly } from "../../@types/common"; export type DeviceClientInformation = { name?: string; @@ -49,7 +50,7 @@ export const getClientInformationEventType = (deviceId: string): string => `${cl */ export const recordClientInformation = async ( matrixClient: MatrixClient, - sdkConfig: IConfigOptions, + sdkConfig: DeepReadonly, platform?: BasePlatform, ): Promise => { const deviceId = matrixClient.getDeviceId()!; diff --git a/src/utils/objects.ts b/src/utils/objects.ts index c2496b4c7c..f505b71a4c 100644 --- a/src/utils/objects.ts +++ b/src/utils/objects.ts @@ -141,3 +141,12 @@ export function objectKeyChanges(a: O, b: O): (keyof O)[] { export function objectClone(obj: O): O { return JSON.parse(JSON.stringify(obj)); } + +/** + * Simple object check. + * @param item + * @returns {boolean} + */ +export function isObject(item: any): item is object { + return item && typeof item === "object" && !Array.isArray(item); +} diff --git a/test/LegacyCallHandler-test.ts b/test/LegacyCallHandler-test.ts index aafbc1275c..be902e54f8 100644 --- a/test/LegacyCallHandler-test.ts +++ b/test/LegacyCallHandler-test.ts @@ -305,7 +305,7 @@ describe("LegacyCallHandler", () => { MatrixClientPeg.unset(); document.body.removeChild(audioElement); - SdkConfig.unset(); + SdkConfig.reset(); }); it("should look up the correct user and start a call in the room when a phone number is dialled", async () => { @@ -516,7 +516,7 @@ describe("LegacyCallHandler without third party protocols", () => { MatrixClientPeg.unset(); document.body.removeChild(audioElement); - SdkConfig.unset(); + SdkConfig.reset(); }); it("should still start a native call", async () => { diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 21d0d7cf0f..1ba7c01d53 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -77,7 +77,7 @@ describe("PosthogAnalytics", () => { Object.defineProperty(window, "crypto", { value: null, }); - SdkConfig.unset(); // we touch the config, so clean up + SdkConfig.reset(); // we touch the config, so clean up }); describe("Initialisation", () => { diff --git a/test/SdkConfig-test.ts b/test/SdkConfig-test.ts index a6ac58e9c5..aba0e9646a 100644 --- a/test/SdkConfig-test.ts +++ b/test/SdkConfig-test.ts @@ -30,6 +30,9 @@ describe("SdkConfig", () => { chunk_length: 42, max_length: 1337, }, + feedback: { + existing_issues_url: "https://existing", + } as any, }); }); @@ -37,7 +40,16 @@ describe("SdkConfig", () => { const customConfig = JSON.parse(JSON.stringify(DEFAULTS)); customConfig.voice_broadcast.chunk_length = 42; customConfig.voice_broadcast.max_length = 1337; + customConfig.feedback.existing_issues_url = "https://existing"; expect(SdkConfig.get()).toEqual(customConfig); }); + + it("should allow overriding individual fields of sub-objects", () => { + const feedback = SdkConfig.getObject("feedback"); + expect(feedback.get("existing_issues_url")).toMatchInlineSnapshot(`"https://existing"`); + expect(feedback.get("new_issue_url")).toMatchInlineSnapshot( + `"https://github.com/vector-im/element-web/issues/new/choose"`, + ); + }); }); }); diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index bf9e8d567c..a84e88b17c 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -61,7 +61,7 @@ describe("Login", function () { afterEach(function () { fetchMock.restore(); - SdkConfig.unset(); // we touch the config, so clean up + SdkConfig.reset(); // we touch the config, so clean up unmockPlatformPeg(); }); diff --git a/test/components/structures/auth/Registration-test.tsx b/test/components/structures/auth/Registration-test.tsx index 16b64bc393..3f6f44db7e 100644 --- a/test/components/structures/auth/Registration-test.tsx +++ b/test/components/structures/auth/Registration-test.tsx @@ -66,7 +66,7 @@ describe("Registration", function () { afterEach(function () { fetchMock.restore(); - SdkConfig.unset(); // we touch the config, so clean up + SdkConfig.reset(); // we touch the config, so clean up unmockPlatformPeg(); }); diff --git a/test/components/views/auth/CountryDropdown-test.tsx b/test/components/views/auth/CountryDropdown-test.tsx index a6beeda233..95cd5abe75 100644 --- a/test/components/views/auth/CountryDropdown-test.tsx +++ b/test/components/views/auth/CountryDropdown-test.tsx @@ -23,7 +23,7 @@ import SdkConfig from "../../../../src/SdkConfig"; describe("CountryDropdown", () => { describe("default_country_code", () => { afterEach(() => { - SdkConfig.unset(); + SdkConfig.reset(); }); it.each([ diff --git a/test/components/views/dialogs/FeedbackDialog-test.tsx b/test/components/views/dialogs/FeedbackDialog-test.tsx new file mode 100644 index 0000000000..73dadd00b5 --- /dev/null +++ b/test/components/views/dialogs/FeedbackDialog-test.tsx @@ -0,0 +1,35 @@ +/* +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 React from "react"; +import { render } from "@testing-library/react"; + +import SdkConfig from "../../../../src/SdkConfig"; +import FeedbackDialog from "../../../../src/components/views/dialogs/FeedbackDialog"; + +describe("FeedbackDialog", () => { + it("should respect feedback config", () => { + SdkConfig.put({ + feedback: { + existing_issues_url: "http://existing?foo=bar", + new_issue_url: "https://new.issue.url?foo=bar", + }, + }); + + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap new file mode 100644 index 0000000000..2682f5234c --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/FeedbackDialog-test.tsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FeedbackDialog should respect feedback config 1`] = ` + +
+ +
+ +`; diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index 35cb5d200c..c74d856a85 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -120,7 +120,7 @@ describe("RoomHeader", () => { await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient)); client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); jest.restoreAllMocks(); - SdkConfig.put({}); + SdkConfig.reset(); }); const mockRoomType = (type: string) => { diff --git a/test/languageHandler-test.ts b/test/languageHandler-test.ts index 556c12fe05..0f22962831 100644 --- a/test/languageHandler-test.ts +++ b/test/languageHandler-test.ts @@ -42,7 +42,7 @@ async function setupTranslationOverridesForTests(overrides: ICustomTranslations) describe("languageHandler", () => { afterEach(() => { - SdkConfig.unset(); + SdkConfig.reset(); CustomTranslationOptions.lookupFn = undefined; }); diff --git a/test/utils/device/clientInformation-test.ts b/test/utils/device/clientInformation-test.ts index 4133619f91..9b90d6cb10 100644 --- a/test/utils/device/clientInformation-test.ts +++ b/test/utils/device/clientInformation-test.ts @@ -20,6 +20,8 @@ import BasePlatform from "../../../src/BasePlatform"; import { IConfigOptions } from "../../../src/IConfigOptions"; import { getDeviceClientInformation, recordClientInformation } from "../../../src/utils/device/clientInformation"; import { getMockClientWithEventEmitter } from "../../test-utils"; +import { DEFAULTS } from "../../../src/SdkConfig"; +import { DeepReadonly } from "../../../src/@types/common"; describe("recordClientInformation()", () => { const deviceId = "my-device-id"; @@ -31,7 +33,8 @@ describe("recordClientInformation()", () => { setAccountData: jest.fn(), }); - const sdkConfig: IConfigOptions = { + const sdkConfig: DeepReadonly = { + ...DEFAULTS, brand: "Test Brand", element_call: { url: "", use_exclusively: false, brand: "Element Call" }, }; diff --git a/test/voice-broadcast/utils/getChunkLength-test.ts b/test/voice-broadcast/utils/getChunkLength-test.ts index a046a47f76..5610bd6caf 100644 --- a/test/voice-broadcast/utils/getChunkLength-test.ts +++ b/test/voice-broadcast/utils/getChunkLength-test.ts @@ -14,24 +14,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { mocked } from "jest-mock"; - -import SdkConfig, { DEFAULTS } from "../../../src/SdkConfig"; +import SdkConfig from "../../../src/SdkConfig"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Features } from "../../../src/settings/Settings"; import SettingsStore from "../../../src/settings/SettingsStore"; import { getChunkLength } from "../../../src/voice-broadcast/utils/getChunkLength"; -jest.mock("../../../src/SdkConfig"); - describe("getChunkLength", () => { afterEach(() => { - jest.resetAllMocks(); + SdkConfig.reset(); }); describe("when there is a value provided by Sdk config", () => { beforeEach(() => { - mocked(SdkConfig.get).mockReturnValue({ chunk_length: 42 }); + SdkConfig.add({ + voice_broadcast: { + chunk_length: 42, + }, + }); }); it("should return this value", () => { @@ -41,9 +41,11 @@ describe("getChunkLength", () => { describe("when Sdk config does not provide a value", () => { beforeEach(() => { - DEFAULTS.voice_broadcast = { - chunk_length: 23, - }; + SdkConfig.add({ + voice_broadcast: { + chunk_length: 23, + }, + }); }); it("should return this value", () => { @@ -52,10 +54,6 @@ describe("getChunkLength", () => { }); describe("when there are no defaults", () => { - beforeEach(() => { - DEFAULTS.voice_broadcast = undefined; - }); - it("should return the fallback value", () => { expect(getChunkLength()).toBe(120); }); diff --git a/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts b/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts index f2dd138954..3f40dd0efc 100644 --- a/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts +++ b/test/voice-broadcast/utils/getMaxBroadcastLength-test.ts @@ -14,21 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { mocked } from "jest-mock"; - import SdkConfig, { DEFAULTS } from "../../../src/SdkConfig"; import { getMaxBroadcastLength } from "../../../src/voice-broadcast"; -jest.mock("../../../src/SdkConfig"); - describe("getMaxBroadcastLength", () => { afterEach(() => { - jest.resetAllMocks(); + SdkConfig.reset(); }); describe("when there is a value provided by Sdk config", () => { beforeEach(() => { - mocked(SdkConfig.get).mockReturnValue({ max_length: 42 }); + SdkConfig.put({ + voice_broadcast: { + max_length: 42, + }, + }); }); it("should return this value", () => { @@ -37,23 +37,14 @@ describe("getMaxBroadcastLength", () => { }); describe("when Sdk config does not provide a value", () => { - beforeEach(() => { - DEFAULTS.voice_broadcast = { - max_length: 23, - }; - }); - it("should return this value", () => { - expect(getMaxBroadcastLength()).toBe(23); + expect(getMaxBroadcastLength()).toBe(DEFAULTS.voice_broadcast!.max_length); }); }); describe("if there are no defaults", () => { - beforeEach(() => { - DEFAULTS.voice_broadcast = undefined; - }); - it("should return the fallback value", () => { + expect(DEFAULTS.voice_broadcast!.max_length).toBe(4 * 60 * 60); expect(getMaxBroadcastLength()).toBe(4 * 60 * 60); }); });