From c4944599cfe7268e32d43f8322cd113b37a8b52c Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 9 Feb 2023 11:52:00 +0100 Subject: [PATCH 01/10] add feature set to keep track of enabled features already include the calls feature --- src/features.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/features.ts 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); + } +} From f65b43f612f0b3377bd895ed200ff4201739fbdd Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 9 Feb 2023 11:52:39 +0100 Subject: [PATCH 02/10] load features at startup and pass them along in all view models --- src/domain/ViewModel.ts | 6 ++++++ src/platform/web/main.js | 4 ++++ 2 files changed, 10 insertions(+) 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/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); From d5929d9ebe6dd561637df57e7c450d58133747e9 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 9 Feb 2023 11:53:39 +0100 Subject: [PATCH 03/10] make features available in Client and Session --- src/domain/login/LoginViewModel.ts | 2 +- src/matrix/Client.js | 5 ++++- src/matrix/Session.js | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) 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/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/Session.js b/src/matrix/Session.js index f1b273ff..3bcb0ff7 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; From f86663fe7be2c05305fa2923ad8562529b50c3fd Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 9 Feb 2023 11:56:22 +0100 Subject: [PATCH 04/10] pass features to tilesCreator (and all options really for comfort) --- src/domain/session/room/RoomViewModel.js | 2 +- src/domain/session/room/timeline/TilesCollection.js | 2 +- src/domain/session/room/timeline/tiles/BaseMessageTile.js | 2 +- src/domain/session/room/timeline/tiles/index.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a741050d..65afd348 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -209,7 +209,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); } 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..32bc9c76 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) { From 22a81822664ffd81be919ca4e3077587eed5f23a Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 9 Feb 2023 11:57:30 +0100 Subject: [PATCH 05/10] feature-gate calls everywhere in the app --- src/domain/session/SessionViewModel.js | 8 +- src/domain/session/room/RoomViewModel.js | 7 ++ .../session/room/timeline/tiles/index.ts | 2 +- .../session/toast/ToastCollectionViewModel.ts | 6 +- src/matrix/Session.js | 103 ++++++++++-------- src/platform/web/ui/session/room/RoomView.js | 6 +- 6 files changed, 76 insertions(+), 56 deletions(-) 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 65afd348..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 => { @@ -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/tiles/index.ts b/src/domain/session/room/timeline/tiles/index.ts index 32bc9c76..e86d61cb 100644 --- a/src/domain/session/room/timeline/tiles/index.ts +++ b/src/domain/session/room/timeline/tiles/index.ts @@ -92,7 +92,7 @@ export function tileClassForEntry(entry: TimelineEntry, options: Options): TileC 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/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/matrix/Session.js b/src/matrix/Session.js index 3bcb0ff7..b3bd6f98 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -75,6 +75,61 @@ export class Session { }; this._roomsBeingCreated = new ObservableMap(); this._user = new User(sessionInfo.userId); + this._roomStateHandler = new RoomStateHandlerSet(); + this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler}); + this._olm = olm; + this._olmUtil = null; + this._e2eeAccount = null; + this._deviceTracker = null; + this._olmEncryption = null; + this._keyLoader = null; + this._megolmEncryption = null; + this._megolmDecryption = null; + this._getSyncToken = () => this.syncToken; + this._olmWorker = olmWorker; + this._keyBackup = new ObservableValue(undefined); + this._observedRoomStatus = new Map(); + + if (olm) { + this._olmUtil = new olm.Utility(); + this._deviceTracker = new DeviceTracker({ + storage, + getSyncToken: this._getSyncToken, + olmUtil: this._olmUtil, + ownUserId: sessionInfo.userId, + ownDeviceId: sessionInfo.deviceId, + }); + } + this._createRoomEncryption = this._createRoomEncryption.bind(this); + this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); + this.needsKeyBackup = new ObservableValue(false); + + if (features.calls) { + this._setupCallHandler(); + } + } + + get fingerprintKey() { + return this._e2eeAccount?.identityKeys.ed25519; + } + + get hasSecretStorageKey() { + return this._hasSecretStorageKey; + } + + get deviceId() { + return this._sessionInfo.deviceId; + } + + get userId() { + return this._sessionInfo.userId; + } + + get callHandler() { + return this._callHandler; + } + + _setupCallHandler() { this._callHandler = new CallHandler({ clock: this._platform.clock, random: this._platform.random, @@ -103,55 +158,7 @@ export class Session { 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; - this._e2eeAccount = null; - this._deviceTracker = null; - this._olmEncryption = null; - this._keyLoader = null; - this._megolmEncryption = null; - this._megolmDecryption = null; - this._getSyncToken = () => this.syncToken; - this._olmWorker = olmWorker; - this._keyBackup = new ObservableValue(undefined); - this._observedRoomStatus = new Map(); - - if (olm) { - this._olmUtil = new olm.Utility(); - this._deviceTracker = new DeviceTracker({ - storage, - getSyncToken: this._getSyncToken, - olmUtil: this._olmUtil, - ownUserId: sessionInfo.userId, - ownDeviceId: sessionInfo.deviceId, - }); - } - this._createRoomEncryption = this._createRoomEncryption.bind(this); - this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); - this.needsKeyBackup = new ObservableValue(false); - } - - get fingerprintKey() { - return this._e2eeAccount?.identityKeys.ed25519; - } - - get hasSecretStorageKey() { - return this._hasSecretStorageKey; - } - - get deviceId() { - return this._sessionInfo.deviceId; - } - - get userId() { - return this._sessionInfo.userId; - } - - get callHandler() { - return this._callHandler; } // called once this._e2eeAccount is assigned 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()); } From 4a46c98d124239fcab319f470eacdc18fb25071b Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Thu, 9 Feb 2023 11:57:45 +0100 Subject: [PATCH 06/10] don't assume the call handler is always set in device message handler --- src/matrix/DeviceMessageHandler.js | 46 ++++++++++++++++-------------- 1 file changed, 24 insertions(+), 22 deletions(-) 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, + }); + } } - } - }); + }); + } } } } From f9fa59609f04b334c26ce7d023427575ef07e0ec Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 10 Feb 2023 08:48:10 +0100 Subject: [PATCH 07/10] fix local variable usage after extracting method --- src/matrix/Session.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index b3bd6f98..bd4de880 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -153,8 +153,8 @@ export class Session { }, storage: this._storage, webRTC: this._platform.webRTC, - ownDeviceId: sessionInfo.deviceId, - ownUserId: sessionInfo.userId, + ownDeviceId: this._sessionInfo.deviceId, + ownUserId: this._sessionInfo.userId, logger: this._platform.logger, forceTURN: false, }); From bb477b6aad0005471edaced6e65d9fa4d883263f Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 10 Feb 2023 08:48:31 +0100 Subject: [PATCH 08/10] fix not passing features to client construction --- src/domain/RootViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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({ From da1b7d410895e9279c8b4017375a74e2dfe258f2 Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 10 Feb 2023 09:27:18 +0100 Subject: [PATCH 09/10] UI in settings for toggling features --- .../session/settings/FeaturesViewModel.ts | 70 +++++++++++++++++++ .../session/settings/SettingsViewModel.js | 6 ++ src/platform/web/Platform.js | 4 ++ .../web/ui/css/themes/element/theme.css | 18 +++++ .../web/ui/session/settings/FeaturesView.ts | 52 ++++++++++++++ .../web/ui/session/settings/SettingsView.js | 7 ++ 6 files changed, 157 insertions(+) create mode 100644 src/domain/session/settings/FeaturesViewModel.ts create mode 100644 src/platform/web/ui/session/settings/FeaturesView.ts 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/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/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/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), From 83d1ea05a1c9739d049bcb91e0663e06980c3fde Mon Sep 17 00:00:00 2001 From: Bruno Windels <274386+bwindels@users.noreply.github.com> Date: Fri, 10 Feb 2023 11:22:13 +0100 Subject: [PATCH 10/10] fix unit test --- src/matrix/Session.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index bd4de880..d4c68a8d 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -1015,6 +1015,7 @@ export class Session { } } +import {FeatureSet} from "../features"; export function tests() { function createStorageMock(session, pendingEvents = []) { return { @@ -1058,7 +1059,8 @@ export function tests() { clock: { createTimeout: () => undefined } - } + }, + features: new FeatureSet(0) }); await session.load(); let syncSet = false;