diff --git a/docs/settings.md b/docs/settings.md index 379f3c5dcd..dae6eb22b8 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,38 +1,38 @@ # Settings Reference -This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify -different values for a setting at particular levels of interest. For example, a user may say that in a particular room -they want URL previews off, but in all other rooms they want them enabled. The `SettingsStore` helps mask the complexity +This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify +different values for a setting at particular levels of interest. For example, a user may say that in a particular room +they want URL previews off, but in all other rooms they want them enabled. The `SettingsStore` helps mask the complexity of dealing with the different levels and exposes easy to use getters and setters. ## Levels -Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in +Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in order of priority, are: * `device` - The current user's device * `room-device` - The current user's device, but only when in a specific room * `room-account` - The current user's account, but only when in a specific room * `account` - The current user's account * `room` - A specific room (setting for all members of the room) -* `config` - Values are defined by the `settingDefaults` key (usually) in `config.json` +* `config` - Values are defined by the `setting_defaults` key (usually) in `config.json` * `default` - The hardcoded default for the settings -Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure +Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure that room administrators cannot force account-only settings upon participants. ## Settings -Settings are the different options a user may set or experience in the application. These are pre-defined in +Settings are the different options a user may set or experience in the application. These are pre-defined in `src/settings/Settings.tsx` under the `SETTINGS` constant, and match the `ISetting` interface as defined there. -Settings that support the config level can be set in the config file under the `settingDefaults` key (note that some +Settings that support the config level can be set in the config file under the `setting_defaults` key (note that some settings, like the "theme" setting, are special cased in the config file): -```json +```json5 { ... - "settingDefaults": { + "setting_defaults": { "settingName": true }, ... @@ -41,20 +41,20 @@ settings, like the "theme" setting, are special cased in the config file): ### Getting values for a setting -After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always -be supplied where possible, even if the setting does not have a per-room level value. This is to ensure that the value +After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always +be supplied where possible, even if the setting does not have a per-room level value. This is to ensure that the value returned is best represented in the room, particularly if the setting ever gets a per-room level in the future. -In settings pages it is often desired to have the value at a particular level instead of getting the calculated value. -Call `SettingsStore.getValueAt` to get the value of a setting at a particular level, and optionally make it explicitly -at that level. By default `getValueAt` will traverse the tree starting at the provided level; making it explicit means -it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the +In settings pages it is often desired to have the value at a particular level instead of getting the calculated value. +Call `SettingsStore.getValueAt` to get the value of a setting at a particular level, and optionally make it explicitly +at that level. By default `getValueAt` will traverse the tree starting at the provided level; making it explicit means +it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the target level. ### Setting values for a setting -Values are defined at particular levels and should be done in a safe manner. There are two checks to perform to ensure a -clean save: is the level supported and can the user actually set the value. In most cases, neither should be an issue +Values are defined at particular levels and should be done in a safe manner. There are two checks to perform to ensure a +clean save: is the level supported and can the user actually set the value. In most cases, neither should be an issue although there are circumstances where this changes. An example of a safe call is: ```javascript const isSupported = SettingsStore.isLevelSupported(SettingLevel.ROOM); @@ -66,12 +66,12 @@ if (isSupported) { } ``` -These checks may also be performed in different areas of the application to avoid the verbose example above. For +These checks may also be performed in different areas of the application to avoid the verbose example above. For instance, the component which allows changing the setting may be hidden conditionally on the above conditions. ##### `SettingsFlag` component -Where possible, the `SettingsFlag` component should be used to set simple "flip-a-bit" (true/false) settings. The +Where possible, the `SettingsFlag` component should be used to set simple "flip-a-bit" (true/false) settings. The `SettingsFlag` also supports simple radio button options, such as the theme the user would like to use. ```html { @@ -163,27 +163,27 @@ SettingsStore.getValue(...); // this will return the value set in `setValue` abo ## Watching for changes -Most use cases do not need to set up a watcher because they are able to react to changes as they are made, or the -changes which are made are not significant enough for it to matter. Watchers are intended to be used in scenarios where -it is important to react to changes made by other logged in devices. Typically, this would be done within the component -itself, however the component should not be aware of the intricacies of setting inversion or remapping to particular -data structures. Instead, a generic watcher interface is provided on `SettingsStore` to watch (and subsequently unwatch) +Most use cases do not need to set up a watcher because they are able to react to changes as they are made, or the +changes which are made are not significant enough for it to matter. Watchers are intended to be used in scenarios where +it is important to react to changes made by other logged in devices. Typically, this would be done within the component +itself, however the component should not be aware of the intricacies of setting inversion or remapping to particular +data structures. Instead, a generic watcher interface is provided on `SettingsStore` to watch (and subsequently unwatch) for changes in a setting. An example of a watcher in action would be: ```javascript class MyComponent extends React.Component { - + settingWatcherRef = null; - + componentWillMount() { const callback = (settingName, roomId, level, newValAtLevel, newVal) => { this.setState({color: newVal}); }; this.settingWatcherRef = SettingsStore.watchSetting("roomColor", "!example:matrix.org", callback); } - + componentWillUnmount() { SettingsStore.unwatchSetting(this.settingWatcherRef); } @@ -193,29 +193,29 @@ class MyComponent extends React.Component { # Maintainers Reference -The granular settings system has a few complex parts to power it. This section is to document how the `SettingsStore` is +The granular settings system has a few complex parts to power it. This section is to document how the `SettingsStore` is supposed to work. ### General information -The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure. -The array is checked from left to right, simulating the behaviour of overriding values from the higher levels. Each +The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure. +The array is checked from left to right, simulating the behaviour of overriding values from the higher levels. Each level should be defined in this array, including `default`. -Handlers (`src/settings/handlers/SettingsHandler.ts`) represent a single level and are responsible for getting and -setting values at that level. Handlers also provide additional information to the `SettingsStore` such as if the level -is supported or if the current user may set values at the level. The `SettingsStore` will use the handler to enforce -checks and manipulate settings. Handlers are also responsible for dealing with migration patterns or legacy settings for -their level (for example, a setting being renamed or using a different key from other settings in the underlying store). -Handlers are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by +Handlers (`src/settings/handlers/SettingsHandler.ts`) represent a single level and are responsible for getting and +setting values at that level. Handlers also provide additional information to the `SettingsStore` such as if the level +is supported or if the current user may set values at the level. The `SettingsStore` will use the handler to enforce +checks and manipulate settings. Handlers are also responsible for dealing with migration patterns or legacy settings for +their level (for example, a setting being renamed or using a different key from other settings in the underlying store). +Handlers are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by only considering handlers that are supported on the platform. -Local echo is achieved through `src/settings/handlers/LocalEchoWrapper.ts` which acts as a wrapper around a given -handler. This is automatically applied to all defined `LEVEL_HANDLERS` and proxies the calls to the wrapped handler -where possible. The echo is achieved by a simple object cache stored within the class itself. The cache is invalidated +Local echo is achieved through `src/settings/handlers/LocalEchoWrapper.ts` which acts as a wrapper around a given +handler. This is automatically applied to all defined `LEVEL_HANDLERS` and proxies the calls to the wrapped handler +where possible. The echo is achieved by a simple object cache stored within the class itself. The cache is invalidated immediately upon the proxied save call succeeding or failing. -Controllers are notified of changes by the `SettingsStore`, and are given the opportunity to override values after the +Controllers are notified of changes by the `SettingsStore`, and are given the opportunity to override values after the `SettingsStore` has deemed the value calculated. Controllers are invoked as the last possible step in the code. ### Features @@ -224,17 +224,17 @@ See above for feature reference. ### Watchers -Watchers can appear complicated under the hood: there is a central `WatchManager` which handles the actual invocation -of callbacks, and callbacks are managed by the SettingsStore by redirecting the caller's callback to a dedicated -callback. This is done so that the caller can reuse the same function as their callback without worrying about whether -or not it'll unsubscribe all watchers. +Watchers can appear complicated under the hood: there is a central `WatchManager` which handles the actual invocation +of callbacks, and callbacks are managed by the SettingsStore by redirecting the caller's callback to a dedicated +callback. This is done so that the caller can reuse the same function as their callback without worrying about whether +or not it'll unsubscribe all watchers. -Setting changes are emitted into the default `WatchManager`, which calculates the new value for the setting. Ideally, -we'd also try and suppress updates which don't have a consequence on this value, however there's not an easy way to do -this. Instead, we just dispatch an update for all changes and leave it up to the consumer to deduplicate. +Setting changes are emitted into the default `WatchManager`, which calculates the new value for the setting. Ideally, +we'd also try and suppress updates which don't have a consequence on this value, however there's not an easy way to do +this. Instead, we just dispatch an update for all changes and leave it up to the consumer to deduplicate. -In practice, handlers which rely on remote changes (account data, room events, etc) will always attach a listener to the -`MatrixClient`. They then watch for changes to events they care about and send off appropriate updates to the -generalized `WatchManager` - a class specifically designed to deduplicate the logic of managing watchers. The handlers -which are localized to the local client (device) generally just trigger the `WatchManager` when they manipulate the +In practice, handlers which rely on remote changes (account data, room events, etc) will always attach a listener to the +`MatrixClient`. They then watch for changes to events they care about and send off appropriate updates to the +generalized `WatchManager` - a class specifically designed to deduplicate the logic of managing watchers. The handlers +which are localized to the local client (device) generally just trigger the `WatchManager` when they manipulate the setting themselves as there's nothing to really 'watch'. diff --git a/src/@types/common.ts b/src/@types/common.ts index d70d80fcd5..b4d01a75a5 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -41,3 +41,11 @@ export type RecursivePartial = { T[P] extends object ? RecursivePartial : T[P]; }; + +// Inspired by https://stackoverflow.com/a/60206860 +export type KeysWithObjectShape = { + [P in keyof Input]: Input[P] extends object + // Arrays are counted as objects - exclude them + ? (Input[P] extends Array ? never : P) + : never; +}[keyof Input]; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 6ac72a2dd6..b793dea22d 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -52,7 +52,7 @@ import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import { Skinner } from "../Skinner"; import AutoRageshakeStore from "../stores/AutoRageshakeStore"; -import { ConfigOptions } from "../SdkConfig"; +import { IConfigOptions } from "../IConfigOptions"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -63,7 +63,7 @@ declare global { Olm: { init: () => Promise; }; - mxReactSdkConfig: ConfigOptions; + mxReactSdkConfig: IConfigOptions; // Needed for Safari, unknown to TypeScript webkitAudioContext: typeof AudioContext; diff --git a/src/Analytics.tsx b/src/Analytics.tsx index d7759562b1..af85bf9755 100644 --- a/src/Analytics.tsx +++ b/src/Analytics.tsx @@ -17,12 +17,15 @@ limitations under the License. import React from 'react'; import { logger } from "matrix-js-sdk/src/logger"; +import { Optional } from "matrix-events-sdk"; import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import Modal from './Modal'; import * as sdk from './index'; +import { SnakedObject } from "./utils/SnakedObject"; +import { IConfigOptions } from "./IConfigOptions"; const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password|home|directory)/; const hashVarRegex = /#\/(group|room|user)\/.*$/; @@ -193,8 +196,12 @@ export class Analytics { } public canEnable() { - const config = SdkConfig.get(); - return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId; + const piwikConfig = SdkConfig.get("piwik"); + let piwik: Optional>>; + if (typeof piwikConfig === 'object') { + piwik = new SnakedObject(piwikConfig); + } + return navigator.doNotTrack !== "1" && piwik?.get("site_id"); } /** @@ -204,12 +211,16 @@ export class Analytics { public async enable() { if (!this.disabled) return; if (!this.canEnable()) return; - const config = SdkConfig.get(); + const piwikConfig = SdkConfig.get("piwik"); + let piwik: Optional>>; + if (typeof piwikConfig === 'object') { + piwik = new SnakedObject(piwikConfig); + } - this.baseUrl = new URL("piwik.php", config.piwik.url); + this.baseUrl = new URL("piwik.php", piwik.get("url")); // set constants this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking - this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking + this.baseUrl.searchParams.set("idsite", piwik.get("site_id")); // idsite is required for tracking this.baseUrl.searchParams.set("apiv", "1"); // API version to use this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF // set user parameters @@ -347,10 +358,14 @@ export class Analytics { public setLoggedIn(isGuest: boolean, homeserverUrl: string) { if (this.disabled) return; - const config = SdkConfig.get(); - if (!config.piwik) return; + const piwikConfig = SdkConfig.get("piwik"); + let piwik: Optional>>; + if (typeof piwikConfig === 'object') { + piwik = new SnakedObject(piwikConfig); + } + if (!piwik) return; - const whitelistedHSUrls = config.piwik.whitelistedHSUrls || []; + const whitelistedHSUrls = piwik.get("whitelisted_hs_urls", "whitelistedHSUrls") || []; this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); @@ -391,7 +406,12 @@ export class Analytics { ]; // FIXME: Using an import will result in test failures - const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl; + const piwikConfig = SdkConfig.get("piwik"); + let piwik: Optional>>; + if (typeof piwikConfig === 'object') { + piwik = new SnakedObject(piwikConfig); + } + const cookiePolicyUrl = piwik?.get("policy_url"); const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); const cookiePolicyLink = _t( "Our complete cookie policy can be found here.", diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 40ac9d1199..383d4eb29e 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -32,6 +32,7 @@ import { hideToast as hideUpdateToast } from "./toasts/UpdateToast"; import { MatrixClientPeg } from "./MatrixClientPeg"; import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; +import { IConfigOptions } from "./IConfigOptions"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; @@ -62,7 +63,7 @@ export default abstract class BasePlatform { this.startUpdateCheck = this.startUpdateCheck.bind(this); } - abstract getConfig(): Promise<{}>; + abstract getConfig(): Promise; abstract getDefaultDeviceDisplayName(): string; diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 42d5aaba33..2572c685b4 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -259,7 +259,7 @@ export default class CallHandler extends EventEmitter { } private shouldObeyAssertedfIdentity(): boolean { - return SdkConfig.get()['voip']?.obeyAssertedIdentity; + return SdkConfig.getObject("voip")?.get("obey_asserted_identity"); } public getSupportsPstnProtocol(): boolean { diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts new file mode 100644 index 0000000000..76c0901df3 --- /dev/null +++ b/src/IConfigOptions.ts @@ -0,0 +1,186 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2019 - 2022 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 { IClientWellKnown } from "matrix-js-sdk/src/matrix"; + +import { ValidatedServerConfig } from "./utils/AutoDiscoveryUtils"; + +// Convention decision: All config options are lower_snake_case +// We use an isolated file for the interface so we can mess around with the eslint options. + +/* eslint-disable camelcase */ +/* eslint @typescript-eslint/naming-convention: ["error", { "selector": "property", "format": ["snake_case"] } ] */ + +// see element-web config.md for non-developer docs +export interface IConfigOptions { + // dev note: while true that this is arbitrary JSON, it's valuable to enforce that all + // config options are documented for "find all usages" sort of searching. + // [key: string]: any; + + // Properties of this interface are roughly grouped by their subject matter, such as + // "instance customisation", "login stuff", "branding", etc. Use blank lines to denote + // a logical separation of properties, but keep similar ones near each other. + + // Exactly one of the following must be supplied + default_server_config?: IClientWellKnown; // copy/paste of client well-known + default_server_name?: string; // domain to do well-known lookup on + default_hs_url?: string; // http url + + default_is_url?: string; // used in combination with default_hs_url, but for the identity server + + // This is intended to be overridden by app startup and not specified by the user + // This is also why it's allowed to have an interface that isn't snake_case + validated_server_config?: ValidatedServerConfig; + + fallback_hs_url?: string; + + disable_custom_urls?: boolean; + disable_guests?: boolean; + disable_login_language_selector?: boolean; + disable_3pid_login?: boolean; + + brand: string; + branding?: { + welcome_background_url?: string | string[]; // chosen at random if array + auth_header_logo_url?: string; + auth_footer_links?: {text: string, url: string}[]; + }; + + map_style_url?: string; // for location-shared maps + + embedded_pages?: { + welcome_url?: string; + home_url?: string; + login_for_welcome?: boolean; + }; + + permalink_prefix?: string; + + update_base_url?: string; + desktop_builds?: { + available: boolean; + logo: string; // url + url: string; // download url + }; + mobile_builds?: { + ios?: string; // download url + android?: string; // download url + fdroid?: string; // download url + }; + + mobile_guide_toast?: boolean; + + default_theme?: "light" | "dark" | string; // custom themes are strings + default_country_code?: string; // ISO 3166 alpha2 country code + default_federate?: boolean; + default_device_display_name?: string; // for device naming on login+registration + + setting_defaults?: Record; // + + integrations_ui_url?: string; + integrations_rest_url?: string; + integrations_widgets_urls?: string[]; + + show_labs_settings?: boolean; + features?: Record; // + + bug_report_endpoint_url?: string; // omission disables bug reporting + uisi_autorageshake_app?: string; + sentry?: { + dsn: string; + environment?: string; // "production", etc + }; + + widget_build_url?: string; // url called to replace jitsi/call widget creation + audio_stream_url?: string; + jitsi?: { + preferred_domain: string; + }; + jitsi_widget?: { + skip_built_in_welcome_screen?: boolean; + }; + voip?: { + obey_asserted_identity?: boolean; // MSC3086 + }; + + logout_redirect_url?: string; + + // sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate + sso_immediate_redirect?: boolean; + sso_redirect_options?: ISsoRedirectOptions; + + custom_translations_url?: string; + + report_event?: { + admin_message_md: string; // message for how to contact the server owner when reporting an event + }; + + welcome_user_id?: string; + + room_directory?: { + servers: string[]; + }; + + // piwik (matomo) is deprecated in favour of posthog + piwik?: false | { + url: string; // piwik instance + site_id: string; + policy_url: string; // cookie policy + whitelisted_hs_urls: string[]; + }; + posthog?: { + project_api_key: string; + api_host: string; // hostname + }; + analytics_owner?: string; // defaults to `brand` + + // Server hosting upsell options + hosting_signup_link?: string; // slightly different from `host_signup` + host_signup?: { + brand?: string; // acts as the enabled flag too (truthy == show) + + // Required-ness denotes when `brand` is truthy + cookie_policy_url: string; + privacy_policy_url: string; + terms_of_service_url: string; + url: string; + domains?: string[]; + }; + + enable_presence_by_hs_url?: Record; // + + terms_and_conditions_links?: { url: string, text: string }[]; + + latex_maths_delims?: { + inline?: { + left?: string; + right?: string; + }; + display?: { + left?: string; + right?: string; + }; + }; + + sync_timeline_limit?: number; + dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option +} + +export interface ISsoRedirectOptions { + immediate?: boolean; + on_welcome_page?: boolean; +} diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 9ac6d745c2..d4f4ffc681 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -161,7 +161,7 @@ const callBindings = (): KeyBinding[] => { }; const labsBindings = (): KeyBinding[] => { - if (!SdkConfig.get()['showLabsSettings']) return []; + if (!SdkConfig.get("show_labs_settings")) return []; return getBindingsByCategory(CategoryName.LABS); }; diff --git a/src/Livestream.ts b/src/Livestream.ts index b136fbeaea..ed7b61dd6c 100644 --- a/src/Livestream.ts +++ b/src/Livestream.ts @@ -21,7 +21,7 @@ import SdkConfig from "./SdkConfig"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; export function getConfigLivestreamUrl() { - return SdkConfig.get()["audioStreamUrl"]; + return SdkConfig.get("audio_stream_url"); } // Dummy rtmp URL used to signal that we want a special audio-only stream diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 3b2ae009ae..27c6396891 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -126,10 +126,10 @@ export class PosthogAnalytics { } constructor(private readonly posthog: PostHog) { - const posthogConfig = SdkConfig.get()["posthog"]; + const posthogConfig = SdkConfig.getObject("posthog"); if (posthogConfig) { - this.posthog.init(posthogConfig.projectApiKey, { - api_host: posthogConfig.apiHost, + this.posthog.init(posthogConfig.get("project_api_key"), { + api_host: posthogConfig.get("api_host"), autocapture: false, mask_all_text: true, mask_all_element_attributes: true, diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts index 9d71bf5b05..b1fb6e44f4 100644 --- a/src/ScalarAuthClient.ts +++ b/src/ScalarAuthClient.ts @@ -44,8 +44,8 @@ export default class ScalarAuthClient { // We try and store the token on a per-manager basis, but need a fallback // for the default manager. - const configApiUrl = SdkConfig.get()['integrations_rest_url']; - const configUiUrl = SdkConfig.get()['integrations_ui_url']; + const configApiUrl = SdkConfig.get("integrations_rest_url"); + const configUiUrl = SdkConfig.get("integrations_ui_url"); this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl; } diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index c4387d1f23..c610c0bf14 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2022 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. @@ -15,39 +15,26 @@ See the License for the specific language governing permissions and limitations under the License. */ -export interface ISsoRedirectOptions { - immediate?: boolean; - on_welcome_page?: boolean; // eslint-disable-line camelcase -} +import { Optional } from "matrix-events-sdk"; -/* eslint-disable camelcase */ -export interface ConfigOptions { - [key: string]: any; +import { SnakedObject } from "./utils/SnakedObject"; +import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions"; +import { KeysWithObjectShape } from "./@types/common"; - logout_redirect_url?: string; - - // sso_immediate_redirect is deprecated in favour of sso_redirect_options.immediate - sso_immediate_redirect?: boolean; - sso_redirect_options?: ISsoRedirectOptions; - - custom_translations_url?: string; -} -/* eslint-enable camelcase*/ - -export const DEFAULTS: ConfigOptions = { - // Brand name of the app +// see element-web config.md for docs, or the IConfigOptions interface for dev docs +export const DEFAULTS: Partial = { brand: "Element", - // URL to a page we show in an iframe to configure integrations integrations_ui_url: "https://scalar.vector.im/", - // Base URL to the REST interface of the integrations server integrations_rest_url: "https://scalar.vector.im/api", - // Where to send bug reports. If not specified, bugs cannot be sent. bug_report_endpoint_url: null, - // Jitsi conference options jitsi: { - // Default conference domain - preferredDomain: "meet.element.io", + preferred_domain: "meet.element.io", }, + + // @ts-ignore - we deliberately use the camelCase version here so we trigger + // the fallback behaviour. If we used the snake_case version then we'd break + // everyone's config which has the camelCase property because our default would + // be preferred over their config. desktopBuilds: { available: true, logo: require("../res/img/element-desktop-logo.svg").default, @@ -56,20 +43,42 @@ export const DEFAULTS: ConfigOptions = { }; export default class SdkConfig { - private static instance: ConfigOptions; + private static instance: IConfigOptions; + private static fallback: SnakedObject; - private static setInstance(i: ConfigOptions) { + private static setInstance(i: IConfigOptions) { SdkConfig.instance = i; + SdkConfig.fallback = new SnakedObject(i); // For debugging purposes window.mxReactSdkConfig = i; } - public static get() { - return SdkConfig.instance || {}; + public static get(): IConfigOptions; + public static get(key: K, altCaseName?: string): IConfigOptions[K]; + public static get( + key?: K, altCaseName?: string, + ): IConfigOptions | IConfigOptions[K] { + if (key === undefined) { + // safe to cast as a fallback - we want to break the runtime contract in this case + return SdkConfig.instance || {}; + } + return SdkConfig.fallback.get(key, altCaseName); } - public static put(cfg: ConfigOptions) { + public static getObject>( + key: K, altCaseName?: string, + ): Optional> { + const val = SdkConfig.get(key, altCaseName); + if (val !== null && val !== undefined) { + return new SnakedObject(val); + } + + // return the same type for sensitive callers (some want `undefined` specifically) + return val === undefined ? undefined : null; + } + + public static put(cfg: IConfigOptions) { const defaultKeys = Object.keys(DEFAULTS); for (let i = 0; i < defaultKeys.length; ++i) { if (cfg[defaultKeys[i]] === undefined) { @@ -79,18 +88,21 @@ export default class SdkConfig { SdkConfig.setInstance(cfg); } + /** + * Resets the config to be completely empty. + */ public static unset() { - SdkConfig.setInstance({}); + SdkConfig.setInstance({}); // safe to cast - defaults will be applied } - public static add(cfg: ConfigOptions) { + public static add(cfg: Partial) { const liveConfig = SdkConfig.get(); const newConfig = Object.assign({}, liveConfig, cfg); SdkConfig.put(newConfig); } } -export function parseSsoRedirectOptions(config: ConfigOptions): ISsoRedirectOptions { +export function parseSsoRedirectOptions(config: IConfigOptions): ISsoRedirectOptions { // Ignore deprecated options if the config is using new ones if (config.sso_redirect_options) return config.sso_redirect_options; diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index 52ac9cc1ea..6ee31114ef 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -103,11 +103,8 @@ const HomePage: React.FC = ({ justRegistered = false }) => { if (justRegistered) { introSection = ; } else { - const brandingConfig = config.branding; - let logoUrl = "themes/element/img/logos/element-logo.svg"; - if (brandingConfig && brandingConfig.authHeaderLogoUrl) { - logoUrl = brandingConfig.authHeaderLogoUrl; - } + const brandingConfig = SdkConfig.getObject("branding"); + const logoUrl = brandingConfig?.get("auth_header_logo_url") ?? "themes/element/img/logos/element-logo.svg"; introSection = {config.brand} diff --git a/src/components/structures/HostSignupAction.tsx b/src/components/structures/HostSignupAction.tsx index 99881f27c4..5b2bafa03c 100644 --- a/src/components/structures/HostSignupAction.tsx +++ b/src/components/structures/HostSignupAction.tsx @@ -39,8 +39,8 @@ export default class HostSignupAction extends React.PureComponent void; enableGuest?: boolean; @@ -355,7 +352,7 @@ export default class MatrixChat extends React.PureComponent { Analytics.enable(); } - initSentry(SdkConfig.get()["sentry"]); + initSentry(SdkConfig.get("sentry")); } private async postLoginSetup() { @@ -474,7 +471,7 @@ export default class MatrixChat extends React.PureComponent { private getServerProperties() { let props = this.state.serverConfig; if (!props) props = this.props.serverConfig; // for unit tests - if (!props) props = SdkConfig.get()["validated_server_config"]; + if (!props) props = SdkConfig.get("validated_server_config"); return { serverConfig: props }; } @@ -865,7 +862,7 @@ export default class MatrixChat extends React.PureComponent { ); // If the hs url matches then take the hs name we know locally as it is likely prettier - const defaultConfig = SdkConfig.get()["validated_server_config"] as ValidatedServerConfig; + const defaultConfig = SdkConfig.get("validated_server_config"); if (defaultConfig && defaultConfig.hsUrl === newState.serverConfig.hsUrl) { newState.serverConfig.hsName = defaultConfig.hsName; newState.serverConfig.hsNameIsDifferent = defaultConfig.hsNameIsDifferent; @@ -1062,11 +1059,12 @@ export default class MatrixChat extends React.PureComponent { } private chatCreateOrReuse(userId: string) { + const snakedConfig = new SnakedObject(this.props.config); // Use a deferred action to reshow the dialog once the user has registered if (MatrixClientPeg.get().isGuest()) { // No point in making 2 DMs with welcome bot. This assumes view_set_mxid will // result in a new DM with the welcome user. - if (userId !== this.props.config.welcomeUserId) { + if (userId !== snakedConfig.get("welcome_user_id")) { dis.dispatch>({ action: Action.DoAfterSyncPrepared, deferred_action: { @@ -1083,7 +1081,7 @@ export default class MatrixChat extends React.PureComponent { // `_chatCreateOrReuse` again) go_welcome_on_cancel: true, screen_after: { - screen: `user/${this.props.config.welcomeUserId}`, + screen: `user/${snakedConfig.get("welcome_user_id")}`, params: { action: 'chat' }, }, }); @@ -1231,12 +1229,13 @@ export default class MatrixChat extends React.PureComponent { } await waitFor; + const snakedConfig = new SnakedObject(this.props.config); const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId( - this.props.config.welcomeUserId, + snakedConfig.get("welcome_user_id"), ); if (welcomeUserRooms.length === 0) { const roomId = await createRoom({ - dmUserId: this.props.config.welcomeUserId, + dmUserId: snakedConfig.get("welcome_user_id"), // Only view the welcome user if we're NOT looking at a room andView: !this.state.currentRoomId, spinner: false, // we're already showing one: we don't need another one @@ -1250,7 +1249,7 @@ export default class MatrixChat extends React.PureComponent { // user room (it doesn't wait for new data from the server, just // the saved sync to be loaded). const saveWelcomeUser = (ev: MatrixEvent) => { - if (ev.getType() === EventType.Direct && ev.getContent()[this.props.config.welcomeUserId]) { + if (ev.getType() === EventType.Direct && ev.getContent()[snakedConfig.get("welcome_user_id")]) { MatrixClientPeg.get().store.save(true); MatrixClientPeg.get().removeListener(ClientEvent.AccountData, saveWelcomeUser); } @@ -1280,7 +1279,8 @@ export default class MatrixChat extends React.PureComponent { } else if (MatrixClientPeg.currentUserIsJustRegistered()) { MatrixClientPeg.setJustRegisteredUserId(null); - if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { + const snakedConfig = new SnakedObject(this.props.config); + if (snakedConfig.get("welcome_user_id") && getCurrentLanguage().startsWith("en")) { const welcomeUserRoom = await this.startWelcomeUserChat(); if (welcomeUserRoom === null) { // We didn't redirect to the welcome user room, so show @@ -1312,7 +1312,7 @@ export default class MatrixChat extends React.PureComponent { showAnonymousAnalyticsOptInToast(); } - if (SdkConfig.get().mobileGuideToast) { + if (SdkConfig.get("mobile_guide_toast")) { // The toast contains further logic to detect mobile platforms, // check if it has been dismissed before, etc. showMobileGuideToast(); @@ -1463,7 +1463,7 @@ export default class MatrixChat extends React.PureComponent { if (!localStorage.getItem("mx_seen_feature_thread_experimental")) { setTimeout(() => { - if (SettingsStore.getValue("feature_thread") && SdkConfig.get()['showLabsSettings']) { + if (SettingsStore.getValue("feature_thread") && SdkConfig.get("show_labs_settings")) { Modal.createDialog(InfoDialog, { title: _t("Threads are no longer experimental! 🎉"), description: <> diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index fc1fc39df9..a7b0e01112 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -103,7 +103,7 @@ export default class RoomDirectory extends React.Component { let roomServer = myHomeserver; if ( - SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) || + SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) || SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer) ) { roomServer = lsRoomServer; diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 82855ebd87..4ab984aadf 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -53,7 +53,6 @@ import IconizedContextMenu, { import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import { UIFeature } from "../../settings/UIFeature"; import HostSignupAction from "./HostSignupAction"; -import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes"; import SpaceStore from "../../stores/spaces/SpaceStore"; import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import { replaceableComponent } from "../../utils/replaceableComponent"; @@ -375,7 +374,7 @@ export default class UserMenu extends React.Component { if (!this.state.contextMenuPosition) return null; let topSection; - const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup; + const hostSignupConfig = SdkConfig.getObject("host_signup"); if (MatrixClientPeg.get().isGuest()) { topSection = (
@@ -395,16 +394,14 @@ export default class UserMenu extends React.Component { }) }
); - } else if (hostSignupConfig) { - if (hostSignupConfig && hostSignupConfig.url) { - // If hostSignup.domains is set to a non-empty array, only show - // dialog if the user is on the domain or a subdomain. - const hostSignupDomains = hostSignupConfig.domains || []; - const mxDomain = MatrixClientPeg.get().getDomain(); - const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`))); - if (!hostSignupConfig.domains || validDomains.length > 0) { - topSection = ; - } + } else if (hostSignupConfig?.get("url")) { + // If hostSignup.domains is set to a non-empty array, only show + // dialog if the user is on the domain or a subdomain. + const hostSignupDomains = hostSignupConfig.get("domains") || []; + const mxDomain = MatrixClientPeg.get().getDomain(); + const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`))); + if (!hostSignupConfig.get("domains") || validDomains.length > 0) { + topSection = ; } } diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index cbf524c1bb..c5e8a05665 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -245,7 +245,7 @@ export default class LoginComponent extends React.PureComponent } else if (error.httpStatus === 401 || error.httpStatus === 403) { if (error.errcode === 'M_USER_DEACTIVATED') { errorText = _t('This account has been deactivated.'); - } else if (SdkConfig.get()['disable_custom_urls']) { + } else if (SdkConfig.get("disable_custom_urls")) { errorText = (
{ _t('Incorrect username and/or password.') }
diff --git a/src/components/views/auth/CountryDropdown.tsx b/src/components/views/auth/CountryDropdown.tsx index eb5b27be9d..d4a3987ccb 100644 --- a/src/components/views/auth/CountryDropdown.tsx +++ b/src/components/views/auth/CountryDropdown.tsx @@ -59,7 +59,7 @@ export default class CountryDropdown extends React.Component { super(props); let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0]; - const defaultCountryCode = SdkConfig.get()["defaultCountryCode"]; + const defaultCountryCode = SdkConfig.get("default_country_code"); if (defaultCountryCode) { const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase()); if (country) defaultCountry = country; diff --git a/src/components/views/auth/LanguageSelector.tsx b/src/components/views/auth/LanguageSelector.tsx index cd88652e43..6a098ba113 100644 --- a/src/components/views/auth/LanguageSelector.tsx +++ b/src/components/views/auth/LanguageSelector.tsx @@ -35,7 +35,7 @@ interface IProps { } export default function LanguageSelector({ disabled }: IProps): JSX.Element { - if (SdkConfig.get()['disable_login_language_selector']) return
; + if (SdkConfig.get("disable_login_language_selector")) return
; return { return false; } const safe = complexity.score >= this.props.minScore; - const allowUnsafe = SdkConfig.get()["dangerously_allow_unsafe_and_insecure_passwords"]; + const allowUnsafe = SdkConfig.get("dangerously_allow_unsafe_and_insecure_passwords"); return allowUnsafe || safe; }, valid: function(complexity) { diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 5f1de17450..2ac974610b 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -39,10 +39,10 @@ export default class Welcome extends React.PureComponent { // FIXME: Using an import will result in wrench-element-tests failures const EmbeddedPage = sdk.getComponent("structures.EmbeddedPage"); - const pagesConfig = SdkConfig.get().embeddedPages; + const pagesConfig = SdkConfig.getObject("embedded_pages"); let pageUrl = null; if (pagesConfig) { - pageUrl = pagesConfig.welcomeUrl; + pageUrl = pagesConfig.get("welcome_url"); } if (!pageUrl) { pageUrl = 'welcome.html'; diff --git a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx index 04573252b5..ae162b7526 100644 --- a/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx +++ b/src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx @@ -15,12 +15,14 @@ limitations under the License. */ import React from "react"; +import { Optional } from "matrix-events-sdk"; import BaseDialog from "./BaseDialog"; import { _t } from "../../../languageHandler"; import DialogButtons from "../elements/DialogButtons"; import Modal from "../../../Modal"; import SdkConfig from "../../../SdkConfig"; +import { SnakedObject } from "../../../utils/SnakedObject"; export enum ButtonClicked { Primary, @@ -96,8 +98,12 @@ const AnalyticsLearnMoreDialog: React.FC = ({ }; export const showDialog = (props: Omit): void => { - const privacyPolicyUrl = SdkConfig.get().piwik?.policyUrl; - const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand; + const piwikConfig = SdkConfig.get("piwik"); + let privacyPolicyUrl: Optional; + if (piwikConfig && typeof piwikConfig === "object") { + privacyPolicyUrl = (new SnakedObject(piwikConfig)).get("policy_url"); + } + const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get("brand"); Modal.createTrackedDialog( "Analytics Learn More", "", diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx index 90588178ce..5b47ea687a 100644 --- a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -71,7 +71,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent< } private buildSuggestions(): IPerson[] { - const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get()['welcomeUserId']]); + const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get("welcome_user_id")]); if (this.props.roomId) { const room = MatrixClientPeg.get().getRoom(this.props.roomId); if (!room) throw new Error("Room ID given to InviteDialog does not look like a room"); diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index a594a64ca3..254e8ba0dc 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -75,7 +75,6 @@ export default class CreateRoomDialog extends React.Component { joinRule = JoinRule.Restricted; } - const config = SdkConfig.get(); this.state = { isPublic: this.props.defaultPublic || false, isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(), @@ -84,7 +83,7 @@ export default class CreateRoomDialog extends React.Component { topic: "", alias: "", detailsOpen: false, - noFederate: config.default_federate === false, + noFederate: SdkConfig.get().default_federate === false, nameIsValid: false, canChangeEncryption: true, }; diff --git a/src/components/views/dialogs/HostSignupDialog.tsx b/src/components/views/dialogs/HostSignupDialog.tsx index 0158640493..5f82ba8086 100644 --- a/src/components/views/dialogs/HostSignupDialog.tsx +++ b/src/components/views/dialogs/HostSignupDialog.tsx @@ -28,12 +28,13 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { HostSignupStore } from "../../../stores/HostSignupStore"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { - IHostSignupConfig, IPostmessage, IPostmessageResponseData, PostmessageAction, } from "./HostSignupDialogTypes"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IConfigOptions } from "../../../IConfigOptions"; +import { SnakedObject } from "../../../utils/SnakedObject"; const HOST_SIGNUP_KEY = "host_signup"; @@ -48,7 +49,7 @@ interface IState { @replaceableComponent("views.dialogs.HostSignupDialog") export default class HostSignupDialog extends React.PureComponent { private iframeRef: React.RefObject = React.createRef(); - private readonly config: IHostSignupConfig; + private readonly config: SnakedObject; constructor(props: IProps) { super(props); @@ -59,11 +60,11 @@ export default class HostSignupDialog extends React.PureComponent { - if (!this.config.url.startsWith(message.origin)) { + if (!this.config.get("url").startsWith(message.origin)) { return; } switch (message.data.action) { @@ -142,7 +143,7 @@ export default class HostSignupDialog extends React.PureComponent { - this.iframeRef.current.contentWindow.postMessage(message, this.config.url); + this.iframeRef.current.contentWindow.postMessage(message, this.config.get("url")); }; private async sendAccountDetails() { @@ -176,12 +177,16 @@ export default class HostSignupDialog extends React.PureComponent { + const cookiePolicyUrl = this.config.get("cookie_policy_url"); + const privacyPolicyUrl = this.config.get("privacy_policy_url"); + const tosUrl = this.config.get("terms_of_service_url"); + const textComponent = ( <>

{ _t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " + "account to fetch verified email addresses. This data is not stored.", { - hostSignupBrand: this.config.brand, + hostSignupBrand: this.config.get("brand"), }) }

@@ -189,17 +194,17 @@ export default class HostSignupDialog extends React.PureComponent ( - + { _t("Cookie Policy") } ), privacyPolicyLink: () => ( - + { _t("Privacy Policy") } ), termsOfServiceLink: () => ( - + { _t("Terms of Service") } ), @@ -247,7 +252,7 @@ export default class HostSignupDialog extends React.PureComponent

{ _t("%(hostSignupBrand)s Setup", { - hostSignupBrand: this.config.brand, + hostSignupBrand: this.config.get("brand"), }) }
diff --git a/src/components/views/dialogs/HostSignupDialogTypes.ts b/src/components/views/dialogs/HostSignupDialogTypes.ts index 9f78592804..46f0a70ce3 100644 --- a/src/components/views/dialogs/HostSignupDialogTypes.ts +++ b/src/components/views/dialogs/HostSignupDialogTypes.ts @@ -45,12 +45,3 @@ export interface IPostmessage { data: IPostmessageRequestData; origin: string; } - -export interface IHostSignupConfig { - brand: string; - cookiePolicyUrl: string; - domains: Array; - privacyPolicyUrl: string; - termsOfServiceUrl: string; - url: string; -} diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index e2117eced7..8058175b97 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -412,7 +412,7 @@ export default class InviteDialog extends React.PureComponent { ); } - const adminMessageMD = - SdkConfig.get().reportEvent && - SdkConfig.get().reportEvent.adminMessageMD; + const adminMessageMD = SdkConfig + .getObject("report_event")?.get("admin_message_md", "adminMessageMD"); let adminMessage; if (adminMessageMD) { const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true }); @@ -272,7 +271,7 @@ export default class ReportEventDialog extends React.Component { // Display report-to-moderator dialog. // We let the user pick a nature. const client = MatrixClientPeg.get(); - const homeServerName = SdkConfig.get()["validated_server_config"].hsName; + const homeServerName = SdkConfig.get("validated_server_config").hsName; let subtitle; switch (this.state.nature) { case Nature.Disagreement: diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 0c22cd1e38..b1433978dd 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -50,7 +50,7 @@ export default class ServerPickerDialog extends React.PureComponent "UserSettingsSecurityPrivacy", )); // Show the Labs tab if enabled or if there are any active betas - if (SdkConfig.get()['showLabsSettings'] + if (SdkConfig.get("show_labs_settings") || SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k)) ) { tabs.push(new Tab( diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx index 43821aa849..cc7a559db0 100644 --- a/src/components/views/directory/NetworkDropdown.tsx +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -40,6 +40,8 @@ import TextInputDialog from "../dialogs/TextInputDialog"; import QuestionDialog from "../dialogs/QuestionDialog"; import UIStore from "../../../stores/UIStore"; import { compare } from "../../../utils/strings"; +import { SnakedObject } from "../../../utils/SnakedObject"; +import { IConfigOptions } from "../../../IConfigOptions"; // XXX: We would ideally use a symbol here but we can't since we save this value to localStorage export const ALL_ROOMS = "ALL_ROOMS"; @@ -122,11 +124,11 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s // we either show the button or the dropdown in its place. let content; if (menuDisplayed) { - const config = SdkConfig.get(); - const roomDirectory = config.roomDirectory || {}; + const roomDirectory = SdkConfig.getObject("room_directory") + ?? new SnakedObject({ servers: [] }); const hsName = MatrixClientPeg.getHomeserverName(); - const configServers = new Set(roomDirectory.servers); + const configServers = new Set(roomDirectory.get("servers")); // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName)); diff --git a/src/components/views/elements/DesktopBuildsNotice.tsx b/src/components/views/elements/DesktopBuildsNotice.tsx index 8c600a152f..ac9b591e95 100644 --- a/src/components/views/elements/DesktopBuildsNotice.tsx +++ b/src/components/views/elements/DesktopBuildsNotice.tsx @@ -59,21 +59,23 @@ export default function DesktopBuildsNotice({ isRoomEncrypted, kind }: IProps) { ; } - const { desktopBuilds, brand } = SdkConfig.get(); + const brand = SdkConfig.get("brand"); + const desktopBuilds = SdkConfig.getObject("desktop_builds"); let text = null; let logo = null; - if (desktopBuilds.available) { - logo = ; + if (desktopBuilds.get("available")) { + logo = ; + const buildUrl = desktopBuilds.get("url"); switch (kind) { case WarningKind.Files: text = _t("Use the Desktop app to see all encrypted files", {}, { - a: sub => ({ sub }), + a: sub => ({ sub }), }); break; case WarningKind.Search: text = _t("Use the Desktop app to search encrypted messages", {}, { - a: sub => ({ sub }), + a: sub => ({ sub }), }); break; } diff --git a/src/components/views/elements/ServerPicker.tsx b/src/components/views/elements/ServerPicker.tsx index 8921dfe19b..aaf1f39d47 100644 --- a/src/components/views/elements/ServerPicker.tsx +++ b/src/components/views/elements/ServerPicker.tsx @@ -53,7 +53,7 @@ const onHelpClick = () => { }; const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => { - const disableCustomUrls = SdkConfig.get()["disable_custom_urls"]; + const disableCustomUrls = SdkConfig.get("disable_custom_urls"); let editBtn; if (!disableCustomUrls && onServerConfigChange) { diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 37347c04bd..394de5bac3 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1575,7 +1575,7 @@ const UserInfoHeader: React.FC<{ presenceCurrentlyActive = member.user.currentlyActive; } - const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"]; + const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url"); let showPresence = true; if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) { showPresence = enablePresenceByHsUrl[cli.baseUrl]; diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index 27377c567b..37caa88100 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -95,7 +95,7 @@ export default class MemberList extends React.Component { } cli.on(ClientEvent.Room, this.onRoom); // invites & joining after peek - const enablePresenceByHsUrl = SdkConfig.get()["enable_presence_by_hs_url"]; + const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url"); const hsUrl = MatrixClientPeg.get().baseUrl; this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true; } diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index fd2c5e4153..1b2585dfa4 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -95,7 +95,7 @@ export default class HelpUserSettingsTab extends React.Component private onStartBotChat = (e) => { this.props.closeSettingsFn(); createRoom({ - dmUserId: SdkConfig.get().welcomeUserId, + dmUserId: SdkConfig.get("welcome_user_id"), andView: true, }); }; @@ -105,7 +105,7 @@ export default class HelpUserSettingsTab extends React.Component if (!tocLinks) return null; const legalLinks = []; - for (const tocEntry of SdkConfig.get().terms_and_conditions_links) { + for (const tocEntry of tocLinks) { legalLinks.push(); @@ -198,7 +198,7 @@ export default class HelpUserSettingsTab extends React.Component , }, ); - if (SdkConfig.get().welcomeUserId && getCurrentLanguage().startsWith('en')) { + if (SdkConfig.get("welcome_user_id") && getCurrentLanguage().startsWith('en')) { faqText = (
{ _t( diff --git a/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx b/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx index e1d7db4c99..ef38c16078 100644 --- a/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx @@ -35,7 +35,7 @@ interface IKeyboardShortcutRowProps { // Filter out the labs section if labs aren't enabled. const visibleCategories = Object.entries(CATEGORIES).filter(([categoryName]) => - categoryName !== CategoryName.LABS || SdkConfig.get()['showLabsSettings']); + categoryName !== CategoryName.LABS || SdkConfig.get("show_labs_settings")); const KeyboardShortcutRow: React.FC = ({ name }) => { const displayName = getKeyboardShortcutDisplayName(name); diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx index a7c7f4df49..cb028ed3ec 100644 --- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.tsx @@ -86,7 +86,7 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> { } let labsSection; - if (SdkConfig.get()['showLabsSettings']) { + if (SdkConfig.get("show_labs_settings")) { const groups = new EnhancedMap(); labs.forEach(f => { groups.getOrCreate(SettingsStore.getLabGroup(f), []).push( diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index 4618cf79c1..2c377119ad 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -95,7 +95,7 @@ export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } patternNames.forEach(function(patternName) { patternTypes.forEach(function(patternType) { // get the regex replace pattern from config or use the default - const pattern = (((SdkConfig.get()["latex_maths_delims"] || + const pattern = (((SdkConfig.get("latex_maths_delims") || {})[patternType] || {})["pattern"] || {})[patternName] || patternDefaults[patternName][patternType]; diff --git a/src/integrations/IntegrationManagers.ts b/src/integrations/IntegrationManagers.ts index 55772a7498..68c82ab55c 100644 --- a/src/integrations/IntegrationManagers.ts +++ b/src/integrations/IntegrationManagers.ts @@ -77,8 +77,8 @@ export class IntegrationManagers { } private setupConfiguredManager() { - const apiUrl: string = SdkConfig.get()['integrations_rest_url']; - const uiUrl: string = SdkConfig.get()['integrations_ui_url']; + const apiUrl: string = SdkConfig.get("integrations_rest_url"); + const uiUrl: string = SdkConfig.get("integrations_ui_url"); if (apiUrl && uiUrl) { this.managers.push(new IntegrationManagerInstance(Kind.Config, apiUrl, uiUrl)); diff --git a/src/sentry.ts b/src/sentry.ts index 4fdcd95635..476da497a7 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -20,6 +20,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import SdkConfig from "./SdkConfig"; import { MatrixClientPeg } from "./MatrixClientPeg"; import SettingsStore from "./settings/SettingsStore"; +import { IConfigOptions } from "./IConfigOptions"; /* eslint-disable camelcase */ @@ -173,7 +174,7 @@ async function getContexts(): Promise { } export async function sendSentryReport(userText: string, issueUrl: string, error: Error): Promise { - const sentryConfig = SdkConfig.get()["sentry"]; + const sentryConfig = SdkConfig.getObject("sentry"); if (!sentryConfig) return; const captureContext = { @@ -198,12 +199,7 @@ export function setSentryUser(mxid: string): void { Sentry.setUser({ username: mxid }); } -interface ISentryConfig { - dsn: string; - environment?: string; -} - -export async function initSentry(sentryConfig: ISentryConfig): Promise { +export async function initSentry(sentryConfig: IConfigOptions["sentry"]): Promise { if (!sentryConfig) return; // Only enable Integrations.GlobalHandlers, which hooks uncaught exceptions, if automaticErrorReporting is true const integrations = [ diff --git a/src/settings/handlers/ConfigSettingsHandler.ts b/src/settings/handlers/ConfigSettingsHandler.ts index 0120b94b13..24d635528e 100644 --- a/src/settings/handlers/ConfigSettingsHandler.ts +++ b/src/settings/handlers/ConfigSettingsHandler.ts @@ -19,6 +19,8 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import SettingsHandler from "./SettingsHandler"; import SdkConfig from "../../SdkConfig"; +import { SnakedObject } from "../../utils/SnakedObject"; +import { IConfigOptions } from "../../IConfigOptions"; /** * Gets and sets settings at the "config" level. This handler does not make use of the @@ -30,10 +32,10 @@ export default class ConfigSettingsHandler extends SettingsHandler { } public getValue(settingName: string, roomId: string): any { - const config = SdkConfig.get() || {}; + const config = new SnakedObject(SdkConfig.get()); if (this.featureNames.includes(settingName)) { - const labsConfig = config["features"] || {}; + const labsConfig = config.get("features") || {}; const val = labsConfig[settingName]; if (isNullOrUndefined(val)) return null; // no definition at this level if (val === true || val === false) return val; // new style: mapped as a boolean @@ -45,10 +47,10 @@ export default class ConfigSettingsHandler extends SettingsHandler { // Special case themes if (settingName === "theme") { - return config["default_theme"]; + return config.get("default_theme"); } - const settingsConfig = config["settingDefaults"]; + const settingsConfig = config.get("setting_defaults"); if (!settingsConfig || isNullOrUndefined(settingsConfig[settingName])) return null; return settingsConfig[settingName]; } diff --git a/src/toasts/AnalyticsToast.tsx b/src/toasts/AnalyticsToast.tsx index 1072ae2907..39f1ef252f 100644 --- a/src/toasts/AnalyticsToast.tsx +++ b/src/toasts/AnalyticsToast.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { ReactNode } from "react"; +import { Optional } from "matrix-events-sdk"; import { _t } from "../languageHandler"; import SdkConfig from "../SdkConfig"; @@ -28,6 +29,8 @@ import { showDialog as showAnalyticsLearnMoreDialog, } from "../components/views/dialogs/AnalyticsLearnMoreDialog"; import { Action } from "../dispatcher/actions"; +import { SnakedObject } from "../utils/SnakedObject"; +import { IConfigOptions } from "../IConfigOptions"; const onAccept = () => { dis.dispatch({ @@ -81,7 +84,12 @@ const TOAST_KEY = "analytics"; const getAnonymousDescription = (): ReactNode => { // get toast description for anonymous tracking (the previous scheme pre-posthog) const brand = SdkConfig.get().brand; - const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl; + const piwikConfig = SdkConfig.get("piwik"); + let piwik: Optional>>; + if (typeof piwikConfig === 'object') { + piwik = new SnakedObject(piwikConfig); + } + const cookiePolicyUrl = piwik?.get("policy_url"); return _t( "Send anonymous usage data which helps us improve %(brand)s. " + "This will use a cookie.", @@ -100,7 +108,7 @@ const getAnonymousDescription = (): ReactNode => { }; const showToast = (props: Omit, "toastKey">) => { - const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand; + const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get().brand; ToastStore.sharedInstance().addOrReplaceToast({ key: TOAST_KEY, title: _t("Help improve %(analyticsOwner)s", { analyticsOwner }), diff --git a/src/utils/AutoDiscoveryUtils.tsx b/src/utils/AutoDiscoveryUtils.tsx index 8860a5104f..101648569a 100644 --- a/src/utils/AutoDiscoveryUtils.tsx +++ b/src/utils/AutoDiscoveryUtils.tsx @@ -207,7 +207,7 @@ export default class AutoDiscoveryUtils { const hsResult = discoveryResult['m.homeserver']; const isResult = discoveryResult['m.identity_server']; - const defaultConfig = SdkConfig.get()["validated_server_config"]; + const defaultConfig = SdkConfig.get("validated_server_config"); // Validate the identity server first because an invalid identity server causes // an invalid homeserver, which may not be picked up correctly. diff --git a/src/utils/IdentityServerUtils.ts b/src/utils/IdentityServerUtils.ts index e760dd5b45..e58750d8df 100644 --- a/src/utils/IdentityServerUtils.ts +++ b/src/utils/IdentityServerUtils.ts @@ -21,7 +21,7 @@ import SdkConfig from '../SdkConfig'; import { MatrixClientPeg } from '../MatrixClientPeg'; export function getDefaultIdentityServerUrl(): string { - return SdkConfig.get()['validated_server_config']['isUrl']; + return SdkConfig.get("validated_server_config").isUrl; } export function useDefaultIdentityServer(): void { diff --git a/src/utils/SnakedObject.ts b/src/utils/SnakedObject.ts new file mode 100644 index 0000000000..bce02512c0 --- /dev/null +++ b/src/utils/SnakedObject.ts @@ -0,0 +1,36 @@ +/* +Copyright 2022 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. +*/ + +export function snakeToCamel(s: string): string { + return s.replace(/._./g, v => `${v[0]}${v[2].toUpperCase()}`); +} + +export class SnakedObject> { + public constructor(private obj: T) { + } + + public get(key: K, altCaseName?: string): T[K] { + const val = this.obj[key]; + if (val !== undefined) return val; + + return this.obj[altCaseName ?? snakeToCamel(key)]; + } + + // Make JSON.stringify() pretend that everything is fine + public toJSON() { + return this.obj; + } +} diff --git a/src/utils/pages.ts b/src/utils/pages.ts index bae76be29d..03bab1563b 100644 --- a/src/utils/pages.ts +++ b/src/utils/pages.ts @@ -14,23 +14,37 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ConfigOptions } from "../SdkConfig"; +import { logger } from "matrix-js-sdk/src/logger"; -export function getHomePageUrl(appConfig: ConfigOptions): string | null { - const pagesConfig = appConfig.embeddedPages; - let pageUrl = pagesConfig?.homeUrl; +import { IConfigOptions } from "../IConfigOptions"; +import { SnakedObject } from "./SnakedObject"; + +export function getHomePageUrl(appConfig: IConfigOptions): string | null { + const config = new SnakedObject(appConfig); + + const pagesConfig = config.get("embedded_pages"); + let pageUrl = pagesConfig ? (new SnakedObject(pagesConfig).get("home_url")) : null; if (!pageUrl) { // This is a deprecated config option for the home page // (despite the name, given we also now have a welcome // page, which is not the same). - pageUrl = appConfig.welcomePageUrl; + pageUrl = (appConfig).welcomePageUrl; + if (pageUrl) { + logger.warn( + "You are using a deprecated config option: `welcomePageUrl`. Please use " + + "`embedded_pages.home_url` instead, per https://github.com/vector-im/element-web/issues/21428", + ); + } } return pageUrl; } -export function shouldUseLoginForWelcome(appConfig: ConfigOptions): boolean { - const pagesConfig = appConfig.embeddedPages; - return pagesConfig?.loginForWelcome === true; +export function shouldUseLoginForWelcome(appConfig: IConfigOptions): boolean { + const config = new SnakedObject(appConfig); + const pagesConfig = config.get("embedded_pages"); + return pagesConfig + ? ((new SnakedObject(pagesConfig).get("login_for_welcome")) === true) + : false; } diff --git a/src/utils/permalinks/Permalinks.ts b/src/utils/permalinks/Permalinks.ts index 1958904f8a..ac3ba71c1b 100644 --- a/src/utils/permalinks/Permalinks.ts +++ b/src/utils/permalinks/Permalinks.ts @@ -413,7 +413,7 @@ export function getPrimaryPermalinkEntity(permalink: string): string { } function getPermalinkConstructor(): PermalinkConstructor { - const elementPrefix = SdkConfig.get()['permalinkPrefix']; + const elementPrefix = SdkConfig.get("permalink_prefix"); if (elementPrefix && elementPrefix !== matrixtoBaseUrl) { return new ElementPermalinkConstructor(elementPrefix); } @@ -423,7 +423,7 @@ function getPermalinkConstructor(): PermalinkConstructor { export function parsePermalink(fullUrl: string): PermalinkParts { try { - const elementPrefix = SdkConfig.get()['permalinkPrefix']; + const elementPrefix = SdkConfig.get("permalink_prefix"); if (decodeURIComponent(fullUrl).startsWith(matrixtoBaseUrl)) { return new MatrixToPermalinkConstructor().parsePermalink(decodeURIComponent(fullUrl)); } else if (fullUrl.startsWith("matrix:")) { diff --git a/src/utils/presence.ts b/src/utils/presence.ts index f2c208265e..a17503ed8e 100644 --- a/src/utils/presence.ts +++ b/src/utils/presence.ts @@ -19,7 +19,7 @@ import SdkConfig from "../SdkConfig"; export function isPresenceEnabled() { const hsUrl = MatrixClientPeg.get().baseUrl; - const urls = SdkConfig.get()['enable_presence_by_hs_url']; + const urls = SdkConfig.get("enable_presence_by_hs_url"); if (!urls) return true; if (urls[hsUrl] || urls[hsUrl] === undefined) return true; return false; diff --git a/src/widgets/Jitsi.ts b/src/widgets/Jitsi.ts index 83be3e5dcf..7d506ace12 100644 --- a/src/widgets/Jitsi.ts +++ b/src/widgets/Jitsi.ts @@ -70,7 +70,7 @@ export class Jitsi { private update = async (discoveryResponse: IClientWellKnown): Promise => { // Start with a default of the config's domain - let domain = SdkConfig.get().jitsi?.preferredDomain || "meet.element.io"; + let domain = SdkConfig.getObject("jitsi")?.get("preferred_domain") || "meet.element.io"; logger.log("Attempting to get Jitsi conference information from homeserver"); const wkPreferredDomain = discoveryResponse?.[JITSI_WK_PROPERTY]?.['preferredDomain']; diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 4f87957a45..c0848b5c94 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -72,20 +72,23 @@ describe("PosthogAnalytics", () => { afterEach(() => { window.crypto = null; + SdkConfig.unset(); // we touch the config, so clean up }); describe("Initialisation", () => { it("Should not be enabled without config being set", () => { - jest.spyOn(SdkConfig, "get").mockReturnValue({}); + // force empty/invalid state for posthog options + SdkConfig.put({ brand: "Testing" }); const analytics = new PosthogAnalytics(fakePosthog); expect(analytics.isEnabled()).toBe(false); }); it("Should be enabled if config is set", () => { - jest.spyOn(SdkConfig, "get").mockReturnValue({ + SdkConfig.put({ + brand: "Testing", posthog: { - projectApiKey: "foo", - apiHost: "bar", + project_api_key: "foo", + api_host: "bar", }, }); const analytics = new PosthogAnalytics(fakePosthog); @@ -98,10 +101,11 @@ describe("PosthogAnalytics", () => { let analytics: PosthogAnalytics; beforeEach(() => { - jest.spyOn(SdkConfig, "get").mockReturnValue({ + SdkConfig.put({ + brand: "Testing", posthog: { - projectApiKey: "foo", - apiHost: "bar", + project_api_key: "foo", + api_host: "bar", }, }); diff --git a/test/components/structures/auth/Login-test.tsx b/test/components/structures/auth/Login-test.tsx index 4f41f57eb5..6d4acda762 100644 --- a/test/components/structures/auth/Login-test.tsx +++ b/test/components/structures/auth/Login-test.tsx @@ -42,9 +42,9 @@ describe('Login', function() { } as unknown as MatrixClient); beforeEach(function() { - jest.spyOn(SdkConfig, "get").mockReturnValue({ + SdkConfig.put({ + brand: "test-brand", disable_custom_urls: true, - brand: 'test-brand', }); mockClient.login.mockClear().mockResolvedValue({}); mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] }); @@ -57,6 +57,7 @@ describe('Login', function() { afterEach(function() { ReactDOM.unmountComponentAtNode(parentDiv); parentDiv.remove(); + SdkConfig.unset(); // we touch the config, so clean up }); function render() { @@ -69,9 +70,9 @@ describe('Login', function() { } it('should show form with change server link', async () => { - jest.spyOn(SdkConfig, "get").mockReturnValue({ + SdkConfig.put({ + brand: "test-brand", disable_custom_urls: false, - brand: 'test', }); const root = render(); diff --git a/test/components/structures/auth/Registration-test.js b/test/components/structures/auth/Registration-test.js index 144ff3ab4d..5e9d3c4d61 100644 --- a/test/components/structures/auth/Registration-test.js +++ b/test/components/structures/auth/Registration-test.js @@ -34,7 +34,7 @@ describe('Registration', function() { let parentDiv; beforeEach(function() { - jest.spyOn(SdkConfig, "get").mockReturnValue({ + SdkConfig.put({ ...DEFAULTS, disable_custom_urls: true, }); @@ -46,6 +46,7 @@ describe('Registration', function() { afterEach(function() { ReactDOM.unmountComponentAtNode(parentDiv); parentDiv.remove(); + SdkConfig.unset(); // we touch the config, so clean up }); function render() { diff --git a/test/utils/SnakedObject-test.ts b/test/utils/SnakedObject-test.ts new file mode 100644 index 0000000000..d1a525773f --- /dev/null +++ b/test/utils/SnakedObject-test.ts @@ -0,0 +1,63 @@ +/* +Copyright 2022 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 { SnakedObject, snakeToCamel } from "../../src/utils/SnakedObject"; + +describe('snakeToCamel', () => { + it('should convert snake_case to camelCase in simple scenarios', () => { + expect(snakeToCamel("snake_case")).toBe("snakeCase"); + expect(snakeToCamel("snake_case_but_longer")).toBe("snakeCaseButLonger"); + expect(snakeToCamel("numbered_123")).toBe("numbered123"); // not a thing we would see normally + }); + + // Not really something we expect to see, but it's defined behaviour of the function + it('should not camelCase a trailing or leading underscore', () => { + expect(snakeToCamel("_snake")).toBe("_snake"); + expect(snakeToCamel("snake_")).toBe("snake_"); + expect(snakeToCamel("_snake_case")).toBe("_snakeCase"); + expect(snakeToCamel("snake_case_")).toBe("snakeCase_"); + }); + + // Another thing we don't really expect to see, but is "defined behaviour" + it('should be predictable with double underscores', () => { + expect(snakeToCamel("__snake__")).toBe("_Snake_"); + expect(snakeToCamel("snake__case")).toBe("snake_case"); + }); +}); + +describe('SnakedObject', () => { + /* eslint-disable camelcase*/ + const input = { + snake_case: "woot", + snakeCase: "oh no", // ensure different value from snake_case for tests + camelCase: "fallback", + }; + const snake = new SnakedObject(input); + /* eslint-enable camelcase*/ + + it('should prefer snake_case keys', () => { + expect(snake.get("snake_case")).toBe(input.snake_case); + expect(snake.get("snake_case", "camelCase")).toBe(input.snake_case); + }); + + it('should fall back to camelCase keys when needed', () => { + // @ts-ignore - we're deliberately supplying a key that doesn't exist + expect(snake.get("camel_case")).toBe(input.camelCase); + + // @ts-ignore - we're deliberately supplying a key that doesn't exist + expect(snake.get("e_no_exist", "camelCase")).toBe(input.camelCase); + }); +});