diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 4c5e746e66..5634a97c53 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -51,7 +51,7 @@ limitations under the License. &.mx_Toast_hasIcon { &::after { content: ""; - width: 20px; + width: 21px; height: 20px; grid-column: 1; grid-row: 1; @@ -64,6 +64,10 @@ limitations under the License. background-color: $primary-fg-color; } + &.mx_Toast_icon_verification_warning::after { + background-image: url("$(res)/img/e2e/warning.svg"); + } + h2, .mx_Toast_body { grid-column: 2; } diff --git a/src/DeviceListener.js b/src/DeviceListener.js new file mode 100644 index 0000000000..15ca931fc8 --- /dev/null +++ b/src/DeviceListener.js @@ -0,0 +1,92 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClientPeg } from './MatrixClientPeg'; +import SettingsStore from './settings/SettingsStore'; +import * as sdk from './index'; +import { _t } from './languageHandler'; +import ToastStore from './stores/ToastStore'; + +function toastKey(device) { + return 'newsession_' + device.deviceId; +} + +export default class DeviceListener { + static sharedInstance() { + if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener(); + return global.mx_DeviceListener; + } + + constructor() { + // device IDs for which the user has dismissed the verify toast ('Later') + this._dismissed = new Set(); + } + + start() { + MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated); + MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged); + this.recheck(); + } + + stop() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated); + MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged); + } + this._dismissed.clear(); + } + + dismissVerification(deviceId) { + this._dismissed.add(deviceId); + this.recheck(); + } + + _onDevicesUpdated = (users) => { + if (!users.includes(MatrixClientPeg.get().getUserId())) return; + if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return; + this.recheck(); + } + + _onDeviceVerificationChanged = (users) => { + if (!users.includes(MatrixClientPeg.get().getUserId())) return; + if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) return; + this.recheck(); + } + + async recheck() { + const cli = MatrixClientPeg.get(); + + if (!cli.isCryptoEnabled()) return false; + + const devices = await cli.getStoredDevicesForUser(cli.getUserId()); + for (const device of devices) { + if (device.deviceId == cli.deviceId) continue; + + const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId); + if (deviceTrust.isVerified() || this._dismissed.has(device.deviceId)) { + ToastStore.sharedInstance().dismissToast(toastKey(device)); + } else { + ToastStore.sharedInstance().addOrReplaceToast({ + key: toastKey(device), + title: _t("New Session"), + icon: "verification_warning", + props: {deviceId: device.deviceId}, + component: sdk.getComponent("toasts.NewSessionToast"), + }); + } + } + } +} diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 0796e326a0..1603c73d25 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -2,6 +2,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -35,8 +36,10 @@ import { sendLoginRequest } from "./Login"; import * as StorageManager from './utils/StorageManager'; import SettingsStore from "./settings/SettingsStore"; import TypingStore from "./stores/TypingStore"; +import ToastStore from "./stores/ToastStore"; import {IntegrationManagers} from "./integrations/IntegrationManagers"; import {Mjolnir} from "./mjolnir/Mjolnir"; +import DeviceListener from "./DeviceListener"; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -575,6 +578,7 @@ async function startMatrixClient(startSyncing=true) { Notifier.start(); UserActivity.sharedInstance().start(); TypingStore.sharedInstance().reset(); // just in case + ToastStore.sharedInstance().reset(); if (!SettingsStore.getValue("lowBandwidth")) { Presence.start(); } @@ -595,6 +599,9 @@ async function startMatrixClient(startSyncing=true) { await MatrixClientPeg.assign(); } + // This needs to be started after crypto is set up + DeviceListener.sharedInstance().start(); + // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. dis.dispatch({action: 'client_started'}); @@ -651,6 +658,7 @@ export function stopMatrixClient(unsetClient=true) { ActiveWidgetStore.stop(); IntegrationManagers.sharedInstance().stopWatching(); Mjolnir.sharedInstance().stop(); + DeviceListener.sharedInstance().stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); EventIndexPeg.stop(); const cli = MatrixClientPeg.get(); diff --git a/src/components/structures/ToastContainer.js b/src/components/structures/ToastContainer.js index bc74133433..8a05f62e61 100644 --- a/src/components/structures/ToastContainer.js +++ b/src/components/structures/ToastContainer.js @@ -22,7 +22,7 @@ import classNames from "classnames"; export default class ToastContainer extends React.Component { constructor() { super(); - this.state = {toasts: []}; + this.state = {toasts: ToastStore.sharedInstance().getToasts()}; } componentDidMount() { diff --git a/src/components/views/toasts/NewSessionToast.js b/src/components/views/toasts/NewSessionToast.js new file mode 100644 index 0000000000..f83326121b --- /dev/null +++ b/src/components/views/toasts/NewSessionToast.js @@ -0,0 +1,57 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import * as sdk from "../../../index"; +import { _t } from '../../../languageHandler'; +import Modal from "../../../Modal"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import DeviceListener from '../../../DeviceListener'; + +export default class VerifySessionToast extends React.PureComponent { + static propTypes = { + toastKey: PropTypes.string.isRequired, + deviceId: PropTypes.string, + }; + + _onLaterClick = () => { + DeviceListener.sharedInstance().dismissVerification(this.props.deviceId); + }; + + _onVerifyClick = async () => { + const cli = MatrixClientPeg.get(); + const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); + + const device = await cli.getStoredDevice(cli.getUserId(), this.props.deviceId); + + Modal.createTrackedDialog('New Session Verify', 'Starting dialog', DeviceVerifyDialog, { + userId: MatrixClientPeg.get().getUserId(), + device, + }, null, /* priority = */ false, /* static = */ true); + }; + + render() { + const FormButton = sdk.getComponent("elements.FormButton"); + return (