diff --git a/src/DeviceListener.js b/src/DeviceListener.js index 1b451310b9..c4beb6c01e 100644 --- a/src/DeviceListener.js +++ b/src/DeviceListener.js @@ -24,6 +24,10 @@ const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; const THIS_DEVICE_TOAST_KEY = 'setupencryption'; const OTHER_DEVICES_TOAST_KEY = 'reviewsessions'; +function toastKey(deviceId) { + return "unverified_session_" + deviceId; +} + export default class DeviceListener { static sharedInstance() { if (!global.mx_DeviceListener) global.mx_DeviceListener = new DeviceListener(); @@ -39,9 +43,18 @@ export default class DeviceListener { // cache of the key backup info this._keyBackupInfo = null; this._keyBackupFetchedAt = null; + + // We keep a list of our own device IDs so we can batch ones that were already + // there the last time the app launched into a single toast, but display new + // ones in their own toasts. + this._ourDeviceIdsAtStart = null; + + // The set of device IDs we're currently displaying toasts for + this._displayingToastsForDeviceIds = new Set(); } start() { + MatrixClientPeg.get().on('crypto.willUpdateDevices', this._onWillUpdateDevices); MatrixClientPeg.get().on('crypto.devicesUpdated', this._onDevicesUpdated); MatrixClientPeg.get().on('deviceVerificationChanged', this._onDeviceVerificationChanged); MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged); @@ -53,6 +66,7 @@ export default class DeviceListener { stop() { if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener('crypto.willUpdateDevices', this._onWillUpdateDevices); MatrixClientPeg.get().removeListener('crypto.devicesUpdated', this._onDevicesUpdated); MatrixClientPeg.get().removeListener('deviceVerificationChanged', this._onDeviceVerificationChanged); MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged); @@ -66,10 +80,15 @@ export default class DeviceListener { this._keyBackupFetchedAt = null; } - async dismissVerifications() { - const cli = MatrixClientPeg.get(); - const devices = await cli.getStoredDevicesForUser(cli.getUserId()); - this._dismissed = new Set(devices.filter(d => d.deviceId !== cli.deviceId).map(d => d.deviceId)); + /** + * Dismiss notifications about our own unverified devices + * + * @param {String[]} deviceIds List of device IDs to dismiss notifications for + */ + async dismissUnverifiedSessions(deviceIds) { + for (const d of deviceIds) { + this._dismissed.add(d); + } this._recheck(); } @@ -79,6 +98,23 @@ export default class DeviceListener { this._recheck(); } + _ensureDeviceIdsAtStartPopulated() { + if (this._ourDeviceIdsAtStart === null) { + const cli = MatrixClientPeg.get(); + this._ourDeviceIdsAtStart = new Set( + cli.getStoredDevicesForUser(cli.getUserId()).map(d => d.deviceId), + ); + } + } + + _onWillUpdateDevices = async (users) => { + const myUserId = MatrixClientPeg.get().getUserId(); + if (users.includes(myUserId)) this._ensureDeviceIdsAtStartPopulated(); + + // No need to do a recheck here: we just need to get a snapshot of our devices + // before we download any new ones. + } + _onDevicesUpdated = (users) => { if (!users.includes(MatrixClientPeg.get().getUserId())) return; this._recheck(); @@ -143,6 +179,8 @@ export default class DeviceListener { const crossSigningReady = await cli.isCrossSigningReady(); + this._ensureDeviceIdsAtStartPopulated(); + if (this._dismissedThisDeviceToast) { ToastStore.sharedInstance().dismissToast(THIS_DEVICE_TOAST_KEY); } else { @@ -197,32 +235,65 @@ export default class DeviceListener { } } + // Unverified devices that were there last time the app ran + // (technically could just be a boolean: we don't actually + // need to remember the device IDs, but for the sake of + // symmetry...). + const oldUnverifiedDeviceIds = new Set(); + // Unverified devices that have appeared since then + const newUnverifiedDeviceIds = new Set(); + // as long as cross-signing isn't ready, // you can't see or dismiss any device toasts if (crossSigningReady) { - let haveUnverifiedDevices = false; - - const devices = await cli.getStoredDevicesForUser(cli.getUserId()); + const devices = 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.isCrossSigningVerified() && !this._dismissed.has(device.deviceId)) { - haveUnverifiedDevices = true; - break; + if (this._ourDeviceIdsAtStart.has(device.deviceId)) { + oldUnverifiedDeviceIds.add(device.deviceId); + } else { + newUnverifiedDeviceIds.add(device.deviceId); + } } } + } - if (haveUnverifiedDevices) { - ToastStore.sharedInstance().addOrReplaceToast({ - key: OTHER_DEVICES_TOAST_KEY, - title: _t("Review where you’re logged in"), - icon: "verification_warning", - component: sdk.getComponent("toasts.UnverifiedSessionToast"), - }); - } else { - ToastStore.sharedInstance().dismissToast(OTHER_DEVICES_TOAST_KEY); + // Display or hide the batch toast for old unverified sessions + if (oldUnverifiedDeviceIds.size > 0) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: OTHER_DEVICES_TOAST_KEY, + title: _t("Review where you’re logged in"), + icon: "verification_warning", + props: { + deviceIds: oldUnverifiedDeviceIds, + }, + component: sdk.getComponent("toasts.BulkUnverifiedSessionsToast"), + }); + } else { + ToastStore.sharedInstance().dismissToast(OTHER_DEVICES_TOAST_KEY); + } + + // Show toasts for new unverified devices if they aren't already there + for (const deviceId of newUnverifiedDeviceIds) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: toastKey(deviceId), + title: _t("New login. Was this you?"), + icon: "verification_warning", + props: { deviceId }, + component: sdk.getComponent("toasts.UnverifiedSessionToast"), + }); + } + + // ...and hide any we don't need any more + for (const deviceId of this._displayingToastsForDeviceIds) { + if (!newUnverifiedDeviceIds.has(deviceId)) { + ToastStore.sharedInstance().dismissToast(toastKey(deviceId)); } } + + this._displayingToastsForDeviceIds = newUnverifiedDeviceIds; } } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 7b9bbeface..bd7e60e2f4 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -836,7 +836,7 @@ export const Commands = [ const fingerprint = matches[3]; return success((async () => { - const device = await cli.getStoredDevice(userId, deviceId); + const device = cli.getStoredDevice(userId, deviceId); if (!device) { throw new Error(_t('Unknown (user, session) pair:') + ` (${userId}, ${deviceId})`); } diff --git a/src/components/views/right_panel/EncryptionPanel.js b/src/components/views/right_panel/EncryptionPanel.js index 476b6cace9..bc580c767b 100644 --- a/src/components/views/right_panel/EncryptionPanel.js +++ b/src/components/views/right_panel/EncryptionPanel.js @@ -22,7 +22,6 @@ import VerificationPanel from "./VerificationPanel"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {ensureDMExists} from "../../../createRoom"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; -import {useAsyncMemo} from "../../../hooks/useAsyncMemo"; import Modal from "../../../Modal"; import {PHASE_REQUESTED, PHASE_UNSENT} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import * as sdk from "../../../index"; @@ -47,10 +46,7 @@ const EncryptionPanel = (props) => { }, [verificationRequest]); const deviceId = request && request.channel.deviceId; - const device = useAsyncMemo(() => { - const cli = MatrixClientPeg.get(); - return cli.getStoredDevice(cli.getUserId(), deviceId); - }, [deviceId]); + const device = MatrixClientPeg.get().getStoredDevice(MatrixClientPeg.get().getUserId(), deviceId); useEffect(() => { async function awaitPromise() { diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.js index cafbf05a23..61f5a8161a 100644 --- a/src/components/views/right_panel/UserInfo.js +++ b/src/components/views/right_panel/UserInfo.js @@ -1110,7 +1110,7 @@ export const useDevices = (userId) => { async function _downloadDeviceList() { try { await cli.downloadKeys([userId], true); - const devices = await cli.getStoredDevicesForUser(userId); + const devices = cli.getStoredDevicesForUser(userId); if (cancelled) { // we got cancelled - presumably a different user now @@ -1135,7 +1135,7 @@ export const useDevices = (userId) => { useEffect(() => { let cancel = false; const updateDevices = async () => { - const newDevices = await cli.getStoredDevicesForUser(userId); + const newDevices = cli.getStoredDevicesForUser(userId); if (cancel) return; setDevices(newDevices); }; diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 9fdd2abedf..be3e8cf971 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -160,13 +160,10 @@ export default createReactClass({ // no need to re-download the whole thing; just update our copy of // the list. - // Promise.resolve to handle transition from static result to promise; can be removed - // in future - Promise.resolve(this.context.getStoredDevicesForUser(userId)).then((devices) => { - this.setState({ - devices: devices, - e2eStatus: this._getE2EStatus(devices), - }); + const devices = this.context.getStoredDevicesForUser(userId); + this.setState({ + devices: devices, + e2eStatus: this._getE2EStatus(devices), }); } }, diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index d830624f8a..1c609afcaa 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -129,7 +129,7 @@ export default createReactClass({ return; } - const devices = await cli.getStoredDevicesForUser(userId); + const devices = cli.getStoredDevicesForUser(userId); const anyDeviceUnverified = devices.some(device => { const { deviceId } = device; // For your own devices, we use the stricter check of cross-signing diff --git a/src/components/views/toasts/BulkUnverifiedSessionsToast.js b/src/components/views/toasts/BulkUnverifiedSessionsToast.js new file mode 100644 index 0000000000..b16dc87f21 --- /dev/null +++ b/src/components/views/toasts/BulkUnverifiedSessionsToast.js @@ -0,0 +1,56 @@ +/* +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 { _t } from '../../../languageHandler'; +import dis from "../../../dispatcher"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import DeviceListener from '../../../DeviceListener'; +import FormButton from '../elements/FormButton'; +import { replaceableComponent } from '../../../utils/replaceableComponent'; + +@replaceableComponent("views.toasts.BulkUnverifiedSessionsToast") +export default class BulkUnverifiedSessionsToast extends React.PureComponent { + static propTypes = { + deviceIds: PropTypes.array, + } + + _onLaterClick = () => { + DeviceListener.sharedInstance().dismissUnverifiedSessions(this.props.deviceIds); + }; + + _onReviewClick = async () => { + DeviceListener.sharedInstance().dismissUnverifiedSessions(this.props.deviceIds); + + dis.dispatch({ + action: 'view_user_info', + userId: MatrixClientPeg.get().getUserId(), + }); + }; + + render() { + return (