Merge pull request #1095 from vector-im/crosssigning

Add UI for cross-signing other users
This commit is contained in:
R Midhun Suresh 2023-06-11 17:08:10 +05:30 committed by GitHub
commit 8593f4f7e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1031 additions and 206 deletions

View File

@ -35,6 +35,7 @@ export type SegmentType = {
"members": true;
"member": string;
"device-verification": string | boolean;
"verification": string | boolean;
"join-room": true;
};
@ -60,7 +61,7 @@ function allowsChild(parent: Segment<SegmentType> | undefined, child: Segment<Se
case "room":
return type === "lightbox" || type === "right-panel";
case "right-panel":
return type === "details"|| type === "members" || type === "member";
return type === "details"|| type === "members" || type === "member" || type === "verification";
case "logout":
return type === "forced";
default:
@ -176,7 +177,7 @@ export function parseUrlPath(urlPath: string, currentNavPath: Path<SegmentType>,
if (sessionSegment) {
segments.push(sessionSegment);
}
} else if (type === "details" || type === "members") {
} else if (type === "details" || type === "members" || type === "verification") {
pushRightPanelSegment(segments, type);
} else if (type === "member") {
let userId = iterator.next().value;

View File

@ -18,6 +18,7 @@ import {ViewModel} from "../../ViewModel";
import {RoomType} from "../../../matrix/room/common";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
import {UserTrust} from "../../../matrix/verification/CrossSigning";
import {RoomStatus} from "../../../matrix/room/common";
export class MemberDetailsViewModel extends ViewModel {
constructor(options) {
@ -43,6 +44,8 @@ export class MemberDetailsViewModel extends ViewModel {
get name() { return this._member.name; }
get userId() { return this._member.userId; }
get canVerifyUser() { return this._member.userId !== this._session.userId; }
get trustDescription() {
switch (this._userTrust?.get()) {
@ -107,6 +110,27 @@ export class MemberDetailsViewModel extends ViewModel {
}
}
async verifyUser() {
await this.logger.run("MemberDetailsViewModel.verifyUser", async () => {
const room = this._session.findDirectMessageForUserId(this.userId);
let roomId = room?.id;
if (!roomId) {
const roomBeingCreated = await this._session.createRoom({
type: RoomType.DirectMessage,
invites: [this.userId]
});
roomId = roomBeingCreated.roomId;
}
const observable = await this._session.observeRoomStatus(roomId);
await observable.waitFor(s => s === RoomStatus.Joined).promise;
let path = this.navigation.path.until("session");
path = path.with(this.navigation.segment("room", roomId));
path = path.with(this.navigation.segment("right-panel", true));
path = path.with(this.navigation.segment("verification", this.userId));
this.navigation.applyPath(path);
});
}
get avatarLetter() {
return avatarInitials(this.name);
}

View File

@ -18,6 +18,7 @@ import {ViewModel} from "../../ViewModel";
import {RoomDetailsViewModel} from "./RoomDetailsViewModel.js";
import {MemberListViewModel} from "./MemberListViewModel.js";
import {MemberDetailsViewModel} from "./MemberDetailsViewModel.js";
import {DeviceVerificationViewModel} from "../verification/DeviceVerificationViewModel";
export class RightPanelViewModel extends ViewModel {
constructor(options) {
@ -68,16 +69,37 @@ export class RightPanelViewModel extends ViewModel {
this.urlRouter.pushUrl(url);
}
);
this._hookUpdaterToSegment("verification", DeviceVerificationViewModel, () => {
const options = {
session: this._session,
room: this._room,
};
const id = this.navigation.path.get("verification").value;
if (typeof id === "string") {
/**
* Here id is:
* 1. id of the request when we receive a sas verification request
* 2. id of the user we are trying to verify when we initiate the verification process
*/
const request = this._session?.crossSigning.get()?.receivedSASVerifications.get(id);
const extraOptions = request ? { request } : { userId: id };
Object.assign(options, extraOptions);
}
return options;
});
}
_hookUpdaterToSegment(segment, viewmodel, argCreator, failCallback) {
async _hookUpdaterToSegment(segment, ViewModel, argCreator, failCallback) {
const observable = this.navigation.observe(segment);
const updater = this._setupUpdater(segment, viewmodel, argCreator, failCallback);
const updater = await this._setupUpdater(segment, ViewModel, argCreator, failCallback);
this.track(observable.subscribe(updater));
}
_setupUpdater(segment, viewmodel, argCreator, failCallback) {
async _setupUpdater(segment, ViewModel, argCreator, failCallback) {
const updater = async (skipDispose = false) => {
if (this._activeViewModel instanceof ViewModel) {
return;
}
if (!skipDispose) {
this._activeViewModel = this.disposeTracked(this._activeViewModel);
}
@ -88,11 +110,11 @@ export class RightPanelViewModel extends ViewModel {
failCallback();
return;
}
this._activeViewModel = this.track(new viewmodel(this.childOptions(args)));
this._activeViewModel = this.track(new ViewModel(this.childOptions(args)));
}
this.emitChange("activeViewModel");
};
updater(true);
await updater(true);
return updater;
}

View File

@ -77,9 +77,10 @@ export class RoomViewModel extends ErrorReportViewModel {
}
async load() {
this.logAndCatch("RoomViewModel.load", async log => {
await this.logAndCatch("RoomViewModel.load", async log => {
this._room.on("change", this._onRoomChange);
const timeline = await this._room.openTimeline(log);
this.track(() => timeline.release());
this._tileOptions = this.childOptions({
session: this.getOption("session"),
roomVM: this,

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
import {SimpleTile} from "./SimpleTile";
import {ReactionsViewModel} from "../ReactionsViewModel.js";
import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar";

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
import {SimpleTile} from "./SimpleTile";
import {ViewModel} from "../../../../ViewModel";
import {LocalMedia} from "../../../../../matrix/calls/LocalMedia";
import {CallType} from "../../../../../matrix/calls/callEventTypes";

View File

@ -3,6 +3,7 @@ import {UpdateAction} from "../UpdateAction";
import {BaseEntry} from "../../../../../matrix/room/timeline/entries/BaseEntry";
import {BaseEventEntry} from "../../../../../matrix/room/timeline/entries/BaseEventEntry";
import {ViewModel} from "../../../../ViewModel";
import type {SegmentType} from "../../../../navigation";
import type {Options} from "../../../../ViewModel";
/**
@ -14,12 +15,12 @@ import type {Options} from "../../../../ViewModel";
* a pending event is removed on remote echo.
* */
export class DateTile extends ViewModel implements ITile<BaseEventEntry> {
export class DateTile<T extends object = SegmentType> extends ViewModel<T, Options<T>> implements ITile<BaseEventEntry> {
private _emitUpdate?: EmitUpdateFn;
private _dateString?: string;
private _machineReadableString?: string;
constructor(private _firstTileInDay: ITile<BaseEventEntry>, options: Options) {
constructor(private _firstTileInDay: ITile<BaseEventEntry>, options: Options<T>) {
super(options);
}
@ -168,11 +169,13 @@ import { SimpleTile } from "./SimpleTile";
export function tests() {
return {
"date tile sorts before reference tile": assert => {
//@ts-ignore
const a = new SimpleTile(new EventEntry({
event: {},
eventIndex: 2,
fragmentId: 1
}, undefined), {});
//@ts-ignore
const b = new SimpleTile(new EventEntry({
event: {},
eventIndex: 3,

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
import {SimpleTile} from "./SimpleTile";
export class EncryptionEnabledTile extends SimpleTile {
get shape() {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
import {SimpleTile} from "./SimpleTile";
import {UpdateAction} from "../UpdateAction.js";
import {ConnectionError} from "../../../../../matrix/error.js";
import {ConnectionStatus} from "../../../../../matrix/net/Reconnector";

View File

@ -1,6 +1,5 @@
import {UpdateAction} from "../UpdateAction.js";
import {BaseEntry} from "../../../../../matrix/room/timeline/entries/BaseEntry";
import {BaseEventEntry} from "../../../../../matrix/room/timeline/entries/BaseEventEntry";
import {IDisposable} from "../../../../../utils/Disposables";
export type EmitUpdateFn = (tile: ITile<BaseEntry>, props: any) => void
@ -18,6 +17,7 @@ export enum TileShape {
Video = "video",
DateHeader = "date-header",
Call = "call",
Verification = "verification",
}
// TODO: should we imply inheriting from view model here?

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
import {SimpleTile} from "./SimpleTile";
export class RoomMemberTile extends SimpleTile {

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
import {SimpleTile} from "./SimpleTile";
export class RoomNameTile extends SimpleTile {

View File

@ -16,24 +16,38 @@ limitations under the License.
import {UpdateAction} from "../UpdateAction.js";
import {ErrorReportViewModel} from "../../../../ErrorReportViewModel";
import {TileShape} from "./ITile";
import {ITile, TileShape} from "./ITile";
import {SendStatus} from "../../../../../matrix/room/sending/PendingEvent.js";
import {DateTile} from "./DateTile";
import {Options as BaseOptions} from "../../../../ErrorReportViewModel";
import {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry.js";
import type {RoomViewModel} from "../../RoomViewModel.js";
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline.js";
import type {SegmentType} from "../../../../navigation/index.js";
export class SimpleTile extends ErrorReportViewModel {
constructor(entry, options) {
export type Options<T extends object = SegmentType> = {
roomVM: RoomViewModel;
timeline: Timeline;
} & BaseOptions<T>;
type EmitUpdate<T extends object = SegmentType> = (tile: SimpleTile<T>, params: any) => void;
export abstract class SimpleTile<T extends object = SegmentType> extends ErrorReportViewModel<T, Options<T>> implements ITile {
private _entry: EventEntry;
private _date?: Date;
private _needsDateSeparator: boolean = false;
private _emitUpdate?: EmitUpdate<T>;
constructor(entry: EventEntry, options: Options<T>) {
super(options);
this._entry = entry;
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : undefined;
this._needsDateSeparator = false;
this._emitUpdate = undefined;
}
// view model props for all subclasses
// hmmm, could also do instanceof ... ?
get shape() {
return null;
// "gap" | "message" | "image" | ... ?
}
// "gap" | "message" | "image" | ... ?
abstract get shape(): TileShape;
// don't show display name / avatar
// probably only for BaseMessageTiles of some sort?
@ -49,7 +63,7 @@ export class SimpleTile extends ErrorReportViewModel {
return new DateTile(this, this.childOptions({}));
}
_updateDateSeparator(prev) {
_updateDateSeparator(prev: SimpleTile) {
if (prev && prev._date && this._date) {
this._needsDateSeparator = prev._date.getFullYear() !== this._date.getFullYear() ||
prev._date.getMonth() !== this._date.getMonth() ||
@ -80,12 +94,12 @@ export class SimpleTile extends ErrorReportViewModel {
!this._entry.pendingEvent.hasStartedSending;
}
abortSending() {
this._entry.pendingEvent?.abort();
async abortSending(): Promise<void> {
await this._entry.pendingEvent?.abort();
}
// TilesCollection contract below
setUpdateEmit(emitUpdate) {
setUpdateEmit(emitUpdate?: EmitUpdate<T>) {
this._emitUpdate = emitUpdate;
}
@ -114,7 +128,7 @@ export class SimpleTile extends ErrorReportViewModel {
return false;
}
compare(tile) {
compare(tile: SimpleTile<T>) {
if (tile.comparisonIsNotCommutative) {
return -tile.compare(this);
} else {
@ -122,7 +136,7 @@ export class SimpleTile extends ErrorReportViewModel {
}
}
compareEntry(entry) {
compareEntry(entry: EventEntry) {
return this._entry.compare(entry);
}
@ -163,7 +177,7 @@ export class SimpleTile extends ErrorReportViewModel {
notifyVisible() {}
dispose() {
this.setUpdateEmit(null);
this.setUpdateEmit(undefined);
super.dispose();
}
// TilesCollection contract above
@ -173,11 +187,11 @@ export class SimpleTile extends ErrorReportViewModel {
}
get _roomVM() {
return this._options.roomVM;
return this.options.roomVM;
}
get _timeline() {
return this._options.timeline;
return this.options.timeline;
}
get _powerLevels() {
@ -185,7 +199,7 @@ export class SimpleTile extends ErrorReportViewModel {
}
get _ownMember() {
return this._options.timeline.me;
return this.options.timeline.me;
}
get displayName() {
@ -197,7 +211,6 @@ export class SimpleTile extends ErrorReportViewModel {
}
}
import { EventEntry } from "../../../../../matrix/room/timeline/entries/EventEntry.js";
export function tests() {
return {
@ -216,7 +229,9 @@ export function tests() {
content: {}
}
}, undefined);
//@ts-ignore
const fridayTile = new SimpleTile(fridayEntry, {});
//@ts-ignore
const thursdayTile = new SimpleTile(thursdayEntry, {});
assert.equal(fridayTile.needsDateSeparator, false);
fridayTile.updatePreviousSibling(thursdayTile);
@ -237,7 +252,9 @@ export function tests() {
content: {}
}
}, undefined);
//@ts-ignore
const fridayTile = new SimpleTile(fridayEntry, {});
//@ts-ignore
const thursdayTile = new SimpleTile(thursdayEntry, {});
assert.equal(fridayTile.needsDateSeparator, false);
fridayTile.updatePreviousSibling(thursdayTile);
@ -251,6 +268,7 @@ export function tests() {
content: {}
}
}, undefined);
//@ts-ignore
const fridayTile = new SimpleTile(fridayEntry, {});
assert.equal(fridayTile.needsDateSeparator, false);
fridayTile.updatePreviousSibling(undefined);

View File

@ -0,0 +1,107 @@
/*
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 {SASRequest} from "../../../../../matrix/verification/SAS/SASRequest";
import {TileShape} from "./ITile";
import {SimpleTile} from "./SimpleTile";
import {UpdateAction} from "../UpdateAction.js"
import {VerificationEventType} from "../../../../../matrix/verification/SAS/channel/types";
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);
// Calculate status based on available context-for entries
// Needed so that tiles reflect their final status when
// events are loaded from storage i.e after a reload.
this.updateStatusFromAvailableContextForEntries();
}
get shape(): TileShape {
return TileShape.Verification;
}
get description(): string {
return this.i18n`${this.sender} wants to verify`;
}
accept(): void {
const crossSigning = this.getOption("session").crossSigning.get();
crossSigning.receivedSASVerifications.set(this.eventId, this.request);
this.openVerificationPanel(this.eventId);
}
async reject(): Promise<void> {
await this.logAndCatch("VerificationTile.reject", async (log) => {
const crossSigning = this.getOption("session").crossSigning.get();
await this.request.reject(crossSigning, this._room, log);
});
}
private openVerificationPanel(eventId: string): void {
let path = this.navigation.path.until("room");
path = path.with(this.navigation.segment("right-panel", true))!;
path = path.with(this.navigation.segment("verification", eventId))!;
this.navigation.applyPath(path);
}
updateEntry(entry: EventEntry, param: any) {
if (param === "context-added") {
/**
* We received a new contextForEntry, maybe it tells us that
* this request was cancelled or that the verification is completed?
* Let's check.
*/
if (this.updateStatusFromAvailableContextForEntries()) {
return UpdateAction.Update(param);
}
return UpdateAction.Nothing();
}
return super.updateEntry(entry, param);
}
private updateStatusFromAvailableContextForEntries(): boolean {
let needsUpdate = false;
for (const e of this.lowerEntry.contextForEntries ?? []) {
switch (e.eventType) {
case VerificationEventType.Cancel:
this.status = Status.Cancelled;
this.isCancelledByUs = e.sender === this.getOption("session").userId;
return true;
case VerificationEventType.Done:
this.status = Status.Completed;
return true;
default:
this.status = Status.InProgress;
needsUpdate = true;
}
}
return needsUpdate;
}
}

View File

@ -27,6 +27,7 @@ import {EncryptedEventTile} from "./EncryptedEventTile.js";
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
import {CallTile} from "./CallTile.js";
import {VerificationTile} from "./VerificationTile.js";
import type {ITile, TileShape} from "./ITile";
import type {Room} from "../../../../../matrix/room/Room";
@ -73,6 +74,13 @@ export function tileClassForEntry(entry: TimelineEntry, options: Options): TileC
return FileTile;
case "m.location":
return LocationTile;
case "m.key.verification.request":
const isCrossSigningDisabled = !options.session.features.crossSigning;
const userId = options.session.userId;
if (isCrossSigningDisabled || entry.sender === userId) {
return undefined;
}
return VerificationTile as unknown as TileConstructor;
default:
// unknown msgtype not rendered
return undefined;

View File

@ -43,6 +43,10 @@ export class VerificationToastCollectionViewModel extends ViewModel<SegmentType,
async onAdd(_, request: SASRequest) {
if (request.sender !== this.getOption("session").userId) {
// Don't show toast for cross-signing other users
return;
}
const dismiss = () => {
const idx = this.toastViewModels.array.findIndex(vm => vm.request.id === request.id);
if (idx !== -1) {

View File

@ -25,10 +25,13 @@ import {VerificationCompleteViewModel} from "./stages/VerificationCompleteViewMo
import type {Session} from "../../../matrix/Session.js";
import type {SASVerification} from "../../../matrix/verification/SAS/SASVerification";
import type {SASRequest} from "../../../matrix/verification/SAS/SASRequest";
import type {Room} from "../../../matrix/room/Room.js";
type Options = BaseOptions & {
session: Session;
request: SASRequest;
request?: SASRequest;
room?: Room;
userId?: string;
};
export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentType, Options> {
@ -37,25 +40,36 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
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);
}
this.start(options);
}
private async start(requestOrUserId: SASRequest | string) {
private async start(options: Options): Promise<void> {
const room = options.room;
let requestOrUserId: SASRequest | string;
requestOrUserId =
options.request ??
options.userId ??
this.getOption("session").userId;
await this.startVerification(requestOrUserId, room);
}
private async startVerification(requestOrUserId: SASRequest | string, room?: Room) {
await this.logAndCatch("DeviceVerificationViewModel.start", (log) => {
const crossSigning = this.getOption("session").crossSigning.get();
this.sas = crossSigning.startVerification(requestOrUserId, log);
this.sas = crossSigning.startVerification(requestOrUserId, room, log);
if (!this.sas) {
throw new Error("CrossSigning.startVerification did not return a sas object!");
}
this.addEventListeners();
if (typeof requestOrUserId === "string") {
this.updateCurrentStageViewModel(new WaitingForOtherUserViewModel(this.childOptions({ sas: this.sas })));
}
return this.sas.start();
if (this.sas.isCrossSigningAnotherUser) {
return crossSigning.signUser(this.sas, log);
}
else {
return crossSigning.signDevice(this.sas, log);
}
});
}
@ -73,13 +87,13 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
this.track(this.sas.disposableOn("VerificationCancelled", (cancellation) => {
this.updateCurrentStageViewModel(
new VerificationCancelledViewModel(
this.childOptions({ cancellation: cancellation! })
this.childOptions({ cancellation: cancellation!, sas: this.sas })
)
);
}));
this.track(this.sas.disposableOn("VerificationCompleted", (deviceId) => {
this.updateCurrentStageViewModel(
new VerificationCompleteViewModel(this.childOptions({ deviceId: deviceId! }))
new VerificationCompleteViewModel(this.childOptions({ deviceId: deviceId!, sas: this.sas }))
);
}));
}
@ -100,4 +114,8 @@ export class DeviceVerificationViewModel extends ErrorReportViewModel<SegmentTyp
get currentStageViewModel() {
return this._currentStageViewModel;
}
get type(): string {
return "verification";
}
}

View File

@ -0,0 +1,41 @@
/*
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<O extends Options> extends ErrorReportViewModel<SegmentType, O> {
dismiss(): void {
/**
* If we're cross-signing another user, redirect to the room (which will just close the right panel).
* If we're verifying a device, redirect to settings.
*/
if (this.getOption("sas").isCrossSigningAnotherUser) {
const path = this.navigation.path.until("room");
this.navigation.applyPath(path);
} else {
this.navigation.push("settings", true);
}
}
}

View File

@ -48,7 +48,15 @@ export class SelectMethodViewModel extends ErrorReportViewModel<SegmentType, Opt
return this.options.stage.otherDeviceName;
}
get otherUserId() {
return this.getOption("sas").otherUserId;
}
get kind(): string {
return "select-method";
}
get isCrossSigningAnotherUser(): boolean {
return this.getOption("sas").isCrossSigningAnotherUser;
}
}

View File

@ -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 {DismissibleVerificationViewModel} from "./DismissibleVerificationViewModel";
import type {CancelReason} from "../../../../matrix/verification/SAS/channel/types";
import type {IChannel} from "../../../../matrix/verification/SAS/channel/Channel";
import type {Session} from "../../../../matrix/Session.js";
import type {IChannel} from "../../../../matrix/verification/SAS/channel/IChannel";
import type {SASVerification} from "../../../../matrix/verification/SAS/SASVerification";
type Options = BaseOptions & {
cancellation: IChannel["cancellation"];
session: Session;
sas: SASVerification;
};
export class VerificationCancelledViewModel extends ViewModel<SegmentType, Options> {
export class VerificationCancelledViewModel extends DismissibleVerificationViewModel<Options> {
get cancelCode(): CancelReason {
return this.options.cancellation!.code;
}
@ -32,10 +36,6 @@ export class VerificationCancelledViewModel extends ViewModel<SegmentType, Optio
return this.options.cancellation!.cancelledByUs;
}
gotoSettings() {
this.navigation.push("settings", true);
}
get kind(): string {
return "verification-cancelled";
}

View File

@ -14,26 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SegmentType} from "../../../navigation/index";
import {ErrorReportViewModel} from "../../../ErrorReportViewModel";
import {DismissibleVerificationViewModel} from "./DismissibleVerificationViewModel";
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 & {
deviceId: string;
session: Session;
sas: SASVerification;
};
export class VerificationCompleteViewModel extends ErrorReportViewModel<SegmentType, Options> {
export class VerificationCompleteViewModel extends DismissibleVerificationViewModel<Options> {
get otherDeviceId(): string {
return this.options.deviceId;
}
gotoSettings() {
this.navigation.push("settings", true);
get otherUsername(): string {
return this.getOption("sas").otherUserId;
}
get kind(): string {
return "verification-completed";
}
get verificationSuccessfulMessage(): string {
if (this.getOption("sas").isCrossSigningAnotherUser) {
return this.i18n`You successfully verified user ${this.otherUsername}`;
}
else {
return this.i18n`You successfully verified device ${this.otherDeviceId}`;
}
}
}

View File

@ -54,7 +54,7 @@ export {RoomMemberTile} from "./domain/session/room/timeline/tiles/RoomMemberTil
export {EncryptedEventTile} from "./domain/session/room/timeline/tiles/EncryptedEventTile.js";
export {EncryptionEnabledTile} from "./domain/session/room/timeline/tiles/EncryptionEnabledTile.js";
export {MissingAttachmentTile} from "./domain/session/room/timeline/tiles/MissingAttachmentTile.js";
export {SimpleTile} from "./domain/session/room/timeline/tiles/SimpleTile.js";
export {SimpleTile} from "./domain/session/room/timeline/tiles/SimpleTile";
export {TimelineView} from "./platform/web/ui/session/room/TimelineView";
export {viewClassForTile} from "./platform/web/ui/session/room/common";

View File

@ -131,6 +131,10 @@ export class Session {
return this._callHandler;
}
get features() {
return this._features;
}
_setupCallHandler() {
this._callHandler = new CallHandler({
clock: this._platform.clock,
@ -694,9 +698,9 @@ export class Session {
return this._roomsBeingCreated;
}
createRoom(options) {
async createRoom(options) {
let roomBeingCreated;
this._platform.logger.runDetached("create room", async log => {
await this._platform.logger.run("create room", async log => {
const id = `local-${Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)}`;
roomBeingCreated = new RoomBeingCreated(
id, options, this._roomsBeingCreatedUpdateCallback,

View File

@ -59,6 +59,7 @@ export class BaseRoom extends EventEmitter {
this._powerLevels = null;
this._powerLevelLoading = null;
this._observedMembers = null;
this._timelineLoadPromise = null;
}
async observeStateType(type, txn = undefined) {
@ -541,11 +542,25 @@ export class BaseRoom extends EventEmitter {
}
/** @public */
openTimeline(log = null) {
return this._platform.logger.wrapOrRun(log, "open timeline", async log => {
async openTimeline(log = null) {
return await this._platform.logger.wrapOrRun(log, "open timeline", async log => {
if (this._timelineLoadPromise) {
// This is to prevent races between multiple calls to this method
await this._timelineLoadPromise;
}
let resolve;
this._timelineLoadPromise = new Promise(r => {
resolve = () => {
this._timelineLoadPromise = null;
r();
};
});
log.set("id", this.id);
if (this._timeline) {
throw new Error("not dealing with load race here for now");
log.log({ l: "Returning existing timeline" });
resolve();
this._timeline.retain();
return this._timeline;
}
this._timeline = new Timeline({
roomId: this.id,
@ -572,7 +587,10 @@ export class BaseRoom extends EventEmitter {
// this also clears this._timeline in the closeCallback
this._timeline.dispose();
throw err;
} finally {
resolve();
}
this._timeline.retain();
return this._timeline;
});
}
@ -610,7 +628,6 @@ export class BaseRoom extends EventEmitter {
dispose() {
this._roomEncryption?.dispose();
this._timeline?.dispose();
}
}

View File

@ -15,6 +15,7 @@ limitations under the License.
*/
import {createEnum} from "../../../utils/enum";
import {AbortError} from "../../../utils/error";
import {Deferred} from "../../../utils/Deferred";
import {REDACTION_TYPE} from "../common";
import {getRelationFromContent, getRelationTarget, setRelationTarget} from "../timeline/relations.js";
@ -40,6 +41,7 @@ export class PendingEvent {
this._status = SendStatus.Waiting;
this._sendRequest = null;
this._attachmentsTotalBytes = 0;
this._deferred = new Deferred()
if (this._attachments) {
this._attachmentsTotalBytes = Object.values(this._attachments).reduce((t, a) => t + a.size, 0);
}
@ -228,11 +230,16 @@ export class PendingEvent {
this._sendRequest = null;
// both /send and /redact have the same response format
this._data.remoteId = response.event_id;
this._deferred.resolve(response.event_id);
log.set("id", this._data.remoteId);
this._status = SendStatus.Sent;
this._emitUpdate("status");
}
getRemoteId() {
return this._deferred.promise;
}
dispose() {
if (this._attachments) {
for (const attachment of Object.values(this._attachments)) {

View File

@ -225,7 +225,7 @@ export class SendQueue {
}
}
}
await this._enqueueEvent(eventType, content, attachments, relatedTxnId, null, log);
return await this._enqueueEvent(eventType, content, attachments, relatedTxnId, null, log);
}
async _enqueueEvent(eventType, content, attachments, relatedTxnId, relatedEventId, log) {
@ -239,6 +239,7 @@ export class SendQueue {
if (this._sendLoopLogItem) {
log.refDetached(this._sendLoopLogItem);
}
return pendingEvent;
}
async enqueueRedaction(eventIdOrTxnId, reason, log) {

View File

@ -25,9 +25,11 @@ import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js";
import {REDACTION_TYPE} from "../common";
import {NonPersistedEventEntry} from "./entries/NonPersistedEventEntry.js";
import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../members/RoomMember.js";
import {RetainedValue} from "../../../utils/RetainedValue";
export class Timeline {
export class Timeline extends RetainedValue {
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock, powerLevelsObservable, hsApi}) {
super(() => { this.dispose(); });
this._roomId = roomId;
this._storage = storage;
this._closeCallback = closeCallback;
@ -320,15 +322,18 @@ export class Timeline {
}
const id = entry.contextEventId;
// before looking into remoteEntries, check the entries
// that about to be added first
// that are about to be added first
let contextEvent = entries.find(e => e.id === id);
if (!contextEvent) {
contextEvent = this._findLoadedEventById(id);
}
if (contextEvent) {
entry.setContextEntry(contextEvent);
// we don't emit an update here, as the add or update
// we don't emit an update for `entry` here, as the add or update
// that the callee will emit hasn't been emitted yet.
// however we do emit an update for the `contextEvent` so that it
// can do something in response to `entry` being added (if needed).
this._emitUpdateForEntry(contextEvent, "context-added");
} else {
// we don't await here, which is not ideal,
// but one of our callers, addEntries, is not async

View File

@ -16,7 +16,7 @@ limitations under the License.
import {BaseEntry} from "./BaseEntry";
import {REDACTION_TYPE} from "../../common";
import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js";
import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent, REFERENCE_RELATION_TYPE} from "../relations.js";
import {PendingAnnotation} from "../PendingAnnotation.js";
import {createReplyContent} from "./reply.js"
@ -35,6 +35,10 @@ export class BaseEventEntry extends BaseEntry {
return !!this.relation?.["m.in_reply_to"];
}
get isReference() {
return this.relation?.rel_type === REFERENCE_RELATION_TYPE;
}
get isRedacting() {
return !!this._pendingRedactions;
}

View File

@ -148,9 +148,9 @@ export class EventEntry extends BaseEventEntry {
return originalRelation || getRelationFromContent(this.content);
}
// similar to relatedEventID but only for replies
// similar to relatedEventID but only for replies and reference relations
get contextEventId() {
if (this.isReply) {
if (this.isReply || this.isReference) {
return this.relatedEventId;
}
return null;

View File

@ -18,6 +18,7 @@ import {REDACTION_TYPE} from "../common";
export const REACTION_TYPE = "m.reaction";
export const ANNOTATION_RELATION_TYPE = "m.annotation";
export const REFERENCE_RELATION_TYPE = "m.reference";
export function createAnnotation(targetId, key) {
return {
@ -29,6 +30,15 @@ export function createAnnotation(targetId, key) {
};
}
export function createReference(targetId) {
return {
"m.relates_to": {
"event_id": targetId,
"rel_type": REFERENCE_RELATION_TYPE
}
};
}
export function getRelationTarget(relation) {
return relation.event_id || relation["m.in_reply_to"]?.event_id
}

View File

@ -18,7 +18,8 @@ import {verifyEd25519Signature, SignatureVerification} from "../e2ee/common";
import {BaseObservableValue, RetainedObservableValue} from "../../observable/value";
import {pkSign} from "./common";
import {SASVerification} from "./SAS/SASVerification";
import {ToDeviceChannel} from "./SAS/channel/Channel";
import {ToDeviceChannel} from "./SAS/channel/ToDeviceChannel";
import {RoomChannel} from "./SAS/channel/RoomChannel";
import {VerificationEventType} from "./SAS/channel/types";
import {ObservableMap} from "../../observable/map";
import {SASRequest} from "./SAS/SASRequest";
@ -31,6 +32,8 @@ import type {Account} from "../e2ee/Account";
import type {ILogItem} from "../../logging/types";
import type {DeviceMessageHandler} from "../DeviceMessageHandler.js";
import type {SignedValue, DeviceKey} from "../e2ee/common";
import type {Room} from "../room/Room.js";
import type {IChannel} from "./SAS/channel/IChannel";
import type * as OlmNamespace from "@matrix-org/olm";
type Olm = typeof OlmNamespace;
@ -78,6 +81,12 @@ enum MSKVerification {
Valid
}
export interface IVerificationMethod {
verify(): Promise<boolean>;
otherDeviceId: string;
otherUserId: string;
}
export class CrossSigning {
private readonly storage: Storage;
private readonly secretStorage: SecretStorage;
@ -172,23 +181,39 @@ export class CrossSigning {
return this._isMasterKeyTrusted;
}
startVerification(requestOrUserId: SASRequest, log: ILogItem): SASVerification | undefined;
startVerification(requestOrUserId: string, log: ILogItem): SASVerification | undefined;
startVerification(requestOrUserId: string | SASRequest, log: ILogItem): SASVerification | undefined {
startVerification(requestOrUserId: SASRequest, logOrRoom: ILogItem): SASVerification | undefined;
startVerification(requestOrUserId: string, logOrRoom: ILogItem): SASVerification | undefined;
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 {
const log: ILogItem = _log ?? logOrRoom;
if (this.sasVerificationInProgress && !this.sasVerificationInProgress.finished) {
log.log({ sasVerificationAlreadyInProgress: true });
return;
}
const otherUserId = requestOrUserId instanceof SASRequest ? requestOrUserId.sender : requestOrUserId;
const startingMessage = requestOrUserId instanceof SASRequest ? requestOrUserId.startingMessage : undefined;
const channel = new ToDeviceChannel({
deviceTracker: this.deviceTracker,
hsApi: this.hsApi,
otherUserId,
clock: this.platform.clock,
deviceMessageHandler: this.deviceMessageHandler,
ourUserDeviceId: this.deviceId,
log
}, startingMessage);
let channel: IChannel;
if (otherUserId === this.ownUserId) {
channel = new ToDeviceChannel({
deviceTracker: this.deviceTracker,
hsApi: this.hsApi,
otherUserId,
clock: this.platform.clock,
deviceMessageHandler: this.deviceMessageHandler,
ourUserDeviceId: this.deviceId,
log
}, startingMessage);
}
else {
channel = new RoomChannel({
room: logOrRoom,
otherUserId,
ourUserId: this.ownUserId,
ourUserDeviceId: this.deviceId,
log,
}, startingMessage);
}
this.sasVerificationInProgress = new SASVerification({
olm: this.olm,
@ -202,7 +227,6 @@ export class CrossSigning {
deviceTracker: this.deviceTracker,
hsApi: this.hsApi,
clock: this.platform.clock,
crossSigning: this,
});
return this.sasVerificationInProgress;
}
@ -249,13 +273,19 @@ export class CrossSigning {
}
/** @return the signed device key for the given device id */
async signDevice(deviceId: string, log: ILogItem): Promise<DeviceKey | undefined> {
async signDevice(verification: IVerificationMethod, log: ILogItem): Promise<DeviceKey | undefined> {
return log.wrap("CrossSigning.signDevice", async log => {
log.set("id", deviceId);
if (!this._isMasterKeyTrusted) {
log.set("mskNotTrusted", true);
return;
}
const shouldSign = await verification.verify();
log.set("shouldSign", shouldSign);
if (!shouldSign) {
return;
}
const deviceId = verification.otherDeviceId;
log.set("id", deviceId);
const keyToSign = await this.deviceTracker.deviceForId(this.ownUserId, deviceId, this.hsApi, log);
if (!keyToSign) {
return undefined;
@ -266,8 +296,9 @@ export class CrossSigning {
}
/** @return the signed MSK for the given user id */
async signUser(userId: string, log: ILogItem): Promise<CrossSigningKey | undefined> {
async signUser(verification: IVerificationMethod, log: ILogItem): Promise<CrossSigningKey | undefined> {
return log.wrap("CrossSigning.signUser", async log => {
const userId = verification.otherUserId;
log.set("id", userId);
if (!this._isMasterKeyTrusted) {
log.set("mskNotTrusted", true);
@ -277,6 +308,11 @@ export class CrossSigning {
if (userId === this.ownUserId) {
return;
}
const shouldSign = await verification.verify();
log.set("shouldSign", shouldSign);
if (!shouldSign) {
return;
}
const keyToSign = await this.deviceTracker.getCrossSigningKeyForUser(userId, KeyUsage.Master, this.hsApi, log);
if (!keyToSign) {
return;

View File

@ -14,6 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type {CrossSigning} from "../CrossSigning";
import type {Room} from "../../room/Room.js";
import type {ILogItem} from "../../../logging/types";
export class SASRequest {
constructor(public readonly startingMessage: any) {}
@ -26,6 +30,11 @@ export class SASRequest {
}
get id(): string {
return this.startingMessage.content.transaction_id;
return this.startingMessage.content.transaction_id ?? this.startingMessage.eventId;
}
async reject(crossSigning: CrossSigning, room: Room, log: ILogItem): Promise<void> {
const sas = crossSigning.startVerification(this, room, log);
await sas?.abort();
}
}

View File

@ -19,17 +19,17 @@ import type {BaseSASVerificationStage} from "./stages/BaseSASVerificationStage";
import type {Account} from "../../e2ee/Account.js";
import type {DeviceTracker} from "../../e2ee/DeviceTracker.js";
import type * as OlmNamespace from "@matrix-org/olm";
import type {IChannel} from "./channel/Channel";
import type {IChannel} from "./channel/IChannel";
import type {HomeServerApi} from "../../net/HomeServerApi";
import type {Timeout} from "../../../platform/types/types";
import type {Clock} from "../../../platform/web/dom/Clock.js";
import type {IVerificationMethod} from "../CrossSigning";
import {CancelReason, VerificationEventType} from "./channel/types";
import {SendReadyStage} from "./stages/SendReadyStage";
import {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage";
import {VerificationCancelledError} from "./VerificationCancelledError";
import {EventEmitter} from "../../../utils/EventEmitter";
import {SASProgressEvents} from "./types";
import type {CrossSigning} from "../CrossSigning";
type Olm = typeof OlmNamespace;
@ -45,15 +45,16 @@ type Options = {
deviceTracker: DeviceTracker;
hsApi: HomeServerApi;
clock: Clock;
crossSigning: CrossSigning
}
export class SASVerification extends EventEmitter<SASProgressEvents> {
export class SASVerification extends EventEmitter<SASProgressEvents> implements IVerificationMethod {
private startStage: BaseSASVerificationStage;
private olmSas: Olm.SAS;
public finished: boolean = false;
public readonly channel: IChannel;
private timeout: Timeout;
public otherUserId: string;
private ourUserId: string;
constructor(options: Options) {
super();
@ -61,6 +62,8 @@ export class SASVerification extends EventEmitter<SASProgressEvents> {
const olmSas = new olm.SAS();
this.olmSas = olmSas;
this.channel = channel;
this.otherUserId = options.otherUserId;
this.ourUserId = options.ourUserId;
this.setupCancelAfterTimeout(clock);
const stageOptions = {...options, olmSas, eventEmitter: this};
if (channel.getReceivedMessage(VerificationEventType.Start)) {
@ -74,7 +77,7 @@ export class SASVerification extends EventEmitter<SASProgressEvents> {
}
}
private async setupCancelAfterTimeout(clock: Clock) {
private async setupCancelAfterTimeout(clock: Clock): Promise<void> {
try {
const tenMinutes = 10 * 60 * 1000;
this.timeout = clock.createTimeout(tenMinutes);
@ -86,11 +89,13 @@ export class SASVerification extends EventEmitter<SASProgressEvents> {
}
}
async abort() {
async abort(): Promise<void> {
await this.channel.cancelVerification(CancelReason.UserCancelled);
this.finished = true;
}
async start() {
async verify(): Promise<boolean> {
let success = true;
try {
let stage = this.startStage;
do {
@ -102,6 +107,7 @@ export class SASVerification extends EventEmitter<SASProgressEvents> {
if (!(e instanceof VerificationCancelledError)) {
throw e;
}
success = false;
}
finally {
if (this.channel.isCancelled) {
@ -111,6 +117,15 @@ export class SASVerification extends EventEmitter<SASProgressEvents> {
this.timeout.abort();
this.finished = true;
}
return success;
}
get otherDeviceId(): string {
return this.channel.otherUserDeviceId;
}
get isCrossSigningAnotherUser(): boolean {
return !(this.otherUserId === this.ourUserId);
}
}
@ -189,7 +204,6 @@ export function tests() {
olm,
startingMessage,
);
const crossSigning = new MockCrossSigning() as unknown as CrossSigning;
const clock = new MockClock();
const logger = new NullLogger();
return logger.run("log", (log) => {
@ -207,7 +221,6 @@ export function tests() {
ourUserId,
ourUserDeviceId: ourDeviceId,
log,
crossSigning
});
// @ts-ignore
channel.setOlmSas(sas.olmSas);
@ -218,16 +231,6 @@ export function tests() {
});
}
class MockCrossSigning {
signDevice(deviceId: string, log: ILogItem) {
return Promise.resolve({}); // device keys, means signing succeeded
}
signUser(userId: string, log: ILogItem) {
return Promise.resolve({}); // cross-signing keys, means signing succeeded
}
}
return {
"Order of stages created matches expected order when I sent request, they sent start": async (assert) => {
const ourDeviceId = "ILQHOACESQ";
@ -247,7 +250,7 @@ export function tests() {
txnId,
receivedMessages
);
await sas.start();
await sas.verify();
const expectedOrder = [
SendRequestVerificationStage,
SelectVerificationMethodStage,
@ -289,7 +292,7 @@ export function tests() {
await stage?.selectEmojiMethod(log);
});
});
await sas.start();
await sas.verify();
const expectedOrder = [
SendRequestVerificationStage,
SelectVerificationMethodStage,
@ -326,7 +329,7 @@ export function tests() {
receivedMessages,
startingMessage,
);
await sas.start();
await sas.verify();
const expectedOrder = [
SelectVerificationMethodStage,
SendAcceptVerificationStage,
@ -364,7 +367,7 @@ export function tests() {
txnId,
receivedMessages
);
await sas.start();
await sas.verify();
const expectedOrder = [
SendRequestVerificationStage,
SelectVerificationMethodStage,
@ -408,7 +411,7 @@ export function tests() {
await stage?.selectEmojiMethod(log);
});
});
await sas.start();
await sas.verify();
const expectedOrder = [
SendRequestVerificationStage,
SelectVerificationMethodStage,
@ -448,7 +451,7 @@ export function tests() {
receivedMessages,
startingMessage,
);
await sas.start();
await sas.verify();
const expectedOrder = [
SelectVerificationMethodStage,
SendAcceptVerificationStage,
@ -494,7 +497,7 @@ export function tests() {
await stage?.selectEmojiMethod(log);
});
});
await sas.start();
await sas.verify();
const expectedOrder = [
SelectVerificationMethodStage,
SendKeyStage,
@ -537,7 +540,7 @@ export function tests() {
await stage?.selectEmojiMethod(log);
});
});
await sas.start();
await sas.verify();
const expectedOrder = [
SendRequestVerificationStage,
SelectVerificationMethodStage,
@ -575,7 +578,7 @@ export function tests() {
txnId,
receivedMessages
);
await sas.start();
await sas.verify();
const expectedOrder = [
SendRequestVerificationStage,
SelectVerificationMethodStage,
@ -613,7 +616,7 @@ export function tests() {
txnId,
receivedMessages
);
const promise = sas.start();
const promise = sas.verify();
clock.elapse(10 * 60 * 1000);
try {
await promise;
@ -643,7 +646,7 @@ export function tests() {
receivedMessages
);
try {
await sas.start()
await sas.verify()
}
catch (e) {
assert.strictEqual(e instanceof VerificationCancelledError, true);

View File

@ -0,0 +1,48 @@
/*
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 {ILogItem} from "../../../../logging/types";
import {CancelReason, VerificationEventType} from "./types";
export const messageFromErrorType = {
[CancelReason.UserCancelled]: "User declined",
[CancelReason.InvalidMessage]: "Invalid Message.",
[CancelReason.KeyMismatch]: "Key Mismatch.",
[CancelReason.OtherDeviceAccepted]: "Another device has accepted this request.",
[CancelReason.TimedOut]: "Timed Out",
[CancelReason.UnexpectedMessage]: "Unexpected Message.",
[CancelReason.UnknownMethod]: "Unknown method.",
[CancelReason.UnknownTransaction]: "Unknown Transaction.",
[CancelReason.UserMismatch]: "User Mismatch",
[CancelReason.MismatchedCommitment]: "Hash commitment does not match.",
[CancelReason.MismatchedSAS]: "Emoji/decimal does not match.",
}
export interface IChannel {
send(eventType: VerificationEventType, content: any, log: ILogItem): Promise<void>;
waitForEvent(eventType: VerificationEventType): Promise<any>;
getSentMessage(event: VerificationEventType): any;
getReceivedMessage(event: VerificationEventType): any;
setStartMessage(content: any): void;
cancelVerification(cancellationType: CancelReason): Promise<void>;
acceptMessage: any;
startMessage: any;
initiatedByUs: boolean;
isCancelled: boolean;
cancellation?: { code: CancelReason, cancelledByUs: boolean };
id: string;
otherUserDeviceId: string;
}

View File

@ -1,7 +1,7 @@
import type {ILogItem} from "../../../../lib";
import {createCalculateMAC} from "../mac";
import {VerificationCancelledError} from "../VerificationCancelledError";
import {IChannel} from "./Channel";
import {IChannel} from "./IChannel";
import {CancelReason, VerificationEventType} from "./types";
import {getKeyEd25519Key} from "../../CrossSigning";
import {getDeviceEd25519Key} from "../../../e2ee/common";

View File

@ -0,0 +1,243 @@
/*
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 {ILogItem} from "../../../../logging/types";
import type {IChannel} from "./IChannel";
import type {Room} from "../../../room/Room.js";
import type {EventEntry} from "../../../room/timeline/entries/EventEntry.js";
import {messageFromErrorType} from "./IChannel";
import {CancelReason, VerificationEventType} from "./types";
import {Disposables} from "../../../../utils/Disposables";
import {VerificationCancelledError} from "../VerificationCancelledError";
import {Deferred} from "../../../../utils/Deferred";
import {getRelatedEventId, createReference} from "../../../room/timeline/relations.js";
type Options = {
otherUserId: string;
ourUserId: string;
log: ILogItem;
ourUserDeviceId: string;
room: Room;
}
export class RoomChannel extends Disposables implements IChannel {
private ourDeviceId: string;
private readonly otherUserId: string;
private readonly sentMessages: Map<VerificationEventType, any> = new Map();
private readonly receivedMessages: Map<VerificationEventType, any> = new Map();
private readonly waitMap: Map<string, Deferred<any>> = new Map();
private readonly log: ILogItem;
private readonly room: Room;
private readonly ourUserId: string;
public otherUserDeviceId: string;
public startMessage: any;
/**
* This is the event-id of the starting message (request/start)
*/
public id: string;
private _initiatedByUs: boolean;
private _cancellation?: { code: CancelReason, cancelledByUs: boolean };
/**
*
* @param startingMessage Create the channel with existing message in the receivedMessage buffer
*/
constructor(options: Options, startingMessage?: any) {
super();
this.otherUserId = options.otherUserId;
this.ourUserId = options.ourUserId;
this.ourDeviceId = options.ourUserDeviceId;
this.log = options.log;
this.room = options.room;
this.subscribeToTimeline();
this.track(() => {
this.waitMap.forEach((value) => {
value.reject(new VerificationCancelledError());
});
});
// Copy over request message
if (startingMessage) {
/**
* startingMessage may be the ready message or the start message.
*/
this.id = startingMessage.id;
const type = startingMessage.content?.msgtype ?? startingMessage.eventType;
this.receivedMessages.set(type, startingMessage);
this.otherUserDeviceId = startingMessage.content.from_device;
}
}
private async subscribeToTimeline() {
const timeline = await this.room.openTimeline();
this.track(() => timeline.release());
this.track(
timeline.entries.subscribe({
onAdd: async (_, entry: EventEntry) => {
this.handleRoomMessage(entry);
},
onRemove: () => { /** noop */ },
onUpdate: () => { /** noop */ },
})
);
}
get cancellation(): IChannel["cancellation"] {
return this._cancellation;
};
get isCancelled(): boolean {
return !!this._cancellation;
}
async send(eventType: VerificationEventType, content: any, log: ILogItem): Promise<void> {
await log.wrap("RoomChannel.send", async () => {
if (this.isCancelled) {
throw new VerificationCancelledError();
}
if (eventType === VerificationEventType.Request) {
// Handle this case specially
await this.handleRequestEventSpecially(eventType, content, log);
return;
}
Object.assign(content, createReference(this.id));
await this.room.sendEvent(eventType, content, undefined, log);
this.sentMessages.set(eventType, {content});
});
}
private async handleRequestEventSpecially(eventType: VerificationEventType, content: any, log: ILogItem) {
await log.wrap("RoomChannel.handleRequestEventSpecially", async () => {
Object.assign(content, {
body: `${this.otherUserId} is requesting to verify your key, but your client does not support in-chat key verification. You will need to use legacy key verification to verify keys.`,
msgtype: VerificationEventType.Request,
to: this.otherUserId,
});
const pendingEvent = await this.room.sendEvent("m.room.message", content, undefined, log);
this.id = await pendingEvent.getRemoteId();
this.sentMessages.set(eventType, {content});
});
}
getReceivedMessage(event: VerificationEventType) {
return this.receivedMessages.get(event);
}
getSentMessage(event: VerificationEventType) {
return this.sentMessages.get(event);
}
get acceptMessage(): any {
return this.receivedMessages.get(VerificationEventType.Accept) ??
this.sentMessages.get(VerificationEventType.Accept);
}
private async handleRoomMessage(entry: EventEntry) {
const type = entry.content?.msgtype ?? entry.eventType;
if (!type?.startsWith("m.key.verification") ||
entry.sender === this.ourUserId ||
entry.isLoadedFromStorage) {
return;
}
console.log("entry", entry);
await this.log.wrap("RoomChannel.handleRoomMessage", async (log) => {
if (!this.id) {
throw new Error("Couldn't find event-id of request message!");
}
if (getRelatedEventId(entry.event) !== this.id) {
/**
* When a device receives an unknown transaction_id, it should send an appropriate
* m.key.verification.cancel message to the other device indicating as such.
* This does not apply for inbound m.key.verification.start or m.key.verification.cancel messages.
*/
console.log("Received entry with unknown transaction id: ", entry);
await this.cancelVerification(CancelReason.UnknownTransaction);
return;
}
this.resolveAnyWaits(entry);
this.receivedMessages.set(entry.eventType, entry);
if (entry.eventType === VerificationEventType.Ready) {
const fromDevice = entry.content.from_device;
this.otherUserDeviceId = fromDevice;
return;
}
if (entry.eventType === VerificationEventType.Cancel) {
this._cancellation = { code: entry.content.code, cancelledByUs: false };
this.dispose();
return;
}
});
}
async cancelVerification(cancellationType: CancelReason) {
await this.log.wrap("RoomChannel.cancelVerification", async log => {
log.log({ reason: messageFromErrorType[cancellationType] });
if (this.isCancelled) {
throw new VerificationCancelledError();
}
const content = {
code: cancellationType,
reason: messageFromErrorType[cancellationType],
}
await this.send(VerificationEventType.Cancel, content, log);
this._cancellation = { code: cancellationType, cancelledByUs: true };
this.dispose();
});
}
private resolveAnyWaits(entry: EventEntry) {
const { eventType } = entry;
const wait = this.waitMap.get(eventType);
if (wait) {
wait.resolve(entry);
this.waitMap.delete(eventType);
}
}
waitForEvent(eventType: VerificationEventType): Promise<any> {
if (this.isCancelled) {
throw new VerificationCancelledError();
}
// Check if we already received the message
const receivedMessage = this.receivedMessages.get(eventType);
if (receivedMessage) {
return Promise.resolve(receivedMessage);
}
// Check if we're already waiting for this message
const existingWait = this.waitMap.get(eventType);
if (existingWait) {
return existingWait.promise;
}
const deferred = new Deferred();
this.waitMap.set(eventType, deferred);
return deferred.promise;
}
setStartMessage(entry) {
if (!entry.content["m.relates_to"]) {
const clone = entry.clone();
clone.content["m.relates_to"] = clone.event.content["m.relates_to"];
this.startMessage = clone;
}
else {
this.startMessage = entry;
}
this._initiatedByUs = entry.content.from_device === this.ourDeviceId;
}
get initiatedByUs(): boolean {
return this._initiatedByUs;
};
}

View File

@ -19,42 +19,14 @@ import type {DeviceTracker} from "../../../e2ee/DeviceTracker.js";
import type {ILogItem} from "../../../../logging/types";
import type {Clock} from "../../../../platform/web/dom/Clock.js";
import type {DeviceMessageHandler} from "../../../DeviceMessageHandler.js";
import type {IChannel} from "./IChannel";
import {messageFromErrorType} from "./IChannel";
import {makeTxnId} from "../../../common.js";
import {CancelReason, VerificationEventType} from "./types";
import {Disposables} from "../../../../utils/Disposables";
import {VerificationCancelledError} from "../VerificationCancelledError";
import {Deferred} from "../../../../utils/Deferred";
const messageFromErrorType = {
[CancelReason.UserCancelled]: "User declined",
[CancelReason.InvalidMessage]: "Invalid Message.",
[CancelReason.KeyMismatch]: "Key Mismatch.",
[CancelReason.OtherDeviceAccepted]: "Another device has accepted this request.",
[CancelReason.TimedOut]: "Timed Out",
[CancelReason.UnexpectedMessage]: "Unexpected Message.",
[CancelReason.UnknownMethod]: "Unknown method.",
[CancelReason.UnknownTransaction]: "Unknown Transaction.",
[CancelReason.UserMismatch]: "User Mismatch",
[CancelReason.MismatchedCommitment]: "Hash commitment does not match.",
[CancelReason.MismatchedSAS]: "Emoji/decimal does not match.",
}
export interface IChannel {
send(eventType: VerificationEventType, content: any, log: ILogItem): Promise<void>;
waitForEvent(eventType: VerificationEventType): Promise<any>;
getSentMessage(event: VerificationEventType): any;
getReceivedMessage(event: VerificationEventType): any;
setStartMessage(content: any): void;
cancelVerification(cancellationType: CancelReason): Promise<void>;
acceptMessage: any;
startMessage: any;
initiatedByUs: boolean;
isCancelled: boolean;
cancellation?: { code: CancelReason, cancelledByUs: boolean };
id: string;
otherUserDeviceId: string;
}
type Options = {
hsApi: HomeServerApi;
deviceTracker: DeviceTracker;

View File

@ -16,8 +16,7 @@ limitations under the License.
import type {ILogItem} from "../../../../logging/types";
import type {Account} from "../../../e2ee/Account.js";
import type {DeviceTracker} from "../../../e2ee/DeviceTracker.js";
import type {CrossSigning} from "../../CrossSigning";
import {IChannel} from "../channel/Channel";
import {IChannel} from "../channel/IChannel";
import {HomeServerApi} from "../../../net/HomeServerApi";
import {SASProgressEvents} from "../types";
import {EventEmitter} from "../../../../utils/EventEmitter";
@ -34,7 +33,6 @@ export type Options = {
deviceTracker: DeviceTracker;
hsApi: HomeServerApi;
eventEmitter: EventEmitter<SASProgressEvents>
crossSigning: CrossSigning
}
export abstract class BaseSASVerificationStage {

View File

@ -75,7 +75,9 @@ export class CalculateSASStage extends BaseSASVerificationStage {
const sasBytes = this.generateSASBytes();
this.emoji = generateEmojiSas(Array.from(sasBytes));
this.eventEmitter.emit("EmojiGenerated", this);
await emojiConfirmationPromise;
const cancellationReceived = this.channel.waitForEvent(VerificationEventType.Cancel);
// Don't get stuck on waiting for user input!
await Promise.race([emojiConfirmationPromise, cancellationReceived]);
this.setNextStage(new SendMacStage(this.options));
});
}

View File

@ -68,11 +68,8 @@ export class VerifyMacStage extends BaseSASVerificationStage {
const deviceIdOrMSK = keyId.split(":", 2)[1];
const device = await this.deviceTracker.deviceForId(userId, deviceIdOrMSK, this.hsApi, log);
if (device) {
if (verifier(keyId, getDeviceEd25519Key(device), keyInfo)) {
await log.wrap("signing device", async log => {
const signedKey = await this.options.crossSigning.signDevice(device.device_id, log);
log.set("success", !!signedKey);
});
if (!verifier(keyId, getDeviceEd25519Key(device), keyInfo)) {
throw new Error(`MAC verification failed for key ${keyInfo}`);
}
} else {
// If we were not able to find the device, then deviceIdOrMSK is actually the MSK!
@ -82,11 +79,8 @@ export class VerifyMacStage extends BaseSASVerificationStage {
throw new Error("Fetching MSK for user failed!");
}
const masterKey = getKeyEd25519Key(key);
if(masterKey && verifier(keyId, masterKey, keyInfo)) {
await log.wrap("signing user", async log => {
const signedKey = await this.options.crossSigning.signUser(userId, log);
log.set("success", !!signedKey);
});
if(!(masterKey && verifier(keyId, masterKey, keyInfo))) {
throw new Error(`MAC verification failed for key ${keyInfo}`);
}
}
}

View File

@ -13,7 +13,7 @@ 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 {IChannel} from "./channel/Channel";
import type {IChannel} from "./channel/IChannel";
import type {CalculateSASStage} from "./stages/CalculateSASStage";
import type {SelectVerificationMethodStage} from "./stages/SelectVerificationMethodStage";

View File

@ -1399,6 +1399,10 @@ button.RoomDetailsView_row::after {
justify-content: center;
}
.VerifyEmojisView {
width: 100%;
}
.VerificationCompleteView,
.DeviceVerificationView,
.SelectMethodView {
@ -1406,6 +1410,14 @@ button.RoomDetailsView_row::after {
align-items: center;
justify-content: center;
flex-direction: column;
width: 100%;
}
.SelectMethodView > div,
.SelectMethodView__heading,
.SelectMethodView__title {
width: inherit;
box-sizing: border-box;
}
.VerificationCompleteView__heading,
@ -1427,6 +1439,15 @@ button.RoomDetailsView_row::after {
padding: 16px;
}
.VerificationCancelledView__title,
.VerificationCompleteView__title,
.VerifyEmojisView__title,
.SelectMethodView__title,
.WaitingForOtherUserView__title {
font-size: 1.5em;
text-align: center;
}
.VerificationCompleteView__title,
.VerifyEmojisView__title,
.SelectMethodView__title,
@ -1454,6 +1475,7 @@ button.RoomDetailsView_row::after {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.EmojiContainer__emoji {
@ -1482,3 +1504,66 @@ button.RoomDetailsView_row::after {
width: 128px;
height: 128px;
}
.SelectMethodView__name {
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
display: inline-block;
}
.VerificationInProgressTileView,
.VerificationCompletedTileView,
.VerificationCancelledTileView,
.VerificationReadyTileView {
display: flex;
gap: 20px;
}
.VerificationTileView__actions {
display: flex;
gap: 16px;
}
.VerificationTileView__description {
display: flex;
align-items: center;
font-weight: bold;
font-size: 1.4rem;
color: var(--text-color);
gap: 4px;
}
.VerificationInProgressTileView,
.VerificationCompletedTileView,
.VerificationCancelledTileView,
.VerificationReadyTileView {
background: var(--background-color-primary--darker-5);
padding: 12px;
box-sizing: border-box;
border-radius: 8px;
}
.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;
}

View File

@ -56,6 +56,9 @@ export class MemberDetailsView extends TemplateView {
t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`)
];
if (vm.features.crossSigning) {
if (vm.canVerifyUser) {
options.push(t.button({ className: "text", onClick: () => vm.verifyUser() }, vm.i18n`Verify`));
}
const onClick = () => {
if (confirm("You don't want to do this with any account but a test account. This will cross-sign this user without verifying their keys first. You won't be able to undo this apart from resetting your cross-signing keys.")) {
vm.signUser();

View File

@ -19,6 +19,7 @@ import {RoomDetailsView} from "./RoomDetailsView.js";
import {MemberListView} from "./MemberListView.js";
import {LoadingView} from "../../general/LoadingView.js";
import {MemberDetailsView} from "./MemberDetailsView.js";
import {DeviceVerificationView} from "../verification/DeviceVerificationView";
export class RightPanelView extends TemplateView {
render(t) {
@ -39,6 +40,8 @@ export class RightPanelView extends TemplateView {
return new MemberListView(vm);
case "member-details":
return new MemberDetailsView(vm);
case "verification":
return new DeviceVerificationView(vm);
default:
return new LoadingView();
}
@ -53,7 +56,7 @@ class ButtonsView extends TemplateView {
className: {
"back": true,
"button-utility": true,
"hide": !vm.activeViewModel.shouldShowBackButton
"hide": (vm) => !vm.activeViewModel.shouldShowBackButton
}, onClick: () => vm.showPreviousPanel()}),
t.button({className: "close button-utility", onClick: () => vm.closePanel()})
]);

View File

@ -15,31 +15,28 @@ limitations under the License.
*/
import {ListView} from "../../general/ListView";
import type {IView} from "../../general/types";
import {TemplateView, Builder} from "../../general/TemplateView";
import {IObservableValue} from "../../general/BaseUpdateView";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.js";
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
import {BaseObservableList as ObservableList} from "../../../../../observable/list/BaseObservableList";
import type {IView} from "../../general/types";
import type {ITile} from "../../../../../domain/session/room/timeline/tiles/ITile";
export interface TileView extends IView {
readonly value: SimpleTile;
readonly value: ITile;
onClick(event: UIEvent);
}
export type TileViewConstructor = new (
tile: SimpleTile,
tile: ITile,
viewClassForTile: ViewClassForEntryFn,
renderFlags?: { reply?: boolean, interactive?: boolean }
) => TileView;
export type ViewClassForEntryFn = (tile: SimpleTile) => TileViewConstructor;
export type ViewClassForEntryFn = (tile: ITile) => TileViewConstructor;
//import {TimelineViewModel} from "../../../../../domain/session/room/timeline/TimelineViewModel.js";
export interface TimelineViewModel extends IObservableValue {
showJumpDown: boolean;
tiles: ObservableList<SimpleTile>;
setVisibleTileRange(start?: SimpleTile, end?: SimpleTile);
tiles: ObservableList<ITile>;
setVisibleTileRange(start?: ITile, end?: ITile);
}
function bottom(node: HTMLElement): number {
@ -184,11 +181,11 @@ export class TimelineView extends TemplateView<TimelineViewModel> {
}
}
class TilesListView extends ListView<SimpleTile, TileView> {
class TilesListView extends ListView<ITile, TileView> {
private onChanged: () => void;
constructor(tiles: ObservableList<SimpleTile>, onChanged: () => void, private readonly viewClassForTile: ViewClassForEntryFn) {
constructor(tiles: ObservableList<ITile>, onChanged: () => void, private readonly viewClassForTile: ViewClassForEntryFn) {
super({
list: tiles,
onItemClick: (tileView, evt) => tileView.onClick(evt),
@ -204,7 +201,7 @@ class TilesListView extends ListView<SimpleTile, TileView> {
this.onChanged();
}
onUpdate(index: number, value: SimpleTile, param: any) {
onUpdate(index: number, value: ITile, param: any) {
if (param === "shape") {
const ExpectedClass = this.viewClassForTile(value);
const child = this.getChildInstanceByIndex(index);
@ -220,17 +217,17 @@ class TilesListView extends ListView<SimpleTile, TileView> {
this.onChanged();
}
onAdd(idx: number, value: SimpleTile) {
onAdd(idx: number, value: ITile) {
super.onAdd(idx, value);
this.onChanged();
}
onRemove(idx: number, value: SimpleTile) {
onRemove(idx: number, value: ITile) {
super.onRemove(idx, value);
this.onChanged();
}
onMove(fromIdx: number, toIdx: number, value: SimpleTile) {
onMove(fromIdx: number, toIdx: number, value: ITile) {
super.onMove(fromIdx, toIdx, value);
this.onChanged();
}

View File

@ -22,11 +22,12 @@ import {LocationView} from "./timeline/LocationView.js";
import {MissingAttachmentView} from "./timeline/MissingAttachmentView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js";
import {RedactedView} from "./timeline/RedactedView.js";
import {ITile, TileShape} from "../../../../../domain/session/room/timeline/tiles/ITile.js";
import {ITile, TileShape} from "../../../../../domain/session/room/timeline/tiles/ITile";
import {GapView} from "./timeline/GapView.js";
import {CallTileView} from "./timeline/CallTileView";
import {DateHeaderView} from "./timeline/DateHeaderView";
import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView";
import {VerificationTileView} from "./timeline/VerificationTileView";
import type {TileViewConstructor} from "./TimelineView";
export function viewClassForTile(vm: ITile): TileViewConstructor {
switch (vm.shape) {
@ -53,6 +54,8 @@ export function viewClassForTile(vm: ITile): TileViewConstructor {
return CallTileView;
case TileShape.DateHeader:
return DateHeaderView;
case TileShape.Verification:
return VerificationTileView;
default:
throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`);
}

View File

@ -15,15 +15,9 @@ limitations under the License.
*/
import {TemplateView} from "../../../general/TemplateView";
import {spinner} from "../../../common.js";
import type {DateTile} from "../../../../../../domain/session/room/timeline/tiles/DateTile";
export class DateHeaderView extends TemplateView<DateTile> {
// ignore other argument
constructor(vm) {
super(vm);
}
render(t, vm) {
return t.h2({className: "DateHeader"}, t.time({dateTime: vm.machineReadableDate}, vm.relativeDate));
}

View File

@ -0,0 +1,106 @@
/*
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 {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<VerificationTile> {
render(t: Builder<VerificationTile>, vm: VerificationTile) {
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);
}
})
);
}
onClick(evt) {
// Propagate click events to the sub-view
this._subViews?.forEach((s: IClickableView) => s.onClick?.(evt));
}
}
class VerificationReadyTileView extends TemplateView<VerificationTile> {
render(t: Builder<VerificationTile>, 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();
} else if (evt.target.classList.contains("VerificationTileView__reject")) {
this.value.reject();
}
}
}
class VerificationCancelledTileView extends TemplateView<VerificationTile> {
render(t: Builder<VerificationTile>, 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<VerificationTile> {
render(t: Builder<VerificationTile>, 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<VerificationTile> {
render(t: Builder<VerificationTile>, vm: VerificationTile) {
return t.div({ className: "VerificationInProgressTileView" }, [
t.div({ className: "VerificationTileView__description" },
vm.i18n`Verification in progress`),
t.div({ className: "VerificationTileView__actions" }, [
spinner(t)
]),
]);
}
}

View File

@ -27,11 +27,9 @@ export class SelectMethodView extends TemplateView<SelectMethodViewModel> {
}
else return t.div([
t.div({ className: "SelectMethodView__heading" }, [
t.h2( { className: "SelectMethodView__title" }, vm.i18n`Verify device '${vm.deviceName}' by comparing emojis?`),
t.h2( { className: "SelectMethodView__title" }, this.getHeading(t, vm)),
]),
t.p({ className: "SelectMethodView__description" },
vm.i18n`You are about to verify your other device by comparing emojis.`
),
t.p({ className: "SelectMethodView__description" }, this.getSubheading(vm)),
t.div({ className: "SelectMethodView__actions" }, [
t.button(
{
@ -59,4 +57,24 @@ export class SelectMethodView extends TemplateView<SelectMethodViewModel> {
}),
]);
}
getHeading(t: Builder<SelectMethodViewModel>, vm: SelectMethodViewModel) {
if (vm.isCrossSigningAnotherUser) {
return [vm.i18n`Verify user `, t.span({
className: "SelectMethodView__name"
}, vm.otherUserId), vm.i18n` by comparing emojis?`];
} else {
return [vm.i18n`Verify device`, t.span({
className: "SelectMethodView__name"
}, vm.deviceName), vm.i18n` by comparing emojis?`];
}
}
getSubheading(vm: SelectMethodViewModel): string {
if (vm.isCrossSigningAnotherUser) {
return vm.i18n`You are about to verify user (${vm.otherUserId}) by comparing emojis.`;
} else {
return vm.i18n`You are about to verify your other device (${vm.deviceName}) by comparing emojis.`;
}
}
}

View File

@ -41,7 +41,7 @@ export class VerificationCancelledView extends TemplateView<VerificationCancelle
"button-action": true,
"primary": true,
},
onclick: () => vm.gotoSettings(),
onclick: () => vm.dismiss(),
}, "Got it")
]),
]

View File

@ -28,16 +28,14 @@ export class VerificationCompleteView extends TemplateView<VerificationCompleteV
),
]),
t.p(
{ className: "VerificationCompleteView__description" },
vm.i18n`You successfully verified device ${vm.otherDeviceId}`
),
{ className: "VerificationCompleteView__description" }, vm.verificationSuccessfulMessage),
t.div({ className: "VerificationCompleteView__actions" }, [
t.button({
className: {
"button-action": true,
"primary": true,
},
onclick: () => vm.gotoSettings(),
onclick: () => vm.dismiss(),
}, "Got it")
]),
]);