Use & enforce snake_case naming convention on config.json settings (#8062)

* Document and support the established naming convention for config opts

This change:
* Rename `ConfigOptions` to `IConfigOptions` to match code convention/style, plus move it to a dedicated file
* Update comments and surrounding documentation
* Define every single documented option (from element-web's config.md)
* Enable a linter to enforce the convention
* Invent a translation layer for a different change to use
* No attempt to fix build errors from doing this (at this stage)

* Add demo of lint rule in action

* Fix all obvious instances of SdkConfig case conflicts

* Fix tests to use SdkConfig directly

* Add docs to make unset() calling safer

* Appease the linter

* Update documentation to match snake_case_config

* Fix more instances of square brackets off SdkConfig
This commit is contained in:
Travis Ralston 2022-03-18 10:12:36 -06:00 committed by GitHub
parent 09c57b228e
commit d8a939df5d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 605 additions and 259 deletions

View file

@ -1,38 +1,38 @@
# Settings Reference # Settings Reference
This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify 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 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 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. of dealing with the different levels and exposes easy to use getters and setters.
## Levels ## 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: order of priority, are:
* `device` - The current user's device * `device` - The current user's device
* `room-device` - The current user's device, but only when in a specific room * `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 * `room-account` - The current user's account, but only when in a specific room
* `account` - The current user's account * `account` - The current user's account
* `room` - A specific room (setting for all members of the room) * `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 * `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. that room administrators cannot force account-only settings upon participants.
## Settings ## 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. `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): settings, like the "theme" setting, are special cased in the config file):
```json ```json5
{ {
... ...
"settingDefaults": { "setting_defaults": {
"settingName": true "settingName": true
}, },
... ...
@ -41,20 +41,20 @@ settings, like the "theme" setting, are special cased in the config file):
### Getting values for a setting ### Getting values for a setting
After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always 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 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. 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. 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 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 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 it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the
target level. target level.
### Setting values for a setting ### 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 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 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: although there are circumstances where this changes. An example of a safe call is:
```javascript ```javascript
const isSupported = SettingsStore.isLevelSupported(SettingLevel.ROOM); 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. instance, the component which allows changing the setting may be hidden conditionally on the above conditions.
##### `SettingsFlag` component ##### `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. `SettingsFlag` also supports simple radio button options, such as the theme the user would like to use.
```html ```html
<SettingsFlag name="theSettingId" <SettingsFlag name="theSettingId"
@ -81,7 +81,7 @@ Where possible, the `SettingsFlag` component should be used to set simple "flip-
onChange={function(newValue) { }} // optional, called after saving onChange={function(newValue) { }} // optional, called after saving
isExplicit={false} // this is passed along to `SettingsStore.getValueAt`, defaulting to false isExplicit={false} // this is passed along to `SettingsStore.getValueAt`, defaulting to false
manualSave={false} // if true, saving is delayed. You will need to call .save() on this component manualSave={false} // if true, saving is delayed. You will need to call .save() on this component
// Options for radio buttons // Options for radio buttons
group="your-radio-group" // this enables radio button support group="your-radio-group" // this enables radio button support
value="yourValueHere" // the value for this particular option value="yourValueHere" // the value for this particular option
@ -90,7 +90,7 @@ Where possible, the `SettingsFlag` component should be used to set simple "flip-
### Getting the display name for a setting ### Getting the display name for a setting
Simply call `SettingsStore.getDisplayName`. The appropriate display name will be returned and automatically translated Simply call `SettingsStore.getDisplayName`. The appropriate display name will be returned and automatically translated
for you. If a display name cannot be found, it will return `null`. for you. If a display name cannot be found, it will return `null`.
@ -135,11 +135,11 @@ UI feature is disabled.
## Setting controllers ## Setting controllers
Settings may have environmental factors that affect their value or need additional code to be called when they are Settings may have environmental factors that affect their value or need additional code to be called when they are
modified. A setting controller is able to override the calculated value for a setting and react to changes in that modified. A setting controller is able to override the calculated value for a setting and react to changes in that
setting. Controllers are not a replacement for the level handlers and should only be used to ensure the environment is setting. Controllers are not a replacement for the level handlers and should only be used to ensure the environment is
kept up to date with the setting where it is otherwise not possible. An example of this is the notification settings: kept up to date with the setting where it is otherwise not possible. An example of this is the notification settings:
they can only be considered enabled if the platform supports notifications, and enabling notifications requires they can only be considered enabled if the platform supports notifications, and enabling notifications requires
additional steps to actually enable notifications. additional steps to actually enable notifications.
For more information, see `src/settings/controllers/SettingController.ts`. For more information, see `src/settings/controllers/SettingController.ts`.
@ -147,11 +147,11 @@ For more information, see `src/settings/controllers/SettingController.ts`.
## Local echo ## Local echo
`SettingsStore` will perform local echo on all settings to ensure that immediately getting values does not cause a `SettingsStore` will perform local echo on all settings to ensure that immediately getting values does not cause a
split-brain scenario. As mentioned in the "Setting values for a setting" section, the appropriate checks should be done split-brain scenario. As mentioned in the "Setting values for a setting" section, the appropriate checks should be done
to ensure that the user is allowed to set the value. The local echo system assumes that the user has permission and that to ensure that the user is allowed to set the value. The local echo system assumes that the user has permission and that
the request will go through successfully. The local echo only takes effect until the request to save a setting has the request will go through successfully. The local echo only takes effect until the request to save a setting has
completed (either successfully or otherwise). completed (either successfully or otherwise).
```javascript ```javascript
SettingsStore.setValue(...).then(() => { SettingsStore.setValue(...).then(() => {
@ -163,27 +163,27 @@ SettingsStore.getValue(...); // this will return the value set in `setValue` abo
## Watching for changes ## 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 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 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 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 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) data structures. Instead, a generic watcher interface is provided on `SettingsStore` to watch (and subsequently unwatch)
for changes in a setting. for changes in a setting.
An example of a watcher in action would be: An example of a watcher in action would be:
```javascript ```javascript
class MyComponent extends React.Component { class MyComponent extends React.Component {
settingWatcherRef = null; settingWatcherRef = null;
componentWillMount() { componentWillMount() {
const callback = (settingName, roomId, level, newValAtLevel, newVal) => { const callback = (settingName, roomId, level, newValAtLevel, newVal) => {
this.setState({color: newVal}); this.setState({color: newVal});
}; };
this.settingWatcherRef = SettingsStore.watchSetting("roomColor", "!example:matrix.org", callback); this.settingWatcherRef = SettingsStore.watchSetting("roomColor", "!example:matrix.org", callback);
} }
componentWillUnmount() { componentWillUnmount() {
SettingsStore.unwatchSetting(this.settingWatcherRef); SettingsStore.unwatchSetting(this.settingWatcherRef);
} }
@ -193,29 +193,29 @@ class MyComponent extends React.Component {
# Maintainers Reference # 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. supposed to work.
### General information ### General information
The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure. 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 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`. 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 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 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 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 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). 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 are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by
only considering handlers that are supported on the platform. 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 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 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 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. 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. `SettingsStore` has deemed the value calculated. Controllers are invoked as the last possible step in the code.
### Features ### Features
@ -224,17 +224,17 @@ See above for feature reference.
### Watchers ### Watchers
Watchers can appear complicated under the hood: there is a central `WatchManager` which handles the actual invocation 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 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 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. or not it'll unsubscribe all watchers.
Setting changes are emitted into the default `WatchManager`, which calculates the new value for the setting. Ideally, 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 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. 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 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 `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 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 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'. setting themselves as there's nothing to really 'watch'.

View file

@ -41,3 +41,11 @@ export type RecursivePartial<T> = {
T[P] extends object ? RecursivePartial<T[P]> : T[P] extends object ? RecursivePartial<T[P]> :
T[P]; T[P];
}; };
// Inspired by https://stackoverflow.com/a/60206860
export type KeysWithObjectShape<Input> = {
[P in keyof Input]: Input[P] extends object
// Arrays are counted as objects - exclude them
? (Input[P] extends Array<unknown> ? never : P)
: never;
}[keyof Input];

View file

@ -52,7 +52,7 @@ import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake";
import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import { Skinner } from "../Skinner"; import { Skinner } from "../Skinner";
import AutoRageshakeStore from "../stores/AutoRageshakeStore"; import AutoRageshakeStore from "../stores/AutoRageshakeStore";
import { ConfigOptions } from "../SdkConfig"; import { IConfigOptions } from "../IConfigOptions";
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
@ -63,7 +63,7 @@ declare global {
Olm: { Olm: {
init: () => Promise<void>; init: () => Promise<void>;
}; };
mxReactSdkConfig: ConfigOptions; mxReactSdkConfig: IConfigOptions;
// Needed for Safari, unknown to TypeScript // Needed for Safari, unknown to TypeScript
webkitAudioContext: typeof AudioContext; webkitAudioContext: typeof AudioContext;

View file

@ -17,12 +17,15 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { Optional } from "matrix-events-sdk";
import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler'; import { getCurrentLanguage, _t, _td, IVariables } from './languageHandler';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './index'; 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 hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password|home|directory)/;
const hashVarRegex = /#\/(group|room|user)\/.*$/; const hashVarRegex = /#\/(group|room|user)\/.*$/;
@ -193,8 +196,12 @@ export class Analytics {
} }
public canEnable() { public canEnable() {
const config = SdkConfig.get(); const piwikConfig = SdkConfig.get("piwik");
return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId; let piwik: Optional<SnakedObject<Extract<IConfigOptions["piwik"], object>>>;
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() { public async enable() {
if (!this.disabled) return; if (!this.disabled) return;
if (!this.canEnable()) return; if (!this.canEnable()) return;
const config = SdkConfig.get(); const piwikConfig = SdkConfig.get("piwik");
let piwik: Optional<SnakedObject<Extract<IConfigOptions["piwik"], object>>>;
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 // set constants
this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking 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("apiv", "1"); // API version to use
this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF
// set user parameters // set user parameters
@ -347,10 +358,14 @@ export class Analytics {
public setLoggedIn(isGuest: boolean, homeserverUrl: string) { public setLoggedIn(isGuest: boolean, homeserverUrl: string) {
if (this.disabled) return; if (this.disabled) return;
const config = SdkConfig.get(); const piwikConfig = SdkConfig.get("piwik");
if (!config.piwik) return; let piwik: Optional<SnakedObject<Extract<IConfigOptions["piwik"], object>>>;
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('User Type', isGuest ? 'Guest' : 'Logged In');
this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
@ -391,7 +406,12 @@ export class Analytics {
]; ];
// FIXME: Using an import will result in test failures // FIXME: Using an import will result in test failures
const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl; const piwikConfig = SdkConfig.get("piwik");
let piwik: Optional<SnakedObject<Extract<IConfigOptions["piwik"], object>>>;
if (typeof piwikConfig === 'object') {
piwik = new SnakedObject(piwikConfig);
}
const cookiePolicyUrl = piwik?.get("policy_url");
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
const cookiePolicyLink = _t( const cookiePolicyLink = _t(
"Our complete cookie policy can be found <CookiePolicyLink>here</CookiePolicyLink>.", "Our complete cookie policy can be found <CookiePolicyLink>here</CookiePolicyLink>.",

View file

@ -32,6 +32,7 @@ import { hideToast as hideUpdateToast } from "./toasts/UpdateToast";
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager"; import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager";
import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
import { IConfigOptions } from "./IConfigOptions";
export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_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); this.startUpdateCheck = this.startUpdateCheck.bind(this);
} }
abstract getConfig(): Promise<{}>; abstract getConfig(): Promise<IConfigOptions>;
abstract getDefaultDeviceDisplayName(): string; abstract getDefaultDeviceDisplayName(): string;

View file

@ -259,7 +259,7 @@ export default class CallHandler extends EventEmitter {
} }
private shouldObeyAssertedfIdentity(): boolean { private shouldObeyAssertedfIdentity(): boolean {
return SdkConfig.get()['voip']?.obeyAssertedIdentity; return SdkConfig.getObject("voip")?.get("obey_asserted_identity");
} }
public getSupportsPstnProtocol(): boolean { public getSupportsPstnProtocol(): boolean {

186
src/IConfigOptions.ts Normal file
View file

@ -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<string, any>; // <SettingName, Value>
integrations_ui_url?: string;
integrations_rest_url?: string;
integrations_widgets_urls?: string[];
show_labs_settings?: boolean;
features?: Record<string, boolean>; // <FeatureName, EnabledBool>
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<string, boolean>; // <HomeserverName, Enabled>
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;
}

View file

@ -161,7 +161,7 @@ const callBindings = (): KeyBinding[] => {
}; };
const labsBindings = (): KeyBinding[] => { const labsBindings = (): KeyBinding[] => {
if (!SdkConfig.get()['showLabsSettings']) return []; if (!SdkConfig.get("show_labs_settings")) return [];
return getBindingsByCategory(CategoryName.LABS); return getBindingsByCategory(CategoryName.LABS);
}; };

View file

@ -21,7 +21,7 @@ import SdkConfig from "./SdkConfig";
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
export function getConfigLivestreamUrl() { 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 // Dummy rtmp URL used to signal that we want a special audio-only stream

View file

@ -126,10 +126,10 @@ export class PosthogAnalytics {
} }
constructor(private readonly posthog: PostHog) { constructor(private readonly posthog: PostHog) {
const posthogConfig = SdkConfig.get()["posthog"]; const posthogConfig = SdkConfig.getObject("posthog");
if (posthogConfig) { if (posthogConfig) {
this.posthog.init(posthogConfig.projectApiKey, { this.posthog.init(posthogConfig.get("project_api_key"), {
api_host: posthogConfig.apiHost, api_host: posthogConfig.get("api_host"),
autocapture: false, autocapture: false,
mask_all_text: true, mask_all_text: true,
mask_all_element_attributes: true, mask_all_element_attributes: true,

View file

@ -44,8 +44,8 @@ export default class ScalarAuthClient {
// We try and store the token on a per-manager basis, but need a fallback // We try and store the token on a per-manager basis, but need a fallback
// for the default manager. // for the default manager.
const configApiUrl = SdkConfig.get()['integrations_rest_url']; const configApiUrl = SdkConfig.get("integrations_rest_url");
const configUiUrl = SdkConfig.get()['integrations_ui_url']; const configUiUrl = SdkConfig.get("integrations_ui_url");
this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl; this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl;
} }

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
export interface ISsoRedirectOptions { import { Optional } from "matrix-events-sdk";
immediate?: boolean;
on_welcome_page?: boolean; // eslint-disable-line camelcase
}
/* eslint-disable camelcase */ import { SnakedObject } from "./utils/SnakedObject";
export interface ConfigOptions { import { IConfigOptions, ISsoRedirectOptions } from "./IConfigOptions";
[key: string]: any; import { KeysWithObjectShape } from "./@types/common";
logout_redirect_url?: string; // see element-web config.md for docs, or the IConfigOptions interface for dev docs
export const DEFAULTS: Partial<IConfigOptions> = {
// 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
brand: "Element", brand: "Element",
// URL to a page we show in an iframe to configure integrations
integrations_ui_url: "https://scalar.vector.im/", 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", 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, bug_report_endpoint_url: null,
// Jitsi conference options
jitsi: { jitsi: {
// Default conference domain preferred_domain: "meet.element.io",
preferredDomain: "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: { desktopBuilds: {
available: true, available: true,
logo: require("../res/img/element-desktop-logo.svg").default, logo: require("../res/img/element-desktop-logo.svg").default,
@ -56,20 +43,42 @@ export const DEFAULTS: ConfigOptions = {
}; };
export default class SdkConfig { export default class SdkConfig {
private static instance: ConfigOptions; private static instance: IConfigOptions;
private static fallback: SnakedObject<IConfigOptions>;
private static setInstance(i: ConfigOptions) { private static setInstance(i: IConfigOptions) {
SdkConfig.instance = i; SdkConfig.instance = i;
SdkConfig.fallback = new SnakedObject(i);
// For debugging purposes // For debugging purposes
window.mxReactSdkConfig = i; window.mxReactSdkConfig = i;
} }
public static get() { public static get(): IConfigOptions;
return SdkConfig.instance || {}; public static get<K extends keyof IConfigOptions>(key: K, altCaseName?: string): IConfigOptions[K];
public static get<K extends keyof IConfigOptions = never>(
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 || <IConfigOptions>{};
}
return SdkConfig.fallback.get(key, altCaseName);
} }
public static put(cfg: ConfigOptions) { public static getObject<K extends KeysWithObjectShape<IConfigOptions>>(
key: K, altCaseName?: string,
): Optional<SnakedObject<IConfigOptions[K]>> {
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); const defaultKeys = Object.keys(DEFAULTS);
for (let i = 0; i < defaultKeys.length; ++i) { for (let i = 0; i < defaultKeys.length; ++i) {
if (cfg[defaultKeys[i]] === undefined) { if (cfg[defaultKeys[i]] === undefined) {
@ -79,18 +88,21 @@ export default class SdkConfig {
SdkConfig.setInstance(cfg); SdkConfig.setInstance(cfg);
} }
/**
* Resets the config to be completely empty.
*/
public static unset() { public static unset() {
SdkConfig.setInstance({}); SdkConfig.setInstance(<IConfigOptions>{}); // safe to cast - defaults will be applied
} }
public static add(cfg: ConfigOptions) { public static add(cfg: Partial<IConfigOptions>) {
const liveConfig = SdkConfig.get(); const liveConfig = SdkConfig.get();
const newConfig = Object.assign({}, liveConfig, cfg); const newConfig = Object.assign({}, liveConfig, cfg);
SdkConfig.put(newConfig); 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 // Ignore deprecated options if the config is using new ones
if (config.sso_redirect_options) return config.sso_redirect_options; if (config.sso_redirect_options) return config.sso_redirect_options;

View file

@ -103,11 +103,8 @@ const HomePage: React.FC<IProps> = ({ justRegistered = false }) => {
if (justRegistered) { if (justRegistered) {
introSection = <UserWelcomeTop />; introSection = <UserWelcomeTop />;
} else { } else {
const brandingConfig = config.branding; const brandingConfig = SdkConfig.getObject("branding");
let logoUrl = "themes/element/img/logos/element-logo.svg"; const logoUrl = brandingConfig?.get("auth_header_logo_url") ?? "themes/element/img/logos/element-logo.svg";
if (brandingConfig && brandingConfig.authHeaderLogoUrl) {
logoUrl = brandingConfig.authHeaderLogoUrl;
}
introSection = <React.Fragment> introSection = <React.Fragment>
<img src={logoUrl} alt={config.brand} /> <img src={logoUrl} alt={config.brand} />

View file

@ -39,8 +39,8 @@ export default class HostSignupAction extends React.PureComponent<IProps, IState
}; };
public render(): React.ReactNode { public render(): React.ReactNode {
const hostSignupConfig = SdkConfig.get().hostSignup; const hostSignupConfig = SdkConfig.getObject("host_signup");
if (!hostSignupConfig?.brand) { if (!hostSignupConfig?.get("brand")) {
return null; return null;
} }
@ -51,7 +51,7 @@ export default class HostSignupAction extends React.PureComponent<IProps, IState
label={_t( label={_t(
"Upgrade to %(hostSignupBrand)s", "Upgrade to %(hostSignupBrand)s",
{ {
hostSignupBrand: hostSignupConfig.brand, hostSignupBrand: hostSignupConfig.get("brand"),
}, },
)} )}
onClick={this.openDialog} onClick={this.openDialog}

View file

@ -75,6 +75,7 @@ import RightPanelStore from '../../stores/right-panel/RightPanelStore';
import { TimelineRenderingType } from "../../contexts/RoomContext"; import { TimelineRenderingType } from "../../contexts/RoomContext";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload";
import { IConfigOptions } from "../../IConfigOptions";
import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning';
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
@ -103,12 +104,7 @@ interface IProps {
roomOobData?: IOOBData; roomOobData?: IOOBData;
currentRoomId: string; currentRoomId: string;
collapseLhs: boolean; collapseLhs: boolean;
config: { config: IConfigOptions;
piwik: {
policyUrl: string;
};
[key: string]: any;
};
currentUserId?: string; currentUserId?: string;
currentGroupId?: string; currentGroupId?: string;
currentGroupIsNew?: boolean; currentGroupIsNew?: boolean;

View file

@ -131,6 +131,8 @@ import { ViewHomePagePayload } from '../../dispatcher/payloads/ViewHomePagePaylo
import { AfterLeaveRoomPayload } from '../../dispatcher/payloads/AfterLeaveRoomPayload'; import { AfterLeaveRoomPayload } from '../../dispatcher/payloads/AfterLeaveRoomPayload';
import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyncPreparedPayload'; import { DoAfterSyncPreparedPayload } from '../../dispatcher/payloads/DoAfterSyncPreparedPayload';
import { ViewStartChatOrReusePayload } from '../../dispatcher/payloads/ViewStartChatOrReusePayload'; import { ViewStartChatOrReusePayload } from '../../dispatcher/payloads/ViewStartChatOrReusePayload';
import { IConfigOptions } from "../../IConfigOptions";
import { SnakedObject } from "../../utils/SnakedObject";
import InfoDialog from '../views/dialogs/InfoDialog'; import InfoDialog from '../views/dialogs/InfoDialog';
// legacy export // legacy export
@ -154,12 +156,7 @@ interface IScreen {
} }
interface IProps { // TODO type things better interface IProps { // TODO type things better
config: { config: IConfigOptions;
piwik: {
policyUrl: string;
};
[key: string]: any;
};
serverConfig?: ValidatedServerConfig; serverConfig?: ValidatedServerConfig;
onNewScreen: (screen: string, replaceLast: boolean) => void; onNewScreen: (screen: string, replaceLast: boolean) => void;
enableGuest?: boolean; enableGuest?: boolean;
@ -355,7 +352,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Analytics.enable(); Analytics.enable();
} }
initSentry(SdkConfig.get()["sentry"]); initSentry(SdkConfig.get("sentry"));
} }
private async postLoginSetup() { private async postLoginSetup() {
@ -474,7 +471,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private getServerProperties() { private getServerProperties() {
let props = this.state.serverConfig; let props = this.state.serverConfig;
if (!props) props = this.props.serverConfig; // for unit tests 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 }; return { serverConfig: props };
} }
@ -865,7 +862,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
); );
// If the hs url matches then take the hs name we know locally as it is likely prettier // 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) { if (defaultConfig && defaultConfig.hsUrl === newState.serverConfig.hsUrl) {
newState.serverConfig.hsName = defaultConfig.hsName; newState.serverConfig.hsName = defaultConfig.hsName;
newState.serverConfig.hsNameIsDifferent = defaultConfig.hsNameIsDifferent; newState.serverConfig.hsNameIsDifferent = defaultConfig.hsNameIsDifferent;
@ -1062,11 +1059,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
private chatCreateOrReuse(userId: string) { private chatCreateOrReuse(userId: string) {
const snakedConfig = new SnakedObject<IConfigOptions>(this.props.config);
// Use a deferred action to reshow the dialog once the user has registered // Use a deferred action to reshow the dialog once the user has registered
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
// No point in making 2 DMs with welcome bot. This assumes view_set_mxid will // No point in making 2 DMs with welcome bot. This assumes view_set_mxid will
// result in a new DM with the welcome user. // result in a new DM with the welcome user.
if (userId !== this.props.config.welcomeUserId) { if (userId !== snakedConfig.get("welcome_user_id")) {
dis.dispatch<DoAfterSyncPreparedPayload<ViewStartChatOrReusePayload>>({ dis.dispatch<DoAfterSyncPreparedPayload<ViewStartChatOrReusePayload>>({
action: Action.DoAfterSyncPrepared, action: Action.DoAfterSyncPrepared,
deferred_action: { deferred_action: {
@ -1083,7 +1081,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// `_chatCreateOrReuse` again) // `_chatCreateOrReuse` again)
go_welcome_on_cancel: true, go_welcome_on_cancel: true,
screen_after: { screen_after: {
screen: `user/${this.props.config.welcomeUserId}`, screen: `user/${snakedConfig.get("welcome_user_id")}`,
params: { action: 'chat' }, params: { action: 'chat' },
}, },
}); });
@ -1231,12 +1229,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
await waitFor; await waitFor;
const snakedConfig = new SnakedObject<IConfigOptions>(this.props.config);
const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId( const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId(
this.props.config.welcomeUserId, snakedConfig.get("welcome_user_id"),
); );
if (welcomeUserRooms.length === 0) { if (welcomeUserRooms.length === 0) {
const roomId = await createRoom({ 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 // Only view the welcome user if we're NOT looking at a room
andView: !this.state.currentRoomId, andView: !this.state.currentRoomId,
spinner: false, // we're already showing one: we don't need another one spinner: false, // we're already showing one: we don't need another one
@ -1250,7 +1249,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// user room (it doesn't wait for new data from the server, just // user room (it doesn't wait for new data from the server, just
// the saved sync to be loaded). // the saved sync to be loaded).
const saveWelcomeUser = (ev: MatrixEvent) => { 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().store.save(true);
MatrixClientPeg.get().removeListener(ClientEvent.AccountData, saveWelcomeUser); MatrixClientPeg.get().removeListener(ClientEvent.AccountData, saveWelcomeUser);
} }
@ -1280,7 +1279,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} else if (MatrixClientPeg.currentUserIsJustRegistered()) { } else if (MatrixClientPeg.currentUserIsJustRegistered()) {
MatrixClientPeg.setJustRegisteredUserId(null); MatrixClientPeg.setJustRegisteredUserId(null);
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { const snakedConfig = new SnakedObject<IConfigOptions>(this.props.config);
if (snakedConfig.get("welcome_user_id") && getCurrentLanguage().startsWith("en")) {
const welcomeUserRoom = await this.startWelcomeUserChat(); const welcomeUserRoom = await this.startWelcomeUserChat();
if (welcomeUserRoom === null) { if (welcomeUserRoom === null) {
// We didn't redirect to the welcome user room, so show // We didn't redirect to the welcome user room, so show
@ -1312,7 +1312,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
showAnonymousAnalyticsOptInToast(); showAnonymousAnalyticsOptInToast();
} }
if (SdkConfig.get().mobileGuideToast) { if (SdkConfig.get("mobile_guide_toast")) {
// The toast contains further logic to detect mobile platforms, // The toast contains further logic to detect mobile platforms,
// check if it has been dismissed before, etc. // check if it has been dismissed before, etc.
showMobileGuideToast(); showMobileGuideToast();
@ -1463,7 +1463,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (!localStorage.getItem("mx_seen_feature_thread_experimental")) { if (!localStorage.getItem("mx_seen_feature_thread_experimental")) {
setTimeout(() => { setTimeout(() => {
if (SettingsStore.getValue("feature_thread") && SdkConfig.get()['showLabsSettings']) { if (SettingsStore.getValue("feature_thread") && SdkConfig.get("show_labs_settings")) {
Modal.createDialog(InfoDialog, { Modal.createDialog(InfoDialog, {
title: _t("Threads are no longer experimental! 🎉"), title: _t("Threads are no longer experimental! 🎉"),
description: <> description: <>

View file

@ -103,7 +103,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
let roomServer = myHomeserver; let roomServer = myHomeserver;
if ( if (
SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) || SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) ||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer) SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
) { ) {
roomServer = lsRoomServer; roomServer = lsRoomServer;

View file

@ -53,7 +53,6 @@ import IconizedContextMenu, {
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import { UIFeature } from "../../settings/UIFeature"; import { UIFeature } from "../../settings/UIFeature";
import HostSignupAction from "./HostSignupAction"; import HostSignupAction from "./HostSignupAction";
import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
import SpaceStore from "../../stores/spaces/SpaceStore"; import SpaceStore from "../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
@ -375,7 +374,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (!this.state.contextMenuPosition) return null; if (!this.state.contextMenuPosition) return null;
let topSection; let topSection;
const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup; const hostSignupConfig = SdkConfig.getObject("host_signup");
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
topSection = ( topSection = (
<div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts"> <div className="mx_UserMenu_contextMenu_header mx_UserMenu_contextMenu_guestPrompts">
@ -395,16 +394,14 @@ export default class UserMenu extends React.Component<IProps, IState> {
}) } }) }
</div> </div>
); );
} else if (hostSignupConfig) { } else if (hostSignupConfig?.get("url")) {
if (hostSignupConfig && hostSignupConfig.url) { // If hostSignup.domains is set to a non-empty array, only show
// If hostSignup.domains is set to a non-empty array, only show // dialog if the user is on the domain or a subdomain.
// dialog if the user is on the domain or a subdomain. const hostSignupDomains = hostSignupConfig.get("domains") || [];
const hostSignupDomains = hostSignupConfig.domains || []; const mxDomain = MatrixClientPeg.get().getDomain();
const mxDomain = MatrixClientPeg.get().getDomain(); const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`)));
const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`))); if (!hostSignupConfig.get("domains") || validDomains.length > 0) {
if (!hostSignupConfig.domains || validDomains.length > 0) { topSection = <HostSignupAction onClick={this.onCloseMenu} />;
topSection = <HostSignupAction onClick={this.onCloseMenu} />;
}
} }
} }

View file

@ -245,7 +245,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
} else if (error.httpStatus === 401 || error.httpStatus === 403) { } else if (error.httpStatus === 401 || error.httpStatus === 403) {
if (error.errcode === 'M_USER_DEACTIVATED') { if (error.errcode === 'M_USER_DEACTIVATED') {
errorText = _t('This account has been deactivated.'); errorText = _t('This account has been deactivated.');
} else if (SdkConfig.get()['disable_custom_urls']) { } else if (SdkConfig.get("disable_custom_urls")) {
errorText = ( errorText = (
<div> <div>
<div>{ _t('Incorrect username and/or password.') }</div> <div>{ _t('Incorrect username and/or password.') }</div>

View file

@ -59,7 +59,7 @@ export default class CountryDropdown extends React.Component<IProps, IState> {
super(props); super(props);
let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0]; let defaultCountry: PhoneNumberCountryDefinition = COUNTRIES[0];
const defaultCountryCode = SdkConfig.get()["defaultCountryCode"]; const defaultCountryCode = SdkConfig.get("default_country_code");
if (defaultCountryCode) { if (defaultCountryCode) {
const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase()); const country = COUNTRIES.find(c => c.iso2 === defaultCountryCode.toUpperCase());
if (country) defaultCountry = country; if (country) defaultCountry = country;

View file

@ -35,7 +35,7 @@ interface IProps {
} }
export default function LanguageSelector({ disabled }: IProps): JSX.Element { export default function LanguageSelector({ disabled }: IProps): JSX.Element {
if (SdkConfig.get()['disable_login_language_selector']) return <div />; if (SdkConfig.get("disable_login_language_selector")) return <div />;
return <LanguageDropdown return <LanguageDropdown
className="mx_AuthBody_language" className="mx_AuthBody_language"
onOptionChange={onChange} onOptionChange={onChange}

View file

@ -73,7 +73,7 @@ class PassphraseField extends PureComponent<IProps> {
return false; return false;
} }
const safe = complexity.score >= this.props.minScore; 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; return allowUnsafe || safe;
}, },
valid: function(complexity) { valid: function(complexity) {

View file

@ -39,10 +39,10 @@ export default class Welcome extends React.PureComponent<IProps> {
// FIXME: Using an import will result in wrench-element-tests failures // FIXME: Using an import will result in wrench-element-tests failures
const EmbeddedPage = sdk.getComponent("structures.EmbeddedPage"); const EmbeddedPage = sdk.getComponent("structures.EmbeddedPage");
const pagesConfig = SdkConfig.get().embeddedPages; const pagesConfig = SdkConfig.getObject("embedded_pages");
let pageUrl = null; let pageUrl = null;
if (pagesConfig) { if (pagesConfig) {
pageUrl = pagesConfig.welcomeUrl; pageUrl = pagesConfig.get("welcome_url");
} }
if (!pageUrl) { if (!pageUrl) {
pageUrl = 'welcome.html'; pageUrl = 'welcome.html';

View file

@ -15,12 +15,14 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { Optional } from "matrix-events-sdk";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import { SnakedObject } from "../../../utils/SnakedObject";
export enum ButtonClicked { export enum ButtonClicked {
Primary, Primary,
@ -96,8 +98,12 @@ const AnalyticsLearnMoreDialog: React.FC<IProps> = ({
}; };
export const showDialog = (props: Omit<IProps, "cookiePolicyUrl" | "analyticsOwner">): void => { export const showDialog = (props: Omit<IProps, "cookiePolicyUrl" | "analyticsOwner">): void => {
const privacyPolicyUrl = SdkConfig.get().piwik?.policyUrl; const piwikConfig = SdkConfig.get("piwik");
const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand; let privacyPolicyUrl: Optional<string>;
if (piwikConfig && typeof piwikConfig === "object") {
privacyPolicyUrl = (new SnakedObject(piwikConfig)).get("policy_url");
}
const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get("brand");
Modal.createTrackedDialog( Modal.createTrackedDialog(
"Analytics Learn More", "Analytics Learn More",
"", "",

View file

@ -71,7 +71,7 @@ export default class CommunityPrototypeInviteDialog extends React.PureComponent<
} }
private buildSuggestions(): IPerson[] { 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) { if (this.props.roomId) {
const room = MatrixClientPeg.get().getRoom(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"); if (!room) throw new Error("Room ID given to InviteDialog does not look like a room");

View file

@ -75,7 +75,6 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
joinRule = JoinRule.Restricted; joinRule = JoinRule.Restricted;
} }
const config = SdkConfig.get();
this.state = { this.state = {
isPublic: this.props.defaultPublic || false, isPublic: this.props.defaultPublic || false,
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(), isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(),
@ -84,7 +83,7 @@ export default class CreateRoomDialog extends React.Component<IProps, IState> {
topic: "", topic: "",
alias: "", alias: "",
detailsOpen: false, detailsOpen: false,
noFederate: config.default_federate === false, noFederate: SdkConfig.get().default_federate === false,
nameIsValid: false, nameIsValid: false,
canChangeEncryption: true, canChangeEncryption: true,
}; };

View file

@ -28,12 +28,13 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { HostSignupStore } from "../../../stores/HostSignupStore"; import { HostSignupStore } from "../../../stores/HostSignupStore";
import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import { import {
IHostSignupConfig,
IPostmessage, IPostmessage,
IPostmessageResponseData, IPostmessageResponseData,
PostmessageAction, PostmessageAction,
} from "./HostSignupDialogTypes"; } from "./HostSignupDialogTypes";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IConfigOptions } from "../../../IConfigOptions";
import { SnakedObject } from "../../../utils/SnakedObject";
const HOST_SIGNUP_KEY = "host_signup"; const HOST_SIGNUP_KEY = "host_signup";
@ -48,7 +49,7 @@ interface IState {
@replaceableComponent("views.dialogs.HostSignupDialog") @replaceableComponent("views.dialogs.HostSignupDialog")
export default class HostSignupDialog extends React.PureComponent<IProps, IState> { export default class HostSignupDialog extends React.PureComponent<IProps, IState> {
private iframeRef: React.RefObject<HTMLIFrameElement> = React.createRef(); private iframeRef: React.RefObject<HTMLIFrameElement> = React.createRef();
private readonly config: IHostSignupConfig; private readonly config: SnakedObject<IConfigOptions["host_signup"]>;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -59,11 +60,11 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
minimized: false, minimized: false,
}; };
this.config = SdkConfig.get().hostSignup; this.config = SdkConfig.getObject("host_signup");
} }
private messageHandler = async (message: IPostmessage) => { private messageHandler = async (message: IPostmessage) => {
if (!this.config.url.startsWith(message.origin)) { if (!this.config.get("url").startsWith(message.origin)) {
return; return;
} }
switch (message.data.action) { switch (message.data.action) {
@ -142,7 +143,7 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
}; };
private sendMessage = (message: IPostmessageResponseData) => { private sendMessage = (message: IPostmessageResponseData) => {
this.iframeRef.current.contentWindow.postMessage(message, this.config.url); this.iframeRef.current.contentWindow.postMessage(message, this.config.get("url"));
}; };
private async sendAccountDetails() { private async sendAccountDetails() {
@ -176,12 +177,16 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
}; };
private onAccountDetailsRequest = () => { private onAccountDetailsRequest = () => {
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 = ( const textComponent = (
<> <>
<p> <p>
{ _t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " + { _t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " +
"account to fetch verified email addresses. This data is not stored.", { "account to fetch verified email addresses. This data is not stored.", {
hostSignupBrand: this.config.brand, hostSignupBrand: this.config.get("brand"),
}) } }) }
</p> </p>
<p> <p>
@ -189,17 +194,17 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
{}, {},
{ {
cookiePolicyLink: () => ( cookiePolicyLink: () => (
<a href={this.config.cookiePolicyUrl} target="_blank" rel="noreferrer noopener"> <a href={cookiePolicyUrl} target="_blank" rel="noreferrer noopener">
{ _t("Cookie Policy") } { _t("Cookie Policy") }
</a> </a>
), ),
privacyPolicyLink: () => ( privacyPolicyLink: () => (
<a href={this.config.privacyPolicyUrl} target="_blank" rel="noreferrer noopener"> <a href={privacyPolicyUrl} target="_blank" rel="noreferrer noopener">
{ _t("Privacy Policy") } { _t("Privacy Policy") }
</a> </a>
), ),
termsOfServiceLink: () => ( termsOfServiceLink: () => (
<a href={this.config.termsOfServiceUrl} target="_blank" rel="noreferrer noopener"> <a href={tosUrl} target="_blank" rel="noreferrer noopener">
{ _t("Terms of Service") } { _t("Terms of Service") }
</a> </a>
), ),
@ -247,7 +252,7 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
<div className="mx_Dialog_header mx_Dialog_headerWithButton"> <div className="mx_Dialog_header mx_Dialog_headerWithButton">
<div className="mx_Dialog_title"> <div className="mx_Dialog_title">
{ _t("%(hostSignupBrand)s Setup", { { _t("%(hostSignupBrand)s Setup", {
hostSignupBrand: this.config.brand, hostSignupBrand: this.config.get("brand"),
}) } }) }
</div> </div>
<AccessibleButton <AccessibleButton
@ -284,10 +289,10 @@ export default class HostSignupDialog extends React.PureComponent<IProps, IState
title={_t( title={_t(
"Upgrade to %(hostSignupBrand)s", "Upgrade to %(hostSignupBrand)s",
{ {
hostSignupBrand: this.config.brand, hostSignupBrand: this.config.get("brand"),
}, },
)} )}
src={this.config.url} src={this.config.get("url")}
ref={this.iframeRef} ref={this.iframeRef}
sandbox="allow-forms allow-scripts allow-same-origin allow-popups" sandbox="allow-forms allow-scripts allow-same-origin allow-popups"
/> />

View file

@ -45,12 +45,3 @@ export interface IPostmessage {
data: IPostmessageRequestData; data: IPostmessageRequestData;
origin: string; origin: string;
} }
export interface IHostSignupConfig {
brand: string;
cookiePolicyUrl: string;
domains: Array<string>;
privacyPolicyUrl: string;
termsOfServiceUrl: string;
url: string;
}

View file

@ -412,7 +412,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
throw new Error("When using KIND_CALL_TRANSFER a call is required for an InviteDialog"); throw new Error("When using KIND_CALL_TRANSFER a call is required for an InviteDialog");
} }
const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get()['welcomeUserId']]); const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get("welcome_user_id")]);
if (props.roomId) { if (props.roomId) {
const room = MatrixClientPeg.get().getRoom(props.roomId); const room = MatrixClientPeg.get().getRoom(props.roomId);
if (!room) throw new Error("Room ID given to InviteDialog does not look like a room"); if (!room) throw new Error("Room ID given to InviteDialog does not look like a room");

View file

@ -259,9 +259,8 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
); );
} }
const adminMessageMD = const adminMessageMD = SdkConfig
SdkConfig.get().reportEvent && .getObject("report_event")?.get("admin_message_md", "adminMessageMD");
SdkConfig.get().reportEvent.adminMessageMD;
let adminMessage; let adminMessage;
if (adminMessageMD) { if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true }); const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
@ -272,7 +271,7 @@ export default class ReportEventDialog extends React.Component<IProps, IState> {
// Display report-to-moderator dialog. // Display report-to-moderator dialog.
// We let the user pick a nature. // We let the user pick a nature.
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const homeServerName = SdkConfig.get()["validated_server_config"].hsName; const homeServerName = SdkConfig.get("validated_server_config").hsName;
let subtitle; let subtitle;
switch (this.state.nature) { switch (this.state.nature) {
case Nature.Disagreement: case Nature.Disagreement:

View file

@ -50,7 +50,7 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
super(props); super(props);
const config = SdkConfig.get(); const config = SdkConfig.get();
this.defaultServer = config["validated_server_config"] as ValidatedServerConfig; this.defaultServer = config["validated_server_config"];
const { serverConfig } = this.props; const { serverConfig } = this.props;
let otherHomeserver = ""; let otherHomeserver = "";

View file

@ -159,7 +159,7 @@ export default class UserSettingsDialog extends React.Component<IProps, IState>
"UserSettingsSecurityPrivacy", "UserSettingsSecurityPrivacy",
)); ));
// Show the Labs tab if enabled or if there are any active betas // 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)) || SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k))
) { ) {
tabs.push(new Tab( tabs.push(new Tab(

View file

@ -40,6 +40,8 @@ import TextInputDialog from "../dialogs/TextInputDialog";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { compare } from "../../../utils/strings"; 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 // 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"; 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. // we either show the button or the dropdown in its place.
let content; let content;
if (menuDisplayed) { if (menuDisplayed) {
const config = SdkConfig.get(); const roomDirectory = SdkConfig.getObject("room_directory")
const roomDirectory = config.roomDirectory || {}; ?? new SnakedObject<IConfigOptions["room_directory"]>({ servers: [] });
const hsName = MatrixClientPeg.getHomeserverName(); const hsName = MatrixClientPeg.getHomeserverName();
const configServers = new Set<string>(roomDirectory.servers); const configServers = new Set<string>(roomDirectory.get("servers"));
// configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. // 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)); const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));

View file

@ -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 text = null;
let logo = null; let logo = null;
if (desktopBuilds.available) { if (desktopBuilds.get("available")) {
logo = <img src={desktopBuilds.logo} />; logo = <img src={desktopBuilds.get("logo")} />;
const buildUrl = desktopBuilds.get("url");
switch (kind) { switch (kind) {
case WarningKind.Files: case WarningKind.Files:
text = _t("Use the <a>Desktop app</a> to see all encrypted files", {}, { text = _t("Use the <a>Desktop app</a> to see all encrypted files", {}, {
a: sub => (<a href={desktopBuilds.url} target="_blank" rel="noreferrer noopener">{ sub }</a>), a: sub => (<a href={buildUrl} target="_blank" rel="noreferrer noopener">{ sub }</a>),
}); });
break; break;
case WarningKind.Search: case WarningKind.Search:
text = _t("Use the <a>Desktop app</a> to search encrypted messages", {}, { text = _t("Use the <a>Desktop app</a> to search encrypted messages", {}, {
a: sub => (<a href={desktopBuilds.url} target="_blank" rel="noreferrer noopener">{ sub }</a>), a: sub => (<a href={buildUrl} target="_blank" rel="noreferrer noopener">{ sub }</a>),
}); });
break; break;
} }

View file

@ -53,7 +53,7 @@ const onHelpClick = () => {
}; };
const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => { const ServerPicker = ({ title, dialogTitle, serverConfig, onServerConfigChange }: IProps) => {
const disableCustomUrls = SdkConfig.get()["disable_custom_urls"]; const disableCustomUrls = SdkConfig.get("disable_custom_urls");
let editBtn; let editBtn;
if (!disableCustomUrls && onServerConfigChange) { if (!disableCustomUrls && onServerConfigChange) {

View file

@ -1575,7 +1575,7 @@ const UserInfoHeader: React.FC<{
presenceCurrentlyActive = member.user.currentlyActive; 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; let showPresence = true;
if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) { if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) {
showPresence = enablePresenceByHsUrl[cli.baseUrl]; showPresence = enablePresenceByHsUrl[cli.baseUrl];

View file

@ -95,7 +95,7 @@ export default class MemberList extends React.Component<IProps, IState> {
} }
cli.on(ClientEvent.Room, this.onRoom); // invites & joining after peek 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; const hsUrl = MatrixClientPeg.get().baseUrl;
this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true; this.showPresence = enablePresenceByHsUrl?.[hsUrl] ?? true;
} }

View file

@ -95,7 +95,7 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
private onStartBotChat = (e) => { private onStartBotChat = (e) => {
this.props.closeSettingsFn(); this.props.closeSettingsFn();
createRoom({ createRoom({
dmUserId: SdkConfig.get().welcomeUserId, dmUserId: SdkConfig.get("welcome_user_id"),
andView: true, andView: true,
}); });
}; };
@ -105,7 +105,7 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
if (!tocLinks) return null; if (!tocLinks) return null;
const legalLinks = []; const legalLinks = [];
for (const tocEntry of SdkConfig.get().terms_and_conditions_links) { for (const tocEntry of tocLinks) {
legalLinks.push(<div key={tocEntry.url}> legalLinks.push(<div key={tocEntry.url}>
<a href={tocEntry.url} rel="noreferrer noopener" target="_blank">{ tocEntry.text }</a> <a href={tocEntry.url} rel="noreferrer noopener" target="_blank">{ tocEntry.text }</a>
</div>); </div>);
@ -198,7 +198,7 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
</a>, </a>,
}, },
); );
if (SdkConfig.get().welcomeUserId && getCurrentLanguage().startsWith('en')) { if (SdkConfig.get("welcome_user_id") && getCurrentLanguage().startsWith('en')) {
faqText = ( faqText = (
<div> <div>
{ _t( { _t(

View file

@ -35,7 +35,7 @@ interface IKeyboardShortcutRowProps {
// Filter out the labs section if labs aren't enabled. // Filter out the labs section if labs aren't enabled.
const visibleCategories = Object.entries(CATEGORIES).filter(([categoryName]) => 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<IKeyboardShortcutRowProps> = ({ name }) => { const KeyboardShortcutRow: React.FC<IKeyboardShortcutRowProps> = ({ name }) => {
const displayName = getKeyboardShortcutDisplayName(name); const displayName = getKeyboardShortcutDisplayName(name);

View file

@ -86,7 +86,7 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> {
} }
let labsSection; let labsSection;
if (SdkConfig.get()['showLabsSettings']) { if (SdkConfig.get("show_labs_settings")) {
const groups = new EnhancedMap<LabGroup, JSX.Element[]>(); const groups = new EnhancedMap<LabGroup, JSX.Element[]>();
labs.forEach(f => { labs.forEach(f => {
groups.getOrCreate(SettingsStore.getLabGroup(f), []).push( groups.getOrCreate(SettingsStore.getLabGroup(f), []).push(

View file

@ -95,7 +95,7 @@ export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false }
patternNames.forEach(function(patternName) { patternNames.forEach(function(patternName) {
patternTypes.forEach(function(patternType) { patternTypes.forEach(function(patternType) {
// get the regex replace pattern from config or use the default // 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] || {})[patternType] || {})["pattern"] || {})[patternName] ||
patternDefaults[patternName][patternType]; patternDefaults[patternName][patternType];

View file

@ -77,8 +77,8 @@ export class IntegrationManagers {
} }
private setupConfiguredManager() { private setupConfiguredManager() {
const apiUrl: string = SdkConfig.get()['integrations_rest_url']; const apiUrl: string = SdkConfig.get("integrations_rest_url");
const uiUrl: string = SdkConfig.get()['integrations_ui_url']; const uiUrl: string = SdkConfig.get("integrations_ui_url");
if (apiUrl && uiUrl) { if (apiUrl && uiUrl) {
this.managers.push(new IntegrationManagerInstance(Kind.Config, apiUrl, uiUrl)); this.managers.push(new IntegrationManagerInstance(Kind.Config, apiUrl, uiUrl));

View file

@ -20,6 +20,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import SdkConfig from "./SdkConfig"; import SdkConfig from "./SdkConfig";
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { IConfigOptions } from "./IConfigOptions";
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -173,7 +174,7 @@ async function getContexts(): Promise<Contexts> {
} }
export async function sendSentryReport(userText: string, issueUrl: string, error: Error): Promise<void> { export async function sendSentryReport(userText: string, issueUrl: string, error: Error): Promise<void> {
const sentryConfig = SdkConfig.get()["sentry"]; const sentryConfig = SdkConfig.getObject("sentry");
if (!sentryConfig) return; if (!sentryConfig) return;
const captureContext = { const captureContext = {
@ -198,12 +199,7 @@ export function setSentryUser(mxid: string): void {
Sentry.setUser({ username: mxid }); Sentry.setUser({ username: mxid });
} }
interface ISentryConfig { export async function initSentry(sentryConfig: IConfigOptions["sentry"]): Promise<void> {
dsn: string;
environment?: string;
}
export async function initSentry(sentryConfig: ISentryConfig): Promise<void> {
if (!sentryConfig) return; if (!sentryConfig) return;
// Only enable Integrations.GlobalHandlers, which hooks uncaught exceptions, if automaticErrorReporting is true // Only enable Integrations.GlobalHandlers, which hooks uncaught exceptions, if automaticErrorReporting is true
const integrations = [ const integrations = [

View file

@ -19,6 +19,8 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import SettingsHandler from "./SettingsHandler"; import SettingsHandler from "./SettingsHandler";
import SdkConfig from "../../SdkConfig"; 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 * 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 { public getValue(settingName: string, roomId: string): any {
const config = SdkConfig.get() || {}; const config = new SnakedObject<IConfigOptions>(SdkConfig.get());
if (this.featureNames.includes(settingName)) { if (this.featureNames.includes(settingName)) {
const labsConfig = config["features"] || {}; const labsConfig = config.get("features") || {};
const val = labsConfig[settingName]; const val = labsConfig[settingName];
if (isNullOrUndefined(val)) return null; // no definition at this level if (isNullOrUndefined(val)) return null; // no definition at this level
if (val === true || val === false) return val; // new style: mapped as a boolean 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 // Special case themes
if (settingName === "theme") { 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; if (!settingsConfig || isNullOrUndefined(settingsConfig[settingName])) return null;
return settingsConfig[settingName]; return settingsConfig[settingName];
} }

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { Optional } from "matrix-events-sdk";
import { _t } from "../languageHandler"; import { _t } from "../languageHandler";
import SdkConfig from "../SdkConfig"; import SdkConfig from "../SdkConfig";
@ -28,6 +29,8 @@ import {
showDialog as showAnalyticsLearnMoreDialog, showDialog as showAnalyticsLearnMoreDialog,
} from "../components/views/dialogs/AnalyticsLearnMoreDialog"; } from "../components/views/dialogs/AnalyticsLearnMoreDialog";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import { SnakedObject } from "../utils/SnakedObject";
import { IConfigOptions } from "../IConfigOptions";
const onAccept = () => { const onAccept = () => {
dis.dispatch({ dis.dispatch({
@ -81,7 +84,12 @@ const TOAST_KEY = "analytics";
const getAnonymousDescription = (): ReactNode => { const getAnonymousDescription = (): ReactNode => {
// get toast description for anonymous tracking (the previous scheme pre-posthog) // get toast description for anonymous tracking (the previous scheme pre-posthog)
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
const cookiePolicyUrl = SdkConfig.get().piwik?.policyUrl; const piwikConfig = SdkConfig.get("piwik");
let piwik: Optional<SnakedObject<Extract<IConfigOptions["piwik"], object>>>;
if (typeof piwikConfig === 'object') {
piwik = new SnakedObject(piwikConfig);
}
const cookiePolicyUrl = piwik?.get("policy_url");
return _t( return _t(
"Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. " + "Send <UsageDataLink>anonymous usage data</UsageDataLink> which helps us improve %(brand)s. " +
"This will use a <PolicyLink>cookie</PolicyLink>.", "This will use a <PolicyLink>cookie</PolicyLink>.",
@ -100,7 +108,7 @@ const getAnonymousDescription = (): ReactNode => {
}; };
const showToast = (props: Omit<React.ComponentProps<typeof GenericToast>, "toastKey">) => { const showToast = (props: Omit<React.ComponentProps<typeof GenericToast>, "toastKey">) => {
const analyticsOwner = SdkConfig.get().analyticsOwner ?? SdkConfig.get().brand; const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get().brand;
ToastStore.sharedInstance().addOrReplaceToast({ ToastStore.sharedInstance().addOrReplaceToast({
key: TOAST_KEY, key: TOAST_KEY,
title: _t("Help improve %(analyticsOwner)s", { analyticsOwner }), title: _t("Help improve %(analyticsOwner)s", { analyticsOwner }),

View file

@ -207,7 +207,7 @@ export default class AutoDiscoveryUtils {
const hsResult = discoveryResult['m.homeserver']; const hsResult = discoveryResult['m.homeserver'];
const isResult = discoveryResult['m.identity_server']; 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 // Validate the identity server first because an invalid identity server causes
// an invalid homeserver, which may not be picked up correctly. // an invalid homeserver, which may not be picked up correctly.

View file

@ -21,7 +21,7 @@ import SdkConfig from '../SdkConfig';
import { MatrixClientPeg } from '../MatrixClientPeg'; import { MatrixClientPeg } from '../MatrixClientPeg';
export function getDefaultIdentityServerUrl(): string { export function getDefaultIdentityServerUrl(): string {
return SdkConfig.get()['validated_server_config']['isUrl']; return SdkConfig.get("validated_server_config").isUrl;
} }
export function useDefaultIdentityServer(): void { export function useDefaultIdentityServer(): void {

36
src/utils/SnakedObject.ts Normal file
View file

@ -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<T = Record<string, any>> {
public constructor(private obj: T) {
}
public get<K extends string & keyof T>(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;
}
}

View file

@ -14,23 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ConfigOptions } from "../SdkConfig"; import { logger } from "matrix-js-sdk/src/logger";
export function getHomePageUrl(appConfig: ConfigOptions): string | null { import { IConfigOptions } from "../IConfigOptions";
const pagesConfig = appConfig.embeddedPages; import { SnakedObject } from "./SnakedObject";
let pageUrl = pagesConfig?.homeUrl;
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) { if (!pageUrl) {
// This is a deprecated config option for the home page // This is a deprecated config option for the home page
// (despite the name, given we also now have a welcome // (despite the name, given we also now have a welcome
// page, which is not the same). // page, which is not the same).
pageUrl = appConfig.welcomePageUrl; pageUrl = (<any>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; return pageUrl;
} }
export function shouldUseLoginForWelcome(appConfig: ConfigOptions): boolean { export function shouldUseLoginForWelcome(appConfig: IConfigOptions): boolean {
const pagesConfig = appConfig.embeddedPages; const config = new SnakedObject(appConfig);
return pagesConfig?.loginForWelcome === true; const pagesConfig = config.get("embedded_pages");
return pagesConfig
? ((new SnakedObject(pagesConfig).get("login_for_welcome")) === true)
: false;
} }

View file

@ -413,7 +413,7 @@ export function getPrimaryPermalinkEntity(permalink: string): string {
} }
function getPermalinkConstructor(): PermalinkConstructor { function getPermalinkConstructor(): PermalinkConstructor {
const elementPrefix = SdkConfig.get()['permalinkPrefix']; const elementPrefix = SdkConfig.get("permalink_prefix");
if (elementPrefix && elementPrefix !== matrixtoBaseUrl) { if (elementPrefix && elementPrefix !== matrixtoBaseUrl) {
return new ElementPermalinkConstructor(elementPrefix); return new ElementPermalinkConstructor(elementPrefix);
} }
@ -423,7 +423,7 @@ function getPermalinkConstructor(): PermalinkConstructor {
export function parsePermalink(fullUrl: string): PermalinkParts { export function parsePermalink(fullUrl: string): PermalinkParts {
try { try {
const elementPrefix = SdkConfig.get()['permalinkPrefix']; const elementPrefix = SdkConfig.get("permalink_prefix");
if (decodeURIComponent(fullUrl).startsWith(matrixtoBaseUrl)) { if (decodeURIComponent(fullUrl).startsWith(matrixtoBaseUrl)) {
return new MatrixToPermalinkConstructor().parsePermalink(decodeURIComponent(fullUrl)); return new MatrixToPermalinkConstructor().parsePermalink(decodeURIComponent(fullUrl));
} else if (fullUrl.startsWith("matrix:")) { } else if (fullUrl.startsWith("matrix:")) {

View file

@ -19,7 +19,7 @@ import SdkConfig from "../SdkConfig";
export function isPresenceEnabled() { export function isPresenceEnabled() {
const hsUrl = MatrixClientPeg.get().baseUrl; 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) return true;
if (urls[hsUrl] || urls[hsUrl] === undefined) return true; if (urls[hsUrl] || urls[hsUrl] === undefined) return true;
return false; return false;

View file

@ -70,7 +70,7 @@ export class Jitsi {
private update = async (discoveryResponse: IClientWellKnown): Promise<any> => { private update = async (discoveryResponse: IClientWellKnown): Promise<any> => {
// Start with a default of the config's domain // 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"); logger.log("Attempting to get Jitsi conference information from homeserver");
const wkPreferredDomain = discoveryResponse?.[JITSI_WK_PROPERTY]?.['preferredDomain']; const wkPreferredDomain = discoveryResponse?.[JITSI_WK_PROPERTY]?.['preferredDomain'];

View file

@ -72,20 +72,23 @@ describe("PosthogAnalytics", () => {
afterEach(() => { afterEach(() => {
window.crypto = null; window.crypto = null;
SdkConfig.unset(); // we touch the config, so clean up
}); });
describe("Initialisation", () => { describe("Initialisation", () => {
it("Should not be enabled without config being set", () => { 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); const analytics = new PosthogAnalytics(fakePosthog);
expect(analytics.isEnabled()).toBe(false); expect(analytics.isEnabled()).toBe(false);
}); });
it("Should be enabled if config is set", () => { it("Should be enabled if config is set", () => {
jest.spyOn(SdkConfig, "get").mockReturnValue({ SdkConfig.put({
brand: "Testing",
posthog: { posthog: {
projectApiKey: "foo", project_api_key: "foo",
apiHost: "bar", api_host: "bar",
}, },
}); });
const analytics = new PosthogAnalytics(fakePosthog); const analytics = new PosthogAnalytics(fakePosthog);
@ -98,10 +101,11 @@ describe("PosthogAnalytics", () => {
let analytics: PosthogAnalytics; let analytics: PosthogAnalytics;
beforeEach(() => { beforeEach(() => {
jest.spyOn(SdkConfig, "get").mockReturnValue({ SdkConfig.put({
brand: "Testing",
posthog: { posthog: {
projectApiKey: "foo", project_api_key: "foo",
apiHost: "bar", api_host: "bar",
}, },
}); });

View file

@ -42,9 +42,9 @@ describe('Login', function() {
} as unknown as MatrixClient); } as unknown as MatrixClient);
beforeEach(function() { beforeEach(function() {
jest.spyOn(SdkConfig, "get").mockReturnValue({ SdkConfig.put({
brand: "test-brand",
disable_custom_urls: true, disable_custom_urls: true,
brand: 'test-brand',
}); });
mockClient.login.mockClear().mockResolvedValue({}); mockClient.login.mockClear().mockResolvedValue({});
mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] }); mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] });
@ -57,6 +57,7 @@ describe('Login', function() {
afterEach(function() { afterEach(function() {
ReactDOM.unmountComponentAtNode(parentDiv); ReactDOM.unmountComponentAtNode(parentDiv);
parentDiv.remove(); parentDiv.remove();
SdkConfig.unset(); // we touch the config, so clean up
}); });
function render() { function render() {
@ -69,9 +70,9 @@ describe('Login', function() {
} }
it('should show form with change server link', async () => { it('should show form with change server link', async () => {
jest.spyOn(SdkConfig, "get").mockReturnValue({ SdkConfig.put({
brand: "test-brand",
disable_custom_urls: false, disable_custom_urls: false,
brand: 'test',
}); });
const root = render(); const root = render();

View file

@ -34,7 +34,7 @@ describe('Registration', function() {
let parentDiv; let parentDiv;
beforeEach(function() { beforeEach(function() {
jest.spyOn(SdkConfig, "get").mockReturnValue({ SdkConfig.put({
...DEFAULTS, ...DEFAULTS,
disable_custom_urls: true, disable_custom_urls: true,
}); });
@ -46,6 +46,7 @@ describe('Registration', function() {
afterEach(function() { afterEach(function() {
ReactDOM.unmountComponentAtNode(parentDiv); ReactDOM.unmountComponentAtNode(parentDiv);
parentDiv.remove(); parentDiv.remove();
SdkConfig.unset(); // we touch the config, so clean up
}); });
function render() { function render() {

View file

@ -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);
});
});