Merge pull request #1057 from vector-im/device-verification-ui

Implement UI for device verification
This commit is contained in:
Bruno Windels 2023-03-30 11:39:04 +02:00 committed by GitHub
commit 02c9d6d9d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1173 additions and 50 deletions

View File

@ -34,6 +34,8 @@ export type SegmentType = {
"details": true; "details": true;
"members": true; "members": true;
"member": string; "member": string;
"device-verification": string | boolean;
"join-room": true;
}; };
export function createNavigation(): Navigation<SegmentType> { export function createNavigation(): Navigation<SegmentType> {
@ -51,7 +53,7 @@ function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<Se
// allowed root segments // allowed root segments
return type === "login" || type === "session" || type === "sso" || type === "logout"; return type === "login" || type === "session" || type === "sso" || type === "logout";
case "session": case "session":
return type === "room" || type === "rooms" || type === "settings" || type === "create-room" || type === "join-room"; return type === "room" || type === "rooms" || type === "settings" || type === "create-room" || type === "join-room" || type === "device-verification";
case "rooms": case "rooms":
// downside of the approach: both of these will control which tile is selected // downside of the approach: both of these will control which tile is selected
return type === "room" || type === "empty-grid-tile"; return type === "room" || type === "empty-grid-tile";

View File

@ -26,6 +26,7 @@ import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {CreateRoomViewModel} from "./CreateRoomViewModel.js"; import {CreateRoomViewModel} from "./CreateRoomViewModel.js";
import {JoinRoomViewModel} from "./JoinRoomViewModel"; import {JoinRoomViewModel} from "./JoinRoomViewModel";
import {DeviceVerificationViewModel} from "./verification/DeviceVerificationViewModel";
import {ViewModel} from "../ViewModel"; import {ViewModel} from "../ViewModel";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
@ -48,6 +49,7 @@ export class SessionViewModel extends ViewModel {
this._gridViewModel = null; this._gridViewModel = null;
this._createRoomViewModel = null; this._createRoomViewModel = null;
this._joinRoomViewModel = null; this._joinRoomViewModel = null;
this._verificationViewModel = null;
this._toastCollectionViewModel = this.track(new ToastCollectionViewModel(this.childOptions({ this._toastCollectionViewModel = this.track(new ToastCollectionViewModel(this.childOptions({
session: this._client.session, session: this._client.session,
}))); })));
@ -95,6 +97,14 @@ export class SessionViewModel extends ViewModel {
})); }));
this._updateJoinRoom(joinRoom.get()); this._updateJoinRoom(joinRoom.get());
if (this.features.crossSigning) {
const verification = this.navigation.observe("device-verification");
this.track(verification.subscribe((txnId) => {
this._updateVerification(txnId);
}));
this._updateVerification(verification.get());
}
const lightbox = this.navigation.observe("lightbox"); const lightbox = this.navigation.observe("lightbox");
this.track(lightbox.subscribe(eventId => { this.track(lightbox.subscribe(eventId => {
this._updateLightbox(eventId); this._updateLightbox(eventId);
@ -143,7 +153,8 @@ export class SessionViewModel extends ViewModel {
this._gridViewModel || this._gridViewModel ||
this._settingsViewModel || this._settingsViewModel ||
this._createRoomViewModel || this._createRoomViewModel ||
this._joinRoomViewModel this._joinRoomViewModel ||
this._verificationViewModel
); );
} }
@ -179,6 +190,10 @@ export class SessionViewModel extends ViewModel {
return this._joinRoomViewModel; return this._joinRoomViewModel;
} }
get verificationViewModel() {
return this._verificationViewModel;
}
get toastCollectionViewModel() { get toastCollectionViewModel() {
return this._toastCollectionViewModel; return this._toastCollectionViewModel;
} }
@ -327,6 +342,17 @@ export class SessionViewModel extends ViewModel {
this.emitChange("activeMiddleViewModel"); this.emitChange("activeMiddleViewModel");
} }
_updateVerification(txnId) {
if (this._verificationViewModel) {
this._verificationViewModel = this.disposeTracked(this._verificationViewModel);
}
if (txnId) {
const request = this._client.session.crossSigning.get()?.receivedSASVerifications.get(txnId);
this._verificationViewModel = this.track(new DeviceVerificationViewModel(this.childOptions({ session: this._client.session, request })));
}
this.emitChange("activeMiddleViewModel");
}
_updateLightbox(eventId) { _updateLightbox(eventId) {
if (this._lightboxViewModel) { if (this._lightboxViewModel) {
this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel);

View File

@ -157,6 +157,10 @@ export class KeyBackupViewModel extends ViewModel<SegmentType, Options> {
} }
} }
navigateToVerification(): void {
this.navigation.push("device-verification", true);
}
get backupWriteStatus(): BackupWriteStatus { get backupWriteStatus(): BackupWriteStatus {
const keyBackup = this._keyBackup; const keyBackup = this._keyBackup;
if (!keyBackup || keyBackup.version === undefined) { if (!keyBackup || keyBackup.version === undefined) {

View File

@ -17,9 +17,11 @@ limitations under the License.
import {ConcatList} from "../../../observable"; import {ConcatList} from "../../../observable";
import {ViewModel, Options as BaseOptions} from "../../ViewModel"; import {ViewModel, Options as BaseOptions} from "../../ViewModel";
import {CallToastCollectionViewModel} from "./calls/CallsToastCollectionViewModel"; import {CallToastCollectionViewModel} from "./calls/CallsToastCollectionViewModel";
import {VerificationToastCollectionViewModel} from "./verification/VerificationToastCollectionViewModel";
import type {Session} from "../../../matrix/Session.js"; import type {Session} from "../../../matrix/Session.js";
import type {SegmentType} from "../../navigation"; import type {SegmentType} from "../../navigation";
import type {BaseToastNotificationViewModel} from "./BaseToastNotificationViewModel"; import type {BaseToastNotificationViewModel} from "./BaseToastNotificationViewModel";
import type {IToastCollection} from "./IToastCollection";
type Options = { type Options = {
session: Session; session: Session;
@ -31,9 +33,16 @@ 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");
const vms = [ const collectionVms: IToastCollection[] = [];
this.track(new CallToastCollectionViewModel(this.childOptions({ session }))), if (this.features.calls) {
].map(vm => vm.toastViewModels); collectionVms.push(this.track(new CallToastCollectionViewModel(this.childOptions({ session }))));
this.toastViewModels = new ConcatList(...vms); }
if (this.features.crossSigning) {
collectionVms.push(this.track(new VerificationToastCollectionViewModel(this.childOptions({ session }))));
}
const vms: IToastCollection["toastViewModels"][] = collectionVms.map(vm => vm.toastViewModels);
if (vms.length !== 0) {
this.toastViewModels = new ConcatList(...vms);
}
} }
} }

View File

@ -0,0 +1,76 @@
/*
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 {VerificationToastNotificationViewModel} from "./VerificationToastNotificationViewModel";
import {ObservableArray} from "../../../../observable";
import {ViewModel, Options as BaseOptions} from "../../../ViewModel";
import type {Session} from "../../../../matrix/Session.js";
import type {SegmentType} from "../../../navigation";
import type {IToastCollection} from "../IToastCollection";
import type {SASRequest} from "../../../../matrix/verification/SAS/SASRequest";
type Options = {
session: Session;
} & BaseOptions;
export class VerificationToastCollectionViewModel extends ViewModel<SegmentType, Options> implements IToastCollection {
public readonly toastViewModels: ObservableArray<VerificationToastNotificationViewModel> = new ObservableArray();
constructor(options: Options) {
super(options);
this.subscribeToSASRequests();
}
private async subscribeToSASRequests() {
await this.getOption("session").crossSigning.waitFor(v => !!v).promise;
const crossSigning = this.getOption("session").crossSigning.get();
this.track(crossSigning.receivedSASVerifications.subscribe(this));
}
async onAdd(_, request: SASRequest) {
const dismiss = () => {
const idx = this.toastViewModels.array.findIndex(vm => vm.request.id === request.id);
if (idx !== -1) {
this.toastViewModels.remove(idx);
}
};
this.toastViewModels.append(
this.track(new VerificationToastNotificationViewModel(this.childOptions({ request, dismiss })))
);
}
onRemove(_, request: SASRequest) {
const idx = this.toastViewModels.array.findIndex(vm => vm.request.id === request.id);
if (idx !== -1) {
this.toastViewModels.remove(idx);
}
}
onUpdate(_, request: SASRequest) {
const idx = this.toastViewModels.array.findIndex(vm => vm.request.id === request.id);
if (idx !== -1) {
this.toastViewModels.update(idx, this.toastViewModels.at(idx)!);
}
}
onReset() {
for (let i = 0; i < this.toastViewModels.length; ++i) {
this.toastViewModels.remove(i);
}
}
}

View File

@ -0,0 +1,53 @@
/*
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 {BaseClassOptions, BaseToastNotificationViewModel} from ".././BaseToastNotificationViewModel";
import {SegmentType} from "../../../navigation";
import {SASRequest} from "../../../../matrix/verification/SAS/SASRequest";
type Options<N extends MinimumNeededSegmentType = SegmentType> = {
request: SASRequest;
} & BaseClassOptions<N>;
type MinimumNeededSegmentType = {
"device-verification": string | boolean;
};
export class VerificationToastNotificationViewModel<N extends MinimumNeededSegmentType = SegmentType, O extends Options<N> = Options<N>> extends BaseToastNotificationViewModel<N, O> {
constructor(options: O) {
super(options);
}
get kind(): "verification" {
return "verification";
}
get request(): SASRequest {
return this.getOption("request");
}
get otherDeviceId(): string {
return this.request.deviceId;
}
accept() {
// @ts-ignore
this.navigation.push("device-verification", this.request.id);
this.dismiss();
}
}

View File

@ -0,0 +1,96 @@
/*
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 {Options as BaseOptions} from "../../ViewModel";
import {SegmentType} from "../../navigation/index";
import {ErrorReportViewModel} from "../../ErrorReportViewModel";
import {WaitingForOtherUserViewModel} from "./stages/WaitingForOtherUserViewModel";
import {VerificationCancelledViewModel} from "./stages/VerificationCancelledViewModel";
import {SelectMethodViewModel} from "./stages/SelectMethodViewModel";
import {VerifyEmojisViewModel} from "./stages/VerifyEmojisViewModel";
import {VerificationCompleteViewModel} from "./stages/VerificationCompleteViewModel";
import type {Session} from "../../../matrix/Session.js";
import type {SASVerification} from "../../../matrix/verification/SAS/SASVerification";
import type {SASRequest} from "../../../matrix/verification/SAS/SASRequest";
type Options = BaseOptions & {
session: Session;
request: SASRequest;
};
export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentType, Options> {
private sas: SASVerification;
private _currentStageViewModel: any;
constructor(options: Readonly<Options>) {
super(options);
const sasRequest = options.request;
if (options.request) {
this.start(sasRequest);
}
else {
// We are about to send the request
this.start(this.getOption("session").userId);
}
}
private async start(requestOrUserId: SASRequest | string) {
await this.logAndCatch("DeviceVerificationViewModel.start", (log) => {
const crossSigning = this.getOption("session").crossSigning.get();
this.sas = crossSigning.startVerification(requestOrUserId, log);
this.addEventListeners();
if (typeof requestOrUserId === "string") {
this.updateCurrentStageViewModel(new WaitingForOtherUserViewModel(this.childOptions({ sas: this.sas })));
}
return this.sas.start();
});
}
private addEventListeners() {
this.track(this.sas.disposableOn("SelectVerificationStage", (stage) => {
this.updateCurrentStageViewModel(
new SelectMethodViewModel(this.childOptions({ sas: this.sas, stage: stage!, }))
);
}));
this.track(this.sas.disposableOn("EmojiGenerated", (stage) => {
this.updateCurrentStageViewModel(
new VerifyEmojisViewModel(this.childOptions({ stage: stage!, }))
);
}));
this.track(this.sas.disposableOn("VerificationCancelled", (cancellation) => {
this.updateCurrentStageViewModel(
new VerificationCancelledViewModel(
this.childOptions({ cancellation: cancellation! })
)
);
}));
this.track(this.sas.disposableOn("VerificationCompleted", (deviceId) => {
this.updateCurrentStageViewModel(
new VerificationCompleteViewModel(this.childOptions({ deviceId: deviceId! }))
);
}));
}
private updateCurrentStageViewModel(vm) {
this._currentStageViewModel = this.disposeTracked(this._currentStageViewModel);
this._currentStageViewModel = this.track(vm);
this.emitChange("currentStageViewModel");
}
get currentStageViewModel() {
return this._currentStageViewModel;
}
}

View File

@ -0,0 +1,54 @@
/*
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 {SegmentType} from "../../../navigation/index";
import {ErrorReportViewModel} from "../../../ErrorReportViewModel";
import type {Options as BaseOptions} from "../../../ViewModel";
import type {Session} from "../../../../matrix/Session.js";
import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification";
import type {SelectVerificationMethodStage} from "../../../../matrix/verification/SAS/stages/SelectVerificationMethodStage";
type Options = BaseOptions & {
sas: SASVerification;
stage: SelectVerificationMethodStage;
session: Session;
};
export class SelectMethodViewModel extends ErrorReportViewModel<SegmentType, Options> {
public hasProceeded: boolean = false;
async proceed() {
await this.logAndCatch("SelectMethodViewModel.proceed", async (log) => {
await this.options.stage.selectEmojiMethod(log);
this.hasProceeded = true;
this.emitChange("hasProceeded");
});
}
async cancel() {
await this.logAndCatch("SelectMethodViewModel.cancel", async () => {
await this.options.sas.abort();
});
}
get deviceName() {
return this.options.stage.otherDeviceName;
}
get kind(): string {
return "select-method";
}
}

View File

@ -0,0 +1,42 @@
/*
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 {ViewModel, Options as BaseOptions} from "../../../ViewModel";
import {SegmentType} from "../../../navigation/index";
import type {CancelReason} from "../../../../matrix/verification/SAS/channel/types";
import type {IChannel} from "../../../../matrix/verification/SAS/channel/Channel";
type Options = BaseOptions & {
cancellation: IChannel["cancellation"];
};
export class VerificationCancelledViewModel extends ViewModel<SegmentType, Options> {
get cancelCode(): CancelReason {
return this.options.cancellation!.code;
}
get isCancelledByUs(): boolean {
return this.options.cancellation!.cancelledByUs;
}
gotoSettings() {
this.navigation.push("settings", true);
}
get kind(): string {
return "verification-cancelled";
}
}

View File

@ -0,0 +1,39 @@
/*
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 {SegmentType} from "../../../navigation/index";
import {ErrorReportViewModel} from "../../../ErrorReportViewModel";
import type {Options as BaseOptions} from "../../../ViewModel";
import type {Session} from "../../../../matrix/Session.js";
type Options = BaseOptions & {
deviceId: string;
session: Session;
};
export class VerificationCompleteViewModel extends ErrorReportViewModel<SegmentType, Options> {
get otherDeviceId(): string {
return this.options.deviceId;
}
gotoSettings() {
this.navigation.push("settings", true);
}
get kind(): string {
return "verification-completed";
}
}

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 {SegmentType} from "../../../navigation/index";
import {ErrorReportViewModel} from "../../../ErrorReportViewModel";
import type {Options as BaseOptions} from "../../../ViewModel";
import type {Session} from "../../../../matrix/Session.js";
import type {CalculateSASStage} from "../../../../matrix/verification/SAS/stages/CalculateSASStage";
type Options = BaseOptions & {
stage: CalculateSASStage;
session: Session;
};
export class VerifyEmojisViewModel extends ErrorReportViewModel<SegmentType, Options> {
private _isWaiting: boolean = false;
async setEmojiMatch(match: boolean) {
await this.logAndCatch("VerifyEmojisViewModel.setEmojiMatch", async () => {
await this.options.stage.setEmojiMatch(match);
this._isWaiting = true;
this.emitChange("isWaiting");
});
}
get emojis() {
return this.options.stage.emoji;
}
get kind(): string {
return "verify-emojis";
}
get isWaiting(): boolean {
return this._isWaiting;
}
}

View File

@ -0,0 +1,33 @@
/*
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 {ViewModel, Options as BaseOptions} from "../../../ViewModel";
import {SegmentType} from "../../../navigation/index";
import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification";
type Options = BaseOptions & {
sas: SASVerification;
};
export class WaitingForOtherUserViewModel extends ViewModel<SegmentType, Options> {
async cancel() {
await this.options.sas.abort();
}
get kind(): string {
return "waiting-for-user";
}
}

View File

@ -365,9 +365,11 @@ export class Session {
olm: this._olm, olm: this._olm,
olmUtil: this._olmUtil, olmUtil: this._olmUtil,
deviceTracker: this._deviceTracker, deviceTracker: this._deviceTracker,
deviceMessageHandler: this._deviceMessageHandler,
hsApi: this._hsApi, hsApi: this._hsApi,
ownUserId: this.userId, ownUserId: this.userId,
e2eeAccount: this._e2eeAccount e2eeAccount: this._e2eeAccount,
deviceId: this.deviceId,
}); });
if (await crossSigning.load(log)) { if (await crossSigning.load(log)) {
this._crossSigning.set(crossSigning); this._crossSigning.set(crossSigning);

View File

@ -19,6 +19,8 @@ import {pkSign} from "./common";
import {SASVerification} from "./SAS/SASVerification"; import {SASVerification} from "./SAS/SASVerification";
import {ToDeviceChannel} from "./SAS/channel/Channel"; import {ToDeviceChannel} from "./SAS/channel/Channel";
import {VerificationEventType} from "./SAS/channel/types"; import {VerificationEventType} from "./SAS/channel/types";
import {ObservableMap} from "../../observable/map";
import {SASRequest} from "./SAS/SASRequest";
import type {SecretStorage} from "../ssss/SecretStorage"; import type {SecretStorage} from "../ssss/SecretStorage";
import type {Storage} from "../storage/idb/Storage"; import type {Storage} from "../storage/idb/Storage";
import type {Platform} from "../../platform/web/Platform"; import type {Platform} from "../../platform/web/Platform";
@ -29,6 +31,7 @@ import type {ILogItem} from "../../logging/types";
import type {DeviceMessageHandler} from "../DeviceMessageHandler.js"; import type {DeviceMessageHandler} from "../DeviceMessageHandler.js";
import type {SignedValue, DeviceKey} from "../e2ee/common"; import type {SignedValue, DeviceKey} from "../e2ee/common";
import type * as OlmNamespace from "@matrix-org/olm"; import type * as OlmNamespace from "@matrix-org/olm";
type Olm = typeof OlmNamespace; type Olm = typeof OlmNamespace;
// we store cross-signing (and device) keys in the format we get them from the server // we store cross-signing (and device) keys in the format we get them from the server
@ -88,6 +91,7 @@ export class CrossSigning {
private _isMasterKeyTrusted: boolean = false; private _isMasterKeyTrusted: boolean = false;
private readonly deviceId: string; private readonly deviceId: string;
private sasVerificationInProgress?: SASVerification; private sasVerificationInProgress?: SASVerification;
public receivedSASVerifications: ObservableMap<string, SASRequest> = new ObservableMap();
constructor(options: { constructor(options: {
storage: Storage, storage: Storage,
@ -115,21 +119,7 @@ export class CrossSigning {
this.deviceMessageHandler = options.deviceMessageHandler; this.deviceMessageHandler = options.deviceMessageHandler;
this.deviceMessageHandler.on("message", async ({ unencrypted: unencryptedEvent }) => { this.deviceMessageHandler.on("message", async ({ unencrypted: unencryptedEvent }) => {
if (this.sasVerificationInProgress && this._handleSASDeviceMessage(unencryptedEvent);
(
!this.sasVerificationInProgress.finished ||
// If the start message is for the previous sasverification, ignore it.
this.sasVerificationInProgress.channel.id === unencryptedEvent.content.transaction_id
)) {
return;
}
if (unencryptedEvent.type === VerificationEventType.Request ||
unencryptedEvent.type === VerificationEventType.Start) {
await this.platform.logger.run("Start verification from request", async (log) => {
const sas = this.startVerification(unencryptedEvent.sender, unencryptedEvent, log);
await sas?.start();
});
}
}) })
} }
@ -182,14 +172,18 @@ export class CrossSigning {
return this._isMasterKeyTrusted; return this._isMasterKeyTrusted;
} }
startVerification(userId: string, startingMessage: any, log: ILogItem): SASVerification | undefined { startVerification(requestOrUserId: SASRequest, log: ILogItem): SASVerification | undefined;
startVerification(requestOrUserId: string, log: ILogItem): SASVerification | undefined;
startVerification(requestOrUserId: string | SASRequest, log: ILogItem): SASVerification | undefined {
if (this.sasVerificationInProgress && !this.sasVerificationInProgress.finished) { if (this.sasVerificationInProgress && !this.sasVerificationInProgress.finished) {
return; return;
} }
const otherUserId = requestOrUserId instanceof SASRequest ? requestOrUserId.sender : requestOrUserId;
const startingMessage = requestOrUserId instanceof SASRequest ? requestOrUserId.startingMessage : undefined;
const channel = new ToDeviceChannel({ const channel = new ToDeviceChannel({
deviceTracker: this.deviceTracker, deviceTracker: this.deviceTracker,
hsApi: this.hsApi, hsApi: this.hsApi,
otherUserId: userId, otherUserId,
clock: this.platform.clock, clock: this.platform.clock,
deviceMessageHandler: this.deviceMessageHandler, deviceMessageHandler: this.deviceMessageHandler,
ourUserDeviceId: this.deviceId, ourUserDeviceId: this.deviceId,
@ -201,7 +195,7 @@ export class CrossSigning {
olmUtil: this.olmUtil, olmUtil: this.olmUtil,
ourUserId: this.ownUserId, ourUserId: this.ownUserId,
ourUserDeviceId: this.deviceId, ourUserDeviceId: this.deviceId,
otherUserId: userId, otherUserId,
log, log,
channel, channel,
e2eeAccount: this.e2eeAccount, e2eeAccount: this.e2eeAccount,
@ -212,6 +206,35 @@ export class CrossSigning {
return this.sasVerificationInProgress; return this.sasVerificationInProgress;
} }
private _handleSASDeviceMessage(event: any) {
const txnId = event.content.transaction_id;
/**
* If we receive an event for the current/previously finished
* SAS verification, we should ignore it because the device channel
* object (who also listens for to_device messages) will take care of it (if needed).
*/
const shouldIgnoreEvent = this.sasVerificationInProgress?.channel.id === txnId;
if (shouldIgnoreEvent) { return; }
/**
* 1. If we receive the cancel message, we need to update the requests map.
* 2. If we receive an starting message (viz request/start), we need to create the SASRequest from it.
*/
switch (event.type) {
case VerificationEventType.Cancel:
this.receivedSASVerifications.remove(txnId);
return;
case VerificationEventType.Request:
case VerificationEventType.Start:
this.platform.logger.run("Create SASRequest", () => {
this.receivedSASVerifications.set(txnId, new SASRequest(event));
});
return;
default:
// we don't care about this event!
return;
}
}
/** returns our own device key signed by our self-signing key. Other signatures will be missing. */ /** returns our own device key signed by our self-signing key. Other signatures will be missing. */
async signOwnDevice(log: ILogItem): Promise<DeviceKey | undefined> { async signOwnDevice(log: ILogItem): Promise<DeviceKey | undefined> {
return log.wrap("CrossSigning.signOwnDevice", async log => { return log.wrap("CrossSigning.signOwnDevice", async log => {

View File

@ -0,0 +1,31 @@
/*
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.
*/
export class SASRequest {
constructor(public readonly startingMessage: any) {}
get deviceId(): string {
return this.startingMessage.content.from_device;
}
get sender(): string {
return this.startingMessage.sender;
}
get id(): string {
return this.startingMessage.content.transaction_id;
}
}

View File

@ -19,14 +19,14 @@ import type {BaseSASVerificationStage} from "./stages/BaseSASVerificationStage";
import type {Account} from "../../e2ee/Account.js"; import type {Account} from "../../e2ee/Account.js";
import type {DeviceTracker} from "../../e2ee/DeviceTracker.js"; import type {DeviceTracker} from "../../e2ee/DeviceTracker.js";
import type * as OlmNamespace from "@matrix-org/olm"; import type * as OlmNamespace from "@matrix-org/olm";
import {IChannel} from "./channel/Channel"; import type {IChannel} from "./channel/Channel";
import {HomeServerApi} from "../../net/HomeServerApi"; import type {HomeServerApi} from "../../net/HomeServerApi";
import type {Timeout} from "../../../platform/types/types";
import type {Clock} from "../../../platform/web/dom/Clock.js";
import {CancelReason, VerificationEventType} from "./channel/types"; import {CancelReason, VerificationEventType} from "./channel/types";
import {SendReadyStage} from "./stages/SendReadyStage"; import {SendReadyStage} from "./stages/SendReadyStage";
import {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage"; import {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage";
import {VerificationCancelledError} from "./VerificationCancelledError"; import {VerificationCancelledError} from "./VerificationCancelledError";
import {Timeout} from "../../../platform/types/types";
import {Clock} from "../../../platform/web/dom/Clock.js";
import {EventEmitter} from "../../../utils/EventEmitter"; import {EventEmitter} from "../../../utils/EventEmitter";
import {SASProgressEvents} from "./types"; import {SASProgressEvents} from "./types";
@ -84,6 +84,10 @@ export class SASVerification extends EventEmitter<SASProgressEvents> {
} }
} }
async abort() {
await this.channel.cancelVerification(CancelReason.UserCancelled);
}
async start() { async start() {
try { try {
let stage = this.startStage; let stage = this.startStage;
@ -98,6 +102,9 @@ export class SASVerification extends EventEmitter<SASProgressEvents> {
} }
} }
finally { finally {
if (this.channel.isCancelled) {
this.emit("VerificationCancelled", this.channel.cancellation);
}
this.olmSas.free(); this.olmSas.free();
this.timeout.abort(); this.timeout.abort();
this.finished = true; this.finished = true;
@ -163,6 +170,9 @@ export function tests() {
device_id: deviceId, device_id: deviceId,
keys: { keys: {
[`ed25519:${deviceId}`]: "D8w9mrokGdEZPdPgrU0kQkYi4vZyzKEBfvGyZsGK7+Q", [`ed25519:${deviceId}`]: "D8w9mrokGdEZPdPgrU0kQkYi4vZyzKEBfvGyZsGK7+Q",
},
unsigned: {
device_display_name: "lala10",
} }
}; };
}, },

View File

@ -49,6 +49,8 @@ export interface IChannel {
acceptMessage: any; acceptMessage: any;
startMessage: any; startMessage: any;
initiatedByUs: boolean; initiatedByUs: boolean;
isCancelled: boolean;
cancellation?: { code: CancelReason, cancelledByUs: boolean };
id: string; id: string;
otherUserDeviceId: string; otherUserDeviceId: string;
} }
@ -78,7 +80,7 @@ export class ToDeviceChannel extends Disposables implements IChannel {
public startMessage: any; public startMessage: any;
public id: string; public id: string;
private _initiatedByUs: boolean; private _initiatedByUs: boolean;
private _isCancelled = false; private _cancellation?: { code: CancelReason, cancelledByUs: boolean };
/** /**
* *
@ -116,8 +118,12 @@ export class ToDeviceChannel extends Disposables implements IChannel {
} }
} }
get cancellation(): IChannel["cancellation"] {
return this._cancellation;
};
get isCancelled(): boolean { get isCancelled(): boolean {
return this._isCancelled; return !!this._cancellation;
} }
async send(eventType: VerificationEventType, content: any, log: ILogItem): Promise<void> { async send(eventType: VerificationEventType, content: any, log: ILogItem): Promise<void> {
@ -199,7 +205,7 @@ export class ToDeviceChannel extends Disposables implements IChannel {
return; return;
} }
if (event.type === VerificationEventType.Cancel) { if (event.type === VerificationEventType.Cancel) {
this._isCancelled = true; this._cancellation = { code: event.content.code, cancelledByUs: false };
this.dispose(); this.dispose();
return; return;
} }
@ -234,7 +240,7 @@ export class ToDeviceChannel extends Disposables implements IChannel {
const payload = { const payload = {
messages: { messages: {
[this.otherUserId]: { [this.otherUserId]: {
[this.otherUserDeviceId]: { [this.otherUserDeviceId ?? "*"]: {
code: cancellationType, code: cancellationType,
reason: messageFromErrorType[cancellationType], reason: messageFromErrorType[cancellationType],
transaction_id: this.id, transaction_id: this.id,
@ -243,7 +249,7 @@ export class ToDeviceChannel extends Disposables implements IChannel {
} }
} }
await this.hsApi.sendToDevice(VerificationEventType.Cancel, payload, makeTxnId(), { log }).response(); await this.hsApi.sendToDevice(VerificationEventType.Cancel, payload, makeTxnId(), { log }).response();
this._isCancelled = true; this._cancellation = { code: cancellationType, cancelledByUs: true };
this.dispose(); this.dispose();
}); });
} }
@ -258,7 +264,7 @@ export class ToDeviceChannel extends Disposables implements IChannel {
} }
waitForEvent(eventType: VerificationEventType): Promise<any> { waitForEvent(eventType: VerificationEventType): Promise<any> {
if (this._isCancelled) { if (this.isCancelled) {
throw new VerificationCancelledError(); throw new VerificationCancelledError();
} }
// Check if we already received the message // Check if we already received the message

View File

@ -17,6 +17,7 @@ export class MockChannel implements ITestChannel {
public initiatedByUs: boolean; public initiatedByUs: boolean;
public startMessage: any; public startMessage: any;
public isCancelled: boolean = false; public isCancelled: boolean = false;
public cancellation: { code: CancelReason; cancelledByUs: boolean; };
private olmSas: any; private olmSas: any;
constructor( constructor(

View File

@ -23,9 +23,11 @@ import type {ILogItem} from "../../../../logging/types";
export class SelectVerificationMethodStage extends BaseSASVerificationStage { export class SelectVerificationMethodStage extends BaseSASVerificationStage {
private hasSentStartMessage = false; private hasSentStartMessage = false;
private allowSelection = true; private allowSelection = true;
public otherDeviceName: string;
async completeStage() { async completeStage() {
await this.log.wrap("SelectVerificationMethodStage.completeStage", async (log) => { await this.log.wrap("SelectVerificationMethodStage.completeStage", async (log) => {
await this.findDeviceName(log);
this.eventEmitter.emit("SelectVerificationStage", this); this.eventEmitter.emit("SelectVerificationStage", this);
const startMessage = this.channel.waitForEvent(VerificationEventType.Start); const startMessage = this.channel.waitForEvent(VerificationEventType.Start);
const acceptMessage = this.channel.waitForEvent(VerificationEventType.Accept); const acceptMessage = this.channel.waitForEvent(VerificationEventType.Accept);
@ -81,6 +83,17 @@ export class SelectVerificationMethodStage extends BaseSASVerificationStage {
}); });
} }
private async findDeviceName(log: ILogItem) {
await log.wrap("SelectVerificationMethodStage.findDeviceName", async () => {
const device = await this.options.deviceTracker.deviceForId(this.otherUserId, this.otherUserDeviceId, this.options.hsApi, log);
if (!device) {
log.log({ l: "Cannot find device", userId: this.otherUserId, deviceId: this.otherUserDeviceId });
throw new Error("Cannot find device");
}
this.otherDeviceName = device.unsigned.device_display_name ?? device.device_id;
})
}
async selectEmojiMethod(log: ILogItem) { async selectEmojiMethod(log: ILogItem) {
if (!this.allowSelection) { return; } if (!this.allowSelection) { return; }
const content = { const content = {

View File

@ -19,6 +19,7 @@ import {VerificationEventType} from "../channel/types";
export class SendDoneStage extends BaseSASVerificationStage { export class SendDoneStage extends BaseSASVerificationStage {
async completeStage() { async completeStage() {
await this.log.wrap("SendDoneStage.completeStage", async (log) => { await this.log.wrap("SendDoneStage.completeStage", async (log) => {
this.eventEmitter.emit("VerificationCompleted", this.otherUserDeviceId);
await this.channel.send(VerificationEventType.Done, {}, log); await this.channel.send(VerificationEventType.Done, {}, log);
}); });
} }

View File

@ -44,7 +44,7 @@ export class SendMacStage extends BaseSASVerificationStage {
this.channel.id; this.channel.id;
const deviceKeyId = `ed25519:${this.ourUserDeviceId}`; const deviceKeyId = `ed25519:${this.ourUserDeviceId}`;
const deviceKeys = this.e2eeAccount.getDeviceKeysToSignWithCrossSigning(); const deviceKeys = this.e2eeAccount.getUnsignedDeviceKey();
mac[deviceKeyId] = calculateMAC(deviceKeys.keys[deviceKeyId], baseInfo + deviceKeyId); mac[deviceKeyId] = calculateMAC(deviceKeys.keys[deviceKeyId], baseInfo + deviceKeyId);
keyList.push(deviceKeyId); keyList.push(deviceKeyId);

View File

@ -13,10 +13,13 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {CalculateSASStage} from "./stages/CalculateSASStage"; import type {IChannel} from "./channel/Channel";
import {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage"; import type {CalculateSASStage} from "./stages/CalculateSASStage";
import type {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage";
export type SASProgressEvents = { export type SASProgressEvents = {
SelectVerificationStage: SelectVerificationMethodStage; SelectVerificationStage: SelectVerificationMethodStage;
EmojiGenerated: CalculateSASStage; EmojiGenerated: CalculateSASStage;
VerificationCompleted: string;
VerificationCancelled: IChannel["cancellation"];
} }

View File

@ -1264,27 +1264,52 @@ button.RoomDetailsView_row::after {
padding: 0; padding: 0;
} }
.VerificationToastNotificationView:not(:first-child),
.CallToastNotificationView:not(:first-child) { .CallToastNotificationView:not(:first-child) {
margin-top: 12px; margin-top: 12px;
} }
.VerificationToastNotificationView {
display: flex;
flex-direction: column;
}
.CallToastNotificationView { .CallToastNotificationView {
display: grid; display: grid;
grid-template-rows: 40px 1fr 1fr 48px; grid-template-rows: 40px 1fr 1fr 48px;
row-gap: 4px; row-gap: 4px;
width: 260px; }
.VerificationToastNotificationView,
.CallToastNotificationView {
background-color: var(--background-color-secondary); background-color: var(--background-color-secondary);
border-radius: 8px; border-radius: 8px;
color: var(--text-color); color: var(--text-color);
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5);
} }
.CallToastNotificationView {
width: 260px;
}
.VerificationToastNotificationView {
width: 248px;
}
.VerificationToastNotificationView__top {
padding: 8px;
display: flex;
}
.CallToastNotificationView__top { .CallToastNotificationView__top {
display: grid; display: grid;
grid-template-columns: auto 176px auto; grid-template-columns: auto 176px auto;
align-items: center; align-items: center;
justify-items: center; justify-items: center;
} }
.VerificationToastNotificationView__dismiss-btn,
.CallToastNotificationView__dismiss-btn { .CallToastNotificationView__dismiss-btn {
background: center var(--background-color-secondary--darker-5) url("./icons/dismiss.svg?primary=text-color") no-repeat; background: center var(--background-color-secondary--darker-5) url("./icons/dismiss.svg?primary=text-color") no-repeat;
border-radius: 100%; border-radius: 100%;
@ -1292,11 +1317,16 @@ button.RoomDetailsView_row::after {
width: 15px; width: 15px;
} }
.VerificationToastNotificationView__title,
.CallToastNotificationView__name { .CallToastNotificationView__name {
font-weight: 600; font-weight: 600;
width: 100%; width: 100%;
} }
.VerificationToastNotificationView__description {
padding: 8px;
}
.CallToastNotificationView__description { .CallToastNotificationView__description {
margin-left: 42px; margin-left: 42px;
} }
@ -1350,7 +1380,105 @@ button.RoomDetailsView_row::after {
margin-right: 10px; margin-right: 10px;
} }
.VerificationToastNotificationView__action {
display: flex;
justify-content: space-between;
padding: 8px;
}
.CallToastNotificationView__action .button-action { .CallToastNotificationView__action .button-action {
width: 100px; width: 100px;
height: 40px; height: 40px;
} }
.VerificationToastNotificationView__action .button-action {
width: 100px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.VerificationCompleteView,
.DeviceVerificationView,
.SelectMethodView {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.VerificationCompleteView__heading,
.VerifyEmojisView__heading,
.SelectMethodView__heading,
.WaitingForOtherUserView__heading {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
padding: 8px;
}
.VerificationCompleteView>*,
.SelectMethodView>*,
.VerifyEmojisView>*,
.WaitingForOtherUserView>* {
padding: 16px;
}
.VerificationCompleteView__title,
.VerifyEmojisView__title,
.SelectMethodView__title,
.WaitingForOtherUserView__title,
.VerificationCancelledView__description,
.VerificationCompleteView__description,
.VerifyEmojisView__description,
.SelectMethodView__description,
.WaitingForOtherUserView__description {
text-align: center;
margin: 0;
}
.VerificationCancelledView__actions,
.SelectMethodView__actions,
.VerifyEmojisView__actions,
.WaitingForOtherUserView__actions {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px;
}
.EmojiCollection {
display: flex;
justify-content: center;
gap: 16px;
}
.EmojiContainer__emoji {
font-size: 3.2rem;
}
.VerifyEmojisView__waiting,
.EmojiContainer__name,
.EmojiContainer__emoji {
display: flex;
justify-content: center;
align-items: center;
}
.EmojiContainer__name {
font-weight: bold;
}
.VerifyEmojisView__waiting {
gap: 12px;
}
.VerificationCompleteView__icon {
background: url("./icons/verified.svg?primary=accent-color") no-repeat;
background-size: contain;
width: 128px;
height: 128px;
}

View File

@ -30,6 +30,7 @@ import {CreateRoomView} from "./CreateRoomView.js";
import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js";
import {viewClassForTile} from "./room/common"; import {viewClassForTile} from "./room/common";
import {JoinRoomView} from "./JoinRoomView"; import {JoinRoomView} from "./JoinRoomView";
import {DeviceVerificationView} from "./verification/DeviceVerificationView";
import {ToastCollectionView} from "./toast/ToastCollectionView"; import {ToastCollectionView} from "./toast/ToastCollectionView";
export class SessionView extends TemplateView { export class SessionView extends TemplateView {
@ -53,6 +54,8 @@ export class SessionView extends TemplateView {
return new CreateRoomView(vm.createRoomViewModel); return new CreateRoomView(vm.createRoomViewModel);
} else if (vm.joinRoomViewModel) { } else if (vm.joinRoomViewModel) {
return new JoinRoomView(vm.joinRoomViewModel); return new JoinRoomView(vm.joinRoomViewModel);
} else if (vm.verificationViewModel) {
return new DeviceVerificationView(vm.verificationViewModel);
} else if (vm.currentRoomViewModel) { } else if (vm.currentRoomViewModel) {
if (vm.currentRoomViewModel.kind === "invite") { if (vm.currentRoomViewModel.kind === "invite") {
return new InviteView(vm.currentRoomViewModel); return new InviteView(vm.currentRoomViewModel);

View File

@ -62,11 +62,24 @@ export class KeyBackupSettingsView extends TemplateView<KeyBackupViewModel> {
return t.p("Cross-signing master key found and trusted.") return t.p("Cross-signing master key found and trusted.")
}), }),
t.if(vm => vm.canSignOwnDevice, t => { t.if(vm => vm.canSignOwnDevice, t => {
return t.button({ return t.div([
onClick: disableTargetCallback(async () => { t.button(
await vm.signOwnDevice(); {
}) onClick: disableTargetCallback(async (evt) => {
}, "Sign own device"); await vm.signOwnDevice();
}),
},
"Sign own device"
),
t.button(
{
onClick: disableTargetCallback(async () => {
vm.navigateToVerification();
}),
},
"Verify by emoji"
),
]);
}), }),
]); ]);

View File

@ -15,17 +15,21 @@ limitations under the License.
*/ */
import {CallToastNotificationView} from "./CallToastNotificationView"; import {CallToastNotificationView} from "./CallToastNotificationView";
import {VerificationToastNotificationView} from "./VerificationToastNotificationView";
import {ListView} from "../../general/ListView"; import {ListView} from "../../general/ListView";
import {TemplateView, Builder} from "../../general/TemplateView"; import {TemplateView, Builder} from "../../general/TemplateView";
import type {IView} from "../../general/types"; import type {IView} from "../../general/types";
import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/calls/CallToastNotificationViewModel"; import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/calls/CallToastNotificationViewModel";
import type {ToastCollectionViewModel} from "../../../../../domain/session/toast/ToastCollectionViewModel"; import type {ToastCollectionViewModel} from "../../../../../domain/session/toast/ToastCollectionViewModel";
import type {BaseToastNotificationViewModel} from "../../../../../domain/session/toast/BaseToastNotificationViewModel"; import type {BaseToastNotificationViewModel} from "../../../../../domain/session/toast/BaseToastNotificationViewModel";
import type {VerificationToastNotificationViewModel} from "../../../../../domain/session/toast/verification/VerificationToastNotificationViewModel";
function toastViewModelToView(vm: BaseToastNotificationViewModel): IView { function toastViewModelToView(vm: BaseToastNotificationViewModel): IView {
switch (vm.kind) { switch (vm.kind) {
case "calls": case "calls":
return new CallToastNotificationView(vm as CallToastNotificationViewModel); return new CallToastNotificationView(vm as CallToastNotificationViewModel);
case "verification":
return new VerificationToastNotificationView(vm as VerificationToastNotificationViewModel);
default: default:
throw new Error(`Cannot find view class for notification kind ${vm.kind}`); throw new Error(`Cannot find view class for notification kind ${vm.kind}`);
} }
@ -33,12 +37,13 @@ function toastViewModelToView(vm: BaseToastNotificationViewModel): IView {
export class ToastCollectionView extends TemplateView<ToastCollectionViewModel> { export class ToastCollectionView extends TemplateView<ToastCollectionViewModel> {
render(t: Builder<ToastCollectionViewModel>, vm: ToastCollectionViewModel) { render(t: Builder<ToastCollectionViewModel>, vm: ToastCollectionViewModel) {
const view = new ListView({
list: vm.toastViewModels,
parentProvidesUpdates: false,
}, (vm: CallToastNotificationViewModel) => toastViewModelToView(vm));
return t.div({ className: "ToastCollectionView" }, [ return t.div({ className: "ToastCollectionView" }, [
t.view(view), t.ifView(vm => !!vm.toastViewModels, t => {
return new ListView({
list: vm.toastViewModels,
parentProvidesUpdates: false,
}, (vm: CallToastNotificationViewModel) => toastViewModelToView(vm));
}),
]); ]);
} }
} }

View File

@ -0,0 +1,45 @@
/*
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, Builder} from "../../general/TemplateView";
import type {VerificationToastNotificationViewModel} from "../../../../../domain/session/toast/verification/VerificationToastNotificationViewModel";
export class VerificationToastNotificationView extends TemplateView<VerificationToastNotificationViewModel> {
render(t: Builder<VerificationToastNotificationViewModel>, vm: VerificationToastNotificationViewModel) {
return t.div({ className: "VerificationToastNotificationView" }, [
t.div({ className: "VerificationToastNotificationView__top" }, [
t.span({ className: "VerificationToastNotificationView__title" },
vm.i18n`Device Verification`),
t.button({
className: "button-action VerificationToastNotificationView__dismiss-btn",
onClick: () => vm.dismiss(),
}),
]),
t.div({ className: "VerificationToastNotificationView__description" }, [
t.span(vm.i18n`Do you want to verify device ${vm.otherDeviceId}?`),
]),
t.div({ className: "VerificationToastNotificationView__action" }, [
t.button({
className: "button-action primary destructive",
onClick: () => vm.dismiss(),
}, vm.i18n`Ignore`),
t.button({
className: "button-action primary",
onClick: () => vm.accept(),
}, vm.i18n`Accept`),
]),
]);
}
}

View File

@ -0,0 +1,45 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {Builder, TemplateView} from "../../general/TemplateView";
import {DeviceVerificationViewModel} from "../../../../../domain/session/verification/DeviceVerificationViewModel";
import {WaitingForOtherUserView} from "./stages/WaitingForOtherUserView";
import {VerificationCancelledView} from "./stages/VerificationCancelledView";
import {SelectMethodView} from "./stages/SelectMethodView";
import {VerifyEmojisView} from "./stages/VerifyEmojisView";
import {VerificationCompleteView} from "./stages/VerificationCompleteView";
export class DeviceVerificationView extends TemplateView<DeviceVerificationViewModel> {
render(t: Builder<DeviceVerificationViewModel>) {
return t.div({
className: {
"middle": true,
"DeviceVerificationView": true,
}
}, [
t.mapView(vm => vm.currentStageViewModel, (vm) => {
switch (vm?.kind) {
case "waiting-for-user": return new WaitingForOtherUserView(vm);
case "verification-cancelled": return new VerificationCancelledView(vm);
case "select-method": return new SelectMethodView(vm);
case "verify-emojis": return new VerifyEmojisView(vm);
case "verification-completed": return new VerificationCompleteView(vm);
default: return null;
}
})
])
}
}

View File

@ -0,0 +1,62 @@
/*
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 {Builder, TemplateView} from "../../../general/TemplateView";
import {spinner} from "../../../common.js"
import type {SelectMethodViewModel} from "../../../../../../domain/session/verification/stages/SelectMethodViewModel";
export class SelectMethodView extends TemplateView<SelectMethodViewModel> {
render(t: Builder<SelectMethodViewModel>) {
return t.div({ className: "SelectMethodView" }, [
t.map(vm => vm.hasProceeded, (hasProceeded, t, vm) => {
if (hasProceeded) {
return spinner(t);
}
else return t.div([
t.div({ className: "SelectMethodView__heading" }, [
t.h2( { className: "SelectMethodView__title" }, vm.i18n`Verify device '${vm.deviceName}' by comparing emojis?`),
]),
t.p({ className: "SelectMethodView__description" },
vm.i18n`You are about to verify your other device by comparing emojis.`
),
t.div({ className: "SelectMethodView__actions" }, [
t.button(
{
className: {
"button-action": true,
primary: true,
destructive: true,
},
onclick: () => vm.cancel(),
},
"Cancel"
),
t.button(
{
className: {
"button-action": true,
primary: true,
},
onclick: () => vm.proceed(),
},
"Proceed"
),
]),
]);
}),
]);
}
}

View File

@ -0,0 +1,78 @@
/*
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 {Builder, TemplateView} from "../../../general/TemplateView";
import {VerificationCancelledViewModel} from "../../../../../../domain/session/verification/stages/VerificationCancelledViewModel";
import {CancelReason} from "../../../../../../matrix/verification/SAS/channel/types";
export class VerificationCancelledView extends TemplateView<VerificationCancelledViewModel> {
render(t: Builder<VerificationCancelledViewModel>, vm: VerificationCancelledViewModel) {
const headerTextStart = vm.isCancelledByUs ? "You" : "The other device";
return t.div(
{
className: "VerificationCancelledView",
},
[
t.h2(
{ className: "VerificationCancelledView__title" },
vm.i18n`${headerTextStart} cancelled the verification!`
),
t.p(
{ className: "VerificationCancelledView__description" },
vm.i18n`${this.getDescriptionFromCancellationCode(vm.cancelCode, vm.isCancelledByUs)}`
),
t.div({ className: "VerificationCancelledView__actions" }, [
t.button({
className: {
"button-action": true,
"primary": true,
},
onclick: () => vm.gotoSettings(),
}, "Got it")
]),
]
);
}
getDescriptionFromCancellationCode(code: CancelReason, isCancelledByUs: boolean): string {
const descriptionsWhenWeCancelled = {
[CancelReason.InvalidMessage]: "You other device sent an invalid message.",
[CancelReason.KeyMismatch]: "The key could not be verified.",
[CancelReason.TimedOut]: "The verification process timed out.",
[CancelReason.UnexpectedMessage]: "Your other device sent an unexpected message.",
[CancelReason.UnknownMethod]: "Your other device is using an unknown method for verification.",
[CancelReason.UnknownTransaction]: "Your other device sent a message with an unknown transaction id.",
[CancelReason.UserMismatch]: "The expected user did not match the user verified.",
[CancelReason.MismatchedCommitment]: "The hash commitment does not match.",
[CancelReason.MismatchedSAS]: "The emoji/decimal did not match.",
}
const descriptionsWhenTheyCancelled = {
[CancelReason.UserCancelled]: "Your other device cancelled the verification!",
[CancelReason.InvalidMessage]: "Invalid message sent to the other device.",
[CancelReason.KeyMismatch]: "The other device could not verify our keys",
[CancelReason.TimedOut]: "The verification process timed out.",
[CancelReason.UnexpectedMessage]: "Unexpected message sent to the other device.",
[CancelReason.UnknownMethod]: "Your other device does not understand the method you chose",
[CancelReason.UnknownTransaction]: "Your other device rejected our message.",
[CancelReason.UserMismatch]: "The expected user did not match the user verified.",
[CancelReason.MismatchedCommitment]: "Your other device was not able to verify the hash commitment",
[CancelReason.MismatchedSAS]: "The emoji/decimal did not match.",
}
const map = isCancelledByUs ? descriptionsWhenWeCancelled : descriptionsWhenTheyCancelled;
return map[code] ?? "";
}
}

View File

@ -0,0 +1,45 @@
/*
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 {Builder, TemplateView} from "../../../general/TemplateView";
import type {VerificationCompleteViewModel} from "../../../../../../domain/session/verification/stages/VerificationCompleteViewModel";
export class VerificationCompleteView extends TemplateView<VerificationCompleteViewModel> {
render(t: Builder<VerificationCompleteViewModel>, vm: VerificationCompleteViewModel) {
return t.div({ className: "VerificationCompleteView" }, [
t.div({className: "VerificationCompleteView__icon"}),
t.div({ className: "VerificationCompleteView__heading" }, [
t.h2(
{ className: "VerificationCompleteView__title" },
vm.i18n`Verification completed successfully!`
),
]),
t.p(
{ className: "VerificationCompleteView__description" },
vm.i18n`You successfully verified device ${vm.otherDeviceId}`
),
t.div({ className: "VerificationCompleteView__actions" }, [
t.button({
className: {
"button-action": true,
"primary": true,
},
onclick: () => vm.gotoSettings(),
}, "Got it")
]),
]);
}
}

View File

@ -0,0 +1,79 @@
/*
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 {Builder, TemplateView} from "../../../general/TemplateView";
import {spinner} from "../../../common.js"
import type {VerifyEmojisViewModel} from "../../../../../../domain/session/verification/stages/VerifyEmojisViewModel";
export class VerifyEmojisView extends TemplateView<VerifyEmojisViewModel> {
render(t: Builder<VerifyEmojisViewModel>, vm: VerifyEmojisViewModel) {
const emojiList = vm.emojis.reduce((acc, [emoji, name]) => {
const e = t.div({ className: "EmojiContainer" }, [
t.div({ className: "EmojiContainer__emoji" }, emoji),
t.div({ className: "EmojiContainer__name" }, name),
]);
acc.push(e);
return acc;
}, [] as any);
const emojiCollection = t.div({ className: "EmojiCollection" }, emojiList);
return t.div({ className: "VerifyEmojisView" }, [
t.div({ className: "VerifyEmojisView__heading" }, [
t.h2(
{ className: "VerifyEmojisView__title" },
vm.i18n`Do the emojis match?`
),
]),
t.p(
{ className: "VerifyEmojisView__description" },
vm.i18n`Confirm the emoji below are displayed on both devices, in the same order:`
),
t.div({ className: "VerifyEmojisView__emojis" }, emojiCollection),
t.map(vm => vm.isWaiting, (isWaiting, t, vm) => {
if (isWaiting) {
return t.div({ className: "VerifyEmojisView__waiting" }, [
spinner(t),
t.span(vm.i18n`Waiting for you to verify on your other device`),
]);
}
else {
return t.div({ className: "VerifyEmojisView__actions" }, [
t.button(
{
className: {
"button-action": true,
primary: true,
destructive: true,
},
onclick: () => vm.setEmojiMatch(false),
},
vm.i18n`They don't match`
),
t.button(
{
className: {
"button-action": true,
primary: true,
},
onclick: () => vm.setEmojiMatch(true),
},
vm.i18n`They match`
),
]);
}
})
]);
}
}

View File

@ -0,0 +1,46 @@
/*
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 {Builder, TemplateView} from "../../../general/TemplateView";
import {spinner} from "../../../common.js";
import {WaitingForOtherUserViewModel} from "../../../../../../domain/session/verification/stages/WaitingForOtherUserViewModel";
export class WaitingForOtherUserView extends TemplateView<WaitingForOtherUserViewModel> {
render(t: Builder<WaitingForOtherUserViewModel>, vm: WaitingForOtherUserViewModel) {
return t.div({ className: "WaitingForOtherUserView" }, [
t.div({ className: "WaitingForOtherUserView__heading" }, [
spinner(t),
t.h2(
{ className: "WaitingForOtherUserView__title" },
vm.i18n`Waiting for any of your device to accept the verification request`
),
]),
t.p({ className: "WaitingForOtherUserView__description" },
vm.i18n`Accept the request from the device you wish to verify!`
),
t.div({ className: "WaitingForOtherUserView__actions" },
t.button({
className: {
"button-action": true,
"primary": true,
"destructive": true,
},
onclick: () => vm.cancel(),
}, "Cancel")
),
]);
}
}