From 443744733dc8fbea8c3bb805c29b1dbcb80a2a98 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 14 Jan 2020 23:32:00 -0700 Subject: [PATCH 01/26] Move DM creation logic into DMInviteDialog Fixes https://github.com/vector-im/riot-web/issues/11645 The copy hasn't been reviewed by anyone and could probably use some work. --- res/css/views/dialogs/_DMInviteDialog.scss | 11 +++ src/RoomInvite.js | 14 ++- .../views/dialogs/DMInviteDialog.js | 92 +++++++++++++++++-- src/i18n/strings/en_EN.json | 2 + src/utils/DMRoomMap.js | 21 +++++ 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/res/css/views/dialogs/_DMInviteDialog.scss b/res/css/views/dialogs/_DMInviteDialog.scss index f806e85120..5d58f3ae8b 100644 --- a/res/css/views/dialogs/_DMInviteDialog.scss +++ b/res/css/views/dialogs/_DMInviteDialog.scss @@ -67,6 +67,17 @@ limitations under the License. height: 25px; line-height: 25px; } + + .mx_DMInviteDialog_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 { diff --git a/src/RoomInvite.js b/src/RoomInvite.js index ba9fe1f541..675efe53c8 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -36,21 +36,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")) { + // This new dialog handles the room creation internally - we don't need to worry about it. 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); + Modal.createTrackedDialog( + 'Start DM', '', DMInviteDialog, {}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); return; } diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index 371768eb4e..e82d63acad 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -31,6 +31,8 @@ import {abbreviateUrl} from "../../../utils/UrlUtils"; import dis from "../../../dispatcher"; import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; +import createRoom from "../../../createRoom"; +import {inviteMultipleToRoom} from "../../../RoomInvite"; // TODO: [TravisR] Make this generic for all kinds of invites @@ -295,6 +297,10 @@ export default class DMInviteDialog extends React.PureComponent { threepidResultsMixin: [], // { user: ThreepidMember, userId: string}[], like recents and suggestions canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(), tryingIdentityServer: false, + + // These two flags are used for the 'Go' button to communicate what is going on. + busy: true, + errorText: _t("We couldn't create your DM. Please check the users you want to invite and try again."), }; this._editorRef = createRef(); @@ -381,11 +387,66 @@ export default class DMInviteDialog extends React.PureComponent { } _startDm = () => { - this.props.onFinished(this.state.targets.map(t => t.userId)); + this.setState({busy: true}); + const targetIds = this.state.targets.map(t => t.userId); + + // Check if there is already a DM with these people and reuse it if possible. + const existingRoom = DMRoomMap.shared().getDMRoomForIdentifiers(targetIds); + if (existingRoom) { + dis.dispatch({ + action: 'view_room', + room_id: existingRoom.roomId, + should_peek: false, + joining: false, + }); + this.props.onFinished(); + return; + } + + // Check if it's a traditional DM and create the room if required. + // TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM + let createRoomPromise = Promise.resolve(); + if (targetIds.length === 1) { + createRoomPromise = createRoom({dmUserId: targetIds[0]}) + } else { + // Create a boring room and try to invite the targets manually. + let room; + createRoomPromise = createRoom().then(roomId => { + room = MatrixClientPeg.get().getRoom(roomId); + return inviteMultipleToRoom(roomId, targetIds); + }).then(result => { + const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); + if (failedUsers.length > 0) { + console.log("Failed to invite users: ", result); + this.setState({ + busy: false, + errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", { + csvUsers: failedUsers.join(", "), + }), + }); + return true; // abort + } + }); + } + + // the createRoom call will show the room for us, so we don't need to worry about that. + createRoomPromise.then(abort => { + if (abort === true) return; // only abort on true booleans, not roomIds or something + this.props.onFinished(); + }).catch(err => { + console.error(err); + this.setState({ + busy: false, + errorText: _t("We couldn't create your DM. Please check the users you want to invite and try again."), + }); + }); }; _cancel = () => { - this.props.onFinished([]); + // We do not want the user to close the dialog while an action is in progress + if (this.state.busy) return; + + this.props.onFinished(); }; _updateFilter = (e) => { @@ -735,6 +796,12 @@ export default class DMInviteDialog extends React.PureComponent { render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + const Spinner = sdk.getComponent("elements.Spinner"); + + let spinner = null; + if (this.state.busy) { + spinner = ; + } const userId = MatrixClientPeg.get().getUserId(); return ( @@ -755,15 +822,20 @@ export default class DMInviteDialog extends React.PureComponent {

