Merge pull request #1027 from vector-im/windels/calls-feature-flag

Put calls support behind a feature flag
This commit is contained in:
Bruno Windels 2023-02-10 11:24:25 +01:00 committed by GitHub
commit df05f40de7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 312 additions and 68 deletions

View File

@ -158,7 +158,7 @@ export class RootViewModel extends ViewModel {
} }
_showSessionLoader(sessionId) { _showSessionLoader(sessionId) {
const client = new Client(this.platform); const client = new Client(this.platform, this.features);
client.startWithExistingSession(sessionId); client.startWithExistingSession(sessionId);
this._setSection(() => { this._setSection(() => {
this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({ this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({

View File

@ -30,6 +30,7 @@ import type {Navigation} from "./navigation/Navigation";
import type {SegmentType} from "./navigation/index"; import type {SegmentType} from "./navigation/index";
import type {IURLRouter} from "./navigation/URLRouter"; import type {IURLRouter} from "./navigation/URLRouter";
import type { ITimeFormatter } from "../platform/types/types"; import type { ITimeFormatter } from "../platform/types/types";
import type { FeatureSet } from "../features";
export type Options<T extends object = SegmentType> = { export type Options<T extends object = SegmentType> = {
platform: Platform; platform: Platform;
@ -37,6 +38,7 @@ export type Options<T extends object = SegmentType> = {
urlRouter: IURLRouter<T>; urlRouter: IURLRouter<T>;
navigation: Navigation<T>; navigation: Navigation<T>;
emitChange?: (params: any) => void; emitChange?: (params: any) => void;
features: FeatureSet
} }
@ -142,6 +144,10 @@ export class ViewModel<N extends object = SegmentType, O extends Options<N> = Op
return this._options.urlRouter; return this._options.urlRouter;
} }
get features(): FeatureSet {
return this._options.features;
}
get navigation(): Navigation<N> { get navigation(): Navigation<N> {
// typescript needs a little help here // typescript needs a little help here
return this._options.navigation as unknown as Navigation<N>; return this._options.navigation as unknown as Navigation<N>;

View File

@ -55,7 +55,7 @@ export class LoginViewModel extends ViewModel<SegmentType, Options> {
const {ready, defaultHomeserver, loginToken} = options; const {ready, defaultHomeserver, loginToken} = options;
this._ready = ready; this._ready = ready;
this._loginToken = loginToken; this._loginToken = loginToken;
this._client = new Client(this.platform); this._client = new Client(this.platform, this.features);
this._homeserver = defaultHomeserver; this._homeserver = defaultHomeserver;
this._initViewModels(); this._initViewModels();
} }

View File

@ -130,10 +130,12 @@ export class SessionViewModel extends ViewModel {
start() { start() {
this._sessionStatusViewModel.start(); this._sessionStatusViewModel.start();
if (this.features.calls) {
this._client.session.callHandler.loadCalls("m.ring"); this._client.session.callHandler.loadCalls("m.ring");
// TODO: only do this when opening the room // TODO: only do this when opening the room
this._client.session.callHandler.loadCalls("m.prompt"); this._client.session.callHandler.loadCalls("m.prompt");
} }
}
get activeMiddleViewModel() { get activeMiddleViewModel() {
return ( return (

View File

@ -50,6 +50,9 @@ export class RoomViewModel extends ErrorReportViewModel {
} }
_setupCallViewModel() { _setupCallViewModel() {
if (!this.features.calls) {
return;
}
// pick call for this room with lowest key // pick call for this room with lowest key
const calls = this.getOption("session").callHandler.calls; const calls = this.getOption("session").callHandler.calls;
this._callObservable = new PickMapObservableValue(calls.filterValues(c => { this._callObservable = new PickMapObservableValue(calls.filterValues(c => {
@ -209,7 +212,7 @@ export class RoomViewModel extends ErrorReportViewModel {
_createTile(entry) { _createTile(entry) {
if (this._tileOptions) { if (this._tileOptions) {
const Tile = this._tileOptions.tileClassForEntry(entry); const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions);
if (Tile) { if (Tile) {
return new Tile(entry, this._tileOptions); return new Tile(entry, this._tileOptions);
} }
@ -421,6 +424,10 @@ export class RoomViewModel extends ErrorReportViewModel {
startCall() { startCall() {
return this.logAndCatch("RoomViewModel.startCall", async log => { return this.logAndCatch("RoomViewModel.startCall", async log => {
if (!this.features.calls) {
log.set("feature_disbled", true);
return;
}
log.set("roomId", this._room.id); log.set("roomId", this._room.id);
let localMedia; let localMedia;
try { try {

View File

@ -34,7 +34,7 @@ export class TilesCollection extends BaseObservableList {
} }
_createTile(entry) { _createTile(entry) {
const Tile = this._tileOptions.tileClassForEntry(entry); const Tile = this._tileOptions.tileClassForEntry(entry, this._tileOptions);
if (Tile) { if (Tile) {
return new Tile(entry, this._tileOptions); return new Tile(entry, this._tileOptions);
} }

View File

@ -126,7 +126,7 @@ export class BaseMessageTile extends SimpleTile {
if (action?.shouldReplace || !this._replyTile) { if (action?.shouldReplace || !this._replyTile) {
this.disposeTracked(this._replyTile); this.disposeTracked(this._replyTile);
const tileClassForEntry = this._options.tileClassForEntry; const tileClassForEntry = this._options.tileClassForEntry;
const ReplyTile = tileClassForEntry(replyEntry); const ReplyTile = tileClassForEntry(replyEntry, this._options);
if (ReplyTile) { if (ReplyTile) {
this._replyTile = new ReplyTile(replyEntry, this._options); this._replyTile = new ReplyTile(replyEntry, this._options);
} }

View File

@ -47,7 +47,7 @@ export type Options = ViewModelOptions & {
}; };
export type TileConstructor = new (entry: TimelineEntry, options: Options) => ITile; 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) { if (entry.isGap) {
return GapTile; return GapTile;
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) { } else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
@ -92,7 +92,7 @@ export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undef
case "org.matrix.msc3401.call": { case "org.matrix.msc3401.call": {
// if prevContent is present, it's an update to a call event, which we don't render // 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 // 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 CallTile;
} }
return undefined; return undefined;

View File

@ -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<FeatureViewModel>;
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<SegmentType, FeatureOptions> {
get enabled(): boolean {
return this.features.isFeatureEnabled(this.getOption("feature"));
}
async enableFeature(enabled: boolean): Promise<void> {
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");
}
}

View File

@ -16,6 +16,7 @@ limitations under the License.
import {ViewModel} from "../../ViewModel"; import {ViewModel} from "../../ViewModel";
import {KeyBackupViewModel} from "./KeyBackupViewModel.js"; import {KeyBackupViewModel} from "./KeyBackupViewModel.js";
import {FeaturesViewModel} from "./FeaturesViewModel";
import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake"; import {submitLogsFromSessionToDefaultServer} from "../../../domain/rageshake";
class PushNotificationStatus { class PushNotificationStatus {
@ -53,6 +54,7 @@ export class SettingsViewModel extends ViewModel {
this.pushNotifications = new PushNotificationStatus(); this.pushNotifications = new PushNotificationStatus();
this._activeTheme = undefined; this._activeTheme = undefined;
this._logsFeedbackMessage = undefined; this._logsFeedbackMessage = undefined;
this._featuresViewModel = new FeaturesViewModel(this.childOptions());
} }
get _session() { get _session() {
@ -125,6 +127,10 @@ export class SettingsViewModel extends ViewModel {
return this._keyBackupViewModel; return this._keyBackupViewModel;
} }
get featuresViewModel() {
return this._featuresViewModel;
}
get storageQuota() { get storageQuota() {
return this._formatBytes(this._estimate?.quota); return this._formatBytes(this._estimate?.quota);
} }

View File

@ -33,9 +33,11 @@ export class ToastCollectionViewModel extends ViewModel<SegmentType, Options> {
constructor(options: Options) { constructor(options: Options) {
super(options); super(options);
const session = this.getOption("session"); const session = this.getOption("session");
if (this.features.calls) {
const callsObservableMap = session.callHandler.calls; const callsObservableMap = session.callHandler.calls;
this.track(callsObservableMap.subscribe(this)); this.track(callsObservableMap.subscribe(this));
} }
}
async onAdd(_, call: GroupCall) { async onAdd(_, call: GroupCall) {
if (this._shouldShowNotification(call)) { if (this._shouldShowNotification(call)) {

50
src/features.ts Normal file
View File

@ -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<FeatureSet> {
const flags = await settingsStorage.getInt("enabled_features") || 0;
return new FeatureSet(flags);
}
async store(settingsStorage: SettingsStorage): Promise<void> {
await settingsStorage.setInt("enabled_features", this.flags);
}
}

View File

@ -31,6 +31,7 @@ import {TokenLoginMethod} from "./login/TokenLoginMethod";
import {SSOLoginHelper} from "./login/SSOLoginHelper"; import {SSOLoginHelper} from "./login/SSOLoginHelper";
import {getDehydratedDevice} from "./e2ee/Dehydration.js"; import {getDehydratedDevice} from "./e2ee/Dehydration.js";
import {Registration} from "./registration/Registration"; import {Registration} from "./registration/Registration";
import {FeatureSet} from "../features";
export const LoadStatus = createEnum( export const LoadStatus = createEnum(
"NotLoading", "NotLoading",
@ -53,7 +54,7 @@ export const LoginFailure = createEnum(
); );
export class Client { export class Client {
constructor(platform) { constructor(platform, features = new FeatureSet(0)) {
this._platform = platform; this._platform = platform;
this._sessionStartedByReconnector = false; this._sessionStartedByReconnector = false;
this._status = new ObservableValue(LoadStatus.NotLoading); this._status = new ObservableValue(LoadStatus.NotLoading);
@ -68,6 +69,7 @@ export class Client {
this._olmPromise = platform.loadOlm(); this._olmPromise = platform.loadOlm();
this._workerPromise = platform.loadOlmWorker(); this._workerPromise = platform.loadOlmWorker();
this._accountSetup = undefined; this._accountSetup = undefined;
this._features = features;
} }
createNewSessionId() { createNewSessionId() {
@ -278,6 +280,7 @@ export class Client {
olmWorker, olmWorker,
mediaRepository, mediaRepository,
platform: this._platform, platform: this._platform,
features: this._features
}); });
await this._session.load(log); await this._session.load(log);
if (dehydratedDevice) { if (dehydratedDevice) {

View File

@ -74,6 +74,7 @@ export class DeviceMessageHandler {
} }
async afterSyncCompleted(decryptionResults, deviceTracker, hsApi, log) { async afterSyncCompleted(decryptionResults, deviceTracker, hsApi, log) {
if (this._callHandler) {
// if we don't have a device, we need to fetch the device keys the message claims // 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 // and check the keys, and we should only do network requests during
// sync processing in the afterSyncCompleted step. // sync processing in the afterSyncCompleted step.
@ -99,6 +100,7 @@ export class DeviceMessageHandler {
}); });
} }
} }
}
} }
class SyncPreparation { class SyncPreparation {

View File

@ -54,7 +54,7 @@ const PUSHER_KEY = "pusher";
export class Session { export class Session {
// sessionInfo contains deviceId, userId and homeserver // 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._platform = platform;
this._storage = storage; this._storage = storage;
this._hsApi = hsApi; this._hsApi = hsApi;
@ -75,36 +75,7 @@ export class Session {
}; };
this._roomsBeingCreated = new ObservableMap(); this._roomsBeingCreated = new ObservableMap();
this._user = new User(sessionInfo.userId); 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._roomStateHandler = new RoomStateHandlerSet();
this.observeRoomState(this._callHandler);
this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler}); this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler});
this._olm = olm; this._olm = olm;
this._olmUtil = null; this._olmUtil = null;
@ -132,6 +103,10 @@ export class Session {
this._createRoomEncryption = this._createRoomEncryption.bind(this); this._createRoomEncryption = this._createRoomEncryption.bind(this);
this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this); this._forgetArchivedRoom = this._forgetArchivedRoom.bind(this);
this.needsKeyBackup = new ObservableValue(false); this.needsKeyBackup = new ObservableValue(false);
if (features.calls) {
this._setupCallHandler();
}
} }
get fingerprintKey() { get fingerprintKey() {
@ -154,6 +129,38 @@ export class Session {
return this._callHandler; 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 // called once this._e2eeAccount is assigned
_setupEncryption() { _setupEncryption() {
// TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account // 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() { export function tests() {
function createStorageMock(session, pendingEvents = []) { function createStorageMock(session, pendingEvents = []) {
return { return {
@ -1051,7 +1059,8 @@ export function tests() {
clock: { clock: {
createTimeout: () => undefined createTimeout: () => undefined
} }
} },
features: new FeatureSet(0)
}); });
await session.load(); await session.load();
let syncSet = false; let syncSet = false;

View File

@ -283,6 +283,10 @@ export class Platform {
} }
} }
restart() {
document.location.reload();
}
openFile(mimeType = null) { openFile(mimeType = null) {
const input = document.createElement("input"); const input = document.createElement("input");
input.setAttribute("type", "file"); input.setAttribute("type", "file");

View File

@ -18,6 +18,8 @@ limitations under the License.
// import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay"; // import {RecordRequester, ReplayRequester} from "./matrix/net/request/replay";
import {RootViewModel} from "../../domain/RootViewModel.js"; import {RootViewModel} from "../../domain/RootViewModel.js";
import {createNavigation, createRouter} from "../../domain/navigation/index"; 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, // Don't use a default export here, as we use multiple entries during legacy build,
// which does not support default exports, // which does not support default exports,
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry // see https://github.com/rollup/plugins/tree/master/packages/multi-entry
@ -33,6 +35,7 @@ export async function main(platform) {
// const request = recorder.request; // const request = recorder.request;
// window.getBrawlFetchLog = () => recorder.log(); // window.getBrawlFetchLog = () => recorder.log();
await platform.init(); await platform.init();
const features = await FeatureSet.load(platform.settingsStorage);
const navigation = createNavigation(); const navigation = createNavigation();
platform.setNavigation(navigation); platform.setNavigation(navigation);
const urlRouter = createRouter({navigation, history: platform.history}); 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 // so we call it that in the view models
urlRouter: urlRouter, urlRouter: urlRouter,
navigation, navigation,
features
}); });
await vm.load(); await vm.load();
platform.createAndMountRootView(vm); platform.createAndMountRootView(vm);

View File

@ -751,6 +751,24 @@ a {
margin-bottom: 0; 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 { .error {
color: var(--error-color); color: var(--error-color);
font-weight: 600; font-weight: 600;

View File

@ -73,8 +73,10 @@ export class RoomView extends TemplateView {
} else { } else {
const vm = this.value; const vm = this.value;
const options = []; const options = [];
options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel())) options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel()));
options.push(Menu.option(vm.i18n`Start call`, () => vm.startCall())) if (vm.features.calls) {
options.push(Menu.option(vm.i18n`Start call`, () => vm.startCall()));
}
if (vm.canLeave) { if (vm.canLeave) {
options.push(Menu.option(vm.i18n`Leave room`, () => this._confirmToLeaveRoom()).setDestructive()); options.push(Menu.option(vm.i18n`Leave room`, () => this._confirmToLeaveRoom()).setDestructive());
} }

View File

@ -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<FeaturesViewModel> {
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<FeatureViewModel> {
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)
])
]);
}
}

View File

@ -17,6 +17,7 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView"; import {TemplateView} from "../../general/TemplateView";
import {disableTargetCallback} from "../../general/utils"; import {disableTargetCallback} from "../../general/utils";
import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js" import {KeyBackupSettingsView} from "./KeyBackupSettingsView.js"
import {FeaturesView} from "./FeaturesView"
export class SettingsView extends TemplateView { export class SettingsView extends TemplateView {
render(t, vm) { 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: disableTargetCallback(() => vm.sendLogsToServer())}, `Submit logs to ${vm.logsServer}`));
} }
logButtons.push(t.button({onClick: () => vm.exportLogs()}, "Download logs")); logButtons.push(t.button({onClick: () => vm.exportLogs()}, "Download logs"));
settingNodes.push(
t.h3("Experimental features"),
t.view(new FeaturesView(vm.featuresViewModel))
);
settingNodes.push( settingNodes.push(
t.h3("Application"), t.h3("Application"),
row(t, vm.i18n`Version`, version), row(t, vm.i18n`Version`, version),