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) {
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({

View File

@ -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<T extends object = SegmentType> = {
platform: Platform;
@ -37,6 +38,7 @@ export type Options<T extends object = SegmentType> = {
urlRouter: IURLRouter<T>;
navigation: Navigation<T>;
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;
}
get features(): FeatureSet {
return this._options.features;
}
get navigation(): Navigation<N> {
// typescript needs a little help here
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;
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();
}

View File

@ -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() {

View File

@ -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 {

View File

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

View File

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

View File

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

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

View File

@ -33,8 +33,10 @@ export class ToastCollectionViewModel extends ViewModel<SegmentType, Options> {
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) {

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 {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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 {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),