{this._renderEditor()} - {this._renderIdentityServerWarning()} - - {_t("Go")} - +
+ + {_t("Go")} + + {spinner} +
+ {this._renderIdentityServerWarning()} +
{this.state.errorText}
{this._renderSection('recents')} {this._renderSection('suggestions')} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9627ac0e14..82f0cf8521 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1423,6 +1423,8 @@ "View Servers in Room": "View Servers in Room", "Toolbox": "Toolbox", "Developer Tools": "Developer Tools", + "Failed to invite the following users to chat: %(csvUsers)s": "Failed to invite the following users to chat: %(csvUsers)s", + "We couldn't create your DM. Please check the users you want to invite and try again.": "We couldn't create your DM. Please check the users you want to invite and try again.", "Failed to find the following users": "Failed to find the following users", "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", "Recent Conversations": "Recent Conversations", diff --git a/src/utils/DMRoomMap.js b/src/utils/DMRoomMap.js index 498c073e0e..e42d724748 100644 --- a/src/utils/DMRoomMap.js +++ b/src/utils/DMRoomMap.js @@ -124,6 +124,27 @@ export default class DMRoomMap { return this._getUserToRooms()[userId] || []; } + /** + * Gets the DM room which the given IDs share, if any. + * @param {string[]} ids The identifiers (user IDs and email addresses) to look for. + * @returns {Room} The DM room which all IDs given share, or falsey if no common room. + */ + getDMRoomForIdentifiers(ids) { + // TODO: [Canonical DMs] Handle lookups for email addresses. + // For now we'll pretend we only get user IDs and end up returning nothing for email addresses + + let commonRooms = this.getDMRoomsForUserId(ids[0]); + for (let i = 1; i < ids.length; i++) { + const userRooms = this.getDMRoomsForUserId(ids[i]); + commonRooms = commonRooms.filter(r => userRooms.includes(r)); + } + + const joinedRooms = commonRooms.map(r => MatrixClientPeg.get().getRoom(r)) + .filter(r => r && r.getMyMembership() === 'join'); + + return joinedRooms[0]; + } + getUserIdForRoomId(roomId) { if (this.roomToUser == null) { // we lazily populate roomToUser so you can use From b9852c7264cff54246bfb3bbd9f42b5dd8cf047d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 14 Jan 2020 23:35:07 -0700 Subject: [PATCH 02/26] Appease the linter --- src/components/views/dialogs/DMInviteDialog.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index e82d63acad..904d531c60 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -407,12 +407,10 @@ export default class DMInviteDialog extends React.PureComponent { // TODO: [Canonical DMs] Remove this check and instead just create the multi-person DM let createRoomPromise = Promise.resolve(); if (targetIds.length === 1) { - createRoomPromise = createRoom({dmUserId: targetIds[0]}) + createRoomPromise = createRoom({dmUserId: targetIds[0]}); } else { // Create a boring room and try to invite the targets manually. - let room; createRoomPromise = createRoom().then(roomId => { - room = MatrixClientPeg.get().getRoom(roomId); return inviteMultipleToRoom(roomId, targetIds); }).then(result => { const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); From 8b6a5d37aa10d4155a833c0b91ddd8de5ed903c4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 14 Jan 2020 23:35:45 -0700 Subject: [PATCH 03/26] Remove simulated error state used for screenshot --- src/components/views/dialogs/DMInviteDialog.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js index 904d531c60..6422749d60 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -299,8 +299,8 @@ export default class DMInviteDialog extends React.PureComponent { tryingIdentityServer: false, // These two flags are used for the 'Go' button to communicate what is going on. - busy: true, - errorText: _t("We couldn't create your DM. Please check the users you want to invite and try again."), + busy: false, + errorText: null, }; this._editorRef = createRef(); From be3ef2b50e2f0b754f7effd67acd2ebc89baf55f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 15 Jan 2020 19:08:14 -0700 Subject: [PATCH 04/26] Remove all text when cutting in the composer The previous function did in fact remove the elements, but left the model thinking there was a zero-length string. This approach deletes the text from the model and the DOM, resulting in the placeholder being shown when cutting all the text. Part of https://github.com/vector-im/riot-web/issues/11378 --- src/components/views/rooms/BasicMessageComposer.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index d9604cf030..c605953473 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -209,8 +209,9 @@ export default class BasicMessageEditor extends React.Component { const selectedParts = range.parts.map(p => p.serialize()); event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts)); if (type === "cut") { - selection.deleteFromDocument(); - range.replace([]); + // Remove the text from the composer + const {caret} = getCaretOffsetAndText(this._editorRef, selection); + this.props.model.update("", event.inputType, caret); } event.preventDefault(); } From b137cd21d3832213ba55ad0c8d74c466f67e96b3 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 15 Jan 2020 21:04:18 -0700 Subject: [PATCH 05/26] Replace the selected range instead of force-setting it This gives people the option of cutting parts or all of their message. --- src/components/views/rooms/BasicMessageComposer.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index c605953473..0dd9d16e51 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -209,9 +209,8 @@ export default class BasicMessageEditor extends React.Component { const selectedParts = range.parts.map(p => p.serialize()); event.clipboardData.setData("application/x-riot-composer", JSON.stringify(selectedParts)); if (type === "cut") { - // Remove the text from the composer - const {caret} = getCaretOffsetAndText(this._editorRef, selection); - this.props.model.update("", event.inputType, caret); + // Remove the text, updating the model as appropriate + replaceRangeAndMoveCaret(range, []); } event.preventDefault(); } From 1b4ab856c9b9fdfe79a2e97685fe6816db68bf9a Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 15 Jan 2020 21:05:00 -0700 Subject: [PATCH 06/26] Assume the position is at the end when the offset has no last part We get an NPE when the user cuts their entire message, and this fixes it. --- src/editor/position.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/position.js b/src/editor/position.js index 4693f62999..726377ef48 100644 --- a/src/editor/position.js +++ b/src/editor/position.js @@ -117,7 +117,7 @@ export default class DocumentPosition { } offset += this.offset; const lastPart = model.parts[this.index]; - const atEnd = offset >= lastPart.text.length; + const atEnd = !lastPart || offset >= lastPart.text.length; // if no last part, we're at the end return new DocumentOffset(offset, atEnd); } From ff05041a5a240410460ce3176fd07351679dccb6 Mon Sep 17 00:00:00 2001 From: Zoe Date: Wed, 15 Jan 2020 13:57:29 +0000 Subject: [PATCH 07/26] Update room shield icons to represent encrypted but unverified chats in less alarmed tones --- src/components/structures/RoomView.js | 58 ++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index a717f485f0..3adcd22d87 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -172,6 +172,7 @@ module.exports = 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); @@ -491,6 +492,7 @@ module.exports = 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); @@ -761,6 +763,14 @@ module.exports = 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)) { @@ -784,29 +794,57 @@ module.exports = createReactClass({ return; } const e2eMembers = await room.getEncryptionTargetMembers(); + + /* + Ensure we trust our own signing key, ie, nobody's used our credentials to + replace it and sign all our devices + */ + if (!cli.checkUserTrust(cli.getUserId())) { + this.setState({ + e2eStatus: "warning", + }); + debuglog("e2e status set to warning due to not trusting our own signing key"); + return; + } + + /* + Gather verification state of every user in the room. + If _any_ user is verified then _every_ user must be verified, or we'll bail. + Note we don't count our own user so that the all/any check behaves properly. + */ + const verificationState = e2eMembers.map(({userId}) => userId) + .filter((userId) => userId !== cli.getUserId()) + .map((userId) => cli.checkUserTrust(userId).isCrossSigningVerified()); + if (verificationState.includes(true) && verificationState.includes(false)) { + this.setState({ + e2eStatus: "warning", + }); + debuglog("e2e status set to warning as some, but not all, users are verified"); + return; + } + + /* + Whether we verify or not, a user having an untrusted device requires warnings. + Check every user's devices, including ourselves. + */ for (const member of e2eMembers) { const { userId } = member; - const userVerified = cli.checkUserTrust(userId).isCrossSigningVerified(); - if (!userVerified) { - this.setState({ - e2eStatus: "warning", - }); - return; - } const devices = await cli.getStoredDevicesForUser(userId); - const allDevicesVerified = devices.every(device => { - const { deviceId } = device; + const allDevicesVerified = devices.every(({deviceId}) => { return cli.checkDeviceTrust(userId, deviceId).isCrossSigningVerified(); }); 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: verificationState.includes(true) ? "verified" : "normal", }); }, From 82c5349c4ed1cf516985f5d5de693d9caf4a7fa6 Mon Sep 17 00:00:00 2001 From: Zoe Date: Thu, 16 Jan 2020 16:31:50 +0000 Subject: [PATCH 08/26] Updated to properly handle logic --- src/components/structures/RoomView.js | 47 +++++++++++---------------- 1 file changed, 19 insertions(+), 28 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 1843a7b64a..3cdc308758 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -792,31 +792,25 @@ 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(); - - /* - Ensure we trust our own signing key, ie, nobody's used our credentials to - replace it and sign all our devices - */ - if (!cli.checkUserTrust(cli.getUserId())) { - this.setState({ - e2eStatus: "warning", - }); - debuglog("e2e status set to warning due to not trusting our own signing key"); - return; - } - - /* - Gather verification state of every user in the room. - If _any_ user is verified then _every_ user must be verified, or we'll bail. - Note we don't count our own user so that the all/any check behaves properly. - */ - const verificationState = e2eMembers.map(({userId}) => userId) + const verified = []; + const unverified = []; + e2eMembers.map(({userId}) => userId) .filter((userId) => userId !== cli.getUserId()) - .map((userId) => cli.checkUserTrust(userId).isCrossSigningVerified()); - if (verificationState.includes(true) && verificationState.includes(false)) { + .forEach((userId) => { + (cli.checkUserTrust(userId).isCrossSigningVerified() ? + verified : unverified).push(userId) + }); + + debuglog("e2e verified", verified, "unverified", unverified); + + /* If we verify any users in this room, expect to verify every user in the room */ + if (verified.length > 0 && unverified.length > 0) { this.setState({ e2eStatus: "warning", }); @@ -824,12 +818,9 @@ export default createReactClass({ return; } - /* - Whether we verify or not, a user having an untrusted device requires warnings. - Check every user's devices, including ourselves. - */ - for (const member of e2eMembers) { - const { userId } = member; + /* At this point, either `verified` or `unverified` is empty, or both */ + /* Check all verified user devices. We don't care if everyone's unverified anyway. */ + for (const userId of [...verified, cli.getUserId()]) { const devices = await cli.getStoredDevicesForUser(userId); const allDevicesVerified = devices.every(({deviceId}) => { return cli.checkDeviceTrust(userId, deviceId).isCrossSigningVerified(); @@ -845,7 +836,7 @@ export default createReactClass({ } this.setState({ - e2eStatus: verificationState.includes(true) ? "verified" : "normal", + e2eStatus: unverified.length === 0 ? "verified" : "normal", }); }, From 83b15054015c06b7201b11c22fda4592c34b24e9 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 16 Jan 2020 20:23:47 +0000 Subject: [PATCH 09/26] Add a ToastStore To store toast. Rather than them being stored in the state of the ToastContainer component, they now have a dedicated store. This mostly fixes problems involving showing toasts when the app loaded because we would otherwise have a race condition where something tries to show a toast before the ToastContainer is mounted. --- src/components/structures/MatrixChat.js | 16 +++--- src/components/structures/ToastContainer.js | 25 +++------ .../views/toasts/VerificationRequestToast.js | 9 ++-- src/stores/ToastStore.js | 52 +++++++++++++++++++ 4 files changed, 71 insertions(+), 31 deletions(-) create mode 100644 src/stores/ToastStore.js diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index c59c44ebd8..978743ca87 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -64,6 +64,7 @@ import { ThemeWatcher } from "../../theme"; import { storeRoomAliasInCache } from '../../RoomAliasCache'; import { defer } from "../../utils/promise"; import KeyVerificationStateObserver from '../../utils/KeyVerificationStateObserver'; +import ToastStore from "../../stores/ToastStore"; /** constants for MatrixChat.state.view */ export const VIEWS = { @@ -1458,15 +1459,12 @@ export default createReactClass({ } if (!requestObserver || requestObserver.pending) { - dis.dispatch({ - action: "show_toast", - toast: { - key: request.event.getId(), - title: _t("Verification Request"), - icon: "verification", - props: {request, requestObserver}, - component: sdk.getComponent("toasts.VerificationRequestToast"), - }, + ToastStore.sharedInstance().addOrReplaceToast({ + key: 'verifreq_' + request.event.getId(), + title: _t("Verification Request"), + icon: "verification", + props: {request, requestObserver}, + component: sdk.getComponent("toasts.VerificationRequestToast"), }); } }); diff --git a/src/components/structures/ToastContainer.js b/src/components/structures/ToastContainer.js index a8dca35747..bc74133433 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,8 +15,8 @@ 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 { @@ -26,26 +26,15 @@ export default class ToastContainer extends React.Component { } componentDidMount() { - 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() { @@ -62,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 = (

{title}{countIndicator}

diff --git a/src/components/views/toasts/VerificationRequestToast.js b/src/components/views/toasts/VerificationRequestToast.js index 6d53c23743..18db5eae66 100644 --- a/src/components/views/toasts/VerificationRequestToast.js +++ b/src/components/views/toasts/VerificationRequestToast.js @@ -23,6 +23,7 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {verificationMethods} from 'matrix-js-sdk/src/crypto'; import KeyVerificationStateObserver, {userLabelForEventRoom} from "../../../utils/KeyVerificationStateObserver"; import dis from "../../../dispatcher"; +import ToastStore from "../../../stores/ToastStore"; export default class VerificationRequestToast extends React.PureComponent { constructor(props) { @@ -63,12 +64,12 @@ export default class VerificationRequestToast extends React.PureComponent { _checkRequestIsPending = () => { if (!this.props.requestObserver.pending) { - this.props.dismiss(); + ToastStore.sharedInstance().dismissToast(this.props.toastKey); } } cancel = () => { - this.props.dismiss(); + ToastStore.sharedInstance().dismissToast(this.props.toastKey); try { this.props.request.cancel(); } catch (err) { @@ -77,7 +78,7 @@ export default class VerificationRequestToast extends React.PureComponent { } accept = () => { - this.props.dismiss(); + ToastStore.sharedInstance().dismissToast(this.props.toastKey); const {event} = this.props.request; // no room id for to_device requests if (event.getRoomId()) { @@ -119,7 +120,7 @@ export default class VerificationRequestToast extends React.PureComponent { } VerificationRequestToast.propTypes = { - dismiss: PropTypes.func.isRequired, request: PropTypes.object.isRequired, requestObserver: PropTypes.instanceOf(KeyVerificationStateObserver), + toastKey: PropTypes.string.isRequired, }; diff --git a/src/stores/ToastStore.js b/src/stores/ToastStore.js new file mode 100644 index 0000000000..f6cc30db67 --- /dev/null +++ b/src/stores/ToastStore.js @@ -0,0 +1,52 @@ +/* +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 EventEmitter from 'events'; + +/** + * Holds the active toasts + */ +export default class ToastStore extends EventEmitter { + static sharedInstance() { + if (!global.mx_ToastStore) global.mx_ToastStore = new ToastStore(); + return global.mx_ToastStore; + } + + constructor() { + super(); + this._dispatcherRef = null; + this._toasts = []; + } + + addOrReplaceToast(newToast) { + const oldIndex = this._toasts.findIndex(t => t.key === newToast.key); + if (oldIndex === -1) { + this._toasts.push(newToast); + } else { + this._toasts[oldIndex] = newToast; + } + this.emit('update'); + } + + dismissToast(key) { + this._toasts = this._toasts.filter(t => t.key !== key); + this.emit('update'); + } + + getToasts() { + return this._toasts; + } +} From 7da9e0582f3887f3dab030d5279c200ce97f1ce8 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 16 Jan 2020 13:42:34 -0700 Subject: [PATCH 10/26] Rename DMInviteDialog to be a generic Invite Dialog --- res/css/_components.scss | 2 +- ...DMInviteDialog.scss => _InviteDialog.scss} | 40 ++++++++--------- src/RoomInvite.js | 4 +- .../{DMInviteDialog.js => InviteDialog.js} | 44 +++++++++---------- 4 files changed, 45 insertions(+), 45 deletions(-) rename res/css/views/dialogs/{_DMInviteDialog.scss => _InviteDialog.scss} (86%) rename src/components/views/dialogs/{DMInviteDialog.js => InviteDialog.js} (94%) 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/views/dialogs/_DMInviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss similarity index 86% rename from res/css/views/dialogs/_DMInviteDialog.scss rename to res/css/views/dialogs/_InviteDialog.scss index 5d58f3ae8b..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,14 +61,14 @@ limitations under the License. } } - .mx_DMInviteDialog_goButton { + .mx_InviteDialog_goButton { width: 48px; margin-left: 10px; height: 25px; line-height: 25px; } - .mx_DMInviteDialog_buttonAndSpinner { + .mx_InviteDialog_buttonAndSpinner { .mx_Spinner { // Width and height are required to trick the layout engine. width: 20px; @@ -80,7 +80,7 @@ limitations under the License. } } -.mx_DMInviteDialog_section { +.mx_InviteDialog_section { padding-bottom: 10px; h3 { @@ -91,7 +91,7 @@ limitations under the License. } } -.mx_DMInviteDialog_roomTile { +.mx_InviteDialog_roomTile { cursor: pointer; padding: 5px 10px; @@ -104,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; @@ -117,7 +117,7 @@ limitations under the License. } } - .mx_DMInviteDialog_roomTile_selected { + .mx_InviteDialog_roomTile_selected { width: 36px; height: 36px; border-radius: 36px; @@ -141,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; @@ -162,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; @@ -181,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/RoomInvite.js b/src/RoomInvite.js index 8b7324d4f5..aaddd58d0b 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -44,9 +44,9 @@ export function inviteMultipleToRoom(roomId, addrs) { export function showStartChatInviteDialog() { if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { // This new dialog handles the room creation internally - we don't need to worry about it. - const DMInviteDialog = sdk.getComponent("dialogs.DMInviteDialog"); + const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); Modal.createTrackedDialog( - 'Start DM', '', DMInviteDialog, {}, + 'Start DM', '', InviteDialog, {}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); return; diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/InviteDialog.js similarity index 94% rename from src/components/views/dialogs/DMInviteDialog.js rename to src/components/views/dialogs/InviteDialog.js index 2a5c896a75..6b8e532854 100644 --- a/src/components/views/dialogs/DMInviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -140,11 +140,11 @@ class DMUserTile extends React.PureComponent { const avatarSize = 20; const avatar = this.props.member.isEmail ? : ; return ( - - + + {avatar} - {this.props.member.name} + {this.props.member.name} {_t('Remove')} @@ -211,7 +211,7 @@ class DMRoomTile extends React.PureComponent { // Highlight the word the user entered const substr = str.substring(i, filterStr.length + i); - result.push({substr}); + result.push({substr}); i += substr.length; } @@ -229,7 +229,7 @@ class DMRoomTile extends React.PureComponent { let timestamp = null; if (this.props.lastActiveTs) { const humanTs = humanizeTime(this.props.lastActiveTs); - timestamp = {humanTs}; + timestamp = {humanTs}; } const avatarSize = 36; @@ -249,30 +249,30 @@ class DMRoomTile extends React.PureComponent { let checkmark = null; if (this.props.isSelected) { // To reduce flickering we put the 'selected' room tile above the real avatar - checkmark =
; + checkmark =
; } // To reduce flickering we put the checkmark on top of the actual avatar (prevents // the browser from reloading the image source when the avatar remounts). const stackedAvatar = ( - + {avatar} {checkmark} ); return ( -
+
{stackedAvatar} - {this._highlightName(this.props.member.name)} - {this._highlightName(this.props.member.userId)} + {this._highlightName(this.props.member.name)} + {this._highlightName(this.props.member.userId)} {timestamp}
); } } -export default class DMInviteDialog extends React.PureComponent { +export default class InviteDialog extends React.PureComponent { static propTypes = { // Takes an array of user IDs/emails to invite. onFinished: PropTypes.func.isRequired, @@ -690,7 +690,7 @@ export default class DMInviteDialog extends React.PureComponent { if (sourceMembers.length === 0 && additionalMembers.length === 0) { return ( -
+

{sectionName}

{_t("No results")}

@@ -731,7 +731,7 @@ export default class DMInviteDialog extends React.PureComponent { /> )); return ( -
+

{sectionName}

{tiles} {showMore} @@ -754,7 +754,7 @@ export default class DMInviteDialog extends React.PureComponent { /> ); return ( -
+
{targets} {input}
@@ -808,12 +808,12 @@ export default class DMInviteDialog extends React.PureComponent { const userId = MatrixClientPeg.get().getUserId(); return ( -
+

{_t( "If you can't find someone, ask them for their username, or share your " + @@ -822,13 +822,13 @@ export default class DMInviteDialog extends React.PureComponent { {a: (sub) => {sub}}, )}

-
+
{this._renderEditor()} -
+
{_t("Go")} From 73fc91aa20a76f8b3a8d2b4a13d3407e4084151f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 16 Jan 2020 13:44:59 -0700 Subject: [PATCH 11/26] Rename feature flag for use in both code paths --- src/settings/Settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 2b8c0aef89..eacf63e55d 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -130,7 +130,7 @@ export const SETTINGS = { }, "feature_ftue_dms": { isFeature: true, - displayName: _td("New DM invite dialog (under development)"), + displayName: _td("New invite dialog"), supportedLevels: LEVELS_FEATURE, default: false, }, From f350167408b916db2fd3fefea84e500f7016f993 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 16 Jan 2020 14:40:12 -0700 Subject: [PATCH 12/26] Support using the InviteDialog for both DMs and invites For https://github.com/vector-im/riot-web/issues/11201 --- src/RoomInvite.js | 14 +- src/components/views/dialogs/InviteDialog.js | 127 +++++++++++++++---- src/i18n/strings/en_EN.json | 26 ++-- 3 files changed, 129 insertions(+), 38 deletions(-) diff --git a/src/RoomInvite.js b/src/RoomInvite.js index aaddd58d0b..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 @@ -46,7 +48,7 @@ export function showStartChatInviteDialog() { // 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, {}, + 'Start DM', '', InviteDialog, {kind: KIND_DM}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); return; @@ -72,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/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index 6b8e532854..7448b1a5a3 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -19,7 +19,7 @@ import PropTypes from 'prop-types'; import {_t} from "../../../languageHandler"; import * as sdk from "../../../index"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {makeUserPermalink} from "../../../utils/permalinks/Permalinks"; +import {makeRoomPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; import DMRoomMap from "../../../utils/DMRoomMap"; import {RoomMember} from "matrix-js-sdk/src/matrix"; import SdkConfig from "../../../SdkConfig"; @@ -34,7 +34,8 @@ import {humanizeTime} from "../../../utils/humanize"; import createRoom from "../../../createRoom"; import {inviteMultipleToRoom} from "../../../RoomInvite"; -// TODO: [TravisR] Make this generic for all kinds of invites +export const KIND_DM = "dm"; +export const KIND_INVITE = "invite"; const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked @@ -276,13 +277,28 @@ export default class InviteDialog extends React.PureComponent { static propTypes = { // Takes an array of user IDs/emails to invite. onFinished: PropTypes.func.isRequired, + + // The kind of invite being performed. Assumed to be KIND_DM if + // not provided. + kind: PropTypes.string, + + // The room ID this dialog is for. Only required for KIND_INVITE. + roomId: PropTypes.string, + }; + + static defaultProps = { + kind: KIND_DM, }; _debounceTimer: number = null; _editorRef: any = null; - constructor() { - super(); + constructor(props) { + super(props); + + if (props.kind === KIND_INVITE && !props.roomId) { + throw new Error("When using KIND_INVITE a roomId is required for an InviteDialog"); + } this.state = { targets: [], // array of Member objects (see interface above) @@ -390,6 +406,21 @@ export default class InviteDialog extends React.PureComponent { return members.map(m => ({userId: m.member.userId, user: m.member})); } + _shouldAbortAfterInviteError(result): boolean { + const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); + if (failedUsers.length > 0) { + console.log("Failed to invite users: ", result); + this.setState({ + busy: false, + errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", { + csvUsers: failedUsers.join(", "), + }), + }); + return true; // abort + } + return false; + } + _startDm = () => { this.setState({busy: true}); const targetIds = this.state.targets.map(t => t.userId); @@ -417,15 +448,7 @@ export default class InviteDialog extends React.PureComponent { createRoomPromise = createRoom().then(roomId => { return inviteMultipleToRoom(roomId, targetIds); }).then(result => { - const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); - if (failedUsers.length > 0) { - console.log("Failed to invite users: ", result); - this.setState({ - busy: false, - errorText: _t("Failed to invite the following users to chat: %(csvUsers)s", { - csvUsers: failedUsers.join(", "), - }), - }); + if (this._shouldAbortAfterInviteError(result)) { return true; // abort } }); @@ -444,6 +467,33 @@ export default class InviteDialog extends React.PureComponent { }); }; + _inviteUsers = () => { + this.setState({busy: true}); + const targetIds = this.state.targets.map(t => t.userId); + + const room = MatrixClientPeg.get().getRoom(this.props.roomId); + if (!room) { + console.error("Failed to find the room to invite users to"); + this.setState({ + busy: false, + errorText: _t("Something went wrong trying to invite the users."), + }); + return; + } + + inviteMultipleToRoom(this.props.roomId, targetIds).then(result => { + if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too + this.props.onFinished(); + } + }).catch(err => { + console.error(err); + this.setState({ + busy: false, + errorText: _t("We couldn't invite those users. Please check the users you want to invite and try again."), + }); + }); + }; + _cancel = () => { // We do not want the user to close the dialog while an action is in progress if (this.state.busy) return; @@ -658,7 +708,11 @@ export default class InviteDialog extends React.PureComponent { let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown; const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this); const lastActive = (m) => kind === 'recents' ? m.lastActive : null; - const sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); + let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); + + if (this.props.kind === KIND_INVITE) { + sectionName = kind === 'recents' ? _t("Recently Direct Messaged") : _t("Suggestions"); + } // Mix in the server results if we have any, but only if we're searching. We track the additional // members separately because we want to filter sourceMembers but trust the mixin arrays to have @@ -805,33 +859,54 @@ export default class InviteDialog extends React.PureComponent { spinner = ; } - const userId = MatrixClientPeg.get().getUserId(); + + let title; + let helpText; + let buttonText; + let goButtonFn; + + if (this.props.kind === KIND_DM) { + const userId = MatrixClientPeg.get().getUserId(); + + title = _t("Direct Messages"); + helpText = _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}}, + ); + buttonText = _t("Go"); + goButtonFn = this._startDm; + } else { // KIND_INVITE + title = _t("Invite to this room"); + helpText = _t( + "If you can't find someone, ask them for their username (e.g. @user:server.com) or " + + "share this room.", {}, + {a: (sub) => {sub}}, + ); + buttonText = _t("Invite"); + goButtonFn = this._inviteUsers; + } + return (
-

- {_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}

{this._renderEditor()}
- {_t("Go")} + {buttonText} {spinner}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b6f61570cd..f8b17db7c5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -372,7 +372,7 @@ "Render simple counters in room header": "Render simple counters in room header", "Multiple integration managers": "Multiple integration managers", "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", - "New DM invite dialog (under development)": "New DM invite dialog (under development)", + "New invite dialog": "New invite dialog", "Show a presence dot next to DMs in the room list": "Show a presence dot next to DMs in the room list", "Enable cross-signing to verify per-user instead of per-device (in development)": "Enable cross-signing to verify per-user instead of per-device (in development)", "Enable local event indexing and E2EE search (requires restart)": "Enable local event indexing and E2EE search (requires restart)", @@ -1438,16 +1438,6 @@ "View Servers in Room": "View Servers in Room", "Toolbox": "Toolbox", "Developer Tools": "Developer Tools", - "Failed to invite the following users to chat: %(csvUsers)s": "Failed to invite the following users to chat: %(csvUsers)s", - "We couldn't create your DM. Please check the users you want to invite and try again.": "We couldn't create your DM. Please check the users you want to invite and try again.", - "Failed to find the following users": "Failed to find the following users", - "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", - "Recent Conversations": "Recent Conversations", - "Suggestions": "Suggestions", - "Show more": "Show more", - "Direct Messages": "Direct Messages", - "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.": "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.", - "Go": "Go", "An error has occurred.": "An error has occurred.", "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.": "Verify this user to mark them as trusted. Trusting users gives you extra peace of mind when using end-to-end encrypted messages.", "Verifying this user will mark their device as trusted, and also mark your device as trusted to them.": "Verifying this user will mark their device as trusted, and also mark your device as trusted to them.", @@ -1457,6 +1447,20 @@ "Enable 'Manage Integrations' in Settings to do this.": "Enable 'Manage Integrations' in Settings to do this.", "Integrations not allowed": "Integrations not allowed", "Your Riot doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "Your Riot doesn't allow you to use an Integration Manager to do this. Please contact an admin.", + "Failed to invite the following users to chat: %(csvUsers)s": "Failed to invite the following users to chat: %(csvUsers)s", + "We couldn't create your DM. Please check the users you want to invite and try again.": "We couldn't create your DM. Please check the users you want to invite and try again.", + "Something went wrong trying to invite the users.": "Something went wrong trying to invite the users.", + "We couldn't invite those users. Please check the users you want to invite and try again.": "We couldn't invite those users. Please check the users you want to invite and try again.", + "Failed to find the following users": "Failed to find the following users", + "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", + "Recent Conversations": "Recent Conversations", + "Suggestions": "Suggestions", + "Recently Direct Messaged": "Recently Direct Messaged", + "Show more": "Show more", + "Direct Messages": "Direct Messages", + "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.": "If you can't find someone, ask them for their username, or share your username (%(userId)s) or profile link.", + "Go": "Go", + "If you can't find someone, ask them for their username (e.g. @user:server.com) or share this room.": "If you can't find someone, ask them for their username (e.g. @user:server.com) or share this room.", "You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.", "Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys.", "Start verification": "Start verification", From 1a961358f0fe7956cefc246281960c9761f500ae Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 16 Jan 2020 14:40:25 -0700 Subject: [PATCH 13/26] Don't show recents and suggestions for users already in the room --- src/components/views/dialogs/InviteDialog.js | 28 +++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index 7448b1a5a3..e176d3b105 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -300,12 +300,24 @@ export default class InviteDialog extends React.PureComponent { throw new Error("When using KIND_INVITE a roomId is required for an InviteDialog"); } + let alreadyInvited = []; + if (props.roomId) { + const room = MatrixClientPeg.get().getRoom(props.roomId); + if (!room) throw new Error("Room ID given to InviteDialog does not look like a room"); + alreadyInvited = [ + ...room.getMembersWithMembership('invite'), + ...room.getMembersWithMembership('join'), + ...room.getMembersWithMembership('ban'), // so we don't try to invite them + ].map(m => m.userId); + } + + this.state = { targets: [], // array of Member objects (see interface above) filterText: "", - recents: this._buildRecents(), + recents: this._buildRecents(alreadyInvited), numRecentsShown: INITIAL_ROOMS_SHOWN, - suggestions: this._buildSuggestions(), + suggestions: this._buildSuggestions(alreadyInvited), numSuggestionsShown: INITIAL_ROOMS_SHOWN, serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions threepidResultsMixin: [], // { user: ThreepidMember, userId: string}[], like recents and suggestions @@ -320,10 +332,13 @@ export default class InviteDialog extends React.PureComponent { this._editorRef = createRef(); } - _buildRecents(): {userId: string, user: RoomMember, lastActive: number} { + _buildRecents(excludedTargetIds: string[]): {userId: string, user: RoomMember, lastActive: number} { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); const recents = []; for (const userId in rooms) { + // Filter out user IDs that are already in the room / should be excluded + if (excludedTargetIds.includes(userId)) continue; + const room = rooms[userId]; const member = room.getMember(userId); if (!member) continue; // just skip people who don't have memberships for some reason @@ -342,7 +357,7 @@ export default class InviteDialog extends React.PureComponent { return recents; } - _buildSuggestions(): {userId: string, user: RoomMember} { + _buildSuggestions(excludedTargetIds: string[]): {userId: string, user: RoomMember} { const maxConsideredMembers = 200; const client = MatrixClientPeg.get(); const excludedUserIds = [client.getUserId(), SdkConfig.get()['welcomeUserId']]; @@ -359,6 +374,11 @@ export default class InviteDialog extends React.PureComponent { const joinedMembers = room.getJoinedMembers().filter(u => !excludedUserIds.includes(u.userId)); for (const member of joinedMembers) { + // Filter out user IDs that are already in the room / should be excluded + if (excludedTargetIds.includes(member.userId)) { + continue; + } + if (!members[member.userId]) { members[member.userId] = { member: member, From e42663fc627187cee9a59e65cd8ff1abccb95bf3 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 16 Jan 2020 14:45:17 -0700 Subject: [PATCH 14/26] Appease the linter --- src/components/views/dialogs/InviteDialog.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index e176d3b105..1b7a50c084 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -509,7 +509,9 @@ export default class InviteDialog extends React.PureComponent { console.error(err); this.setState({ busy: false, - errorText: _t("We couldn't invite those users. Please check the users you want to invite and try again."), + errorText: _t( + "We couldn't invite those users. Please check the users you want to invite and try again.", + ), }); }); }; From 03448313e6b4f0ad41b329d08d95f1d8188bb84c Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 16 Jan 2020 21:52:33 +0000 Subject: [PATCH 15/26] Fix event handler leak in MemberStatusMessageAvatar A typo led to an event handler leak with the custom status labs feature. A new handler would leak each time you change rooms, which can add up over the course of a long-lived session. --- src/components/views/avatars/MemberStatusMessageAvatar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js index aaac61ce7d..54f11e8e91 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -63,7 +63,7 @@ export default class MemberStatusMessageAvatar extends React.Component { user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); } - componentWillUmount() { + componentWillUnmount() { const { user } = this.props.member; if (!user) { return; From f535fdbcaa5f6f43f1b10ee61feaa8071953ff32 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 16 Jan 2020 15:39:07 -0700 Subject: [PATCH 16/26] Update chokidar to fix reskindex not working The major version bump doesn't appear to affect us. It wasn't working before on Windows, but now it is. --- package.json | 2 +- yarn.lock | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 4 deletions(-) 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/yarn.lock b/yarn.lock index d2135f7aa6..81602b4e3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1570,6 +1570,14 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" +anymatch@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" + integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + aproba@^1.0.3, aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -1884,6 +1892,11 @@ binary-extensions@^1.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== +binary-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" + integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== + bluebird@^3.5.0, bluebird@^3.5.5: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -1928,6 +1941,13 @@ braces@^2.3.1, braces@^2.3.2: split-string "^3.0.2" to-regex "^3.0.1" +braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + brorand@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" @@ -2232,7 +2252,7 @@ cheerio@^1.0.0-rc.3: lodash "^4.15.0" parse5 "^3.0.1" -chokidar@^2.0.2, chokidar@^2.1.2, chokidar@^2.1.8: +chokidar@^2.0.2, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== @@ -2251,6 +2271,21 @@ chokidar@^2.0.2, chokidar@^2.1.2, chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" +chokidar@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450" + integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.3.0" + optionalDependencies: + fsevents "~2.1.2" + chownr@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142" @@ -3654,6 +3689,13 @@ fill-range@^4.0.0: repeat-string "^1.6.1" to-regex-range "^2.1.0" +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + find-cache-dir@^2.0.0, find-cache-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" @@ -3815,6 +3857,11 @@ fsevents@^1.2.7: nan "^2.12.1" node-pre-gyp "^0.12.0" +fsevents@~2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" + integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -3909,6 +3956,13 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" +glob-parent@~5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2" + integrity sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw== + dependencies: + is-glob "^4.0.1" + glob-to-regexp@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" @@ -4463,6 +4517,13 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-boolean-object@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.0.0.tgz#98f8b28030684219a95f375cfbd88ce3405dff93" @@ -4622,7 +4683,7 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" -is-glob@^4.0.0, is-glob@^4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== @@ -4663,6 +4724,11 @@ is-number@^3.0.0: dependencies: kind-of "^3.0.2" +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + is-obj@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" @@ -6104,7 +6170,7 @@ normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" -normalize-path@^3.0.0: +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -6578,6 +6644,11 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picomatch@^2.0.4, picomatch@^2.0.7: + version "2.2.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" + integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== + pify@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" @@ -7169,6 +7240,13 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" +readdirp@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" + integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ== + dependencies: + picomatch "^2.0.7" + realpath-native@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" @@ -8375,6 +8453,13 @@ to-regex-range@^2.1.0: is-number "^3.0.0" repeat-string "^1.6.1" +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + to-regex@^3.0.1, to-regex@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" From 8efc45b31a504cfe65593077a9c6e8fc01f3c857 Mon Sep 17 00:00:00 2001 From: Zoe Date: Fri, 17 Jan 2020 10:04:34 +0000 Subject: [PATCH 17/26] no need to verify our own devices for every room --- src/components/structures/RoomView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 3cdc308758..e0997f87da 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -820,7 +820,7 @@ export default createReactClass({ /* At this point, either `verified` or `unverified` is empty, or both */ /* Check all verified user devices. We don't care if everyone's unverified anyway. */ - for (const userId of [...verified, cli.getUserId()]) { + for (const userId of verified) { const devices = await cli.getStoredDevicesForUser(userId); const allDevicesVerified = devices.every(({deviceId}) => { return cli.checkDeviceTrust(userId, deviceId).isCrossSigningVerified(); From 9877fd9e85c04fe03d9dfa0ae8cfd08ab3694392 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 17 Jan 2020 10:04:38 +0000 Subject: [PATCH 18/26] Fix Array.concat undefined --- src/components/views/settings/tabs/room/BridgeSettingsTab.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/settings/tabs/room/BridgeSettingsTab.js b/src/components/views/settings/tabs/room/BridgeSettingsTab.js index 71b0169788..19c19d3bc6 100644 --- a/src/components/views/settings/tabs/room/BridgeSettingsTab.js +++ b/src/components/views/settings/tabs/room/BridgeSettingsTab.js @@ -137,7 +137,7 @@ export default class BridgeSettingsTab extends React.Component { const client = MatrixClientPeg.get(); const roomState = (client.getRoom(roomId)).currentState; - const bridgeEvents = Array.concat(...BRIDGE_EVENT_TYPES.map((typeName) => + const bridgeEvents = [].concat(...BRIDGE_EVENT_TYPES.map((typeName) => Object.values(roomState.events[typeName] || {}), )); From 510b08c88bd6f0dfeac1f56c39a12855f67990c1 Mon Sep 17 00:00:00 2001 From: Zoe Date: Fri, 17 Jan 2020 10:18:50 +0000 Subject: [PATCH 19/26] changed logic to reflect the task --- src/components/structures/RoomView.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index e0997f87da..aa3e86fa60 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -809,17 +809,16 @@ export default createReactClass({ debuglog("e2e verified", verified, "unverified", unverified); - /* If we verify any users in this room, expect to verify every user in the room */ - if (verified.length > 0 && unverified.length > 0) { + /* If we've not verified anyone, set state to "normal" */ + if (verified.length == 0) { this.setState({ - e2eStatus: "warning", + e2eStatus: "normal", }); - debuglog("e2e status set to warning as some, but not all, users are verified"); + debuglog("e2e state set to normal as we have no verified users to worry about"); return; } - /* At this point, either `verified` or `unverified` is empty, or both */ - /* Check all verified user devices. We don't care if everyone's unverified anyway. */ + /* Check all verified user devices. */ for (const userId of verified) { const devices = await cli.getStoredDevicesForUser(userId); const allDevicesVerified = devices.every(({deviceId}) => { @@ -836,7 +835,7 @@ export default createReactClass({ } this.setState({ - e2eStatus: unverified.length === 0 ? "verified" : "normal", + e2eStatus: "verified", }); }, From d02185e4af661542e39a568d43aa58f19670ec0c Mon Sep 17 00:00:00 2001 From: Zoe Date: Fri, 17 Jan 2020 10:22:53 +0000 Subject: [PATCH 20/26] whoops, the number of unverified users matters to the logic --- src/components/structures/RoomView.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index aa3e86fa60..8ecb6a6a02 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -809,15 +809,6 @@ export default createReactClass({ debuglog("e2e verified", verified, "unverified", unverified); - /* If we've not verified anyone, set state to "normal" */ - if (verified.length == 0) { - this.setState({ - e2eStatus: "normal", - }); - debuglog("e2e state set to normal as we have no verified users to worry about"); - return; - } - /* Check all verified user devices. */ for (const userId of verified) { const devices = await cli.getStoredDevicesForUser(userId); @@ -835,7 +826,7 @@ export default createReactClass({ } this.setState({ - e2eStatus: "verified", + e2eStatus: unverified.length === 0 ? "verified" : "normal", }); }, From 908630c0d942de6ec9115c7b197b0d2b47f87488 Mon Sep 17 00:00:00 2001 From: Zoe Date: Fri, 17 Jan 2020 11:30:45 +0000 Subject: [PATCH 21/26] *rude grumbling noises about @dbkr* --- src/components/structures/RoomView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 8ecb6a6a02..9b02f6d503 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -813,7 +813,7 @@ export default createReactClass({ for (const userId of verified) { const devices = await cli.getStoredDevicesForUser(userId); const allDevicesVerified = devices.every(({deviceId}) => { - return cli.checkDeviceTrust(userId, deviceId).isCrossSigningVerified(); + return cli.checkDeviceTrust(userId, deviceId).isVerified(); }); if (!allDevicesVerified) { this.setState({ From 9e43abaf3aa5eb4e76492c34209a46f8e8796c7c Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 17 Jan 2020 11:43:35 +0000 Subject: [PATCH 22/26] Toasts for new, unverified sessions Fixes https://github.com/vector-im/riot-web/issues/11218 --- res/css/structures/_ToastContainer.scss | 6 +- src/DeviceListener.js | 90 +++++++++++++++++++ src/Lifecycle.js | 8 ++ src/components/structures/ToastContainer.js | 2 +- .../views/toasts/NewSessionToast.js | 57 ++++++++++++ src/i18n/strings/en_EN.json | 5 +- src/stores/ToastStore.js | 4 + 7 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 src/DeviceListener.js create mode 100644 src/components/views/toasts/NewSessionToast.js 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..4b779377e8 --- /dev/null +++ b/src/DeviceListener.js @@ -0,0 +1,90 @@ +/* +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(); + + 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 (
+
{_t("Other users may not trust it")}
+
+ + +
+
); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f8b17db7c5..4af203177c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -85,6 +85,7 @@ "%(weekDayName)s, %(monthName)s %(day)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(time)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s", "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s": "%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s", + "New Session": "New Session", "Who would you like to add to this community?": "Who would you like to add to this community?", "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID": "Warning: any person you add to a community will be publicly visible to anyone who knows the community ID", "Invite new community members": "Invite new community members", @@ -513,6 +514,9 @@ "Headphones": "Headphones", "Folder": "Folder", "Pin": "Pin", + "Other users may not trust it": "Other users may not trust it", + "Later": "Later", + "Verify": "Verify", "Decline (%(counter)s)": "Decline (%(counter)s)", "Accept to continue:": "Accept to continue:", "Upload": "Upload", @@ -1130,7 +1134,6 @@ "This client does not support end-to-end encryption.": "This client does not support end-to-end encryption.", "Messages in this room are not end-to-end encrypted.": "Messages in this room are not end-to-end encrypted.", "Messages in this room are end-to-end encrypted.": "Messages in this room are end-to-end encrypted.", - "Verify": "Verify", "Security": "Security", "Sunday": "Sunday", "Monday": "Monday", diff --git a/src/stores/ToastStore.js b/src/stores/ToastStore.js index f6cc30db67..2c4464813b 100644 --- a/src/stores/ToastStore.js +++ b/src/stores/ToastStore.js @@ -31,6 +31,10 @@ export default class ToastStore extends EventEmitter { this._toasts = []; } + reset() { + this._toasts = []; + } + addOrReplaceToast(newToast) { const oldIndex = this._toasts.findIndex(t => t.key === newToast.key); if (oldIndex === -1) { From fb9962b08e702081572fbf7c1263496f747fd3fc Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 17 Jan 2020 13:09:08 +0000 Subject: [PATCH 23/26] Disable key request dialogs with cross-signing Cross-signing verification is meant to replace the old key share between devices flow. This disables it when the cross-signing lab is enabled. Fixes https://github.com/vector-im/riot-web/issues/11904 --- src/KeyRequestHandler.js | 13 +++++++++++++ src/components/structures/MatrixChat.js | 2 ++ src/components/views/dialogs/KeyShareDialog.js | 3 +++ 3 files changed, 18 insertions(+) diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 65dc7fdb0f..0aca6529e4 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. @@ -17,6 +18,8 @@ limitations under the License. import * as sdk from './index'; import Modal from './Modal'; +// 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 +33,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 +68,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/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 978743ca87..2797887f29 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -1383,6 +1383,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); diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index 3afb5546cf..507f8b4678 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -22,6 +22,9 @@ import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; +// TODO: We can remove this once cross-signing is the only way. +// https://github.com/vector-im/riot-web/issues/11908 + /** * Dialog which asks the user whether they want to share their keys with * an unverified device. From 066a01ae94444f7c13f5709bcd74dbd445bd0e2c Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 17 Jan 2020 13:35:51 +0000 Subject: [PATCH 24/26] Check for a matrixclient before trying to use it Was being caught by the try block but still logging an error to the console unnecessarily: we should not expect there to necessarily be a matrix client since we run this from the constructor and there's a shared instance which could be constructed at any point. --- src/integrations/IntegrationManagers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/integrations/IntegrationManagers.js b/src/integrations/IntegrationManagers.js index b482ec73ce..c933e5c433 100644 --- a/src/integrations/IntegrationManagers.js +++ b/src/integrations/IntegrationManagers.js @@ -83,6 +83,7 @@ export class IntegrationManagers { } async _setupHomeserverManagers() { + if (!MatrixClientPeg.get()) return; try { console.log("Updating homeserver-configured integration managers..."); const homeserverDomain = MatrixClientPeg.getHomeserverName(); From 9b64686041b9ef4c28fc76bbf3ab54cc795ce5f1 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 17 Jan 2020 13:50:24 +0000 Subject: [PATCH 25/26] Add missing import --- src/KeyRequestHandler.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 0aca6529e4..30f3b7d50e 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -17,6 +17,7 @@ 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 From 42fe69aec9777d868da6504f3981e6023959c550 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 17 Jan 2020 14:08:37 +0000 Subject: [PATCH 26/26] Don't check devices if crypto is disabled --- src/DeviceListener.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/DeviceListener.js b/src/DeviceListener.js index 4b779377e8..15ca931fc8 100644 --- a/src/DeviceListener.js +++ b/src/DeviceListener.js @@ -69,6 +69,8 @@ export default class DeviceListener { 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;