diff --git a/src/domain/session/room/timeline/tiles/VerificationTile.ts b/src/domain/session/room/timeline/tiles/VerificationTile.ts index 493706ca..7d5e8771 100644 --- a/src/domain/session/room/timeline/tiles/VerificationTile.ts +++ b/src/domain/session/room/timeline/tiles/VerificationTile.ts @@ -17,15 +17,31 @@ limitations under the License. import {SASRequest} from "../../../../../matrix/verification/SAS/SASRequest"; import {TileShape} from "./ITile"; import {SimpleTile} from "./SimpleTile"; +import type {SASVerification} from "../../../../../matrix/verification/SAS/SASVerification"; import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry.js"; import type {Options} from "./SimpleTile"; +export const enum Status { + Ready, + InProgress, + Completed, + Cancelled, +}; + export class VerificationTile extends SimpleTile { private request: SASRequest; + public isCancelledByUs: boolean; + public status: Status = Status.Ready; constructor(entry: EventEntry, options: Options) { super(entry, options); this.request = new SASRequest(this.lowerEntry); + const crossSigning = this.getOption("session").crossSigning.get(); + this.track( + crossSigning.sasVerificationObservable.subscribe(sas => { + this.subscribeToSASVerification(sas); + }) + ) } get shape(): TileShape { @@ -40,6 +56,8 @@ export class VerificationTile extends SimpleTile { const crossSigning = this.getOption("session").crossSigning.get() crossSigning.receivedSASVerifications.set(this.eventId, this.request); this.openVerificationPanel(this.eventId); + this.status = Status.InProgress; + this.emitChange("status"); } async reject(): Promise { @@ -47,6 +65,9 @@ export class VerificationTile extends SimpleTile { await this.logAndCatch("VerificationTile.reject", async (log) => { const crossSigning = this.getOption("session").crossSigning.get(); await this.request.reject(crossSigning, this._room, log); + this.isCancelledByUs = true; + this.status = Status.Cancelled; + this.emitChange("status"); }); } @@ -56,4 +77,27 @@ export class VerificationTile extends SimpleTile { path = path.with(this.navigation.segment("verification", eventId))!; this.navigation.applyPath(path); } + + private subscribeToSASVerification(sas: SASVerification | undefined) { + if (!sas || sas.channel.id !== this.eventId) { + return; + } + /** + * Subscribe to SAS events so that we can update the UI when each stage is + * completed. + */ + this.track( + sas.disposableOn("VerificationCancelled", (cancellation) => { + this.isCancelledByUs = cancellation?.cancelledByUs!; + this.status = Status.Cancelled; + this.emitChange("status"); + }) + ); + this.track( + sas.disposableOn("VerificationCompleted", () => { + this.status = Status.Completed; + this.emitChange("status"); + }) + ); + } } diff --git a/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts b/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts index 2cd3da01..35e77e89 100644 --- a/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts +++ b/src/domain/session/toast/verification/VerificationToastCollectionViewModel.ts @@ -43,6 +43,9 @@ export class VerificationToastCollectionViewModel extends ViewModel { const idx = this.toastViewModels.array.findIndex(vm => vm.request.id === request.id); if (idx !== -1) { diff --git a/src/domain/session/verification/DeviceVerificationViewModel.ts b/src/domain/session/verification/DeviceVerificationViewModel.ts index 301b5e9e..f6cc6c23 100644 --- a/src/domain/session/verification/DeviceVerificationViewModel.ts +++ b/src/domain/session/verification/DeviceVerificationViewModel.ts @@ -99,7 +99,7 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel { this.updateCurrentStageViewModel( new VerificationCancelledViewModel( - this.childOptions({ cancellation: cancellation! }) + this.childOptions({ cancellation: cancellation!, sas: this.sas }) ) ); })); diff --git a/src/domain/session/verification/stages/DismissibleVerificationViewModel.ts b/src/domain/session/verification/stages/DismissibleVerificationViewModel.ts new file mode 100644 index 00000000..63d5ae05 --- /dev/null +++ b/src/domain/session/verification/stages/DismissibleVerificationViewModel.ts @@ -0,0 +1,37 @@ +/* +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"; + +type Options = BaseOptions & { + sas: SASVerification; + session: Session; +}; + +export abstract class DismissibleVerificationViewModel extends ErrorReportViewModel { + dismiss(): void { + if (this.getOption("sas").isCrossSigningAnotherUser) { + const path = this.navigation.path.until("room"); + this.navigation.applyPath(path); + } else { + this.navigation.push("settings", true); + } + } +} diff --git a/src/domain/session/verification/stages/VerificationCancelledViewModel.ts b/src/domain/session/verification/stages/VerificationCancelledViewModel.ts index 3e6abbcf..e3e956e3 100644 --- a/src/domain/session/verification/stages/VerificationCancelledViewModel.ts +++ b/src/domain/session/verification/stages/VerificationCancelledViewModel.ts @@ -14,16 +14,20 @@ 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 {Options as BaseOptions} from "../../../ViewModel"; import type {CancelReason} from "../../../../matrix/verification/SAS/channel/types"; +import type {Session} from "../../../../matrix/Session.js"; import type {IChannel} from "../../../../matrix/verification/SAS/channel/IChannel"; +import {DismissibleVerificationViewModel} from "./DismissibleVerificationViewModel"; +import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification"; type Options = BaseOptions & { cancellation: IChannel["cancellation"]; + session: Session; + sas: SASVerification; }; -export class VerificationCancelledViewModel extends ViewModel { +export class VerificationCancelledViewModel extends DismissibleVerificationViewModel { get cancelCode(): CancelReason { return this.options.cancellation!.code; } @@ -32,10 +36,6 @@ export class VerificationCancelledViewModel extends ViewModel { +export class VerificationCompleteViewModel extends DismissibleVerificationViewModel { get otherDeviceId(): string { return this.options.deviceId; } @@ -35,10 +36,6 @@ export class VerificationCompleteViewModel extends ErrorReportViewModel> = new Map(); private readonly deviceId: string; - private sasVerificationInProgress?: SASVerification; + public sasVerificationObservable: ObservableValue = new ObservableValue(undefined); public receivedSASVerifications: ObservableMap = new ObservableMap(); constructor(options: { @@ -185,7 +186,8 @@ export class CrossSigning { startVerification(requestOrUserId: SASRequest, logOrRoom: Room, _log: ILogItem): SASVerification | undefined; startVerification(requestOrUserId: string, logOrRoom: Room, _log: ILogItem): SASVerification | undefined; startVerification(requestOrUserId: string | SASRequest, logOrRoom: Room | ILogItem, _log?: ILogItem): SASVerification | undefined { - if (this.sasVerificationInProgress && !this.sasVerificationInProgress.finished) { + const sasVerificationInProgress = this.sasVerificationObservable.get(); + if (sasVerificationInProgress && !sasVerificationInProgress.finished) { return; } const otherUserId = requestOrUserId instanceof SASRequest ? requestOrUserId.sender : requestOrUserId; @@ -213,7 +215,7 @@ export class CrossSigning { }, startingMessage); } - this.sasVerificationInProgress = new SASVerification({ + const sas = new SASVerification({ olm: this.olm, olmUtil: this.olmUtil, ourUserId: this.ownUserId, @@ -226,7 +228,8 @@ export class CrossSigning { hsApi: this.hsApi, clock: this.platform.clock, }); - return this.sasVerificationInProgress; + this.sasVerificationObservable.set(sas); + return sas; } private handleSASDeviceMessage({ unencrypted: event }) { @@ -236,7 +239,8 @@ export class CrossSigning { * 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; + const sasVerificationInProgress = this.sasVerificationObservable.get(); + const shouldIgnoreEvent = sasVerificationInProgress?.channel.id === txnId; if (shouldIgnoreEvent) { return; } /** * 1. If we receive the cancel message, we need to update the requests map. diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index e6adbb92..945a2696 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1511,7 +1511,14 @@ button.RoomDetailsView_row::after { display: inline-block; } -.VerificationTileView, +.VerificationInProgressTileView, +.VerificationCompletedTileView, +.VerificationCancelledTileView, +.VerificationReadyTileView { + display: flex; + gap: 20px; +} + .VerificationTileView__actions { display: flex; gap: 16px; @@ -1523,18 +1530,39 @@ button.RoomDetailsView_row::after { font-weight: bold; font-size: 1.4rem; color: var(--text-color); + gap: 4px; } -.VerificationTileView { +.VerificationInProgressTileView, +.VerificationCompletedTileView, +.VerificationCancelledTileView, +.VerificationReadyTileView { background: var(--background-color-primary--darker-5); padding: 12px; box-sizing: border-box; border-radius: 8px; } -.VerificationTileContainer { +.VerificationTileView { display: flex; justify-content: center; padding: 16px; box-sizing: border-box; } + + +.VerificationInProgressTileView .VerificationTileView__shield, +.VerificationReadyTileView .VerificationTileView__shield { + background: url("./icons/e2ee-normal.svg?primary=background-color-secondary--darker-40"); +} + +.VerificationCompletedTileView .VerificationTileView__shield { + background: url("./icons/e2ee-normal.svg?primary=accent-color"); +} + +.VerificationTileView__shield { + width: 20px; + height: 20px; + display: flex; + align-self: center; +} diff --git a/src/platform/web/ui/session/room/timeline/VerificationTileView.ts b/src/platform/web/ui/session/room/timeline/VerificationTileView.ts index f7f5a29f..56e50211 100644 --- a/src/platform/web/ui/session/room/timeline/VerificationTileView.ts +++ b/src/platform/web/ui/session/room/timeline/VerificationTileView.ts @@ -14,24 +14,55 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {Builder, TemplateView} from "../../../general/TemplateView"; +import {TemplateView} from "../../../general/TemplateView"; +import {Status} from "../../../../../../domain/session/room/timeline/tiles/VerificationTile"; +import {spinner} from "../../../common.js"; +import type {IView} from "../../../general/types"; +import type {Builder} from "../../../general/TemplateView"; import type {VerificationTile} from "../../../../../../domain/session/room/timeline/tiles/VerificationTile"; +type IClickableView = { + onClick: (evt) => void; +} & IView; + export class VerificationTileView extends TemplateView { render(t: Builder, vm: VerificationTile) { - return t.div( { className: "VerificationTileContainer" }, - t.div({ className: "VerificationTileView" }, [ - t.div({className: "VerificationTileView__shield"}), - t.div({ className: "VerificationTileView__description" }, vm.description), - t.div({ className: "VerificationTileView__actions" }, [ - t.button({ className: "VerificationTileView__accept button-action primary" }, "Accept"), - t.button({ className: "VerificationTileView__reject button-action secondary" }, "Reject"), - ]), - ]) + return t.div({ className: "VerificationTileView" }, + t.mapView(vm => vm.status, (status: Status) => { + switch (status) { + case Status.Ready: + return new VerificationReadyTileView(vm); + case Status.Cancelled: + return new VerificationCancelledTileView(vm); + case Status.Completed: + return new VerificationCompletedTileView(vm); + case Status.InProgress: + return new VerificationInProgressTileView(vm); + } + }) ); } - - /* This is called by the parent ListView, which just has 1 listener for the whole list */ + + onClick(evt) { + // Propagate click events to the sub-view + this._subViews?.forEach((s: IClickableView) => s.onClick?.(evt)); + } +} + +class VerificationReadyTileView extends TemplateView { + render(t: Builder, vm: VerificationTile) { + return t.div({ className: "VerificationReadyTileView" }, [ + t.div({ className: "VerificationTileView__description" }, [ + t.div({ className: "VerificationTileView__shield" }), + t.div(vm.description) + ]), + t.div({ className: "VerificationTileView__actions" }, [ + t.button({ className: "VerificationTileView__accept button-action primary" }, "Accept"), + t.button({ className: "VerificationTileView__reject button-action secondary" }, "Reject"), + ]), + ]); + } + onClick(evt) { if (evt.target.classList.contains("VerificationTileView__accept")) { this.value.accept(); @@ -40,3 +71,36 @@ export class VerificationTileView extends TemplateView { } } } + + +class VerificationCancelledTileView extends TemplateView { + render(t: Builder, vm: VerificationTile) { + return t.div({ className: "VerificationCancelledTileView" }, [ + t.div({ className: "VerificationTileView__description" }, + vm.i18n`${vm.isCancelledByUs? "You": vm.sender} cancelled the verification!`), + ]); + } +} + +class VerificationCompletedTileView extends TemplateView { + render(t: Builder, vm: VerificationTile) { + return t.div({ className: "VerificationCompletedTileView" }, [ + t.div({ className: "VerificationTileView__description" }, [ + t.div({ className: "VerificationTileView__shield" }), + t.div(vm.i18n`You verified ${vm.sender}`), + ]), + ]); + } +} + +class VerificationInProgressTileView extends TemplateView { + render(t: Builder, vm: VerificationTile) { + return t.div({ className: "VerificationInProgressTileView" }, [ + t.div({ className: "VerificationTileView__description" }, + vm.i18n`Verification in progress`), + t.div({ className: "VerificationTileView__actions" }, [ + spinner(t) + ]), + ]); + } +} diff --git a/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts b/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts index d2832ddb..3e761607 100644 --- a/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts +++ b/src/platform/web/ui/session/verification/stages/VerificationCancelledView.ts @@ -41,7 +41,7 @@ export class VerificationCancelledView extends TemplateView vm.gotoSettings(), + onclick: () => vm.dismiss(), }, "Got it") ]), ] diff --git a/src/platform/web/ui/session/verification/stages/VerificationCompleteView.ts b/src/platform/web/ui/session/verification/stages/VerificationCompleteView.ts index 5a9405a3..2bee5d06 100644 --- a/src/platform/web/ui/session/verification/stages/VerificationCompleteView.ts +++ b/src/platform/web/ui/session/verification/stages/VerificationCompleteView.ts @@ -35,7 +35,7 @@ export class VerificationCompleteView extends TemplateView vm.gotoSettings(), + onclick: () => vm.dismiss(), }, "Got it") ]), ]);