diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 524dfe13..2896fba6 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -158,7 +158,7 @@ export class RootViewModel extends ViewModel { } _showSessionLoader(sessionId) { - const client = new Client(this.platform); + const client = new Client(this.platform, this.features); client.startWithExistingSession(sessionId); this._setSection(() => { this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({ diff --git a/src/domain/ViewModel.ts b/src/domain/ViewModel.ts index 34a05855..409e61c9 100644 --- a/src/domain/ViewModel.ts +++ b/src/domain/ViewModel.ts @@ -30,6 +30,7 @@ import type {Navigation} from "./navigation/Navigation"; import type {SegmentType} from "./navigation/index"; import type {IURLRouter} from "./navigation/URLRouter"; import type { ITimeFormatter } from "../platform/types/types"; +import type { FeatureSet } from "../features"; export type Options = { platform: Platform; @@ -37,6 +38,7 @@ export type Options = { urlRouter: IURLRouter; navigation: Navigation; emitChange?: (params: any) => void; + features: FeatureSet } @@ -142,6 +144,10 @@ export class ViewModel = Op return this._options.urlRouter; } + get features(): FeatureSet { + return this._options.features; + } + get navigation(): Navigation { // typescript needs a little help here return this._options.navigation as unknown as Navigation; diff --git a/src/domain/login/LoginViewModel.ts b/src/domain/login/LoginViewModel.ts index 75d57880..f43361d0 100644 --- a/src/domain/login/LoginViewModel.ts +++ b/src/domain/login/LoginViewModel.ts @@ -55,7 +55,7 @@ export class LoginViewModel extends ViewModel { const {ready, defaultHomeserver, loginToken} = options; this._ready = ready; this._loginToken = loginToken; - this._client = new Client(this.platform); + this._client = new Client(this.platform, this.features); this._homeserver = defaultHomeserver; this._initViewModels(); } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 072e1b74..7d1dac3c 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -130,9 +130,11 @@ export class SessionViewModel extends ViewModel { start() { this._sessionStatusViewModel.start(); - this._client.session.callHandler.loadCalls("m.ring"); - // TODO: only do this when opening the room - this._client.session.callHandler.loadCalls("m.prompt"); + if (this.features.calls) { + this._client.session.callHandler.loadCalls("m.ring"); + // TODO: only do this when opening the room + this._client.session.callHandler.loadCalls("m.prompt"); + } } get activeMiddleViewModel() { diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a741050d..5cd610f2 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -50,6 +50,9 @@ export class RoomViewModel extends ErrorReportViewModel { } _setupCallViewModel() { + if (!this.features.calls) { + return; + } // pick call for this room with lowest key const calls = this.getOption("session").callHandler.calls; this._callObservable = new PickMapObservableValue(calls.filterValues(c => { @@ -209,7 +212,7 @@ export class RoomViewModel extends ErrorReportViewModel { _createTile(entry) { if (this._tileOptions) { - const Tile = this._tileOptions.tileClassForEntry(entry); + const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions); if (Tile) { return new Tile(entry, this._tileOptions); } @@ -421,6 +424,10 @@ export class RoomViewModel extends ErrorReportViewModel { startCall() { return this.logAndCatch("RoomViewModel.startCall", async log => { + if (!this.features.calls) { + log.set("feature_disbled", true); + return; + } log.set("roomId", this._room.id); let localMedia; try { diff --git a/src/domain/session/room/timeline/TilesCollection.js b/src/domain/session/room/timeline/TilesCollection.js index 458697ca..c5bddc2c 100644 --- a/src/domain/session/room/timeline/TilesCollection.js +++ b/src/domain/session/room/timeline/TilesCollection.js @@ -34,7 +34,7 @@ export class TilesCollection extends BaseObservableList { } _createTile(entry) { - const Tile = this._tileOptions.tileClassForEntry(entry); + const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions); if (Tile) { return new Tile(entry, this._tileOptions); } diff --git a/src/domain/session/room/timeline/tiles/BaseMessageTile.js b/src/domain/session/room/timeline/tiles/BaseMessageTile.js index 62c9632e..da5304ed 100644 --- a/src/domain/session/room/timeline/tiles/BaseMessageTile.js +++ b/src/domain/session/room/timeline/tiles/BaseMessageTile.js @@ -126,7 +126,7 @@ export class BaseMessageTile extends SimpleTile { if (action?.shouldReplace || !this._replyTile) { this.disposeTracked(this._replyTile); const tileClassForEntry = this._options.tileClassForEntry; - const ReplyTile = tileClassForEntry(replyEntry); + const ReplyTile = tileClassForEntry(replyEntry, this._options); if (ReplyTile) { this._replyTile = new ReplyTile(replyEntry, this._options); } diff --git a/src/domain/session/room/timeline/tiles/index.ts b/src/domain/session/room/timeline/tiles/index.ts index b4805635..e86d61cb 100644 --- a/src/domain/session/room/timeline/tiles/index.ts +++ b/src/domain/session/room/timeline/tiles/index.ts @@ -47,7 +47,7 @@ export type Options = ViewModelOptions & { }; export type TileConstructor = new (entry: TimelineEntry, options: Options) => ITile; -export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undefined { +export function tileClassForEntry(entry: TimelineEntry, options: Options): TileConstructor | undefined { if (entry.isGap) { return GapTile; } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) { @@ -92,7 +92,7 @@ export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undef case "org.matrix.msc3401.call": { // if prevContent is present, it's an update to a call event, which we don't render // as the original event is updated through the call object which receive state event updates - if (entry.stateKey && !entry.prevContent) { + if (options.features.calls && entry.stateKey && !entry.prevContent) { return CallTile; } return undefined; diff --git a/src/domain/session/settings/FeaturesViewModel.ts b/src/domain/session/settings/FeaturesViewModel.ts new file mode 100644 index 00000000..69018bd2 --- /dev/null +++ b/src/domain/session/settings/FeaturesViewModel.ts @@ -0,0 +1,70 @@ +/* +Copyright 2020 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 {ViewModel} from "../../ViewModel"; +import type {Options as BaseOptions} from "../../ViewModel"; +import {FeatureFlag, FeatureSet} from "../../../features"; +import type {SegmentType} from "../../navigation/index"; + +export class FeaturesViewModel extends ViewModel { + public readonly featureViewModels: ReadonlyArray; + + constructor(options) { + super(options); + this.featureViewModels = [ + new FeatureViewModel(this.childOptions({ + name: this.i18n`Audio/video calls (experimental)`, + description: this.i18n`Allows starting and participating in A/V calls compatible with Element Call (MSC3401). Look for the start call option in the room menu ((...) in the right corner) to start a call.`, + feature: FeatureFlag.Calls + })), + ]; + } +} + +type FeatureOptions = BaseOptions & { + feature: FeatureFlag, + description: string, + name: string +}; + +export class FeatureViewModel extends ViewModel { + get enabled(): boolean { + return this.features.isFeatureEnabled(this.getOption("feature")); + } + + async enableFeature(enabled: boolean): Promise { + let newFeatures; + if (enabled) { + newFeatures = this.features.withFeature(this.getOption("feature")); + } else { + newFeatures = this.features.withoutFeature(this.getOption("feature")); + } + await newFeatures.store(this.platform.settingsStorage); + this.platform.restart(); + } + + get id(): string { + return `${this.getOption("feature")}`; + } + + get name(): string { + return this.getOption("name"); + } + + get description(): string { + return this.getOption("description"); + } +} diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 952c910b..f8420a53 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -16,6 +16,7 @@ limitations under the License. import {ViewModel} from "../../ViewModel"; import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; +import {FeaturesViewModel} from "./FeaturesViewModel"; import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake"; class PushNotificationStatus { @@ -53,6 +54,7 @@ export class SettingsViewModel extends ViewModel { this.pushNotifications = new PushNotificationStatus(); this._activeTheme = undefined; this._logsFeedbackMessage = undefined; + this._featuresViewModel = new FeaturesViewModel(this.childOptions()); } get _session() { @@ -125,6 +127,10 @@ export class SettingsViewModel extends ViewModel { return this._keyBackupViewModel; } + get featuresViewModel() { + return this._featuresViewModel; + } + get storageQuota() { return this._formatBytes(this._estimate?.quota); } diff --git a/src/domain/session/toast/ToastCollectionViewModel.ts b/src/domain/session/toast/ToastCollectionViewModel.ts index d1595b2d..df4da88f 100644 --- a/src/domain/session/toast/ToastCollectionViewModel.ts +++ b/src/domain/session/toast/ToastCollectionViewModel.ts @@ -33,8 +33,10 @@ export class ToastCollectionViewModel extends ViewModel { constructor(options: Options) { super(options); const session = this.getOption("session"); - const callsObservableMap = session.callHandler.calls; - this.track(callsObservableMap.subscribe(this)); + if (this.features.calls) { + const callsObservableMap = session.callHandler.calls; + this.track(callsObservableMap.subscribe(this)); + } } async onAdd(_, call: GroupCall) { diff --git a/src/features.ts b/src/features.ts new file mode 100644 index 00000000..e503f0e0 --- /dev/null +++ b/src/features.ts @@ -0,0 +1,50 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type {SettingsStorage} from "./platform/web/dom/SettingsStorage"; + +export enum FeatureFlag { + Calls = 1 << 0, +} + +export class FeatureSet { + constructor(public readonly flags: number = 0) {} + + withFeature(flag: FeatureFlag): FeatureSet { + return new FeatureSet(this.flags | flag); + } + + withoutFeature(flag: FeatureFlag): FeatureSet { + return new FeatureSet(this.flags ^ flag); + } + + isFeatureEnabled(flag: FeatureFlag): boolean { + return (this.flags & flag) !== 0; + } + + get calls(): boolean { + return this.isFeatureEnabled(FeatureFlag.Calls); + } + + static async load(settingsStorage: SettingsStorage): Promise { + const flags = await settingsStorage.getInt("enabled_features") || 0; + return new FeatureSet(flags); + } + + async store(settingsStorage: SettingsStorage): Promise { + await settingsStorage.setInt("enabled_features", this.flags); + } +} diff --git a/src/matrix/Client.js b/src/matrix/Client.js index 5b53958b..fabb489b 100644 --- a/src/matrix/Client.js +++ b/src/matrix/Client.js @@ -31,6 +31,7 @@ import {TokenLoginMethod} from "./login/TokenLoginMethod"; import {SSOLoginHelper} from "./login/SSOLoginHelper"; import {getDehydratedDevice} from "./e2ee/Dehydration.js"; import {Registration} from "./registration/Registration"; +import {FeatureSet} from "../features"; export const LoadStatus = createEnum( "NotLoading", @@ -53,7 +54,7 @@ export const LoginFailure = createEnum( ); export class Client { - constructor(platform) { + constructor(platform, features = new FeatureSet(0)) { this._platform = platform; this._sessionStartedByReconnector = false; this._status = new ObservableValue(LoadStatus.NotLoading); @@ -68,6 +69,7 @@ export class Client { this._olmPromise = platform.loadOlm(); this._workerPromise = platform.loadOlmWorker(); this._accountSetup = undefined; + this._features = features; } createNewSessionId() { @@ -278,6 +280,7 @@ export class Client { olmWorker, mediaRepository, platform: this._platform, + features: this._features }); await this._session.load(log); if (dehydratedDevice) { diff --git a/src/matrix/DeviceMessageHandler.js b/src/matrix/DeviceMessageHandler.js index 05276549..f6e7cad7 100644 --- a/src/matrix/DeviceMessageHandler.js +++ b/src/matrix/DeviceMessageHandler.js @@ -74,29 +74,31 @@ export class DeviceMessageHandler { } async afterSyncCompleted(decryptionResults, deviceTracker, hsApi, log) { - // if we don't have a device, we need to fetch the device keys the message claims - // and check the keys, and we should only do network requests during - // sync processing in the afterSyncCompleted step. - const callMessages = decryptionResults.filter(dr => this._callHandler.handlesDeviceMessageEventType(dr.event?.type)); - if (callMessages.length) { - await log.wrap("process call signalling messages", async log => { - for (const dr of callMessages) { - // serialize device loading, so subsequent messages for the same device take advantage of the cache - const device = await deviceTracker.deviceForId(dr.event.sender, dr.event.content.device_id, hsApi, log); - dr.setDevice(device); - if (dr.isVerified) { - this._callHandler.handleDeviceMessage(dr.event, dr.userId, dr.deviceId, log); - } else { - log.log({ - l: "could not verify olm fingerprint key matches, ignoring", - ed25519Key: dr.device.ed25519Key, - claimedEd25519Key: dr.claimedEd25519Key, - deviceId: device.deviceId, - userId: device.userId, - }); + if (this._callHandler) { + // if we don't have a device, we need to fetch the device keys the message claims + // and check the keys, and we should only do network requests during + // sync processing in the afterSyncCompleted step. + const callMessages = decryptionResults.filter(dr => this._callHandler.handlesDeviceMessageEventType(dr.event?.type)); + if (callMessages.length) { + await log.wrap("process call signalling messages", async log => { + for (const dr of callMessages) { + // serialize device loading, so subsequent messages for the same device take advantage of the cache + const device = await deviceTracker.deviceForId(dr.event.sender, dr.event.content.device_id, hsApi, log); + dr.setDevice(device); + if (dr.isVerified) { + this._callHandler.handleDeviceMessage(dr.event, dr.userId, dr.deviceId, log); + } else { + log.log({ + l: "could not verify olm fingerprint key matches, ignoring", + ed25519Key: dr.device.ed25519Key, + claimedEd25519Key: dr.claimedEd25519Key, + deviceId: device.deviceId, + userId: device.userId, + }); + } } - } - }); + }); + } } } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index f1b273ff..d4c68a8d 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -54,7 +54,7 @@ const PUSHER_KEY = "pusher"; export class Session { // sessionInfo contains deviceId, userId and homeserver - constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository}) { + constructor({storage, hsApi, sessionInfo, olm, olmWorker, platform, mediaRepository, features}) { this._platform = platform; this._storage = storage; this._hsApi = hsApi; @@ -75,36 +75,7 @@ export class Session { }; this._roomsBeingCreated = new ObservableMap(); this._user = new User(sessionInfo.userId); - this._callHandler = new CallHandler({ - clock: this._platform.clock, - random: this._platform.random, - hsApi: this._hsApi, - encryptDeviceMessage: async (roomId, userId, deviceId, message, log) => { - if (!this._deviceTracker || !this._olmEncryption) { - log.set("encryption_disabled", true); - return; - } - const device = await log.wrap("get device key", async log => { - const device = this._deviceTracker.deviceForId(userId, deviceId, this._hsApi, log); - if (!device) { - log.set("not_found", true); - } - return device; - }); - if (device) { - const encryptedMessages = await this._olmEncryption.encrypt(message.type, message.content, [device], this._hsApi, log); - return encryptedMessages; - } - }, - storage: this._storage, - webRTC: this._platform.webRTC, - ownDeviceId: sessionInfo.deviceId, - ownUserId: sessionInfo.userId, - logger: this._platform.logger, - forceTURN: false, - }); this._roomStateHandler = new RoomStateHandlerSet(); - this.observeRoomState(this._callHandler); this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler}); this._olm = olm; this._olmUtil = null; @@ -132,6 +103,10 @@ export class Session { this._createRoomEncryption = this._createRoomEncryption.bind(this); this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); this.needsKeyBackup = new ObservableValue(false); + + if (features.calls) { + this._setupCallHandler(); + } } get fingerprintKey() { @@ -154,6 +129,38 @@ export class Session { return this._callHandler; } + _setupCallHandler() { + this._callHandler = new CallHandler({ + clock: this._platform.clock, + random: this._platform.random, + hsApi: this._hsApi, + encryptDeviceMessage: async (roomId, userId, deviceId, message, log) => { + if (!this._deviceTracker || !this._olmEncryption) { + log.set("encryption_disabled", true); + return; + } + const device = await log.wrap("get device key", async log => { + const device = this._deviceTracker.deviceForId(userId, deviceId, this._hsApi, log); + if (!device) { + log.set("not_found", true); + } + return device; + }); + if (device) { + const encryptedMessages = await this._olmEncryption.encrypt(message.type, message.content, [device], this._hsApi, log); + return encryptedMessages; + } + }, + storage: this._storage, + webRTC: this._platform.webRTC, + ownDeviceId: this._sessionInfo.deviceId, + ownUserId: this._sessionInfo.userId, + logger: this._platform.logger, + forceTURN: false, + }); + this.observeRoomState(this._callHandler); + } + // called once this._e2eeAccount is assigned _setupEncryption() { // TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account @@ -1008,6 +1015,7 @@ export class Session { } } +import {FeatureSet} from "../features"; export function tests() { function createStorageMock(session, pendingEvents = []) { return { @@ -1051,7 +1059,8 @@ export function tests() { clock: { createTimeout: () => undefined } - } + }, + features: new FeatureSet(0) }); await session.load(); let syncSet = false; diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index bfb585a3..be8c9970 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -283,6 +283,10 @@ export class Platform { } } + restart() { + document.location.reload(); + } + openFile(mimeType = null) { const input = document.createElement("input"); input.setAttribute("type", "file"); diff --git a/src/platform/web/main.js b/src/platform/web/main.js index 2b28187e..d9c6fe8a 100644 --- a/src/platform/web/main.js +++ b/src/platform/web/main.js @@ -18,6 +18,8 @@ limitations under the License. // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay"; import {RootViewModel} from "../../domain/RootViewModel.js"; import {createNavigation, createRouter} from "../../domain/navigation/index"; +import {FeatureSet} from "../../features"; + // Don't use a default export here, as we use multiple entries during legacy build, // which does not support default exports, // see https://github.com/rollup/plugins/tree/master/packages/multi-entry @@ -33,6 +35,7 @@ export async function main(platform) { // const request = recorder.request; // window.getBrawlFetchLog = () => recorder.log(); await platform.init(); + const features = await FeatureSet.load(platform.settingsStorage); const navigation = createNavigation(); platform.setNavigation(navigation); const urlRouter = createRouter({navigation, history: platform.history}); @@ -43,6 +46,7 @@ export async function main(platform) { // so we call it that in the view models urlRouter: urlRouter, navigation, + features }); await vm.load(); platform.createAndMountRootView(vm); diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 7bea169e..fd8e69b0 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -751,6 +751,24 @@ a { margin-bottom: 0; } +.FeatureView { + display: flex; + gap: 8px; +} + +.FeaturesView ul { + list-style: none; + padding: 8px 16px; +} + +.FeaturesView input[type="checkbox"] { + align-self: start; +} + +.FeatureView h4 { + margin: 0; +} + .error { color: var(--error-color); font-weight: 600; diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 892cb25e..727fb44d 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -73,8 +73,10 @@ export class RoomView extends TemplateView { } else { const vm = this.value; const options = []; - options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())) - options.push(Menu.option(vm.i18n`Start call`, () => vm.startCall())) + options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())); + if (vm.features.calls) { + options.push(Menu.option(vm.i18n`Start call`, () => vm.startCall())); + } if (vm.canLeave) { options.push(Menu.option(vm.i18n`Leave room`, () => this._confirmToLeaveRoom()).setDestructive()); } diff --git a/src/platform/web/ui/session/settings/FeaturesView.ts b/src/platform/web/ui/session/settings/FeaturesView.ts new file mode 100644 index 00000000..625fc362 --- /dev/null +++ b/src/platform/web/ui/session/settings/FeaturesView.ts @@ -0,0 +1,52 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView, TemplateBuilder} from "../../general/TemplateView"; +import {ViewNode} from "../../general/types"; +import {disableTargetCallback} from "../../general/utils"; +import type {FeaturesViewModel, FeatureViewModel} from "../../../../../domain/session/settings/FeaturesViewModel"; + +export class FeaturesView extends TemplateView { + render(t, vm: FeaturesViewModel): ViewNode { + return t.div({ + className: "FeaturesView", + }, [ + t.p("Enable experimental features here that are still in development. These are not yet ready for primetime, so expect bugs."), + // we don't use a binding/ListView because this is a static list + t.ul(vm.featureViewModels.map(vm => { + return t.li(t.view(new FeatureView(vm))); + })) + ]); + } +} + +class FeatureView extends TemplateView { + render(t, vm): ViewNode { + let id = `feature_${vm.id}`; + return t.div({className: "FeatureView"}, [ + t.input({ + type: "checkbox", + id, + checked: vm => vm.enabled, + onChange: evt => vm.enableFeature(evt.target.checked) + }), + t.div({class: "FeatureView_container"}, [ + t.h4(t.label({for: id}, vm.name)), + t.p(vm.description) + ]) + ]); + } +} diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index d7c48351..0d0d6941 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -17,6 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView"; import {disableTargetCallback} from "../../general/utils"; import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" +import {FeaturesView} from "./FeaturesView" export class SettingsView extends TemplateView { render(t, vm) { @@ -110,6 +111,12 @@ export class SettingsView extends TemplateView { logButtons.push(t.button({onClick: disableTargetCallback(() => vm.sendLogsToServer())}, `Submit logs to ${vm.logsServer}`)); } logButtons.push(t.button({onClick: () => vm.exportLogs()}, "Download logs")); + + settingNodes.push( + t.h3("Experimental features"), + t.view(new FeaturesView(vm.featuresViewModel)) + ); + settingNodes.push( t.h3("Application"), row(t, vm.i18n`Version`, version),