diff --git a/package.json b/package.json index 16e7f943f1..3686966870 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "@peculiar/webcrypto": "^1.0.22", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", - "chokidar": "^2.1.2", + "chokidar": "^3.3.1", "concurrently": "^4.0.1", "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.15.1", diff --git a/res/css/_components.scss b/res/css/_components.scss index a9a114a4cf..60f749de9c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -57,13 +57,13 @@ @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; -@import "./views/dialogs/_DMInviteDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DeviceVerifyDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_EncryptedEventDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; +@import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; 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/res/css/views/dialogs/_DMInviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss similarity index 82% rename from res/css/views/dialogs/_DMInviteDialog.scss rename to res/css/views/dialogs/_InviteDialog.scss index f806e85120..d0b53b7766 100644 --- a/res/css/views/dialogs/_DMInviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_DMInviteDialog_addressBar { +.mx_InviteDialog_addressBar { display: flex; flex-direction: row; - .mx_DMInviteDialog_editor { + .mx_InviteDialog_editor { flex: 1; width: 100%; // Needed to make the Field inside grow background-color: $user-tile-hover-bg-color; @@ -28,7 +28,7 @@ limitations under the License. overflow-x: hidden; overflow-y: auto; - .mx_DMInviteDialog_userTile { + .mx_InviteDialog_userTile { display: inline-block; float: left; position: relative; @@ -61,15 +61,26 @@ limitations under the License. } } - .mx_DMInviteDialog_goButton { + .mx_InviteDialog_goButton { width: 48px; margin-left: 10px; height: 25px; line-height: 25px; } + + .mx_InviteDialog_buttonAndSpinner { + .mx_Spinner { + // Width and height are required to trick the layout engine. + width: 20px; + height: 20px; + margin-left: 5px; + display: inline-block; + vertical-align: middle; + } + } } -.mx_DMInviteDialog_section { +.mx_InviteDialog_section { padding-bottom: 10px; h3 { @@ -80,7 +91,7 @@ limitations under the License. } } -.mx_DMInviteDialog_roomTile { +.mx_InviteDialog_roomTile { cursor: pointer; padding: 5px 10px; @@ -93,7 +104,7 @@ limitations under the License. vertical-align: middle; } - .mx_DMInviteDialog_roomTile_avatarStack { + .mx_InviteDialog_roomTile_avatarStack { display: inline-block; position: relative; width: 36px; @@ -106,7 +117,7 @@ limitations under the License. } } - .mx_DMInviteDialog_roomTile_selected { + .mx_InviteDialog_roomTile_selected { width: 36px; height: 36px; border-radius: 36px; @@ -130,20 +141,20 @@ limitations under the License. } } - .mx_DMInviteDialog_roomTile_name { + .mx_InviteDialog_roomTile_name { font-weight: 600; font-size: 14px; color: $primary-fg-color; margin-left: 7px; } - .mx_DMInviteDialog_roomTile_userId { + .mx_InviteDialog_roomTile_userId { font-size: 12px; color: $muted-fg-color; margin-left: 7px; } - .mx_DMInviteDialog_roomTile_time { + .mx_InviteDialog_roomTile_time { text-align: right; font-size: 12px; color: $muted-fg-color; @@ -151,16 +162,16 @@ limitations under the License. line-height: 36px; // Height of the avatar to keep the time vertically aligned } - .mx_DMInviteDialog_roomTile_highlight { + .mx_InviteDialog_roomTile_highlight { font-weight: 900; } } // Many of these styles are stolen from mx_UserPill, but adjusted for the invite dialog. -.mx_DMInviteDialog_userTile { +.mx_InviteDialog_userTile { margin-right: 8px; - .mx_DMInviteDialog_userTile_pill { + .mx_InviteDialog_userTile_pill { background-color: $username-variant1-color; border-radius: 12px; display: inline-block; @@ -170,27 +181,27 @@ limitations under the License. padding-right: 8px; color: #ffffff; // this is fine without a var because it's for both themes - .mx_DMInviteDialog_userTile_avatar { + .mx_InviteDialog_userTile_avatar { border-radius: 20px; position: relative; left: -5px; top: 2px; } - img.mx_DMInviteDialog_userTile_avatar { + img.mx_InviteDialog_userTile_avatar { vertical-align: top; } - .mx_DMInviteDialog_userTile_name { + .mx_InviteDialog_userTile_name { vertical-align: top; } - .mx_DMInviteDialog_userTile_threepidAvatar { + .mx_InviteDialog_userTile_threepidAvatar { background-color: #ffffff; // this is fine without a var because it's for both themes } } - .mx_DMInviteDialog_userTile_remove { + .mx_InviteDialog_userTile_remove { display: inline-block; margin-left: 4px; } 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/KeyRequestHandler.js b/src/KeyRequestHandler.js index 65dc7fdb0f..30f3b7d50e 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +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. @@ -16,7 +17,10 @@ limitations under the License. import * as sdk from './index'; import Modal from './Modal'; +import SettingsStore from './settings/SettingsStore'; +// TODO: We can remove this once cross-signing is the only way. +// https://github.com/vector-im/riot-web/issues/11908 export default class KeyRequestHandler { constructor(matrixClient) { this._matrixClient = matrixClient; @@ -30,6 +34,11 @@ export default class KeyRequestHandler { } handleKeyRequest(keyRequest) { + // Ignore own device key requests if cross-signing lab enabled + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + return; + } + const userId = keyRequest.userId; const deviceId = keyRequest.deviceId; const requestId = keyRequest.requestId; @@ -60,6 +69,11 @@ export default class KeyRequestHandler { } handleKeyRequestCancellation(cancellation) { + // Ignore own device key requests if cross-signing lab enabled + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + return; + } + // see if we can find the request in the queue const userId = cancellation.userId; const deviceId = cancellation.deviceId; 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/RoomInvite.js b/src/RoomInvite.js index 2fe64c994f..2eccf69b0f 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017, 2018 New Vector Ltd +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. @@ -26,6 +27,7 @@ import dis from './dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import { _t } from './languageHandler'; import SettingsStore from "./settings/SettingsStore"; +import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; /** * Invites multiple addresses to a room @@ -36,21 +38,19 @@ import SettingsStore from "./settings/SettingsStore"; * @param {string[]} addrs Array of strings of addresses to invite. May be matrix IDs or 3pids. * @returns {Promise} Promise */ -function inviteMultipleToRoom(roomId, addrs) { +export function inviteMultipleToRoom(roomId, addrs) { const inviter = new MultiInviter(roomId); return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); } export function showStartChatInviteDialog() { if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { - const DMInviteDialog = sdk.getComponent("dialogs.DMInviteDialog"); - Modal.createTrackedDialog('Start DM', '', DMInviteDialog, { - onFinished: (inviteIds) => { - // TODO: Replace _onStartDmFinished with less hacks - if (inviteIds.length > 0) _onStartDmFinished(true, inviteIds.map(i => ({address: i}))); - // else ignore and just do nothing - }, - }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); + // This new dialog handles the room creation internally - we don't need to worry about it. + const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); + Modal.createTrackedDialog( + 'Start DM', '', InviteDialog, {kind: KIND_DM}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); return; } @@ -74,6 +74,16 @@ export function showStartChatInviteDialog() { } export function showRoomInviteDialog(roomId) { + if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { + // This new dialog handles the room creation internally - we don't need to worry about it. + const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); + Modal.createTrackedDialog( + 'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); + return; + } + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 0361cdaeb9..7cbec11e08 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -63,6 +63,7 @@ import { countRoomsWithNotif } from '../../RoomNotifs'; import { ThemeWatcher } from "../../theme"; import { storeRoomAliasInCache } from '../../RoomAliasCache'; import { defer } from "../../utils/promise"; +import ToastStore from "../../stores/ToastStore"; /** constants for MatrixChat.state.view */ export const VIEWS = { @@ -1381,6 +1382,8 @@ export default createReactClass({ cli.on("Session.logged_out", () => dft.stop()); cli.on("Event.decrypted", (e, err) => dft.eventDecrypted(e, err)); + // TODO: We can remove this once cross-signing is the only way. + // https://github.com/vector-im/riot-web/issues/11908 const krh = new KeyRequestHandler(cli); cli.on("crypto.roomKeyRequest", (req) => { krh.handleKeyRequest(req); @@ -1453,15 +1456,12 @@ export default createReactClass({ console.log(`MatrixChat got a .request ${request.channel.transactionId}`, request.event.getRoomId()); if (request.pending) { console.log(`emitting toast for verification request with txnid ${request.channel.transactionId}`, request.event && request.event.getId()); - dis.dispatch({ - action: "show_toast", - toast: { - key: request.channel.transactionId, - title: _t("Verification Request"), - icon: "verification", - props: {request}, - component: sdk.getComponent("toasts.VerificationRequestToast"), - }, + ToastStore.sharedInstance().addOrReplaceToast({ + key: 'verifreq_' + request.channel.transactionId, + title: _t("Verification Request"), + icon: "verification", + props: {request}, + component: sdk.getComponent("toasts.VerificationRequestToast"), }); } }); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index ef28dd2c69..9b02f6d503 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -173,6 +173,7 @@ export default createReactClass({ MatrixClientPeg.get().on("accountData", this.onAccountData); MatrixClientPeg.get().on("crypto.keyBackupStatus", this.onKeyBackupStatus); MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged); + MatrixClientPeg.get().on("userTrustStatusChanged", this.onUserVerificationChanged); // Start listening for RoomViewStore updates this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(true); @@ -492,6 +493,7 @@ export default createReactClass({ MatrixClientPeg.get().removeListener("accountData", this.onAccountData); MatrixClientPeg.get().removeListener("crypto.keyBackupStatus", this.onKeyBackupStatus); MatrixClientPeg.get().removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); + MatrixClientPeg.get().removeListener("userTrustStatusChanged", this.onUserVerificationChanged); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -762,6 +764,14 @@ export default createReactClass({ this._updateE2EStatus(room); }, + onUserVerificationChanged: function(userId, _trustStatus) { + const room = this.state.room; + if (!room.currentState.getMember(userId)) { + return; + } + this._updateE2EStatus(room); + }, + _updateE2EStatus: async function(room) { const cli = MatrixClientPeg.get(); if (!cli.isRoomEncrypted(room.roomId)) { @@ -782,32 +792,41 @@ export default createReactClass({ e2eStatus: hasUnverifiedDevices ? "warning" : "verified", }); }); + debuglog("e2e check is warning/verified only as cross-signing is off"); return; } + + /* At this point, the user has encryption on and cross-signing on */ const e2eMembers = await room.getEncryptionTargetMembers(); - for (const member of e2eMembers) { - const { userId } = member; - const userVerified = cli.checkUserTrust(userId).isCrossSigningVerified(); - if (!userVerified) { - this.setState({ - e2eStatus: "warning", - }); - return; - } + const verified = []; + const unverified = []; + e2eMembers.map(({userId}) => userId) + .filter((userId) => userId !== cli.getUserId()) + .forEach((userId) => { + (cli.checkUserTrust(userId).isCrossSigningVerified() ? + verified : unverified).push(userId) + }); + + debuglog("e2e verified", verified, "unverified", unverified); + + /* Check all verified user devices. */ + for (const userId of verified) { const devices = await cli.getStoredDevicesForUser(userId); - const allDevicesVerified = devices.every(device => { - const { deviceId } = device; - return cli.checkDeviceTrust(userId, deviceId).isCrossSigningVerified(); + const allDevicesVerified = devices.every(({deviceId}) => { + return cli.checkDeviceTrust(userId, deviceId).isVerified(); }); if (!allDevicesVerified) { this.setState({ e2eStatus: "warning", }); + debuglog("e2e status set to warning as not all users trust all of their devices." + + " Aborted on user", userId); return; } } + this.setState({ - e2eStatus: "verified", + e2eStatus: unverified.length === 0 ? "verified" : "normal", }); }, diff --git a/src/components/structures/ToastContainer.js b/src/components/structures/ToastContainer.js index a9c8267d0d..8a05f62e61 100644 --- a/src/components/structures/ToastContainer.js +++ b/src/components/structures/ToastContainer.js @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +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. @@ -15,38 +15,26 @@ limitations under the License. */ import * as React from "react"; -import dis from "../../dispatcher"; import { _t } from '../../languageHandler'; +import ToastStore from "../../stores/ToastStore"; import classNames from "classnames"; export default class ToastContainer extends React.Component { constructor() { super(); - this.state = {toasts: []}; + this.state = {toasts: ToastStore.sharedInstance().getToasts()}; } componentDidMount() { - console.log("ToastContainer mounted"); - this._dispatcherRef = dis.register(this.onAction); + ToastStore.sharedInstance().on('update', this._onToastStoreUpdate); } componentWillUnmount() { - dis.unregister(this._dispatcherRef); + ToastStore.sharedInstance().removeListener('update', this._onToastStoreUpdate); } - onAction = (payload) => { - if (payload.action === "show_toast") { - this._addToast(payload.toast); - } - }; - - _addToast(toast) { - this.setState({toasts: this.state.toasts.concat(toast)}); - } - - dismissTopToast = () => { - const [, ...remaining] = this.state.toasts; - this.setState({toasts: remaining}); + _onToastStoreUpdate = () => { + this.setState({toasts: ToastStore.sharedInstance().getToasts()}); }; render() { @@ -63,8 +51,8 @@ export default class ToastContainer extends React.Component { const countIndicator = isStacked ? _t(" (1/%(totalCount)s)", {totalCount}) : null; const toastProps = Object.assign({}, props, { - dismiss: this.dismissTopToast, key, + toastKey: key, }); toast = (
{_t("No results")}
- {_t( - "If you can't find someone, ask them for their username, or share your " + - "username (%(userId)s) or profile link.", - {userId}, - {a: (sub) => {sub}}, - )} -
-{helpText}
+