From af2302265af29751f6dd6d6022d4618ca8770756 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 15 Nov 2019 13:36:59 +0000 Subject: [PATCH 001/179] Convert CreateKeyBackupDialog to class --- .../keybackup/CreateKeyBackupDialog.js | 130 +++++++++--------- 1 file changed, 66 insertions(+), 64 deletions(-) diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 4953cdff68..c43fdb0626 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -1,5 +1,6 @@ /* Copyright 2018, 2019 New Vector Ltd +Copyright 2019 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,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; import { scorePassword } from '../../../../utils/PasswordScorer'; @@ -49,9 +49,11 @@ function selectText(target) { * Walks the user through the process of creating an e2e key backup * on the server. */ -export default createReactClass({ - getInitialState: function() { - return { +export default class CreateKeyBackupDialog extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { phase: PHASE_PASSPHRASE, passPhrase: '', passPhraseConfirm: '', @@ -60,25 +62,25 @@ export default createReactClass({ zxcvbnResult: null, setPassPhrase: false, }; - }, + } - componentWillMount: function() { + componentWillMount() { this._recoveryKeyNode = null; this._keyBackupInfo = null; this._setZxcvbnResultTimeout = null; - }, + } - componentWillUnmount: function() { + componentWillUnmount() { if (this._setZxcvbnResultTimeout !== null) { clearTimeout(this._setZxcvbnResultTimeout); } - }, + } - _collectRecoveryKeyNode: function(n) { + _collectRecoveryKeyNode = (n) => { this._recoveryKeyNode = n; - }, + } - _onCopyClick: function() { + _onCopyClick = () => { selectText(this._recoveryKeyNode); const successful = document.execCommand('copy'); if (successful) { @@ -87,9 +89,9 @@ export default createReactClass({ phase: PHASE_KEEPITSAFE, }); } - }, + } - _onDownloadClick: function() { + _onDownloadClick = () => { const blob = new Blob([this._keyBackupInfo.recovery_key], { type: 'text/plain;charset=us-ascii', }); @@ -99,9 +101,9 @@ export default createReactClass({ downloaded: true, phase: PHASE_KEEPITSAFE, }); - }, + } - _createBackup: async function() { + _createBackup = async () => { this.setState({ phase: PHASE_BACKINGUP, error: null, @@ -128,38 +130,38 @@ export default createReactClass({ error: e, }); } - }, + } - _onCancel: function() { + _onCancel = () => { this.props.onFinished(false); - }, + } - _onDone: function() { + _onDone = () => { this.props.onFinished(true); - }, + } - _onOptOutClick: function() { + _onOptOutClick = () => { this.setState({phase: PHASE_OPTOUT_CONFIRM}); - }, + } - _onSetUpClick: function() { + _onSetUpClick = () => { this.setState({phase: PHASE_PASSPHRASE}); - }, + } - _onSkipPassPhraseClick: async function() { + _onSkipPassPhraseClick = async () => { this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(); this.setState({ copied: false, downloaded: false, phase: PHASE_SHOWKEY, }); - }, + } - _onPassPhraseNextClick: function() { + _onPassPhraseNextClick = () => { this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); - }, + } - _onPassPhraseKeyPress: async function(e) { + _onPassPhraseKeyPress = async (e) => { if (e.key === 'Enter') { // If we're waiting for the timeout before updating the result at this point, // skip ahead and do it now, otherwise we'll deny the attempt to proceed @@ -177,9 +179,9 @@ export default createReactClass({ this._onPassPhraseNextClick(); } } - }, + } - _onPassPhraseConfirmNextClick: async function() { + _onPassPhraseConfirmNextClick = async () => { this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase); this.setState({ setPassPhrase: true, @@ -187,30 +189,30 @@ export default createReactClass({ downloaded: false, phase: PHASE_SHOWKEY, }); - }, + } - _onPassPhraseConfirmKeyPress: function(e) { + _onPassPhraseConfirmKeyPress = (e) => { if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { this._onPassPhraseConfirmNextClick(); } - }, + } - _onSetAgainClick: function() { + _onSetAgainClick = () => { this.setState({ passPhrase: '', passPhraseConfirm: '', phase: PHASE_PASSPHRASE, zxcvbnResult: null, }); - }, + } - _onKeepItSafeBackClick: function() { + _onKeepItSafeBackClick = () => { this.setState({ phase: PHASE_SHOWKEY, }); - }, + } - _onPassPhraseChange: function(e) { + _onPassPhraseChange = (e) => { this.setState({ passPhrase: e.target.value, }); @@ -227,19 +229,19 @@ export default createReactClass({ zxcvbnResult: scorePassword(this.state.passPhrase), }); }, PASSPHRASE_FEEDBACK_DELAY); - }, + } - _onPassPhraseConfirmChange: function(e) { + _onPassPhraseConfirmChange = (e) => { this.setState({ passPhraseConfirm: e.target.value, }); - }, + } - _passPhraseIsValid: function() { + _passPhraseIsValid() { return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; - }, + } - _renderPhasePassPhrase: function() { + _renderPhasePassPhrase() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); let strengthMeter; @@ -305,9 +307,9 @@ export default createReactClass({

; - }, + } - _renderPhasePassPhraseConfirm: function() { + _renderPhasePassPhraseConfirm() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let matchText; @@ -361,9 +363,9 @@ export default createReactClass({ disabled={this.state.passPhrase !== this.state.passPhraseConfirm} /> ; - }, + } - _renderPhaseShowKey: function() { + _renderPhaseShowKey() { let bodyText; if (this.state.setPassPhrase) { bodyText = _t( @@ -402,9 +404,9 @@ export default createReactClass({ ; - }, + } - _renderPhaseKeepItSafe: function() { + _renderPhaseKeepItSafe() { let introText; if (this.state.copied) { introText = _t( @@ -431,16 +433,16 @@ export default createReactClass({ ; - }, + } - _renderBusyPhase: function(text) { + _renderBusyPhase(text) { const Spinner = sdk.getComponent('views.elements.Spinner'); return
; - }, + } - _renderPhaseDone: function() { + _renderPhaseDone() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( @@ -451,9 +453,9 @@ export default createReactClass({ hasCancel={false} />

; - }, + } - _renderPhaseOptOutConfirm: function() { + _renderPhaseOptOutConfirm() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
{_t( @@ -467,9 +469,9 @@ export default createReactClass({
; - }, + } - _titleForPhase: function(phase) { + _titleForPhase(phase) { switch (phase) { case PHASE_PASSPHRASE: return _t('Secure your backup with a passphrase'); @@ -488,9 +490,9 @@ export default createReactClass({ default: return _t("Create Key Backup"); } - }, + } - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); let content; @@ -543,5 +545,5 @@ export default createReactClass({ ); - }, -}); + } +} From cf26f14644172c167f80ce21ed1c9af0d1d34f03 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Mon, 18 Nov 2019 12:50:54 +0000 Subject: [PATCH 002/179] Switch to function properties to avoid manual binding in KeyBackupPanel --- src/components/views/settings/KeyBackupPanel.js | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js index ec1e52a90c..3d00695e73 100644 --- a/src/components/views/settings/KeyBackupPanel.js +++ b/src/components/views/settings/KeyBackupPanel.js @@ -25,13 +25,6 @@ export default class KeyBackupPanel extends React.PureComponent { constructor(props) { super(props); - this._startNewBackup = this._startNewBackup.bind(this); - this._deleteBackup = this._deleteBackup.bind(this); - this._onKeyBackupSessionsRemaining = - this._onKeyBackupSessionsRemaining.bind(this); - this._onKeyBackupStatus = this._onKeyBackupStatus.bind(this); - this._restoreBackup = this._restoreBackup.bind(this); - this._unmounted = false; this.state = { loading: true, @@ -63,13 +56,13 @@ export default class KeyBackupPanel extends React.PureComponent { } } - _onKeyBackupSessionsRemaining(sessionsRemaining) { + _onKeyBackupSessionsRemaining = (sessionsRemaining) => { this.setState({ sessionsRemaining, }); } - _onKeyBackupStatus() { + _onKeyBackupStatus = () => { // This just loads the current backup status rather than forcing // a re-check otherwise we risk causing infinite loops this._loadBackupStatus(); @@ -120,7 +113,7 @@ export default class KeyBackupPanel extends React.PureComponent { } } - _startNewBackup() { + _startNewBackup = () => { Modal.createTrackedDialogAsync('Key Backup', 'Key Backup', import('../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'), { @@ -131,7 +124,7 @@ export default class KeyBackupPanel extends React.PureComponent { ); } - _deleteBackup() { + _deleteBackup = () => { const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); Modal.createTrackedDialog('Delete Backup', '', QuestionDialog, { title: _t('Delete Backup'), @@ -151,7 +144,7 @@ export default class KeyBackupPanel extends React.PureComponent { }); } - _restoreBackup() { + _restoreBackup = () => { const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { }); From 9dea84892720c760dfd1d241d633b87a2c87a6ab Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Tue, 19 Nov 2019 16:28:49 +0000 Subject: [PATCH 003/179] Use div around buttons to fix React warning --- res/css/views/settings/_KeyBackupPanel.scss | 4 ++++ src/components/views/settings/KeyBackupPanel.js | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/res/css/views/settings/_KeyBackupPanel.scss b/res/css/views/settings/_KeyBackupPanel.scss index 1bcc0ab10d..4c4190c604 100644 --- a/res/css/views/settings/_KeyBackupPanel.scss +++ b/res/css/views/settings/_KeyBackupPanel.scss @@ -30,3 +30,7 @@ limitations under the License. .mx_KeyBackupPanel_deviceName { font-style: italic; } + +.mx_KeyBackupPanel_buttonRow { + margin: 1em 0; +} diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js index 3d00695e73..67d2d32d50 100644 --- a/src/components/views/settings/KeyBackupPanel.js +++ b/src/components/views/settings/KeyBackupPanel.js @@ -288,14 +288,14 @@ export default class KeyBackupPanel extends React.PureComponent {
{backupSigStatuses}
{trustedLocally}
-

+

{restoreButtonCaption}     { _t("Delete Backup") } -

+
; } else { return
From c568c15186ab475d7a310ea5735ec31076f46486 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 20 Nov 2019 17:35:10 +0000 Subject: [PATCH 004/179] In-memory keys need an object --- src/MatrixClientPeg.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index ef0130ec15..30983c452a 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -223,7 +223,7 @@ class MatrixClientPeg { if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { // TODO: Cross-signing keys are temporarily in memory only. A // separate task in the cross-signing project will build from here. - const keys = []; + const keys = {}; opts.cryptoCallbacks = { getCrossSigningKey: k => keys[k], saveCrossSigningKeys: newKeys => Object.assign(keys, newKeys), From e6dea37693d1db03d680f469d683aa7c30084016 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 20 Nov 2019 17:56:44 +0000 Subject: [PATCH 005/179] Add hidden button for bootstrapping SSSS This adds an testing button to the key backup panel which bootstraps the Secure Secret Storage system (and also cross-signing keys). Fixes https://github.com/vector-im/riot-web/issues/11212 --- .../views/settings/KeyBackupPanel.js | 46 +++++++++++++++++-- src/i18n/strings/en_EN.json | 2 + 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js index 67d2d32d50..f4740ea649 100644 --- a/src/components/views/settings/KeyBackupPanel.js +++ b/src/components/views/settings/KeyBackupPanel.js @@ -20,6 +20,7 @@ import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; +import SettingsStore from '../../../../lib/settings/SettingsStore'; export default class KeyBackupPanel extends React.PureComponent { constructor(props) { @@ -124,6 +125,27 @@ export default class KeyBackupPanel extends React.PureComponent { ); } + _bootstrapSecureSecretStorage = async () => { + try { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await MatrixClientPeg.get().bootstrapSecretStorage({ + doInteractiveAuthFlow: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Send cross-signing keys to homeserver"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + await finished; + }, + }); + } catch (e) { + console.error(e); + } + } + _deleteBackup = () => { const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); Modal.createTrackedDialog('Delete Backup', '', QuestionDialog, { @@ -298,6 +320,21 @@ export default class KeyBackupPanel extends React.PureComponent {
; } else { + // This is a temporary button for testing SSSS. Initialising SSSS + // depends on cross-signing and is part of the same project, so we + // only show this mode when the cross-signing feature is enabled. + // TODO: Clean this up when removing the feature flag. + let bootstrapSecureSecretStorage; + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + bootstrapSecureSecretStorage = ( +
+ + {_t("Bootstrap Secure Secret Storage (MSC1946)")} + +
+ ); + } + return

{_t( @@ -307,9 +344,12 @@ export default class KeyBackupPanel extends React.PureComponent {

{encryptedMessageAreEncrypted}

{_t("Back up your keys before signing out to avoid losing them.")}

- - { _t("Start using Key Backup") } - +
+ + {_t("Start using Key Backup")} + +
+ {bootstrapSecureSecretStorage}
; } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c42a137800..e60007be5e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -511,6 +511,7 @@ "Connecting to integrations server...": "Connecting to integrations server...", "Cannot connect to integrations server": "Cannot connect to integrations server", "The integrations server is offline or it cannot reach your homeserver.": "The integrations server is offline or it cannot reach your homeserver.", + "Send cross-signing keys to homeserver": "Send cross-signing keys to homeserver", "Delete Backup": "Delete Backup", "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", @@ -533,6 +534,7 @@ "This backup is trusted because it has been restored on this device": "This backup is trusted because it has been restored on this device", "Backup version: ": "Backup version: ", "Algorithm: ": "Algorithm: ", + "Bootstrap Secure Secret Storage (MSC1946)": "Bootstrap Secure Secret Storage (MSC1946)", "Your keys are not being backed up from this device.": "Your keys are not being backed up from this device.", "Back up your keys before signing out to avoid losing them.": "Back up your keys before signing out to avoid losing them.", "Start using Key Backup": "Start using Key Backup", From 812b0785bf704bb65a6a6b19819860d42a928132 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 21 Nov 2019 10:22:31 -0700 Subject: [PATCH 006/179] Fix i18n post-merge --- src/i18n/strings/en_EN.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9feded09b6..367450656e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -507,15 +507,10 @@ "Failed to set display name": "Failed to set display name", "Disable Notifications": "Disable Notifications", "Enable Notifications": "Enable Notifications", - "No integrations server configured": "No integrations server configured", - "This Riot instance does not have an integrations server configured.": "This Riot instance does not have an integrations server configured.", - "Connecting to integrations server...": "Connecting to integrations server...", - "Cannot connect to integrations server": "Cannot connect to integrations server", - "The integrations server is offline or it cannot reach your homeserver.": "The integrations server is offline or it cannot reach your homeserver.", - "Send cross-signing keys to homeserver": "Send cross-signing keys to homeserver", "Connecting to integration manager...": "Connecting to integration manager...", "Cannot connect to integration manager": "Cannot connect to integration manager", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", + "Send cross-signing keys to homeserver": "Send cross-signing keys to homeserver", "Delete Backup": "Delete Backup", "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", From b55a1a107788f027ca8cc85afc81edac79fe37e1 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Mon, 25 Nov 2019 14:39:04 +0000 Subject: [PATCH 007/179] Appease linter --- .../views/dialogs/keybackup/CreateKeyBackupDialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index c43fdb0626..ba75032ea4 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -45,7 +45,7 @@ function selectText(target) { selection.addRange(range); } -/** +/* * Walks the user through the process of creating an e2e key backup * on the server. */ From c103fe42738161c6a0aaf7b342af64ba29114033 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 28 Nov 2019 16:45:29 +0000 Subject: [PATCH 008/179] Add cross-signing diagnostic panel This is not part of any designs, so it may be short-lived, but it's quite handy for diagnosing issues with cross-signing at least while the feature is in development. --- res/css/_components.scss | 1 + .../views/settings/_CrossSigningPanel.scss | 31 ++++++ res/css/views/settings/_KeyBackupPanel.scss | 1 + .../views/settings/CrossSigningPanel.js | 100 ++++++++++++++++++ .../views/settings/KeyBackupPanel.js | 39 +------ .../tabs/user/SecurityUserSettingsTab.js | 20 +++- src/i18n/strings/en_EN.json | 12 ++- 7 files changed, 163 insertions(+), 41 deletions(-) create mode 100644 res/css/views/settings/_CrossSigningPanel.scss create mode 100644 src/components/views/settings/CrossSigningPanel.js diff --git a/res/css/_components.scss b/res/css/_components.scss index e39003fbec..eb0181aee1 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -171,6 +171,7 @@ @import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; +@import "./views/settings/_CrossSigningPanel.scss"; @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; @import "./views/settings/_IntegrationManager.scss"; diff --git a/res/css/views/settings/_CrossSigningPanel.scss b/res/css/views/settings/_CrossSigningPanel.scss new file mode 100644 index 0000000000..fa9f76a963 --- /dev/null +++ b/res/css/views/settings/_CrossSigningPanel.scss @@ -0,0 +1,31 @@ +/* +Copyright 2019 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. +*/ + +.mx_CrossSigningPanel_statusList { + border-spacing: 0; + + td { + padding: 0; + + &:first-of-type { + padding-inline-end: 1em; + } + } +} + +.mx_CrossSigningPanel_buttonRow { + margin: 1em 0; +} diff --git a/res/css/views/settings/_KeyBackupPanel.scss b/res/css/views/settings/_KeyBackupPanel.scss index 4c4190c604..872162caad 100644 --- a/res/css/views/settings/_KeyBackupPanel.scss +++ b/res/css/views/settings/_KeyBackupPanel.scss @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 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. diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js new file mode 100644 index 0000000000..c4715648f9 --- /dev/null +++ b/src/components/views/settings/CrossSigningPanel.js @@ -0,0 +1,100 @@ +/* +Copyright 2019 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 MatrixClientPeg from '../../../MatrixClientPeg'; +import { _t } from '../../../languageHandler'; +import sdk from '../../../index'; +import Modal from '../../../Modal'; + +export default class CrossSigningPanel extends React.PureComponent { + constructor(props) { + super(props); + this.state = this._getUpdatedStatus(); + } + + _getUpdatedStatus() { + // XXX: Add public accessors if we keep this around in production + const cli = MatrixClientPeg.get(); + const crossSigning = cli._crypto._crossSigningInfo; + const secretStorage = cli._crypto._secretStorage; + const crossSigningPublicKeysOnDevice = crossSigning.getId(); + const crossSigningPrivateKeysInStorage = crossSigning.isStoredInSecretStorage(secretStorage); + const secretStorageKeyInAccount = secretStorage.hasKey(); + + return { + crossSigningPublicKeysOnDevice, + crossSigningPrivateKeysInStorage, + secretStorageKeyInAccount, + }; + } + + _bootstrapSecureSecretStorage = async () => { + try { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await MatrixClientPeg.get().bootstrapSecretStorage({ + doInteractiveAuthFlow: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Send cross-signing keys to homeserver"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + await finished; + }, + }); + this.setState(this._getUpdatedStatus()); + } catch (e) { + console.error(e); + } + } + + render() { + const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + const { + crossSigningPublicKeysOnDevice, + crossSigningPrivateKeysInStorage, + secretStorageKeyInAccount, + } = this.state; + + return ( +
+ + + + + + + + + + + + + +
{_t("Cross-signing public keys:")}{crossSigningPublicKeysOnDevice ? _t("on device") : _t("not found")}
{_t("Cross-signing private keys:")}{crossSigningPrivateKeysInStorage ? _t("in secret storage") : _t("not found")}
{_t("Secret storage public key:")}{secretStorageKeyInAccount ? _t("in account data") : _t("not found")}
+
+ + {_t("Bootstrap Secure Secret Storage")} + +
+
+ ); + } +} diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js index f4740ea649..c2fb3dc9db 100644 --- a/src/components/views/settings/KeyBackupPanel.js +++ b/src/components/views/settings/KeyBackupPanel.js @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 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. @@ -20,7 +21,6 @@ import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -import SettingsStore from '../../../../lib/settings/SettingsStore'; export default class KeyBackupPanel extends React.PureComponent { constructor(props) { @@ -125,27 +125,6 @@ export default class KeyBackupPanel extends React.PureComponent { ); } - _bootstrapSecureSecretStorage = async () => { - try { - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - await MatrixClientPeg.get().bootstrapSecretStorage({ - doInteractiveAuthFlow: async (makeRequest) => { - const { finished } = Modal.createTrackedDialog( - 'Cross-signing keys dialog', '', InteractiveAuthDialog, - { - title: _t("Send cross-signing keys to homeserver"), - matrixClient: MatrixClientPeg.get(), - makeRequest, - }, - ); - await finished; - }, - }); - } catch (e) { - console.error(e); - } - } - _deleteBackup = () => { const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); Modal.createTrackedDialog('Delete Backup', '', QuestionDialog, { @@ -320,21 +299,6 @@ export default class KeyBackupPanel extends React.PureComponent { ; } else { - // This is a temporary button for testing SSSS. Initialising SSSS - // depends on cross-signing and is part of the same project, so we - // only show this mode when the cross-signing feature is enabled. - // TODO: Clean this up when removing the feature flag. - let bootstrapSecureSecretStorage; - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - bootstrapSecureSecretStorage = ( -
- - {_t("Bootstrap Secure Secret Storage (MSC1946)")} - -
- ); - } - return

{_t( @@ -349,7 +313,6 @@ export default class KeyBackupPanel extends React.PureComponent { {_t("Start using Key Backup")}

- {bootstrapSecureSecretStorage}
; } } diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 0732bcf926..98ec18df5a 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import {_t} from "../../../../../languageHandler"; -import {SettingLevel} from "../../../../../settings/SettingsStore"; +import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore"; import MatrixClientPeg from "../../../../../MatrixClientPeg"; import * as FormattingUtils from "../../../../../utils/FormattingUtils"; import AccessibleButton from "../../../elements/AccessibleButton"; @@ -252,6 +252,23 @@ export default class SecurityUserSettingsTab extends React.Component { ); + // XXX: There's no such panel in the current cross-signing designs, but + // it's useful to have for testing the feature. If there's no interest + // in having advanced details here once all flows are implemented, we + // can remove this. + const CrossSigningPanel = sdk.getComponent('views.settings.CrossSigningPanel'); + let crossSigning; + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + crossSigning = ( +
+ {_t("Cross-signing")} +
+ +
+
+ ); + } + return (
{_t("Security & Privacy")}
@@ -263,6 +280,7 @@ export default class SecurityUserSettingsTab extends React.Component {
{keyBackup} + {crossSigning} {this._renderCurrentDeviceInfo()}
{_t("Analytics")} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 367450656e..80254ed54e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -496,6 +496,15 @@ "New Password": "New Password", "Confirm password": "Confirm password", "Change Password": "Change Password", + "Send cross-signing keys to homeserver": "Send cross-signing keys to homeserver", + "Cross-signing public keys:": "Cross-signing public keys:", + "on device": "on device", + "not found": "not found", + "Cross-signing private keys:": "Cross-signing private keys:", + "in secret storage": "in secret storage", + "Secret storage public key:": "Secret storage public key:", + "in account data": "in account data", + "Bootstrap Secure Secret Storage": "Bootstrap Secure Secret Storage", "Your homeserver does not support device management.": "Your homeserver does not support device management.", "Unable to load device list": "Unable to load device list", "Authentication": "Authentication", @@ -510,7 +519,6 @@ "Connecting to integration manager...": "Connecting to integration manager...", "Cannot connect to integration manager": "Cannot connect to integration manager", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", - "Send cross-signing keys to homeserver": "Send cross-signing keys to homeserver", "Delete Backup": "Delete Backup", "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", @@ -533,7 +541,6 @@ "This backup is trusted because it has been restored on this device": "This backup is trusted because it has been restored on this device", "Backup version: ": "Backup version: ", "Algorithm: ": "Algorithm: ", - "Bootstrap Secure Secret Storage (MSC1946)": "Bootstrap Secure Secret Storage (MSC1946)", "Your keys are not being backed up from this device.": "Your keys are not being backed up from this device.", "Back up your keys before signing out to avoid losing them.": "Back up your keys before signing out to avoid losing them.", "Start using Key Backup": "Start using Key Backup", @@ -697,6 +704,7 @@ "Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites", "Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites", "Key backup": "Key backup", + "Cross-signing": "Cross-signing", "Security & Privacy": "Security & Privacy", "Devices": "Devices", "A device's public name is visible to people you communicate with": "A device's public name is visible to people you communicate with", From a21285143f74314a6873d27045a0e2226b9936a4 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 29 Nov 2019 11:55:36 +0000 Subject: [PATCH 009/179] Add tbody to silence React warning --- src/components/views/settings/CrossSigningPanel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index c4715648f9..9c7f04555a 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -75,7 +75,7 @@ export default class CrossSigningPanel extends React.PureComponent { return (
- +
@@ -88,7 +88,7 @@ export default class CrossSigningPanel extends React.PureComponent { -
{_t("Cross-signing public keys:")} {crossSigningPublicKeysOnDevice ? _t("on device") : _t("not found")}{_t("Secret storage public key:")} {secretStorageKeyInAccount ? _t("in account data") : _t("not found")}
+
{_t("Bootstrap Secure Secret Storage")} From 92c0fdf085b336c39aa6c7a030c1fbdff738d3b8 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 29 Nov 2019 15:57:40 +0000 Subject: [PATCH 010/179] Clarify current state of cross-signing private keys --- src/MatrixClientPeg.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 30983c452a..a65ebbb763 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -221,8 +221,14 @@ class MatrixClientPeg { }; if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - // TODO: Cross-signing keys are temporarily in memory only. A - // separate task in the cross-signing project will build from here. + // This stores the cross-signing private keys in memory for the JS SDK. They + // are also persisted to Secure Secret Storage in account data by + // the JS SDK when created. + // XXX: On desktop platforms, we plan to store only the SSSS default + // key in a secure enclave, while the cross-signing private keys + // will still be retrieved from SSSS, so it's unclear that we + // actually need these cross-signing application callbacks for Riot. + // Should the JS SDK default to in-memory storage of these itself? const keys = {}; opts.cryptoCallbacks = { getCrossSigningKey: k => keys[k], From 6140803b7f74454d2549e36a2cdba752b8afe649 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 29 Nov 2019 17:43:24 +0000 Subject: [PATCH 011/179] Fix key upload auth to test confirmation --- src/components/views/settings/CrossSigningPanel.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 9c7f04555a..de4d7bccbf 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -56,7 +56,10 @@ export default class CrossSigningPanel extends React.PureComponent { makeRequest, }, ); - await finished; + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } }, }); this.setState(this._getUpdatedStatus()); From c32c1d201c9ff645240ed065826d9f923a2ae0cd Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 29 Nov 2019 17:49:51 +0000 Subject: [PATCH 012/179] Rename device signing auth param --- src/components/views/settings/CrossSigningPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index de4d7bccbf..6c11c4d5c3 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -47,7 +47,7 @@ export default class CrossSigningPanel extends React.PureComponent { try { const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); await MatrixClientPeg.get().bootstrapSecretStorage({ - doInteractiveAuthFlow: async (makeRequest) => { + authUploadDeviceSigningKeys: async (makeRequest) => { const { finished } = Modal.createTrackedDialog( 'Cross-signing keys dialog', '', InteractiveAuthDialog, { From 798d5c8ada8184805c290be094e570188c351889 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 29 Nov 2019 17:53:31 +0000 Subject: [PATCH 013/179] Always update cross-signing status even if error --- src/components/views/settings/CrossSigningPanel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 6c11c4d5c3..58f452674b 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -62,10 +62,10 @@ export default class CrossSigningPanel extends React.PureComponent { } }, }); - this.setState(this._getUpdatedStatus()); } catch (e) { console.error(e); } + this.setState(this._getUpdatedStatus()); } render() { From c21c0e1150092a461ff330947c9f3b909438d759 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Mon, 2 Dec 2019 14:22:47 +0000 Subject: [PATCH 014/179] Add error to debug panel --- src/components/views/settings/CrossSigningPanel.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 58f452674b..027f6bea26 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -24,7 +24,10 @@ import Modal from '../../../Modal'; export default class CrossSigningPanel extends React.PureComponent { constructor(props) { super(props); - this.state = this._getUpdatedStatus(); + this.state = { + error: null, + ...this._getUpdatedStatus(), + }; } _getUpdatedStatus() { @@ -44,6 +47,7 @@ export default class CrossSigningPanel extends React.PureComponent { } _bootstrapSecureSecretStorage = async () => { + this.setState({ error: null }); try { const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); await MatrixClientPeg.get().bootstrapSecretStorage({ @@ -63,6 +67,7 @@ export default class CrossSigningPanel extends React.PureComponent { }, }); } catch (e) { + this.setState({ error: e }); console.error(e); } this.setState(this._getUpdatedStatus()); @@ -71,11 +76,17 @@ export default class CrossSigningPanel extends React.PureComponent { render() { const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); const { + error, crossSigningPublicKeysOnDevice, crossSigningPrivateKeysInStorage, secretStorageKeyInAccount, } = this.state; + let errorSection; + if (error) { + errorSection =
{error.toString()}
; + } + return (
@@ -97,6 +108,7 @@ export default class CrossSigningPanel extends React.PureComponent { {_t("Bootstrap Secure Secret Storage")} + {errorSection} ); } From 139e19630a6e28019af26f5300eb7fa46cd27aa3 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Mon, 2 Dec 2019 14:34:32 +0000 Subject: [PATCH 015/179] Watch for account data changes in debug panel --- .../views/settings/CrossSigningPanel.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 027f6bea26..9c7c2ea38a 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -30,6 +30,24 @@ export default class CrossSigningPanel extends React.PureComponent { }; } + componentDidMount() { + const cli = MatrixClientPeg.get(); + cli.on("accountData", this.onAccountData); + } + + componentWillUnmount() { + const cli = MatrixClientPeg.get(); + if (!cli) return; + cli.removeListener("accountData", this.onAccountData); + } + + onAccountData = (event) => { + const type = event.getType(); + if (type.startsWith("m.cross_signing") || type.startsWith("m.secret_storage")) { + this.setState(this._getUpdatedStatus()); + } + }; + _getUpdatedStatus() { // XXX: Add public accessors if we keep this around in production const cli = MatrixClientPeg.get(); From a7d94ebcaac062255963e25853e05f790156a373 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 4 Dec 2019 17:23:48 +0000 Subject: [PATCH 016/179] Convert RestoreKeyBackupDialog to modern style --- .../keybackup/RestoreKeyBackupDialog.js | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js index 300e6b7f18..9fcb663af9 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -15,7 +15,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; import Modal from '../../../../Modal'; @@ -31,9 +30,10 @@ const RESTORE_TYPE_RECOVERYKEY = 1; /** * Dialog for restoring e2e keys from a backup and the user's recovery key */ -export default createReactClass({ - getInitialState: function() { - return { +export default class RestoreKeyBackupDialog extends React.PureComponent { + constructor(props) { + super(props); + this.state = { backupInfo: null, loading: false, loadError: null, @@ -45,27 +45,27 @@ export default createReactClass({ passPhrase: '', restoreType: null, }; - }, + } - componentWillMount: function() { + componentWillMount() { this._loadBackupStatus(); - }, + } - _onCancel: function() { + _onCancel = () => { this.props.onFinished(false); - }, + } - _onDone: function() { + _onDone = () => { this.props.onFinished(true); - }, + } - _onUseRecoveryKeyClick: function() { + _onUseRecoveryKeyClick = () => { this.setState({ forceRecoveryKey: true, }); - }, + } - _onResetRecoveryClick: function() { + _onResetRecoveryClick = () => { this.props.onFinished(false); Modal.createTrackedDialogAsync('Key Backup', 'Key Backup', import('../../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog'), @@ -75,16 +75,16 @@ export default createReactClass({ }, }, ); - }, + } - _onRecoveryKeyChange: function(e) { + _onRecoveryKeyChange = (e) => { this.setState({ recoveryKey: e.target.value, recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value), }); - }, + } - _onPassPhraseNext: async function() { + _onPassPhraseNext = async () => { this.setState({ loading: true, restoreError: null, @@ -105,9 +105,9 @@ export default createReactClass({ restoreError: e, }); } - }, + } - _onRecoveryKeyNext: async function() { + _onRecoveryKeyNext = async () => { this.setState({ loading: true, restoreError: null, @@ -128,27 +128,27 @@ export default createReactClass({ restoreError: e, }); } - }, + } - _onPassPhraseChange: function(e) { + _onPassPhraseChange = (e) => { this.setState({ passPhrase: e.target.value, }); - }, + } - _onPassPhraseKeyPress: function(e) { + _onPassPhraseKeyPress = (e) => { if (e.key === Key.ENTER) { this._onPassPhraseNext(); } - }, + } - _onRecoveryKeyKeyPress: function(e) { + _onRecoveryKeyKeyPress = (e) => { if (e.key === Key.ENTER && this.state.recoveryKeyValid) { this._onRecoveryKeyNext(); } - }, + } - _loadBackupStatus: async function() { + async _loadBackupStatus() { this.setState({ loading: true, loadError: null, @@ -167,9 +167,9 @@ export default createReactClass({ loading: false, }); } - }, + } - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Spinner = sdk.getComponent("elements.Spinner"); @@ -345,5 +345,5 @@ export default createReactClass({ ); - }, -}); + } +} From 2a8853dd82747e95cacb4505b26652fc89b11acb Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Wed, 4 Dec 2019 17:24:49 +0000 Subject: [PATCH 017/179] Remove duplicate dialog CSS --- res/css/_components.scss | 1 - .../dialogs/_RestoreKeyBackupDialog.scss | 19 ------------------- .../keybackup/_RestoreKeyBackupDialog.scss | 5 +++++ 3 files changed, 5 insertions(+), 20 deletions(-) delete mode 100644 res/css/views/dialogs/_RestoreKeyBackupDialog.scss diff --git a/res/css/_components.scss b/res/css/_components.scss index b174b95598..9796b59213 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -64,7 +64,6 @@ @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; -@import "./views/dialogs/_RestoreKeyBackupDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; diff --git a/res/css/views/dialogs/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/_RestoreKeyBackupDialog.scss deleted file mode 100644 index 69e00c416a..0000000000 --- a/res/css/views/dialogs/_RestoreKeyBackupDialog.scss +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -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. -*/ - -.mx_RestoreKeyBackupDialog_keyStatus { - height: 30px; -} diff --git a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss index 415a2021cc..9cba8e0da9 100644 --- a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss +++ b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 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. @@ -14,6 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_RestoreKeyBackupDialog_keyStatus { + height: 30px; +} + .mx_RestoreKeyBackupDialog_primaryContainer { /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ padding: 20px; From 9f1c2cd3e15065640677af8a6098f7e0a561cb91 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 5 Dec 2019 15:05:28 +0000 Subject: [PATCH 018/179] Add dialogs for creating and accessing secret storage This adds dialogs for creating and accessing secret storage via a passphrase or recovery key. These flows are adapted from the ones used for key backup. --- res/css/_components.scss | 2 + .../_AccessSecretStorageDialog.scss | 34 ++ .../_CreateSecretStorageDialog.scss | 88 +++ src/MatrixClientPeg.js | 40 +- .../keybackup/CreateKeyBackupDialog.js | 8 +- .../CreateSecretStorageDialog.js | 564 ++++++++++++++++++ .../keybackup/RestoreKeyBackupDialog.js | 8 +- .../AccessSecretStorageDialog.js | 224 +++++++ .../views/settings/CrossSigningPanel.js | 63 +- src/i18n/strings/en_EN.json | 49 +- 10 files changed, 1034 insertions(+), 46 deletions(-) create mode 100644 res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss create mode 100644 res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss create mode 100644 src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js create mode 100644 src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js diff --git a/res/css/_components.scss b/res/css/_components.scss index 9796b59213..b1fbe30f13 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -81,6 +81,8 @@ @import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; @import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; @import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; +@import "./views/dialogs/secretstorage/_AccessSecretStorageDialog.scss"; +@import "./views/dialogs/secretstorage/_CreateSecretStorageDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AddressSelector.scss"; diff --git a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss new file mode 100644 index 0000000000..db11e91bdb --- /dev/null +++ b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss @@ -0,0 +1,34 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 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. +*/ + +.mx_AccessSecretStorageDialog_keyStatus { + height: 30px; +} + +.mx_AccessSecretStorageDialog_primaryContainer { + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; +} + +.mx_AccessSecretStorageDialog_passPhraseInput, +.mx_AccessSecretStorageDialog_recoveryKeyInput { + width: 300px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; +} + diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss new file mode 100644 index 0000000000..757d8028f0 --- /dev/null +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -0,0 +1,88 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 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. +*/ + +.mx_CreateSecretStorageDialog .mx_Dialog_title { + /* TODO: Consider setting this for all dialog titles. */ + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_primaryContainer { + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; +} + +.mx_CreateSecretStorageDialog_primaryContainer::after { + content: ""; + clear: both; + display: block; +} + +.mx_CreateSecretStorageDialog_passPhraseContainer { + display: flex; + align-items: start; +} + +.mx_CreateSecretStorageDialog_passPhraseHelp { + flex: 1; + height: 85px; + margin-left: 20px; + font-size: 80%; +} + +.mx_CreateSecretStorageDialog_passPhraseHelp progress { + width: 100%; +} + +.mx_CreateSecretStorageDialog_passPhraseInput { + flex: none; + width: 250px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_passPhraseMatch { + margin-left: 20px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyHeader { + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_recoveryKeyContainer { + display: flex; +} + +.mx_CreateSecretStorageDialog_recoveryKey { + width: 262px; + padding: 20px; + color: $info-plinth-fg-color; + background-color: $info-plinth-bg-color; + margin-right: 12px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons { + flex: 1; + display: flex; + align-items: center; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons button { + flex: 1; + white-space: nowrap; +} diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index a65ebbb763..d73931f57b 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -30,6 +30,8 @@ import {verificationMethods} from 'matrix-js-sdk/lib/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; +import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase'; +import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto/recoverykey'; interface MatrixClientCreds { homeserverUrl: string, @@ -224,13 +226,41 @@ class MatrixClientPeg { // This stores the cross-signing private keys in memory for the JS SDK. They // are also persisted to Secure Secret Storage in account data by // the JS SDK when created. - // XXX: On desktop platforms, we plan to store only the SSSS default - // key in a secure enclave, while the cross-signing private keys - // will still be retrieved from SSSS, so it's unclear that we - // actually need these cross-signing application callbacks for Riot. - // Should the JS SDK default to in-memory storage of these itself? const keys = {}; opts.cryptoCallbacks = { + // XXX: This flow should maybe be reworked to allow retries in + // case of typos, etc. + getSecretStorageKey: async keyInfos => { + const keyInfoEntries = Object.entries(keyInfos); + if (keyInfoEntries.length > 1) { + throw new Error("Multiple storage key requests not implemented"); + } + const [name, info] = keyInfoEntries[0]; + const AccessSecretStorageDialog = + sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, { + keyInfo: info, + }, + ); + const [input] = await finished; + if (!input) { + throw new Error("Secret storage access canceled"); + } + let key; + const { passphrase } = info; + if (passphrase) { + key = await deriveKey(input, passphrase.salt, passphrase.iterations); + } else { + key = decodeRecoveryKey(input); + } + return [name, key]; + }, + // XXX: On desktop platforms, we plan to store only the SSSS default + // key in a secure enclave, while the cross-signing private keys + // will still be retrieved from SSSS, so it's unclear that we + // actually need these cross-signing application callbacks for Riot. + // Should the JS SDK default to in-memory storage of these itself? getCrossSigningKey: k => keys[k], saveCrossSigningKeys: newKeys => Object.assign(keys, newKeys), }; diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index ba75032ea4..eae102196f 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -268,7 +268,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { return

{_t( - "Warning: you should only set up key backup from a trusted computer.", {}, + "Warning: You should only set up key backup from a trusted computer.", {}, { b: sub => {sub} }, )}

{_t( @@ -382,7 +382,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { "access to your encrypted messages if you forget your passphrase.", )}

{_t( - "Keep your recovery key somewhere very secure, like a password manager (or a safe)", + "Keep your recovery key somewhere very secure, like a password manager (or a safe).", )}

{bodyText}

@@ -410,12 +410,12 @@ export default class CreateKeyBackupDialog extends React.PureComponent { let introText; if (this.state.copied) { introText = _t( - "Your Recovery Key has been copied to your clipboard, paste it to:", + "Your recovery key has been copied to your clipboard, paste it to:", {}, {b: s => {s}}, ); } else if (this.state.downloaded) { introText = _t( - "Your Recovery Key is in your Downloads folder.", + "Your recovery key is in your Downloads folder.", {}, {b: s => {s}}, ); } diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js new file mode 100644 index 0000000000..78ff2a1698 --- /dev/null +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -0,0 +1,564 @@ +/* +Copyright 2018, 2019 New Vector Ltd +Copyright 2019 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 sdk from '../../../../index'; +import MatrixClientPeg from '../../../../MatrixClientPeg'; +import { scorePassword } from '../../../../utils/PasswordScorer'; +import FileSaver from 'file-saver'; +import { _t } from '../../../../languageHandler'; +import Modal from '../../../../Modal'; + +const PHASE_PASSPHRASE = 0; +const PHASE_PASSPHRASE_CONFIRM = 1; +const PHASE_SHOWKEY = 2; +const PHASE_KEEPITSAFE = 3; +const PHASE_STORING = 4; +const PHASE_DONE = 5; +const PHASE_OPTOUT_CONFIRM = 6; + +const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. +const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms. + +// XXX: copied from ShareDialog: factor out into utils +function selectText(target) { + const range = document.createRange(); + range.selectNodeContents(target); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/* + * Walks the user through the process of creating a passphrase to guard Secure + * Secret Storage in account data. + */ +export default class CreateSecretStorageDialog extends React.PureComponent { + constructor(props) { + super(props); + + this._keyInfo = null; + this._encodedRecoveryKey = null; + this._recoveryKeyNode = null; + this._setZxcvbnResultTimeout = null; + + this.state = { + phase: PHASE_PASSPHRASE, + passPhrase: '', + passPhraseConfirm: '', + copied: false, + downloaded: false, + zxcvbnResult: null, + setPassPhrase: false, + }; + } + + componentWillUnmount() { + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + } + + _collectRecoveryKeyNode = (n) => { + this._recoveryKeyNode = n; + } + + _onCopyClick = () => { + selectText(this._recoveryKeyNode); + const successful = document.execCommand('copy'); + if (successful) { + this.setState({ + copied: true, + phase: PHASE_KEEPITSAFE, + }); + } + } + + _onDownloadClick = () => { + const blob = new Blob([this._encodedRecoveryKey], { + type: 'text/plain;charset=us-ascii', + }); + FileSaver.saveAs(blob, 'recovery-key.txt'); + + this.setState({ + downloaded: true, + phase: PHASE_KEEPITSAFE, + }); + } + + _bootstrapSecretStorage = async () => { + this.setState({ + phase: PHASE_STORING, + error: null, + }); + const cli = MatrixClientPeg.get(); + try { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Send cross-signing keys to homeserver"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + createSecretStorageKey: async () => this._keyInfo, + }); + this.setState({ + phase: PHASE_DONE, + }); + } catch (e) { + this.setState({ error: e }); + console.error("Error bootstrapping secret storage", e); + } + } + + _onCancel = () => { + this.props.onFinished(false); + } + + _onDone = () => { + this.props.onFinished(true); + } + + _onOptOutClick = () => { + this.setState({phase: PHASE_OPTOUT_CONFIRM}); + } + + _onSetUpClick = () => { + this.setState({phase: PHASE_PASSPHRASE}); + } + + _onSkipPassPhraseClick = async () => { + const [keyInfo, encodedRecoveryKey] = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); + this._keyInfo = keyInfo; + this._encodedRecoveryKey = encodedRecoveryKey; + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseNextClick = () => { + this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + } + + _onPassPhraseKeyPress = async (e) => { + if (e.key === 'Enter') { + // If we're waiting for the timeout before updating the result at this point, + // skip ahead and do it now, otherwise we'll deny the attempt to proceed + // even if the user entered a valid passphrase + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + this._setZxcvbnResultTimeout = null; + await new Promise((resolve) => { + this.setState({ + zxcvbnResult: scorePassword(this.state.passPhrase), + }, resolve); + }); + } + if (this._passPhraseIsValid()) { + this._onPassPhraseNextClick(); + } + } + } + + _onPassPhraseConfirmNextClick = async () => { + const [keyInfo, encodedRecoveryKey] = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); + this._keyInfo = keyInfo; + this._encodedRecoveryKey = encodedRecoveryKey; + this.setState({ + setPassPhrase: true, + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseConfirmKeyPress = (e) => { + if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { + this._onPassPhraseConfirmNextClick(); + } + } + + _onSetAgainClick = () => { + this.setState({ + passPhrase: '', + passPhraseConfirm: '', + phase: PHASE_PASSPHRASE, + zxcvbnResult: null, + }); + } + + _onKeepItSafeBackClick = () => { + this.setState({ + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseChange = (e) => { + this.setState({ + passPhrase: e.target.value, + }); + + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + this._setZxcvbnResultTimeout = setTimeout(() => { + this._setZxcvbnResultTimeout = null; + this.setState({ + // precompute this and keep it in state: zxcvbn is fast but + // we use it in a couple of different places so no point recomputing + // it unnecessarily. + zxcvbnResult: scorePassword(this.state.passPhrase), + }); + }, PASSPHRASE_FEEDBACK_DELAY); + } + + _onPassPhraseConfirmChange = (e) => { + this.setState({ + passPhraseConfirm: e.target.value, + }); + } + + _passPhraseIsValid() { + return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; + } + + _renderPhasePassPhrase() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + let strengthMeter; + let helpText; + if (this.state.zxcvbnResult) { + if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) { + helpText = _t("Great! This passphrase looks strong enough."); + } else { + const suggestions = []; + for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) { + suggestions.push(
{this.state.zxcvbnResult.feedback.suggestions[i]}
); + } + const suggestionBlock =
{suggestions.length > 0 ? suggestions : _t("Keep going...")}
; + + helpText =
+ {this.state.zxcvbnResult.feedback.warning} + {suggestionBlock} +
; + } + strengthMeter =
+ +
; + } + + return
+

{_t( + "Warning: You should only set up secret storage from a trusted computer.", {}, + { b: sub => {sub} }, + )}

+

{_t( + "We'll use secret storage to optionally store an encrypted copy of " + + "your cross-signing identity for verifying other devices and message " + + "keys on our server. Protect your access to encrypted messages with a " + + "passphrase to keep it secure.", + )}

+

{_t("For maximum security, this should be different from your account password.")}

+ +
+
+ +
+ {strengthMeter} + {helpText} +
+
+
+ + + +
+ {_t("Advanced")} +

+
+
; + } + + _renderPhasePassPhraseConfirm() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + let matchText; + if (this.state.passPhraseConfirm === this.state.passPhrase) { + matchText = _t("That matches!"); + } else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) { + // only tell them they're wrong if they've actually gone wrong. + // Security concious readers will note that if you left riot-web unattended + // on this screen, this would make it easy for a malicious person to guess + // your passphrase one letter at a time, but they could get this faster by + // just opening the browser's developer tools and reading it. + // Note that not having typed anything at all will not hit this clause and + // fall through so empty box === no hint. + matchText = _t("That doesn't match."); + } + + let passPhraseMatch = null; + if (matchText) { + passPhraseMatch =
+
{matchText}
+
+ + {_t("Go back to set it again.")} + +
+
; + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+

{_t( + "Please enter your passphrase a second time to confirm.", + )}

+
+
+
+ +
+ {passPhraseMatch} +
+
+ +
; + } + + _renderPhaseShowKey() { + let bodyText; + if (this.state.setPassPhrase) { + bodyText = _t( + "As a safety net, you can use it to restore your access to encrypted " + + "messages if you forget your passphrase.", + ); + } else { + bodyText = _t( + "As a safety net, you can use it to restore your access to encrypted " + + "messages.", + ); + } + + return
+

{_t( + "Your recovery key is a safety net - you can use it to restore " + + "access to your encrypted messages if you forget your passphrase.", + )}

+

{_t( + "Keep your recovery key somewhere very secure, like a password manager (or a safe).", + )}

+

{bodyText}

+
+
+ {_t("Your Recovery Key")} +
+
+
+ {this._encodedRecoveryKey} +
+
+ + +
+
+
+
; + } + + _renderPhaseKeepItSafe() { + let introText; + if (this.state.copied) { + introText = _t( + "Your recovery key has been copied to your clipboard, paste it to:", + {}, {b: s => {s}}, + ); + } else if (this.state.downloaded) { + introText = _t( + "Your recovery key is in your Downloads folder.", + {}, {b: s => {s}}, + ); + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+ {introText} +
    +
  • {_t("Print it and store it somewhere safe", {}, {b: s => {s}})}
  • +
  • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
  • +
  • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
  • +
+ + + +
; + } + + _renderBusyPhase(text) { + const Spinner = sdk.getComponent('views.elements.Spinner'); + return
+ +
; + } + + _renderPhaseDone() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+

{_t( + "Your access to encrypted messages is now protected.", + )}

+ +
; + } + + _renderPhaseOptOutConfirm() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+ {_t( + "Without setting up secret storage, you won't be able to restore your " + + "access to encrypted messages or your cross-signing identity for " + + "verifying other devices if you log out or use another device.", + )} + + + +
; + } + + _titleForPhase(phase) { + switch (phase) { + case PHASE_PASSPHRASE: + return _t('Secure your encrypted messages with a passphrase'); + case PHASE_PASSPHRASE_CONFIRM: + return _t('Confirm your passphrase'); + case PHASE_OPTOUT_CONFIRM: + return _t('Warning!'); + case PHASE_SHOWKEY: + return _t('Recovery key'); + case PHASE_KEEPITSAFE: + return _t('Keep it safe'); + case PHASE_STORING: + return _t('Storing secrets...'); + case PHASE_DONE: + return _t('Success!'); + default: + return null; + } + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + let content; + if (this.state.error) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + content =
+

{_t("Unable to set up secret storage")}

+
+ +
+
; + } else { + switch (this.state.phase) { + case PHASE_PASSPHRASE: + content = this._renderPhasePassPhrase(); + break; + case PHASE_PASSPHRASE_CONFIRM: + content = this._renderPhasePassPhraseConfirm(); + break; + case PHASE_SHOWKEY: + content = this._renderPhaseShowKey(); + break; + case PHASE_KEEPITSAFE: + content = this._renderPhaseKeepItSafe(); + break; + case PHASE_STORING: + content = this._renderBusyPhase(); + break; + case PHASE_DONE: + content = this._renderPhaseDone(); + break; + case PHASE_OPTOUT_CONFIRM: + content = this._renderPhaseOptOutConfirm(); + break; + } + } + + return ( + +
+ {content} +
+
+ ); + } +} diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js index 9fcb663af9..45168c381e 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js @@ -27,7 +27,7 @@ import {Key} from "../../../../Keyboard"; const RESTORE_TYPE_PASSPHRASE = 0; const RESTORE_TYPE_RECOVERYKEY = 1; -/** +/* * Dialog for restoring e2e keys from a backup and the user's recovery key */ export default class RestoreKeyBackupDialog extends React.PureComponent { @@ -47,7 +47,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { }; } - componentWillMount() { + componentDidMount() { this._loadBackupStatus(); } @@ -296,7 +296,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { content =

{_t( - "Warning: you should only set up key backup " + + "Warning: You should only set up key backup " + "from a trusted computer.", {}, { b: sub => {sub} }, )}

@@ -322,7 +322,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { />
{_t( - "If you've forgotten your recovery passphrase you can "+ + "If you've forgotten your recovery key you can "+ "" , {}, { button: s => { + this.props.onFinished(false); + } + + _onUseRecoveryKeyClick = () => { + this.setState({ + forceRecoveryKey: true, + }); + } + + _onResetRecoveryClick = () => { + this.props.onFinished(false); + throw new Error("Resetting secret storage unimplemented"); + } + + _onRecoveryKeyChange = (e) => { + this.setState({ + recoveryKey: e.target.value, + recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value), + }); + } + + _onPassPhraseNext = async () => { + this.props.onFinished(this.state.passPhrase); + } + + _onRecoveryKeyNext = async () => { + this.props.onFinished(this.state.recoveryKey); + } + + _onPassPhraseChange = (e) => { + this.setState({ + passPhrase: e.target.value, + }); + } + + _onPassPhraseKeyPress = (e) => { + if (e.key === Key.ENTER) { + this._onPassPhraseNext(); + } + } + + _onRecoveryKeyKeyPress = (e) => { + if (e.key === Key.ENTER && this.state.recoveryKeyValid) { + this._onRecoveryKeyNext(); + } + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + const hasPassphrase = ( + this.props.keyInfo && + this.props.keyInfo.passphrase && + this.props.keyInfo.passphrase.salt && + this.props.keyInfo.passphrase.iterations + ); + + let content; + let title; + if (hasPassphrase && !this.state.forceRecoveryKey) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + title = _t("Enter secret storage passphrase"); + content =
+

{_t( + "Warning: You should only access secret storage " + + "from a trusted computer.", {}, + { b: sub => {sub} }, + )}

+

{_t( + "Access your secure message history and your cross-signing " + + "identity for verifying other devices by entering your passphrase.", + )}

+ +
+ + +
+ {_t( + "If you've forgotten your passphrase you can "+ + "use your recovery key or " + + "set up new recovery options." + , {}, { + button1: s => + {s} + , + button2: s => + {s} + , + })} +
; + } else { + title = _t("Enter secret storage recovery key"); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + let keyStatus; + if (this.state.recoveryKey.length === 0) { + keyStatus =
; + } else if (this.state.recoveryKeyValid) { + keyStatus =
+ {"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")} +
; + } else { + keyStatus =
+ {"\uD83D\uDC4E "}{_t("Not a valid recovery key")} +
; + } + + content =
+

{_t( + "Warning: You should only access secret storage " + + "from a trusted computer.", {}, + { b: sub => {sub} }, + )}

+

{_t( + "Access your secure message history and your cross-signing " + + "identity for verifying other devices by entering your recovery key.", + )}

+ +
+ + {keyStatus} + +
+ {_t( + "If you've forgotten your recovery key you can "+ + "." + , {}, { + button: s => + {s} + , + })} +
; + } + + return ( + +
+ {content} +
+
+ ); + } +} diff --git a/src/components/views/settings/CrossSigningPanel.js b/src/components/views/settings/CrossSigningPanel.js index 9c7c2ea38a..fda92ebac9 100644 --- a/src/components/views/settings/CrossSigningPanel.js +++ b/src/components/views/settings/CrossSigningPanel.js @@ -24,6 +24,9 @@ import Modal from '../../../Modal'; export default class CrossSigningPanel extends React.PureComponent { constructor(props) { super(props); + + this._unmounted = false; + this.state = { error: null, ...this._getUpdatedStatus(), @@ -36,6 +39,7 @@ export default class CrossSigningPanel extends React.PureComponent { } componentWillUnmount() { + this._unmounted = true; const cli = MatrixClientPeg.get(); if (!cli) return; cli.removeListener("accountData", this.onAccountData); @@ -64,30 +68,53 @@ export default class CrossSigningPanel extends React.PureComponent { }; } + /** + * Bootstrapping secret storage may take one of these paths: + * 1. Create secret storage from a passphrase and store cross-signing keys + * in secret storage. + * 2. Access existing secret storage by requesting passphrase and accessing + * cross-signing keys as needed. + * 3. All keys are loaded and there's nothing to do. + */ _bootstrapSecureSecretStorage = async () => { this.setState({ error: null }); + const cli = MatrixClientPeg.get(); try { - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - await MatrixClientPeg.get().bootstrapSecretStorage({ - authUploadDeviceSigningKeys: async (makeRequest) => { - const { finished } = Modal.createTrackedDialog( - 'Cross-signing keys dialog', '', InteractiveAuthDialog, - { - title: _t("Send cross-signing keys to homeserver"), - matrixClient: MatrixClientPeg.get(), - makeRequest, - }, - ); - const [confirmed] = await finished; - if (!confirmed) { - throw new Error("Cross-signing key upload auth canceled"); - } - }, - }); + if (!cli.hasSecretStorageKey()) { + // This dialog calls bootstrap itself after guiding the user through + // passphrase creation. + const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', + import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"), + null, null, /* priority = */ false, /* static = */ true, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Secret storage creation canceled"); + } + } else { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Send cross-signing keys to homeserver"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + }); + } } catch (e) { this.setState({ error: e }); - console.error(e); + console.error("Error bootstrapping secret storage", e); } + if (this._unmounted) return; this.setState(this._getUpdatedStatus()); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b60a684e05..ab26d677a3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1517,6 +1517,15 @@ "Remember my selection for this widget": "Remember my selection for this widget", "Allow": "Allow", "Deny": "Deny", + "Enter secret storage passphrase": "Enter secret storage passphrase", + "Warning: You should only access secret storage from a trusted computer.": "Warning: You should only access secret storage from a trusted computer.", + "Access your secure message history and your cross-signing identity for verifying other devices by entering your passphrase.": "Access your secure message history and your cross-signing identity for verifying other devices by entering your passphrase.", + "If you've forgotten your passphrase you can use your recovery key or set up new recovery options.": "If you've forgotten your passphrase you can use your recovery key or set up new recovery options.", + "Enter secret storage recovery key": "Enter secret storage recovery key", + "This looks like a valid recovery key!": "This looks like a valid recovery key!", + "Not a valid recovery key": "Not a valid recovery key", + "Access your secure message history and your cross-signing identity for verifying other devices by entering your recovery key.": "Access your secure message history and your cross-signing identity for verifying other devices by entering your recovery key.", + "If you've forgotten your recovery key you can .": "If you've forgotten your recovery key you can .", "Unable to load backup status": "Unable to load backup status", "Recovery Key Mismatch": "Recovery Key Mismatch", "Backup could not be decrypted with this key: please verify that you entered the correct recovery key.": "Backup could not be decrypted with this key: please verify that you entered the correct recovery key.", @@ -1532,10 +1541,9 @@ "Access your secure message history and set up secure messaging by entering your recovery passphrase.": "Access your secure message history and set up secure messaging by entering your recovery passphrase.", "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options": "If you've forgotten your recovery passphrase you can use your recovery key or set up new recovery options", "Enter Recovery Key": "Enter Recovery Key", - "This looks like a valid recovery key!": "This looks like a valid recovery key!", - "Not a valid recovery key": "Not a valid recovery key", + "Warning: You should only set up key backup from a trusted computer.": "Warning: You should only set up key backup from a trusted computer.", "Access your secure message history and set up secure messaging by entering your recovery key.": "Access your secure message history and set up secure messaging by entering your recovery key.", - "If you've forgotten your recovery passphrase you can ": "If you've forgotten your recovery passphrase you can ", + "If you've forgotten your recovery key you can ": "If you've forgotten your recovery key you can ", "Private Chat": "Private Chat", "Public Chat": "Public Chat", "Custom": "Custom", @@ -1885,39 +1893,50 @@ "File to import": "File to import", "Import": "Import", "Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.", - "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.", + "Warning: You should only set up secret storage from a trusted computer.": "Warning: You should only set up secret storage from a trusted computer.", + "We'll use secret storage to optionally store an encrypted copy of your cross-signing identity for verifying other devices and message keys on our server. Protect your access to encrypted messages with a passphrase to keep it secure.": "We'll use secret storage to optionally store an encrypted copy of your cross-signing identity for verifying other devices and message keys on our server. Protect your access to encrypted messages with a passphrase to keep it secure.", "For maximum security, this should be different from your account password.": "For maximum security, this should be different from your account password.", "Enter a passphrase...": "Enter a passphrase...", - "Set up with a Recovery Key": "Set up with a Recovery Key", + "Set up with a recovery key": "Set up with a recovery key", "That matches!": "That matches!", "That doesn't match.": "That doesn't match.", "Go back to set it again.": "Go back to set it again.", "Please enter your passphrase a second time to confirm.": "Please enter your passphrase a second time to confirm.", "Repeat your passphrase...": "Repeat your passphrase...", - "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.": "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.", - "As a safety net, you can use it to restore your encrypted message history.": "As a safety net, you can use it to restore your encrypted message history.", + "As a safety net, you can use it to restore your access to encrypted messages if you forget your passphrase.": "As a safety net, you can use it to restore your access to encrypted messages if you forget your passphrase.", + "As a safety net, you can use it to restore your access to encrypted messages.": "As a safety net, you can use it to restore your access to encrypted messages.", "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.": "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.", - "Keep your recovery key somewhere very secure, like a password manager (or a safe)": "Keep your recovery key somewhere very secure, like a password manager (or a safe)", + "Keep your recovery key somewhere very secure, like a password manager (or a safe).": "Keep your recovery key somewhere very secure, like a password manager (or a safe).", "Your Recovery Key": "Your Recovery Key", "Copy to clipboard": "Copy to clipboard", "Download": "Download", - "Your Recovery Key has been copied to your clipboard, paste it to:": "Your Recovery Key has been copied to your clipboard, paste it to:", - "Your Recovery Key is in your Downloads folder.": "Your Recovery Key is in your Downloads folder.", + "Your recovery key has been copied to your clipboard, paste it to:": "Your recovery key has been copied to your clipboard, paste it to:", + "Your recovery key is in your Downloads folder.": "Your recovery key is in your Downloads folder.", "Print it and store it somewhere safe": "Print it and store it somewhere safe", "Save it on a USB key or backup drive": "Save it on a USB key or backup drive", "Copy it to your personal cloud storage": "Copy it to your personal cloud storage", + "Your access to encrypted messages is now protected.": "Your access to encrypted messages is now protected.", + "Without setting up secret storage, you won't be able to restore your access to encrypted messages or your cross-signing identity for verifying other devices if you log out or use another device.": "Without setting up secret storage, you won't be able to restore your access to encrypted messages or your cross-signing identity for verifying other devices if you log out or use another device.", + "Set up secret storage": "Set up secret storage", + "Secure your encrypted messages with a passphrase": "Secure your encrypted messages with a passphrase", + "Confirm your passphrase": "Confirm your passphrase", + "Recovery key": "Recovery key", + "Keep it safe": "Keep it safe", + "Storing secrets...": "Storing secrets...", + "Success!": "Success!", + "Unable to set up secret storage": "Unable to set up secret storage", + "Retry": "Retry", + "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.": "We'll store an encrypted copy of your keys on our server. Protect your backup with a passphrase to keep it secure.", + "Set up with a Recovery Key": "Set up with a Recovery Key", + "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.": "As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase.", + "As a safety net, you can use it to restore your encrypted message history.": "As a safety net, you can use it to restore your encrypted message history.", "Your keys are being backed up (the first backup could take a few minutes).": "Your keys are being backed up (the first backup could take a few minutes).", "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.": "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.", "Set up Secure Message Recovery": "Set up Secure Message Recovery", "Secure your backup with a passphrase": "Secure your backup with a passphrase", - "Confirm your passphrase": "Confirm your passphrase", - "Recovery key": "Recovery key", - "Keep it safe": "Keep it safe", "Starting backup...": "Starting backup...", - "Success!": "Success!", "Create Key Backup": "Create Key Backup", "Unable to create key backup": "Unable to create key backup", - "Retry": "Retry", "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.", "If you don't want to set this up now, you can later in Settings.": "If you don't want to set this up now, you can later in Settings.", "Set up": "Set up", From 7446bcdedb1ab40cb433496b6e0c2aad3e51a914 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 5 Dec 2019 15:20:30 +0000 Subject: [PATCH 019/179] Extract callbacks to a new module --- src/CrossSigningManager.js | 62 ++++++++++++++++++++++++++++++++++++++ src/MatrixClientPeg.js | 49 +++--------------------------- 2 files changed, 67 insertions(+), 44 deletions(-) create mode 100644 src/CrossSigningManager.js diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js new file mode 100644 index 0000000000..56feadd5d7 --- /dev/null +++ b/src/CrossSigningManager.js @@ -0,0 +1,62 @@ +/* +Copyright 2019 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 Modal from './Modal'; +import sdk from './index'; +import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase'; +import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto/recoverykey'; + +// This stores the cross-signing private keys in memory for the JS SDK. They are +// also persisted to Secure Secret Storage in account data by the JS SDK when +// created. +const crossSigningKeys = {}; + +// XXX: On desktop platforms, we plan to store only the SSSS default key in a +// secure enclave, while the cross-signing private keys will still be retrieved +// from SSSS, so it's unclear that we actually need these cross-signing +// application callbacks for Riot. Should the JS SDK default to in-memory +// storage of these itself? +export const getCrossSigningKey = k => crossSigningKeys[k]; +export const saveCrossSigningKeys = newKeys => Object.assign(crossSigningKeys, newKeys); + +// XXX: This flow should maybe be reworked to allow retries in case of typos, +// etc. +export const getSecretStorageKey = async keyInfos => { + const keyInfoEntries = Object.entries(keyInfos); + if (keyInfoEntries.length > 1) { + throw new Error("Multiple storage key requests not implemented"); + } + const [name, info] = keyInfoEntries[0]; + const AccessSecretStorageDialog = + sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, { + keyInfo: info, + }, + ); + const [input] = await finished; + if (!input) { + throw new Error("Secret storage access canceled"); + } + let key; + const { passphrase } = info; + if (passphrase) { + key = await deriveKey(input, passphrase.salt, passphrase.iterations); + } else { + key = decodeRecoveryKey(input); + } + return [name, key]; +}; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index d73931f57b..a3a0588bfc 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -1,7 +1,8 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd. -Copyright 2017 New Vector Ltd +Copyright 2017, 2018, 2019 New Vector Ltd +Copyright 2019 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. @@ -30,8 +31,7 @@ import {verificationMethods} from 'matrix-js-sdk/lib/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; -import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase'; -import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto/recoverykey'; +import * as CrossSigningManager from './CrossSigningManager'; interface MatrixClientCreds { homeserverUrl: string, @@ -222,48 +222,9 @@ class MatrixClientPeg { identityServer: new IdentityAuthClient(), }; + opts.cryptoCallbacks = {}; if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - // This stores the cross-signing private keys in memory for the JS SDK. They - // are also persisted to Secure Secret Storage in account data by - // the JS SDK when created. - const keys = {}; - opts.cryptoCallbacks = { - // XXX: This flow should maybe be reworked to allow retries in - // case of typos, etc. - getSecretStorageKey: async keyInfos => { - const keyInfoEntries = Object.entries(keyInfos); - if (keyInfoEntries.length > 1) { - throw new Error("Multiple storage key requests not implemented"); - } - const [name, info] = keyInfoEntries[0]; - const AccessSecretStorageDialog = - sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); - const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", - AccessSecretStorageDialog, { - keyInfo: info, - }, - ); - const [input] = await finished; - if (!input) { - throw new Error("Secret storage access canceled"); - } - let key; - const { passphrase } = info; - if (passphrase) { - key = await deriveKey(input, passphrase.salt, passphrase.iterations); - } else { - key = decodeRecoveryKey(input); - } - return [name, key]; - }, - // XXX: On desktop platforms, we plan to store only the SSSS default - // key in a secure enclave, while the cross-signing private keys - // will still be retrieved from SSSS, so it's unclear that we - // actually need these cross-signing application callbacks for Riot. - // Should the JS SDK default to in-memory storage of these itself? - getCrossSigningKey: k => keys[k], - saveCrossSigningKeys: newKeys => Object.assign(keys, newKeys), - }; + Object.assign(opts.cryptoCallbacks, CrossSigningManager); } this.matrixClient = createMatrixClient(opts); From 7601ce93d90b37917d7bc824495db427e192022a Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 5 Dec 2019 15:33:10 +0000 Subject: [PATCH 020/179] Add in-memory cache of secret storage keys --- src/CrossSigningManager.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index 56feadd5d7..c8738ece88 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -32,6 +32,13 @@ const crossSigningKeys = {}; export const getCrossSigningKey = k => crossSigningKeys[k]; export const saveCrossSigningKeys = newKeys => Object.assign(crossSigningKeys, newKeys); +// This stores the secret storage private keys in memory for the JS SDK. This is +// only meant to act as a cache to avoid prompting the user multiple times +// during the same session. It is considered unsafe to persist this to normal +// web storage. For platforms with a secure enclave, we will store this key +// there. +const secretStorageKeys = {}; + // XXX: This flow should maybe be reworked to allow retries in case of typos, // etc. export const getSecretStorageKey = async keyInfos => { @@ -40,6 +47,10 @@ export const getSecretStorageKey = async keyInfos => { throw new Error("Multiple storage key requests not implemented"); } const [name, info] = keyInfoEntries[0]; + // Check the in-memory cache + if (secretStorageKeys[name]) { + return [name, secretStorageKeys[name]]; + } const AccessSecretStorageDialog = sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", @@ -58,5 +69,7 @@ export const getSecretStorageKey = async keyInfos => { } else { key = decodeRecoveryKey(input); } + // Save to cache to avoid future prompts in the current session + secretStorageKeys[name] = key; return [name, key]; }; From 2bdc16b4bd904a22d7ca888ad8183b99bf8f4bbf Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 5 Dec 2019 16:11:12 +0000 Subject: [PATCH 021/179] Key requests have an object wrapper --- src/CrossSigningManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index c8738ece88..b580fd7f8d 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -41,7 +41,7 @@ const secretStorageKeys = {}; // XXX: This flow should maybe be reworked to allow retries in case of typos, // etc. -export const getSecretStorageKey = async keyInfos => { +export const getSecretStorageKey = async ({ keys: keyInfos }) => { const keyInfoEntries = Object.entries(keyInfos); if (keyInfoEntries.length > 1) { throw new Error("Multiple storage key requests not implemented"); From d66dbdea61437bacfae2eb20e5035d4e07c32797 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Thu, 5 Dec 2019 16:23:00 +0000 Subject: [PATCH 022/179] Indicate which access flow was used --- src/CrossSigningManager.js | 11 +++++++---- .../secretstorage/AccessSecretStorageDialog.js | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index b580fd7f8d..1a0c7fefa4 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -63,11 +63,14 @@ export const getSecretStorageKey = async ({ keys: keyInfos }) => { throw new Error("Secret storage access canceled"); } let key; - const { passphrase } = info; - if (passphrase) { - key = await deriveKey(input, passphrase.salt, passphrase.iterations); + if (input.passphrase) { + key = await deriveKey( + input.passphrase, + info.passphrase.salt, + info.passphrase.iterations, + ); } else { - key = decodeRecoveryKey(input); + key = decodeRecoveryKey(input.recoveryKey); } // Save to cache to avoid future prompts in the current session secretStorageKeys[name] = key; diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js index 8db56e6dfb..f74e96bc2e 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -65,11 +65,11 @@ export default class AccessSecretStorageDialog extends React.PureComponent { } _onPassPhraseNext = async () => { - this.props.onFinished(this.state.passPhrase); + this.props.onFinished({ passphrase: this.state.passPhrase }); } _onRecoveryKeyNext = async () => { - this.props.onFinished(this.state.recoveryKey); + this.props.onFinished({ recoveryKey: this.state.recoveryKey }); } _onPassPhraseChange = (e) => { From 5253f2992898fe3359657b768f8c318f598ecc9a Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 15:31:01 -0700 Subject: [PATCH 023/179] Build out a store for the right panel state machine This should make it easier to funnel the expected behaviour through a central block of code. --- src/components/structures/RightPanel.js | 17 +-- src/settings/Settings.js | 19 +++ .../handlers/DeviceSettingsHandler.js | 26 ++++ src/stores/RightPanelStore.js | 126 ++++++++++++++++++ 4 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 src/stores/RightPanelStore.js diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 895f6ae57e..fb0924848f 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -1,9 +1,9 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd -Copyright 2017 New Vector Ltd -Copyright 2018 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019 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. @@ -28,6 +28,7 @@ import RateLimitedFunc from '../../ratelimitedfunc'; import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; import SettingsStore from "../../settings/SettingsStore"; +import {RIGHT_PANEL_PHASES} from "../../stores/RightPanelStore"; export default class RightPanel extends React.Component { static get propTypes() { @@ -44,17 +45,7 @@ export default class RightPanel extends React.Component { }; } - static Phase = Object.freeze({ - RoomMemberList: 'RoomMemberList', - GroupMemberList: 'GroupMemberList', - GroupRoomList: 'GroupRoomList', - GroupRoomInfo: 'GroupRoomInfo', - FilePanel: 'FilePanel', - NotificationPanel: 'NotificationPanel', - RoomMemberInfo: 'RoomMemberInfo', - Room3pidMemberInfo: 'Room3pidMemberInfo', - GroupMemberInfo: 'GroupMemberInfo', - }); + static Phase = RIGHT_PANEL_PHASES; constructor(props, context) { super(props, context); diff --git a/src/settings/Settings.js b/src/settings/Settings.js index b02ab82400..53a95c9c6d 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -1,6 +1,7 @@ /* Copyright 2017 Travis Ralston Copyright 2018, 2019 New Vector Ltd. +Copyright 2019 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. @@ -24,6 +25,8 @@ import { import CustomStatusController from "./controllers/CustomStatusController"; import ThemeController from './controllers/ThemeController'; import ReloadOnChangeController from "./controllers/ReloadOnChangeController"; +import RightPanel from "../components/structures/RightPanel"; +import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStore"; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config']; @@ -463,4 +466,20 @@ export const SETTINGS = { displayName: _td("Show previews/thumbnails for images"), default: true, }, + "showRightPanelInRoom": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: false, + }, + "showRightPanelInGroup": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: false, + }, + "lastRightPanelPhaseForRoom": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: RIGHT_PANEL_PHASES.RoomMemberInfo, + }, + "lastRightPanelPhaseForGroup": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: RIGHT_PANEL_PHASES.GroupMemberList, + }, }; diff --git a/src/settings/handlers/DeviceSettingsHandler.js b/src/settings/handlers/DeviceSettingsHandler.js index 76c518b97b..ed61e9f3be 100644 --- a/src/settings/handlers/DeviceSettingsHandler.js +++ b/src/settings/handlers/DeviceSettingsHandler.js @@ -1,6 +1,7 @@ /* Copyright 2017 Travis Ralston Copyright 2019 New Vector Ltd. +Copyright 2019 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. @@ -56,6 +57,17 @@ export default class DeviceSettingsHandler extends SettingsHandler { return null; // wrong type or otherwise not set } + // Special case the right panel - see `setValue` for rationale. + if ([ + "showRightPanelInRoom", + "showRightPanelInGroup", + "lastRightPanelPhaseForRoom", + "lastRightPanelPhaseForGroup", + ].includes(settingName)) { + const val = JSON.parse(localStorage.getItem(`mx_${settingName}`) || "{}"); + return val['value']; + } + const settings = this._getSettings() || {}; return settings[settingName]; } @@ -81,6 +93,20 @@ export default class DeviceSettingsHandler extends SettingsHandler { return Promise.resolve(); } + // Special case the right panel because we want to be able to update these all + // concurrently without stomping on one another. We could use async/await, though + // that introduces just enough latency to be annoying. + if ([ + "showRightPanelInRoom", + "showRightPanelInGroup", + "lastRightPanelPhaseForRoom", + "lastRightPanelPhaseForGroup", + ].includes(settingName)) { + localStorage.setItem(`mx_${settingName}`, JSON.stringify({value: newValue})); + this._watchers.notifyUpdate(settingName, null, SettingLevel.DEVICE, newValue); + return Promise.resolve(); + } + const settings = this._getSettings() || {}; settings[settingName] = newValue; localStorage.setItem("mx_local_settings", JSON.stringify(settings)); diff --git a/src/stores/RightPanelStore.js b/src/stores/RightPanelStore.js new file mode 100644 index 0000000000..fe4be81fd6 --- /dev/null +++ b/src/stores/RightPanelStore.js @@ -0,0 +1,126 @@ +/* +Copyright 2019 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 dis from '../dispatcher'; +import {Store} from 'flux/utils'; +import SettingsStore, {SettingLevel} from "../settings/SettingsStore"; + +const INITIAL_STATE = { + // Whether or not to show the right panel at all. We split out rooms and groups + // because they're different flows for the user to follow. + showRoomPanel: SettingsStore.getValue("showRightPanelInRoom"), + showGroupPanel: SettingsStore.getValue("showRightPanelInGroup"), + + // The last phase (screen) the right panel was showing + lastRoomPhase: SettingsStore.getValue("lastRightPanelPhaseForRoom"), + lastGroupPhase: SettingsStore.getValue("lastRightPanelPhaseForGroup"), +}; + +export const RIGHT_PANEL_PHASES = Object.freeze({ + // Room stuff + RoomMemberList: 'RoomMemberList', + FilePanel: 'FilePanel', + NotificationPanel: 'NotificationPanel', + RoomMemberInfo: 'RoomMemberInfo', + Room3pidMemberInfo: 'Room3pidMemberInfo', + + // Group stuff + GroupMemberList: 'GroupMemberList', + GroupRoomList: 'GroupRoomList', + GroupRoomInfo: 'GroupRoomInfo', + GroupMemberInfo: 'GroupMemberInfo', +}); + +const GROUP_PHASES = Object.keys(RIGHT_PANEL_PHASES).filter(k => k.startsWith("Group")); + +/** + * A class for tracking the state of the right panel between layouts and + * sessions. + */ +export default class RightPanelStore extends Store { + static _instance; + + constructor() { + super(dis); + + // Initialise state + this._state = INITIAL_STATE; + } + + get isOpenForRoom(): boolean { + return this._state.showRoomPanel; + } + + get isOpenForGroup(): boolean { + return this._state.showGroupPanel; + } + + get roomPanelPhase(): string { + return this._state.lastRoomPhase; + } + + get groupPanelPhase(): string { + return this._state.lastGroupPhase; + } + + _setState(newState) { + this._state = Object.assign(this._state, newState); + SettingsStore.setValue("showRightPanelInRoom", null, SettingLevel.DEVICE, this._state.showRoomPanel); + SettingsStore.setValue("showRightPanelInGroup", null, SettingLevel.DEVICE, this._state.showGroupPanel); + SettingsStore.setValue("lastRightPanelPhaseForRoom", null, SettingLevel.DEVICE, this._state.lastRoomPhase); + SettingsStore.setValue("lastRightPanelPhaseForGroup", null, SettingLevel.DEVICE, this._state.lastGroupPhase); + this.__emitChange(); + } + + __onDispatch(payload) { + if (payload.action !== 'set_right_panel_phase') return; + + const targetPhase = payload.phase; + if (!RIGHT_PANEL_PHASES[targetPhase]) { + console.warn(`Tried to switch right panel to unknown phase: ${targetPhase}`); + return; + } + + if (GROUP_PHASES.includes(targetPhase)) { + if (targetPhase === this._state.lastGroupPhase) { + this._setState({ + showGroupPanel: !this._state.showGroupPanel, + }); + } else { + this._setState({ + lastGroupPhase: targetPhase, + }); + } + } else { + if (targetPhase === this._state.lastRoomPhase) { + this._setState({ + showRoomPanel: !this._state.showRoomPanel, + }); + } else { + this._setState({ + lastRoomPhase: targetPhase, + }); + } + } + } + + static getSharedInstance(): RightPanelStore { + if (!RightPanelStore._instance) { + RightPanelStore._instance = new RightPanelStore(); + } + return RightPanelStore._instance; + } +} From 6e882251bd6845893210bc37f555d33d93389f33 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 17:47:18 -0700 Subject: [PATCH 024/179] Break the right panel completely This lays a foundation for redirecting all the traffic through the new store, but for now the core parts of the app need to stop caring if the right panel is open. --- src/components/structures/GroupView.js | 9 ++++-- src/components/structures/LoggedInView.js | 3 -- src/components/structures/MainSplit.js | 4 ++- src/components/structures/MatrixChat.js | 15 --------- src/components/structures/RightPanel.js | 2 +- src/components/structures/RoomView.js | 18 +++++------ .../views/right_panel/RoomHeaderButtons.js | 2 -- src/components/views/rooms/RoomHeader.js | 3 +- src/settings/Settings.js | 3 +- src/stores/RightPanelStore.js | 16 +--------- src/stores/RightPanelStorePhases.js | 31 +++++++++++++++++++ 11 files changed, 52 insertions(+), 54 deletions(-) create mode 100644 src/stores/RightPanelStorePhases.js diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index a0aa36803f..65f8a832d7 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -38,6 +38,7 @@ import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks"; import {Group} from "matrix-js-sdk"; import {allSettled, sleep} from "../../utils/promise"; +import RightPanelStore from "../../stores/RightPanelStore"; const LONG_DESC_PLACEHOLDER = _td( `

HTML for your community's page

@@ -1298,7 +1299,9 @@ export default createReactClass({ ); } - const rightPanel = !this.props.collapsedRhs ? : undefined; + const rightPanel = !RightPanelStore.getSharedInstance().isOpenForGroup + ? + : undefined; const headerClasses = { "mx_GroupView_header": true, @@ -1326,9 +1329,9 @@ export default createReactClass({
{ rightButtons }
- +
- + { this._getMembershipSection() } { this._getGroupSection() } diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index ae71af1a85..8bc51f1d32 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -70,7 +70,6 @@ const LoggedInView = createReactClass({ // Called with the credentials of a registered user (if they were a ROU that // transitioned to PWLU) onRegistered: PropTypes.func, - collapsedRhs: PropTypes.bool, // Used by the RoomView to handle joining rooms viaServers: PropTypes.arrayOf(PropTypes.string), @@ -552,7 +551,6 @@ const LoggedInView = createReactClass({ eventPixelOffset={this.props.initialEventPixelOffset} key={this.props.currentRoomId || 'roomview'} disabled={this.props.middleDisabled} - collapsedRhs={this.props.collapsedRhs} ConferenceHandler={this.props.ConferenceHandler} resizeNotifier={this.props.resizeNotifier} />; @@ -583,7 +581,6 @@ const LoggedInView = createReactClass({ pageElement = ; break; } diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 163755ff1a..8cf6765de4 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -62,7 +62,7 @@ export default class MainSplit extends React.Component { } componentDidMount() { - if (this.props.panel && !this.props.collapsedRhs) { + if (this.props.panel) { this._createResizer(); } } @@ -80,6 +80,8 @@ export default class MainSplit extends React.Component { const wasPanelSet = this.props.panel && !prevProps.panel; const wasPanelCleared = !this.props.panel && prevProps.panel; + // TODO: TravisR - fix this logic + if (this.resizeContainer && (wasExpanded || wasPanelSet)) { // The resizer can only be created when **both** expanded and the panel is // set. Once both are true, the container ref will mount, which is required diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 585499ddeb..0b56ffc27a 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -175,7 +175,6 @@ export default createReactClass({ viewUserId: null, // this is persisted as mx_lhs_size, loaded in LoggedInView collapseLhs: false, - collapsedRhs: window.localStorage.getItem("mx_rhs_collapsed") === "true", leftDisabled: false, middleDisabled: false, rightDisabled: false, @@ -657,18 +656,6 @@ export default createReactClass({ collapseLhs: false, }); break; - case 'hide_right_panel': - window.localStorage.setItem("mx_rhs_collapsed", true); - this.setState({ - collapsedRhs: true, - }); - break; - case 'show_right_panel': - window.localStorage.setItem("mx_rhs_collapsed", false); - this.setState({ - collapsedRhs: false, - }); - break; case 'panel_disable': { this.setState({ leftDisabled: payload.leftDisabled || payload.sideDisabled || false, @@ -1245,7 +1232,6 @@ export default createReactClass({ view: VIEWS.LOGIN, ready: false, collapseLhs: false, - collapsedRhs: false, currentRoomId: null, }); this.subTitleStatus = ''; @@ -1261,7 +1247,6 @@ export default createReactClass({ view: VIEWS.SOFT_LOGOUT, ready: false, collapseLhs: false, - collapsedRhs: false, currentRoomId: null, }); this.subTitleStatus = ''; diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index fb0924848f..73e3e8ecdd 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -28,7 +28,7 @@ import RateLimitedFunc from '../../ratelimitedfunc'; import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; import SettingsStore from "../../settings/SettingsStore"; -import {RIGHT_PANEL_PHASES} from "../../stores/RightPanelStore"; +import {RIGHT_PANEL_PHASES} from "../../stores/RightPanelStorePhases"; export default class RightPanel extends React.Component { static get propTypes() { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 5cc1e2b765..f4900139ec 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -54,6 +54,7 @@ import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import WidgetUtils from '../../utils/WidgetUtils'; import AccessibleButton from "../views/elements/AccessibleButton"; +import RightPanelStore from "../../stores/RightPanelStore"; const DEBUG = false; let debuglog = function() {}; @@ -98,9 +99,6 @@ module.exports = createReactClass({ // * invited us to the room oobData: PropTypes.object, - // is the RightPanel collapsed? - collapsedRhs: PropTypes.bool, - // Servers the RoomView can use to try and assist joins viaServers: PropTypes.arrayOf(PropTypes.string), }, @@ -1714,7 +1712,7 @@ module.exports = createReactClass({ let aux = null; let previewBar; let hideCancel = false; - let hideRightPanel = false; + let forceHideRightPanel = false; if (this.state.forwardingEvent !== null) { aux = ; } else if (this.state.searching) { @@ -1760,7 +1758,7 @@ module.exports = createReactClass({
); } else { - hideRightPanel = true; + forceHideRightPanel = true; } } else if (hiddenHighlightCount > 0) { aux = ( @@ -1947,9 +1945,11 @@ module.exports = createReactClass({ }, ); - const rightPanel = !hideRightPanel && this.state.room && - ; - const collapsedRhs = hideRightPanel || this.props.collapsedRhs; + const showRightPanel = !forceHideRightPanel && this.state.room + && RightPanelStore.getSharedInstance().isOpenForRoom; + const rightPanel = showRightPanel + ? + : null; return (
@@ -1957,7 +1957,6 @@ module.exports = createReactClass({
diff --git a/src/components/views/right_panel/RoomHeaderButtons.js b/src/components/views/right_panel/RoomHeaderButtons.js index 950fa30e38..9ccd94e117 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.js +++ b/src/components/views/right_panel/RoomHeaderButtons.js @@ -45,8 +45,6 @@ export default class RoomHeaderButtons extends HeaderButtons { } else { this.setPhase(RightPanel.Phase.RoomMemberList); } - } else if (payload.action === "view_room" && !this.props.collapsedRhs) { - this.setPhase(RightPanel.Phase.RoomMemberList); } else if (payload.action === "view_3pid_invite") { if (payload.event) { this.setPhase(RightPanel.Phase.Room3pidMemberInfo, {event: payload.event}); diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 5b6c0f6d2d..7c74717ed3 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -39,7 +39,6 @@ module.exports = createReactClass({ room: PropTypes.object, oobData: PropTypes.object, inRoom: PropTypes.bool, - collapsedRhs: PropTypes.bool, onSettingsClick: PropTypes.func, onPinnedClick: PropTypes.func, onSearchClick: PropTypes.func, @@ -304,7 +303,7 @@ module.exports = createReactClass({ { topicElement } { cancelButton } { rightRow } - +
); diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 53a95c9c6d..82dd639819 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -25,8 +25,7 @@ import { import CustomStatusController from "./controllers/CustomStatusController"; import ThemeController from './controllers/ThemeController'; import ReloadOnChangeController from "./controllers/ReloadOnChangeController"; -import RightPanel from "../components/structures/RightPanel"; -import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStore"; +import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStorePhases"; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config']; diff --git a/src/stores/RightPanelStore.js b/src/stores/RightPanelStore.js index fe4be81fd6..0d66e825ea 100644 --- a/src/stores/RightPanelStore.js +++ b/src/stores/RightPanelStore.js @@ -17,6 +17,7 @@ limitations under the License. import dis from '../dispatcher'; import {Store} from 'flux/utils'; import SettingsStore, {SettingLevel} from "../settings/SettingsStore"; +import {RIGHT_PANEL_PHASES} from "./RightPanelStorePhases"; const INITIAL_STATE = { // Whether or not to show the right panel at all. We split out rooms and groups @@ -29,21 +30,6 @@ const INITIAL_STATE = { lastGroupPhase: SettingsStore.getValue("lastRightPanelPhaseForGroup"), }; -export const RIGHT_PANEL_PHASES = Object.freeze({ - // Room stuff - RoomMemberList: 'RoomMemberList', - FilePanel: 'FilePanel', - NotificationPanel: 'NotificationPanel', - RoomMemberInfo: 'RoomMemberInfo', - Room3pidMemberInfo: 'Room3pidMemberInfo', - - // Group stuff - GroupMemberList: 'GroupMemberList', - GroupRoomList: 'GroupRoomList', - GroupRoomInfo: 'GroupRoomInfo', - GroupMemberInfo: 'GroupMemberInfo', -}); - const GROUP_PHASES = Object.keys(RIGHT_PANEL_PHASES).filter(k => k.startsWith("Group")); /** diff --git a/src/stores/RightPanelStorePhases.js b/src/stores/RightPanelStorePhases.js new file mode 100644 index 0000000000..83a6d97345 --- /dev/null +++ b/src/stores/RightPanelStorePhases.js @@ -0,0 +1,31 @@ +/* +Copyright 2019 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. +*/ + +// These are in their own file because of circular imports being a problem. +export const RIGHT_PANEL_PHASES = Object.freeze({ + // Room stuff + RoomMemberList: 'RoomMemberList', + FilePanel: 'FilePanel', + NotificationPanel: 'NotificationPanel', + RoomMemberInfo: 'RoomMemberInfo', + Room3pidMemberInfo: 'Room3pidMemberInfo', + + // Group stuff + GroupMemberList: 'GroupMemberList', + GroupRoomList: 'GroupRoomList', + GroupRoomInfo: 'GroupRoomInfo', + GroupMemberInfo: 'GroupMemberInfo', +}); From ca0c393783f6980b8372f264cb2dd45fe4cfe46c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:28:06 -0700 Subject: [PATCH 025/179] Use new right panel store for header buttons This introduces a new dispatch action (unused, so far) and routes the buttons towards the RightPanelStore for processing. --- .../views/right_panel/GroupHeaderButtons.js | 35 +++++---- .../views/right_panel/HeaderButtons.js | 74 +++++++------------ .../views/right_panel/RoomHeaderButtons.js | 34 +++++---- src/stores/RightPanelStore.js | 60 ++++++++++++++- 4 files changed, 121 insertions(+), 82 deletions(-) diff --git a/src/components/views/right_panel/GroupHeaderButtons.js b/src/components/views/right_panel/GroupHeaderButtons.js index ec14331ad2..c112d0195a 100644 --- a/src/components/views/right_panel/GroupHeaderButtons.js +++ b/src/components/views/right_panel/GroupHeaderButtons.js @@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2017 New Vector Ltd Copyright 2018 New Vector Ltd +Copyright 2019 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. @@ -20,21 +21,21 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; import HeaderButton from './HeaderButton'; -import HeaderButtons from './HeaderButtons'; -import RightPanel from '../../structures/RightPanel'; +import HeaderButtons, {HEADER_KIND_GROUP} from './HeaderButtons'; +import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases"; const GROUP_PHASES = [ - RightPanel.Phase.GroupMemberInfo, - RightPanel.Phase.GroupMemberList, + RIGHT_PANEL_PHASES.GroupMemberInfo, + RIGHT_PANEL_PHASES.GroupMemberList, ]; const ROOM_PHASES = [ - RightPanel.Phase.GroupRoomList, - RightPanel.Phase.GroupRoomInfo, + RIGHT_PANEL_PHASES.GroupRoomList, + RIGHT_PANEL_PHASES.GroupRoomInfo, ]; export default class GroupHeaderButtons extends HeaderButtons { constructor(props) { - super(props, RightPanel.Phase.GroupMemberList); + super(props, HEADER_KIND_GROUP); this._onMembersClicked = this._onMembersClicked.bind(this); this._onRoomsClicked = this._onRoomsClicked.bind(this); } @@ -44,29 +45,31 @@ export default class GroupHeaderButtons extends HeaderButtons { if (payload.action === "view_user") { if (payload.member) { - this.setPhase(RightPanel.Phase.RoomMemberInfo, {member: payload.member}); + this.setPhase(RIGHT_PANEL_PHASES.RoomMemberInfo, {member: payload.member}); } else { - this.setPhase(RightPanel.Phase.GroupMemberList); + this.setPhase(RIGHT_PANEL_PHASES.GroupMemberList); } } else if (payload.action === "view_group") { - this.setPhase(RightPanel.Phase.GroupMemberList); + this.setPhase(RIGHT_PANEL_PHASES.GroupMemberList); } else if (payload.action === "view_group_room") { - this.setPhase(RightPanel.Phase.GroupRoomInfo, {groupRoomId: payload.groupRoomId, groupId: payload.groupId}); + this.setPhase(RIGHT_PANEL_PHASES.GroupRoomInfo, {groupRoomId: payload.groupRoomId, groupId: payload.groupId}); } else if (payload.action === "view_group_room_list") { - this.setPhase(RightPanel.Phase.GroupRoomList); + this.setPhase(RIGHT_PANEL_PHASES.GroupRoomList); } else if (payload.action === "view_group_member_list") { - this.setPhase(RightPanel.Phase.GroupMemberList); + this.setPhase(RIGHT_PANEL_PHASES.GroupMemberList); } else if (payload.action === "view_group_user") { - this.setPhase(RightPanel.Phase.GroupMemberInfo, {member: payload.member}); + this.setPhase(RIGHT_PANEL_PHASES.GroupMemberInfo, {member: payload.member}); } } _onMembersClicked() { - this.togglePhase(RightPanel.Phase.GroupMemberList, GROUP_PHASES); + // This toggles for us, if needed + this.setPhase(RIGHT_PANEL_PHASES.GroupMemberList); } _onRoomsClicked() { - this.togglePhase(RightPanel.Phase.GroupRoomList, ROOM_PHASES); + // This toggles for us, if needed + this.setPhase(RIGHT_PANEL_PHASES.GroupRoomList); } renderButtons() { diff --git a/src/components/views/right_panel/HeaderButtons.js b/src/components/views/right_panel/HeaderButtons.js index a01b511dc8..c43e8fc47e 100644 --- a/src/components/views/right_panel/HeaderButtons.js +++ b/src/components/views/right_panel/HeaderButtons.js @@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2017 New Vector Ltd Copyright 2018 New Vector Ltd +Copyright 2019 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. @@ -18,62 +19,44 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; +import RightPanelStore from "../../../stores/RightPanelStore"; + +export const HEADER_KIND_ROOM = "room"; +export const HEADER_KIND_GROUP = "group"; + +const HEADER_KINDS = [HEADER_KIND_GROUP, HEADER_KIND_ROOM]; export default class HeaderButtons extends React.Component { - constructor(props, initialPhase) { + constructor(props, kind) { super(props); + if (!HEADER_KINDS.includes(kind)) throw new Error(`Invalid header kind: ${kind}`); + + const rps = RightPanelStore.getSharedInstance(); this.state = { - phase: props.collapsedRhs ? null : initialPhase, - isUserPrivilegedInGroup: null, + headerKind: kind, + phase: kind === HEADER_KIND_ROOM ? rps.visibleRoomPanelPhase : rps.visibleGroupPanelPhase, }; - this.onAction = this.onAction.bind(this); } componentWillMount() { - this.dispatcherRef = dis.register(this.onAction); + this._storeToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelUpdate.bind(this)); } componentWillUnmount() { - dis.unregister(this.dispatcherRef); - } - - componentDidUpdate(prevProps) { - if (!prevProps.collapsedRhs && this.props.collapsedRhs) { - this.setState({ - phase: null, - }); - } + if (this._storeToken) this._storeToken.remove(); } setPhase(phase, extras) { - if (this.props.collapsedRhs) { - dis.dispatch({ - action: 'show_right_panel', - }); - } - dis.dispatch(Object.assign({ - action: 'view_right_panel_phase', + dis.dispatch({ + action: 'set_right_panel_phase', phase: phase, - }, extras)); + refireParams: extras, + }); } - togglePhase(phase, validPhases = [phase]) { - if (validPhases.includes(this.state.phase)) { - dis.dispatch({ - action: 'hide_right_panel', - }); - } else { - this.setPhase(phase); - } - } - - isPhase(phases) { - if (this.props.collapsedRhs) { - return false; - } + isPhase(phases: string | string[]) { if (Array.isArray(phases)) { return phases.includes(this.state.phase); } else { @@ -81,22 +64,19 @@ export default class HeaderButtons extends React.Component { } } - onAction(payload) { - if (payload.action === "view_right_panel_phase") { - this.setState({ - phase: payload.phase, - }); + onRightPanelUpdate() { + const rps = RightPanelStore.getSharedInstance(); + if (this.state.headerKind === HEADER_KIND_ROOM) { + this.setState({phase: rps.visibleRoomPanelPhase}); + } else if (this.state.head === HEADER_KIND_GROUP) { + this.setState({phase: rps.visibleGroupPanelPhase}); } } render() { // inline style as this will be swapped around in future commits return
- { this.renderButtons() } + {this.renderButtons()}
; } } - -HeaderButtons.propTypes = { - collapsedRhs: PropTypes.bool, -}; diff --git a/src/components/views/right_panel/RoomHeaderButtons.js b/src/components/views/right_panel/RoomHeaderButtons.js index 9ccd94e117..f59159d1d9 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.js +++ b/src/components/views/right_panel/RoomHeaderButtons.js @@ -3,6 +3,7 @@ Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2017 New Vector Ltd Copyright 2018 New Vector Ltd +Copyright 2019 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. @@ -20,18 +21,18 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; import HeaderButton from './HeaderButton'; -import HeaderButtons from './HeaderButtons'; -import RightPanel from '../../structures/RightPanel'; +import HeaderButtons, {HEADER_KIND_ROOM} from './HeaderButtons'; +import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases"; const MEMBER_PHASES = [ - RightPanel.Phase.RoomMemberList, - RightPanel.Phase.RoomMemberInfo, - RightPanel.Phase.Room3pidMemberInfo, + RIGHT_PANEL_PHASES.RoomMemberList, + RIGHT_PANEL_PHASES.RoomMemberInfo, + RIGHT_PANEL_PHASES.Room3pidMemberInfo, ]; export default class RoomHeaderButtons extends HeaderButtons { constructor(props) { - super(props, RightPanel.Phase.RoomMemberList); + super(props, HEADER_KIND_ROOM); this._onMembersClicked = this._onMembersClicked.bind(this); this._onFilesClicked = this._onFilesClicked.bind(this); this._onNotificationsClicked = this._onNotificationsClicked.bind(this); @@ -41,29 +42,32 @@ export default class RoomHeaderButtons extends HeaderButtons { super.onAction(payload); if (payload.action === "view_user") { if (payload.member) { - this.setPhase(RightPanel.Phase.RoomMemberInfo, {member: payload.member}); + this.setPhase(RIGHT_PANEL_PHASES.RoomMemberInfo, {member: payload.member}); } else { - this.setPhase(RightPanel.Phase.RoomMemberList); + this.setPhase(RIGHT_PANEL_PHASES.RoomMemberList); } } else if (payload.action === "view_3pid_invite") { if (payload.event) { - this.setPhase(RightPanel.Phase.Room3pidMemberInfo, {event: payload.event}); + this.setPhase(RIGHT_PANEL_PHASES.Room3pidMemberInfo, {event: payload.event}); } else { - this.setPhase(RightPanel.Phase.RoomMemberList); + this.setPhase(RIGHT_PANEL_PHASES.RoomMemberList); } } } _onMembersClicked() { - this.togglePhase(RightPanel.Phase.RoomMemberList, MEMBER_PHASES); + // This toggles for us, if needed + this.setPhase(RIGHT_PANEL_PHASES.RoomMemberList); } _onFilesClicked() { - this.togglePhase(RightPanel.Phase.FilePanel); + // This toggles for us, if needed + this.setPhase(RIGHT_PANEL_PHASES.FilePanel); } _onNotificationsClicked() { - this.togglePhase(RightPanel.Phase.NotificationPanel); + // This toggles for us, if needed + this.setPhase(RIGHT_PANEL_PHASES.NotificationPanel); } renderButtons() { @@ -76,13 +80,13 @@ export default class RoomHeaderButtons extends HeaderButtons { />, , , diff --git a/src/stores/RightPanelStore.js b/src/stores/RightPanelStore.js index 0d66e825ea..8cb0df514a 100644 --- a/src/stores/RightPanelStore.js +++ b/src/stores/RightPanelStore.js @@ -32,6 +32,16 @@ const INITIAL_STATE = { const GROUP_PHASES = Object.keys(RIGHT_PANEL_PHASES).filter(k => k.startsWith("Group")); +// These are the phases that are safe to persist (the ones that don't require additional +// arguments, basically). +const PHASES_TO_PERSIST = [ + RIGHT_PANEL_PHASES.NotificationPanel, + RIGHT_PANEL_PHASES.FilePanel, + RIGHT_PANEL_PHASES.RoomMemberList, + RIGHT_PANEL_PHASES.GroupMemberList, + RIGHT_PANEL_PHASES.GroupRoomList, +]; + /** * A class for tracking the state of the right panel between layouts and * sessions. @@ -62,12 +72,47 @@ export default class RightPanelStore extends Store { return this._state.lastGroupPhase; } + get visibleRoomPanelPhase(): string { + return this.isOpenForRoom ? this.roomPanelPhase : null; + } + + get visibleGroupPanelPhase(): string { + return this.isOpenForGroup ? this.groupPanelPhase : null; + } + _setState(newState) { this._state = Object.assign(this._state, newState); - SettingsStore.setValue("showRightPanelInRoom", null, SettingLevel.DEVICE, this._state.showRoomPanel); - SettingsStore.setValue("showRightPanelInGroup", null, SettingLevel.DEVICE, this._state.showGroupPanel); - SettingsStore.setValue("lastRightPanelPhaseForRoom", null, SettingLevel.DEVICE, this._state.lastRoomPhase); - SettingsStore.setValue("lastRightPanelPhaseForGroup", null, SettingLevel.DEVICE, this._state.lastGroupPhase); + + SettingsStore.setValue( + "showRightPanelInRoom", + null, + SettingLevel.DEVICE, + this._state.showRoomPanel, + ); + SettingsStore.setValue( + "showRightPanelInGroup", + null, + SettingLevel.DEVICE, + this._state.showGroupPanel, + ); + + if (PHASES_TO_PERSIST.includes(this._state.lastRoomPhase)) { + SettingsStore.setValue( + "lastRightPanelPhaseForRoom", + null, + SettingLevel.DEVICE, + this._state.lastRoomPhase, + ); + } + if (PHASES_TO_PERSIST.includes(this._state.lastGroupPhase)) { + SettingsStore.setValue( + "lastRightPanelPhaseForGroup", + null, + SettingLevel.DEVICE, + this._state.lastGroupPhase, + ); + } + this.__emitChange(); } @@ -101,6 +146,13 @@ export default class RightPanelStore extends Store { }); } } + + // Let things like the member info panel actually open to the right member. + dis.dispatch({ + action: 'after_right_panel_phase_change', + phase: targetPhase, + ...(payload.refireParams || {}), + }); } static getSharedInstance(): RightPanelStore { From 8b492fdaa5470b3519d64565408a8956800d5ef1 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:29:43 -0700 Subject: [PATCH 026/179] Remove dead code from GroupView This was for a caret that is no longer in the app. Instead, the header buttons act as a toggle. --- src/components/structures/GroupView.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 65f8a832d7..3571e0e89a 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -543,10 +543,6 @@ export default createReactClass({ }); }, - _onShowRhsClick: function(ev) { - dis.dispatch({ action: 'show_right_panel' }); - }, - _onEditClick: function() { this.setState({ editing: true, From d8d8e590021abd2e60e530f6572fc49c6655313b Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:30:13 -0700 Subject: [PATCH 027/179] Don't show/hide the right panel depending on window size Fixes https://github.com/vector-im/riot-web/issues/8772 --- src/components/structures/MatrixChat.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 0b56ffc27a..16a7061a63 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -1699,12 +1699,6 @@ export default createReactClass({ if (this._windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) { dis.dispatch({ action: 'show_left_panel' }); } - if (this._windowWidth > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) { - dis.dispatch({ action: 'hide_right_panel' }); - } - if (this._windowWidth <= showRhsThreshold && window.innerWidth > showRhsThreshold) { - dis.dispatch({ action: 'show_right_panel' }); - } this.state.resizeNotifier.notifyWindowResized(); this._windowWidth = window.innerWidth; From eda712ece84ac931b2c773c217cf28eec764c99f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:30:26 -0700 Subject: [PATCH 028/179] Update sticker picker handling for new right panel actions --- src/components/views/rooms/Stickerpicker.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index 8a00725718..24f256e706 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -181,8 +181,7 @@ export default class Stickerpicker extends React.Component { case "stickerpicker_close": this.setState({showStickers: false}); break; - case "show_right_panel": - case "hide_right_panel": + case "after_right_panel_phase_change": case "show_left_panel": case "hide_left_panel": this.setState({showStickers: false}); From 756cf3a88bda2b27dcad2c1d33cfbbbcb7466cc7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:34:44 -0700 Subject: [PATCH 029/179] Convert the GroupMemberList actions to the new RightPanelStore --- src/components/views/groups/GroupMemberList.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/views/groups/GroupMemberList.js b/src/components/views/groups/GroupMemberList.js index 433625419d..3228a862ce 100644 --- a/src/components/views/groups/GroupMemberList.js +++ b/src/components/views/groups/GroupMemberList.js @@ -1,6 +1,7 @@ /* Copyright 2017 Vector Creations Ltd. Copyright 2017 New Vector Ltd. +Copyright 2019 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. @@ -24,7 +25,7 @@ import PropTypes from 'prop-types'; import { showGroupInviteDialog } from '../../../GroupAddressPicker'; import AccessibleButton from '../elements/AccessibleButton'; import TintableSvg from '../elements/TintableSvg'; -import RightPanel from '../../structures/RightPanel'; +import {RIGHT_PANEL_PHASES} from "../../../stores/RightPanelStorePhases"; const INITIAL_LOAD_NUM_MEMBERS = 30; @@ -163,8 +164,8 @@ export default createReactClass({ onInviteToGroupButtonClick() { showGroupInviteDialog(this.props.groupId).then(() => { dis.dispatch({ - action: 'view_right_panel_phase', - phase: RightPanel.Phase.GroupMemberList, + action: 'set_right_panel_phase', + phase: RIGHT_PANEL_PHASES.GroupMemberList, groupId: this.props.groupId, }); }); From 42898ec41431457265d9abbe2678271edc599569 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:35:12 -0700 Subject: [PATCH 030/179] Rid ourselves of RightPanel.Phases completely --- src/components/structures/RightPanel.js | 32 ++++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 73e3e8ecdd..76a6076892 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -45,8 +45,6 @@ export default class RightPanel extends React.Component { }; } - static Phase = RIGHT_PANEL_PHASES; - constructor(props, context) { super(props, context); this.state = { @@ -66,11 +64,11 @@ export default class RightPanel extends React.Component { _getPhaseFromProps() { if (this.props.groupId) { - return RightPanel.Phase.GroupMemberList; + return RIGHT_PANEL_PHASES.GroupMemberList; } else if (this.props.user) { - return RightPanel.Phase.RoomMemberInfo; + return RIGHT_PANEL_PHASES.RoomMemberInfo; } else { - return RightPanel.Phase.RoomMemberList; + return RIGHT_PANEL_PHASES.RoomMemberList; } } @@ -117,7 +115,7 @@ export default class RightPanel extends React.Component { onInviteToGroupButtonClick() { showGroupInviteDialog(this.props.groupId).then(() => { this.setState({ - phase: RightPanel.Phase.GroupMemberList, + phase: RIGHT_PANEL_PHASES.GroupMemberList, }); }); } @@ -133,9 +131,9 @@ export default class RightPanel extends React.Component { return; } // redraw the badge on the membership list - if (this.state.phase === RightPanel.Phase.RoomMemberList && member.roomId === this.props.roomId) { + if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList && member.roomId === this.props.roomId) { this._delayedUpdate(); - } else if (this.state.phase === RightPanel.Phase.RoomMemberInfo && member.roomId === this.props.roomId && + } else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo && member.roomId === this.props.roomId && member.userId === this.state.member.userId) { // refresh the member info (e.g. new power level) this._delayedUpdate(); @@ -169,13 +167,13 @@ export default class RightPanel extends React.Component { let panel =
; - if (this.props.roomId && this.state.phase === RightPanel.Phase.RoomMemberList) { + if (this.props.roomId && this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList) { panel = ; - } else if (this.props.groupId && this.state.phase === RightPanel.Phase.GroupMemberList) { + } else if (this.props.groupId && this.state.phase === RIGHT_PANEL_PHASES.GroupMemberList) { panel = ; - } else if (this.state.phase === RightPanel.Phase.GroupRoomList) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomList) { panel = ; - } else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo) { if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { const onClose = () => { dis.dispatch({ @@ -192,9 +190,9 @@ export default class RightPanel extends React.Component { } else { panel = ; } - } else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.Room3pidMemberInfo) { panel = ; - } else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupMemberInfo) { if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { const onClose = () => { dis.dispatch({ @@ -216,14 +214,14 @@ export default class RightPanel extends React.Component { /> ); } - } else if (this.state.phase === RightPanel.Phase.GroupRoomInfo) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomInfo) { panel = ; - } else if (this.state.phase === RightPanel.Phase.NotificationPanel) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.NotificationPanel) { panel = ; - } else if (this.state.phase === RightPanel.Phase.FilePanel) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.FilePanel) { panel = ; } From bbdff701b4cf0f7926a69e401cc75a403da711ed Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:40:18 -0700 Subject: [PATCH 031/179] Actually render the right panel in the new system --- src/components/structures/GroupView.js | 4 ++++ src/components/structures/RightPanel.js | 2 +- src/components/structures/RoomView.js | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 3571e0e89a..1bbd561027 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -580,6 +580,10 @@ export default createReactClass({ profileForm: null, }); break; + case 'after_right_panel_phase_change': + // We don't keep state on the right panel, so just re-render to update + this.forceUpdate(); + break; default: break; } diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 76a6076892..39aff9be8d 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -141,7 +141,7 @@ export default class RightPanel extends React.Component { } onAction(payload) { - if (payload.action === "view_right_panel_phase") { + if (payload.action === "after_right_panel_phase_change") { this.setState({ phase: payload.phase, groupRoomId: payload.groupRoomId, diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index f4900139ec..53ff1fb760 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -582,6 +582,10 @@ module.exports = createReactClass({ onAction: function(payload) { switch (payload.action) { + case 'after_right_panel_phase_change': + // We don't keep state on the right panel, so just re-render to update + this.forceUpdate(); + break; case 'message_send_failed': case 'message_sent': this._checkIfAlone(this.state.room); From 4873b526df86d5d9f10454d16f05013d158283f8 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:48:05 -0700 Subject: [PATCH 032/179] Ensure the right panel stays the same between room changes if possible Fixes https://github.com/vector-im/riot-web/issues/10149 --- src/components/structures/RightPanel.js | 16 +++++++++++++--- src/stores/RightPanelStore.js | 16 +++------------- src/stores/RightPanelStorePhases.js | 10 ++++++++++ 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 39aff9be8d..9f78e09c6e 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -28,7 +28,8 @@ import RateLimitedFunc from '../../ratelimitedfunc'; import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; import SettingsStore from "../../settings/SettingsStore"; -import {RIGHT_PANEL_PHASES} from "../../stores/RightPanelStorePhases"; +import {RIGHT_PANEL_PHASES, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases"; +import RightPanelStore from "../../stores/RightPanelStore"; export default class RightPanel extends React.Component { static get propTypes() { @@ -63,12 +64,21 @@ export default class RightPanel extends React.Component { } _getPhaseFromProps() { + const rps = RightPanelStore.getSharedInstance(); if (this.props.groupId) { - return RIGHT_PANEL_PHASES.GroupMemberList; + if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) { + rps.setPhase(RIGHT_PANEL_PHASES.GroupMemberList); + return RIGHT_PANEL_PHASES.GroupMemberList; + } + return rps.groupPanelPhase; } else if (this.props.user) { return RIGHT_PANEL_PHASES.RoomMemberInfo; } else { - return RIGHT_PANEL_PHASES.RoomMemberList; + if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) { + rps.setPhase(RIGHT_PANEL_PHASES.RoomMemberList); + return RIGHT_PANEL_PHASES.RoomMemberList; + } + return rps.roomPanelPhase; } } diff --git a/src/stores/RightPanelStore.js b/src/stores/RightPanelStore.js index 8cb0df514a..520a458755 100644 --- a/src/stores/RightPanelStore.js +++ b/src/stores/RightPanelStore.js @@ -17,7 +17,7 @@ limitations under the License. import dis from '../dispatcher'; import {Store} from 'flux/utils'; import SettingsStore, {SettingLevel} from "../settings/SettingsStore"; -import {RIGHT_PANEL_PHASES} from "./RightPanelStorePhases"; +import {RIGHT_PANEL_PHASES, RIGHT_PANEL_PHASES_NO_ARGS} from "./RightPanelStorePhases"; const INITIAL_STATE = { // Whether or not to show the right panel at all. We split out rooms and groups @@ -32,16 +32,6 @@ const INITIAL_STATE = { const GROUP_PHASES = Object.keys(RIGHT_PANEL_PHASES).filter(k => k.startsWith("Group")); -// These are the phases that are safe to persist (the ones that don't require additional -// arguments, basically). -const PHASES_TO_PERSIST = [ - RIGHT_PANEL_PHASES.NotificationPanel, - RIGHT_PANEL_PHASES.FilePanel, - RIGHT_PANEL_PHASES.RoomMemberList, - RIGHT_PANEL_PHASES.GroupMemberList, - RIGHT_PANEL_PHASES.GroupRoomList, -]; - /** * A class for tracking the state of the right panel between layouts and * sessions. @@ -96,7 +86,7 @@ export default class RightPanelStore extends Store { this._state.showGroupPanel, ); - if (PHASES_TO_PERSIST.includes(this._state.lastRoomPhase)) { + if (RIGHT_PANEL_PHASES_NO_ARGS.includes(this._state.lastRoomPhase)) { SettingsStore.setValue( "lastRightPanelPhaseForRoom", null, @@ -104,7 +94,7 @@ export default class RightPanelStore extends Store { this._state.lastRoomPhase, ); } - if (PHASES_TO_PERSIST.includes(this._state.lastGroupPhase)) { + if (RIGHT_PANEL_PHASES_NO_ARGS.includes(this._state.lastGroupPhase)) { SettingsStore.setValue( "lastRightPanelPhaseForGroup", null, diff --git a/src/stores/RightPanelStorePhases.js b/src/stores/RightPanelStorePhases.js index 83a6d97345..96807ebf5b 100644 --- a/src/stores/RightPanelStorePhases.js +++ b/src/stores/RightPanelStorePhases.js @@ -29,3 +29,13 @@ export const RIGHT_PANEL_PHASES = Object.freeze({ GroupRoomInfo: 'GroupRoomInfo', GroupMemberInfo: 'GroupMemberInfo', }); + +// These are the phases that are safe to persist (the ones that don't require additional +// arguments). +export const RIGHT_PANEL_PHASES_NO_ARGS = [ + RIGHT_PANEL_PHASES.NotificationPanel, + RIGHT_PANEL_PHASES.FilePanel, + RIGHT_PANEL_PHASES.RoomMemberList, + RIGHT_PANEL_PHASES.GroupMemberList, + RIGHT_PANEL_PHASES.GroupRoomList, +]; From 75c32a2f025408681a43f226f281154f99d8d5ac Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:50:19 -0700 Subject: [PATCH 033/179] Fix a bug where the icons need to be clicked twice after reload Clicking on the member icon was fine, but clicking on the file panel wouldn't bring it up - it had to be clicked a second time to actually show the panel. --- src/stores/RightPanelStore.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/stores/RightPanelStore.js b/src/stores/RightPanelStore.js index 520a458755..2ad18fe521 100644 --- a/src/stores/RightPanelStore.js +++ b/src/stores/RightPanelStore.js @@ -123,6 +123,7 @@ export default class RightPanelStore extends Store { } else { this._setState({ lastGroupPhase: targetPhase, + showGroupPanel: true, }); } } else { @@ -133,6 +134,7 @@ export default class RightPanelStore extends Store { } else { this._setState({ lastRoomPhase: targetPhase, + showRoomPanel: true, }); } } From a24bbdffd0ecf76cddadaa1550f299923fcee321 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 5 Dec 2019 23:58:19 -0700 Subject: [PATCH 034/179] Appease the linter Mid-PR cleanup. --- src/components/structures/MatrixChat.js | 2 -- src/components/views/right_panel/GroupHeaderButtons.js | 5 ++++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 16a7061a63..5b602fd058 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -1690,8 +1690,6 @@ export default createReactClass({ handleResize: function(e) { const hideLhsThreshold = 1000; const showLhsThreshold = 1000; - const hideRhsThreshold = 820; - const showRhsThreshold = 820; if (this._windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) { dis.dispatch({ action: 'hide_left_panel' }); diff --git a/src/components/views/right_panel/GroupHeaderButtons.js b/src/components/views/right_panel/GroupHeaderButtons.js index c112d0195a..c134a5d237 100644 --- a/src/components/views/right_panel/GroupHeaderButtons.js +++ b/src/components/views/right_panel/GroupHeaderButtons.js @@ -52,7 +52,10 @@ export default class GroupHeaderButtons extends HeaderButtons { } else if (payload.action === "view_group") { this.setPhase(RIGHT_PANEL_PHASES.GroupMemberList); } else if (payload.action === "view_group_room") { - this.setPhase(RIGHT_PANEL_PHASES.GroupRoomInfo, {groupRoomId: payload.groupRoomId, groupId: payload.groupId}); + this.setPhase( + RIGHT_PANEL_PHASES.GroupRoomInfo, + {groupRoomId: payload.groupRoomId, groupId: payload.groupId}, + ); } else if (payload.action === "view_group_room_list") { this.setPhase(RIGHT_PANEL_PHASES.GroupRoomList); } else if (payload.action === "view_group_member_list") { From 9b9e074d3020e981d6a726439c1b543bbb1a59b3 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 6 Dec 2019 14:15:41 +0000 Subject: [PATCH 035/179] Use consistent import style --- .../views/dialogs/secretstorage/AccessSecretStorageDialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js index f74e96bc2e..05adeb48de 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -21,7 +21,7 @@ import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; import { _t } from '../../../../languageHandler'; -import {Key} from "../../../../Keyboard"; +import { Key } from "../../../../Keyboard"; /* * Access Secure Secret Storage by requesting the user's passphrase. From 24d6e7e4564772fbc235234bd831595ed008c4e2 Mon Sep 17 00:00:00 2001 From: "J. Ryan Stinnett" Date: Fri, 6 Dec 2019 17:54:00 +0000 Subject: [PATCH 036/179] Use private key check to provide feedback --- src/CrossSigningManager.js | 40 ++++++++++------ .../AccessSecretStorageDialog.js | 47 +++++++++++++++++-- src/i18n/strings/en_EN.json | 2 + 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index 1a0c7fefa4..dd77eb1f87 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -16,6 +16,7 @@ limitations under the License. import Modal from './Modal'; import sdk from './index'; +import MatrixClientPeg from './MatrixClientPeg'; import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto/recoverykey'; @@ -39,40 +40,49 @@ export const saveCrossSigningKeys = newKeys => Object.assign(crossSigningKeys, n // there. const secretStorageKeys = {}; -// XXX: This flow should maybe be reworked to allow retries in case of typos, -// etc. export const getSecretStorageKey = async ({ keys: keyInfos }) => { const keyInfoEntries = Object.entries(keyInfos); if (keyInfoEntries.length > 1) { throw new Error("Multiple storage key requests not implemented"); } const [name, info] = keyInfoEntries[0]; + // Check the in-memory cache if (secretStorageKeys[name]) { return [name, secretStorageKeys[name]]; } + + const inputToKey = async ({ passphrase, recoveryKey }) => { + if (passphrase) { + return deriveKey( + passphrase, + info.passphrase.salt, + info.passphrase.iterations, + ); + } else { + return decodeRecoveryKey(recoveryKey); + } + }; const AccessSecretStorageDialog = sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", - AccessSecretStorageDialog, { - keyInfo: info, - }, + AccessSecretStorageDialog, + { + keyInfo: info, + checkPrivateKey: async (input) => { + const key = await inputToKey(input); + return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey); + }, + }, ); const [input] = await finished; if (!input) { throw new Error("Secret storage access canceled"); } - let key; - if (input.passphrase) { - key = await deriveKey( - input.passphrase, - info.passphrase.salt, - info.passphrase.iterations, - ); - } else { - key = decodeRecoveryKey(input.recoveryKey); - } + const key = await inputToKey(input); + // Save to cache to avoid future prompts in the current session secretStorageKeys[name] = key; + return [name, key]; }; diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js index 05adeb48de..d116ce505f 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -30,6 +30,8 @@ export default class AccessSecretStorageDialog extends React.PureComponent { static propTypes = { // { passphrase, pubkey } keyInfo: PropTypes.object.isRequired, + // Function from one of { passphrase, recoveryKey } -> boolean + checkPrivateKey: PropTypes.func.isRequired, } constructor(props) { @@ -39,6 +41,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { recoveryKeyValid: false, forceRecoveryKey: false, passPhrase: '', + keyMatches: null, }; } @@ -61,25 +64,41 @@ export default class AccessSecretStorageDialog extends React.PureComponent { this.setState({ recoveryKey: e.target.value, recoveryKeyValid: MatrixClientPeg.get().isValidRecoveryKey(e.target.value), + keyMatches: null, }); } _onPassPhraseNext = async () => { - this.props.onFinished({ passphrase: this.state.passPhrase }); + this.setState({ keyMatches: null }); + const input = { passphrase: this.state.passPhrase }; + const keyMatches = await this.props.checkPrivateKey(input); + if (keyMatches) { + this.props.onFinished(input); + } else { + this.setState({ keyMatches }); + } } _onRecoveryKeyNext = async () => { - this.props.onFinished({ recoveryKey: this.state.recoveryKey }); + this.setState({ keyMatches: null }); + const input = { recoveryKey: this.state.recoveryKey }; + const keyMatches = await this.props.checkPrivateKey(input); + if (keyMatches) { + this.props.onFinished(input); + } else { + this.setState({ keyMatches }); + } } _onPassPhraseChange = (e) => { this.setState({ passPhrase: e.target.value, + keyMatches: null, }); } _onPassPhraseKeyPress = (e) => { - if (e.key === Key.ENTER) { + if (e.key === Key.ENTER && this.state.passPhrase.length > 0) { this._onPassPhraseNext(); } } @@ -106,6 +125,19 @@ export default class AccessSecretStorageDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); title = _t("Enter secret storage passphrase"); + + let keyStatus; + if (this.state.keyMatches === false) { + keyStatus =
+ {"\uD83D\uDC4E "}{_t( + "Unable to access secret storage. Please verify that you " + + "entered the correct passphrase.", + )} +
; + } else { + keyStatus =
; + } + content =

{_t( "Warning: You should only access secret storage " + @@ -125,11 +157,13 @@ export default class AccessSecretStorageDialog extends React.PureComponent { value={this.state.passPhrase} autoFocus={true} /> + {keyStatus}

{_t( @@ -163,6 +197,13 @@ export default class AccessSecretStorageDialog extends React.PureComponent { keyStatus =
{"\uD83D\uDC4D "}{_t("This looks like a valid recovery key!")}
; + } else if (this.state.keyMatches === false) { + keyStatus =
+ {"\uD83D\uDC4E "}{_t( + "Unable to access secret storage. Please verify that you " + + "entered the correct recovery key.", + )} +
; } else { keyStatus =
{"\uD83D\uDC4E "}{_t("Not a valid recovery key")} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ab26d677a3..f96756d59f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1518,11 +1518,13 @@ "Allow": "Allow", "Deny": "Deny", "Enter secret storage passphrase": "Enter secret storage passphrase", + "Unable to access secret storage. Please verify that you entered the correct passphrase.": "Unable to access secret storage. Please verify that you entered the correct passphrase.", "Warning: You should only access secret storage from a trusted computer.": "Warning: You should only access secret storage from a trusted computer.", "Access your secure message history and your cross-signing identity for verifying other devices by entering your passphrase.": "Access your secure message history and your cross-signing identity for verifying other devices by entering your passphrase.", "If you've forgotten your passphrase you can use your recovery key or set up new recovery options.": "If you've forgotten your passphrase you can use your recovery key or set up new recovery options.", "Enter secret storage recovery key": "Enter secret storage recovery key", "This looks like a valid recovery key!": "This looks like a valid recovery key!", + "Unable to access secret storage. Please verify that you entered the correct recovery key.": "Unable to access secret storage. Please verify that you entered the correct recovery key.", "Not a valid recovery key": "Not a valid recovery key", "Access your secure message history and your cross-signing identity for verifying other devices by entering your recovery key.": "Access your secure message history and your cross-signing identity for verifying other devices by entering your recovery key.", "If you've forgotten your recovery key you can .": "If you've forgotten your recovery key you can .", From 814c408e2328e5d81224d502a169a449438337d5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Dec 2019 14:18:18 -0700 Subject: [PATCH 037/179] Disable the right panel when the app asks us to Currently this is only used in the GroupView and for forwarding messages. --- src/components/structures/MatrixChat.js | 4 ++-- src/components/structures/RoomDirectory.js | 11 ----------- src/stores/RightPanelStore.js | 9 ++++++++- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 5b602fd058..b4e6ee52f2 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -177,7 +177,7 @@ export default createReactClass({ collapseLhs: false, leftDisabled: false, middleDisabled: false, - rightDisabled: false, + // the right panel's disabled state is tracked in its store. version: null, newVersion: null, @@ -660,7 +660,7 @@ export default createReactClass({ this.setState({ leftDisabled: payload.leftDisabled || payload.sideDisabled || false, middleDisabled: payload.middleDisabled || false, - rightDisabled: payload.rightDisabled || payload.sideDisabled || false, + // We don't track the right panel being disabled here - it's tracked in the store. }); break; } diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index efca8d12a8..715bbc5f42 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -108,20 +108,9 @@ module.exports = createReactClass({ ), }); }); - - // dis.dispatch({ - // action: 'panel_disable', - // sideDisabled: true, - // middleDisabled: true, - // }); }, componentWillUnmount: function() { - // dis.dispatch({ - // action: 'panel_disable', - // sideDisabled: false, - // middleDisabled: false, - // }); if (this.filterTimeout) { clearTimeout(this.filterTimeout); } diff --git a/src/stores/RightPanelStore.js b/src/stores/RightPanelStore.js index 2ad18fe521..39a65e2810 100644 --- a/src/stores/RightPanelStore.js +++ b/src/stores/RightPanelStore.js @@ -39,6 +39,8 @@ const GROUP_PHASES = Object.keys(RIGHT_PANEL_PHASES).filter(k => k.startsWith("G export default class RightPanelStore extends Store { static _instance; + _inhibitUpdates = false; + constructor() { super(dis); @@ -107,7 +109,12 @@ export default class RightPanelStore extends Store { } __onDispatch(payload) { - if (payload.action !== 'set_right_panel_phase') return; + if (payload.action === 'panel_disable') { + this._inhibitUpdates = payload.rightDisabled || payload.sideDisabled || false; + return; + } + + if (payload.action !== 'set_right_panel_phase' || this._inhibitUpdates) return; const targetPhase = payload.phase; if (!RIGHT_PANEL_PHASES[targetPhase]) { From 185830e4f395b9eaf471e187c7d3565d9d377331 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Dec 2019 14:22:27 -0700 Subject: [PATCH 038/179] Fix end-to-end tests The right panel needs to be opened manually now. --- test/end-to-end-tests/src/usecases/invite.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/end-to-end-tests/src/usecases/invite.js b/test/end-to-end-tests/src/usecases/invite.js index d7e02a38d8..838b632ddd 100644 --- a/test/end-to-end-tests/src/usecases/invite.js +++ b/test/end-to-end-tests/src/usecases/invite.js @@ -17,6 +17,8 @@ limitations under the License. module.exports = async function invite(session, userId) { session.log.step(`invites "${userId}" to room`); await session.delay(1000); + const memberPanelButton = await session.query(".mx_RightPanel_membersButton"); + await memberPanelButton.click(); const inviteButton = await session.query(".mx_MemberList_invite"); await inviteButton.click(); const inviteTextArea = await session.query(".mx_AddressPickerDialog textarea"); From 4bcf99f65e2ebb62104dde8d46fc7e6d28b8285f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Dec 2019 14:50:56 -0700 Subject: [PATCH 039/179] Fix member info not opening The subclasses listen for view_user and similar dispatches, which then start up the RightPanel. We weren't registering a listener though because we changed to using the RightPanelStore for most of our logic. --- src/components/views/right_panel/HeaderButtons.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/views/right_panel/HeaderButtons.js b/src/components/views/right_panel/HeaderButtons.js index c43e8fc47e..ebe1f5f915 100644 --- a/src/components/views/right_panel/HeaderButtons.js +++ b/src/components/views/right_panel/HeaderButtons.js @@ -42,10 +42,16 @@ export default class HeaderButtons extends React.Component { componentWillMount() { this._storeToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelUpdate.bind(this)); + this._dispatcherRef = dis.register(this.onAction.bind(this)); // used by subclasses } componentWillUnmount() { if (this._storeToken) this._storeToken.remove(); + if (this._dispatcherRef) dis.unregister(this._dispatcherRef); + } + + onAction(payload) { + // Ignore - intended to be overridden by subclasses } setPhase(phase, extras) { From 78ce801c25d44b35367bfba1ebad5b0b316fe94c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Dec 2019 14:52:31 -0700 Subject: [PATCH 040/179] Fix incorrect function call into RightPanelStore We dispatch to open, not call directly into the store. --- src/components/structures/RightPanel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 9f78e09c6e..7497ecd8b9 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -67,7 +67,7 @@ export default class RightPanel extends React.Component { const rps = RightPanelStore.getSharedInstance(); if (this.props.groupId) { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) { - rps.setPhase(RIGHT_PANEL_PHASES.GroupMemberList); + dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.GroupMemberList}); return RIGHT_PANEL_PHASES.GroupMemberList; } return rps.groupPanelPhase; @@ -75,7 +75,7 @@ export default class RightPanel extends React.Component { return RIGHT_PANEL_PHASES.RoomMemberInfo; } else { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) { - rps.setPhase(RIGHT_PANEL_PHASES.RoomMemberList); + dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.RoomMemberList}); return RIGHT_PANEL_PHASES.RoomMemberList; } return rps.roomPanelPhase; From 94ae06db4d964b22b568105f644d2ba57fdeef65 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Dec 2019 15:04:44 -0700 Subject: [PATCH 041/179] Fix cold open of the RightPanel directly to MemberInfo This requires us to track some of the phase's state in the RightPanelStore, which is not great - trying to get it through the app is a bit difficult. --- src/components/structures/RightPanel.js | 16 +++++++++++----- src/stores/RightPanelStore.js | 8 ++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 7497ecd8b9..1745c9d7dc 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -36,7 +36,7 @@ export default class RightPanel extends React.Component { return { roomId: PropTypes.string, // if showing panels for a given room, this is set groupId: PropTypes.string, // if showing panels for a given group, this is set - user: PropTypes.object, + user: PropTypes.object, // used if we know the user ahead of opening the panel }; } @@ -51,6 +51,7 @@ export default class RightPanel extends React.Component { this.state = { phase: this._getPhaseFromProps(), isUserPrivilegedInGroup: null, + member: this._getUserForPanel(), }; this.onAction = this.onAction.bind(this); this.onRoomStateMember = this.onRoomStateMember.bind(this); @@ -63,6 +64,14 @@ export default class RightPanel extends React.Component { }, 500); } + // Helper function to split out the logic for _getPhaseFromProps() and the constructor + // as both are called at the same time in the constructor. + _getUserForPanel() { + if (this.state && this.state.member) return this.state.member; + const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams; + return this.props.user || lastParams['member']; + } + _getPhaseFromProps() { const rps = RightPanelStore.getSharedInstance(); if (this.props.groupId) { @@ -71,7 +80,7 @@ export default class RightPanel extends React.Component { return RIGHT_PANEL_PHASES.GroupMemberList; } return rps.groupPanelPhase; - } else if (this.props.user) { + } else if (this._getUserForPanel()) { return RIGHT_PANEL_PHASES.RoomMemberInfo; } else { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) { @@ -87,9 +96,6 @@ export default class RightPanel extends React.Component { const cli = this.context.matrixClient; cli.on("RoomState.members", this.onRoomStateMember); this._initGroupStore(this.props.groupId); - if (this.props.user) { - this.setState({member: this.props.user}); - } } componentWillUnmount() { diff --git a/src/stores/RightPanelStore.js b/src/stores/RightPanelStore.js index 39a65e2810..4542902ae8 100644 --- a/src/stores/RightPanelStore.js +++ b/src/stores/RightPanelStore.js @@ -28,6 +28,9 @@ const INITIAL_STATE = { // The last phase (screen) the right panel was showing lastRoomPhase: SettingsStore.getValue("lastRightPanelPhaseForRoom"), lastGroupPhase: SettingsStore.getValue("lastRightPanelPhaseForGroup"), + + // Extra information about the last phase + lastRoomPhaseParams: {}, }; const GROUP_PHASES = Object.keys(RIGHT_PANEL_PHASES).filter(k => k.startsWith("Group")); @@ -72,6 +75,10 @@ export default class RightPanelStore extends Store { return this.isOpenForGroup ? this.groupPanelPhase : null; } + get roomPanelPhaseParams(): any { + return this._state.lastRoomPhaseParams || {}; + } + _setState(newState) { this._state = Object.assign(this._state, newState); @@ -142,6 +149,7 @@ export default class RightPanelStore extends Store { this._setState({ lastRoomPhase: targetPhase, showRoomPanel: true, + lastRoomPhaseParams: payload.refireParams || {}, }); } } From 405b3f6be62bee8992fa78d66cae583d49b72a51 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Dec 2019 15:17:31 -0700 Subject: [PATCH 042/179] Fix member list not being open for end-to-end tests --- test/end-to-end-tests/src/usecases/invite.js | 11 ++++++++++- test/end-to-end-tests/src/usecases/memberlist.js | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/test/end-to-end-tests/src/usecases/invite.js b/test/end-to-end-tests/src/usecases/invite.js index 838b632ddd..814ecd30a6 100644 --- a/test/end-to-end-tests/src/usecases/invite.js +++ b/test/end-to-end-tests/src/usecases/invite.js @@ -18,7 +18,16 @@ module.exports = async function invite(session, userId) { session.log.step(`invites "${userId}" to room`); await session.delay(1000); const memberPanelButton = await session.query(".mx_RightPanel_membersButton"); - await memberPanelButton.click(); + try { + await session.query(".mx_RightPanel_headerButton_highlight", 500); + // Right panel is open - toggle it to ensure it's the member list + // Sometimes our tests have this opened to MemberInfo + await memberPanelButton.click(); + await memberPanelButton.click(); + } catch (e) { + // Member list is closed - open it + await memberPanelButton.click(); + } const inviteButton = await session.query(".mx_MemberList_invite"); await inviteButton.click(); const inviteTextArea = await session.query(".mx_AddressPickerDialog textarea"); diff --git a/test/end-to-end-tests/src/usecases/memberlist.js b/test/end-to-end-tests/src/usecases/memberlist.js index f6b07b3500..42601b6610 100644 --- a/test/end-to-end-tests/src/usecases/memberlist.js +++ b/test/end-to-end-tests/src/usecases/memberlist.js @@ -62,6 +62,17 @@ module.exports.verifyDeviceForUser = async function(session, name, expectedDevic }; async function getMembersInMemberlist(session) { + const memberPanelButton = await session.query(".mx_RightPanel_membersButton"); + try { + await session.query(".mx_RightPanel_headerButton_highlight", 500); + // Right panel is open - toggle it to ensure it's the member list + // Sometimes our tests have this opened to MemberInfo + await memberPanelButton.click(); + await memberPanelButton.click(); + } catch (e) { + // Member list is closed - open it + await memberPanelButton.click(); + } const memberNameElements = await session.queryAll(".mx_MemberList .mx_EntityTile_name"); return Promise.all(memberNameElements.map(async (el) => { return {label: el, displayName: await session.innerText(el)}; From 8d3418dfa99e6f0eb40f747990953ab684e53e6f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Dec 2019 17:08:24 -0700 Subject: [PATCH 043/179] Update copy for DM invites Fixes https://github.com/vector-im/riot-web/issues/10766 --- src/components/views/rooms/RoomPreviewBar.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js index 513b867d4f..a43a4df158 100644 --- a/src/components/views/rooms/RoomPreviewBar.js +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -451,16 +451,21 @@ module.exports = createReactClass({ if (isDM) { title = _t("Do you want to chat with %(user)s?", { user: inviteMember.name }); + subTitle = [ + avatar, + _t(" wants to chat", {}, {userName: () => inviterElement}), + ]; + primaryActionLabel = _t("Start chatting"); } else { title = _t("Do you want to join %(roomName)s?", { roomName: this._roomName() }); + subTitle = [ + avatar, + _t(" invited you", {}, {userName: () => inviterElement}), + ]; + primaryActionLabel = _t("Accept"); } - subTitle = [ - avatar, - _t(" invited you", {}, {userName: () => inviterElement}), - ]; - primaryActionLabel = _t("Accept"); primaryActionHandler = this.props.onJoinClick; secondaryActionLabel = _t("Reject"); secondaryActionHandler = this.props.onRejectClick; From 0e1d5daee4a799e0b01e0c156a8adf473e9ea44f Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Fri, 6 Dec 2019 17:08:55 -0700 Subject: [PATCH 044/179] i18n --- src/i18n/strings/en_EN.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 182c761c5f..d1bd34860d 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -980,6 +980,8 @@ "Use an identity server in Settings to receive invites directly in Riot.": "Use an identity server in Settings to receive invites directly in Riot.", "Share this email in Settings to receive invites directly in Riot.": "Share this email in Settings to receive invites directly in Riot.", "Do you want to chat with %(user)s?": "Do you want to chat with %(user)s?", + " wants to chat": " wants to chat", + "Start chatting": "Start chatting", "Do you want to join %(roomName)s?": "Do you want to join %(roomName)s?", " invited you": " invited you", "Reject": "Reject", From 680c5c2b27e1d931ad8ac32a2d95844d22c8561e Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 7 Dec 2019 12:20:06 +0000 Subject: [PATCH 045/179] Switch ReactionsRowButton to an AccessibleButton for space/enter handling Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/messages/ReactionsRowButton.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/views/messages/ReactionsRowButton.js b/src/components/views/messages/ReactionsRowButton.js index 10dea34809..89bd6e7938 100644 --- a/src/components/views/messages/ReactionsRowButton.js +++ b/src/components/views/messages/ReactionsRowButton.js @@ -126,10 +126,9 @@ export default class ReactionsRowButton extends React.PureComponent { ); } - return {tooltip} - ; + ; } } From 86e52d1ef334d7b9333f965d3b34fc17808e38b0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 7 Dec 2019 12:45:28 +0000 Subject: [PATCH 046/179] Mark the This/All Rooms scope buttons as radios for a11y Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/views/rooms/_SearchBar.scss | 4 ++++ src/components/views/rooms/SearchBar.js | 26 ++++++++++++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/res/css/views/rooms/_SearchBar.scss b/res/css/views/rooms/_SearchBar.scss index 894473a5fe..b6748e5ad2 100644 --- a/res/css/views/rooms/_SearchBar.scss +++ b/res/css/views/rooms/_SearchBar.scss @@ -37,6 +37,10 @@ limitations under the License. mask-position: center; } + .mx_SearchBar_buttons { + display: inherit; + } + .mx_SearchBar_button { border: 0; margin: 0 0 0 22px; diff --git a/src/components/views/rooms/SearchBar.js b/src/components/views/rooms/SearchBar.js index 5e0ac9a953..08cb9bbee0 100644 --- a/src/components/views/rooms/SearchBar.js +++ b/src/components/views/rooms/SearchBar.js @@ -57,19 +57,31 @@ module.exports = createReactClass({ }, render: function() { - const searchButtonClasses = classNames({ mx_SearchBar_searchButton: true, mx_SearchBar_searching: this.props.searchInProgress }); - const thisRoomClasses = classNames({ mx_SearchBar_button: true, mx_SearchBar_unselected: this.state.scope !== 'Room' }); - const allRoomsClasses = classNames({ mx_SearchBar_button: true, mx_SearchBar_unselected: this.state.scope !== 'All' }); + const searchButtonClasses = classNames("mx_SearchBar_searchButton", { + mx_SearchBar_searching: this.props.searchInProgress, + }); + const thisRoomClasses = classNames("mx_SearchBar_button", { + mx_SearchBar_unselected: this.state.scope !== 'Room', + }); + const allRoomsClasses = classNames("mx_SearchBar_button", { + mx_SearchBar_unselected: this.state.scope !== 'All', + }); return (
- {_t("This Room")} - {_t("All Rooms")} +
+ + {_t("This Room")} + + + {_t("All Rooms")} + +
- +
- +
); }, From 4be8b878692e20aee94bd0487e9a7cb073f00a9b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 7 Dec 2019 13:07:52 +0000 Subject: [PATCH 047/179] Add what-input to allow different scoping to focus-visible for MessageActionBar keyboard a11y Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 1 + res/css/views/rooms/_EventTile.scss | 1 + src/components/structures/MatrixChat.js | 2 ++ yarn.lock | 5 +++++ 4 files changed, 9 insertions(+) diff --git a/package.json b/package.json index 5b82d9b111..58216f0181 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "text-encoding-utf-8": "^1.0.1", "url": "^0.11.0", "velocity-animate": "^1.5.2", + "what-input": "^5.2.6", "whatwg-fetch": "^1.1.1", "zxcvbn": "^4.4.2" }, diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 43aed34a23..5359992f84 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -169,6 +169,7 @@ limitations under the License. .mx_EventTile:hover .mx_MessageActionBar, .mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar, +[data-whatinput='keyboard'] .mx_EventTile:focus-within .mx_MessageActionBar, .mx_EventTile.focus-visible:focus-within .mx_MessageActionBar { visibility: visible; } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 585499ddeb..1ea7243303 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -24,6 +24,8 @@ import Matrix from "matrix-js-sdk"; // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss import 'focus-visible'; +// what-input helps improve keyboard accessibility +import 'what-input'; import Analytics from "../../Analytics"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; diff --git a/yarn.lock b/yarn.lock index 883a1ee90c..f5f291d15d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8646,6 +8646,11 @@ webpack@^4.20.2: watchpack "^1.6.0" webpack-sources "^1.4.1" +what-input@^5.2.6: + version "5.2.6" + resolved "https://registry.yarnpkg.com/what-input/-/what-input-5.2.6.tgz#ac6f003bf8d3592a0031dea7a03565469b00020b" + integrity sha512-a0BcI5YR7xp87vSzGcbN0IszJKpUQuTmrZaTSQBl7TLDIdKj6rDhluQ7b/7lYGG81gWDvkySsEvwv4BW5an9kg== + whatwg-fetch@>=0.10.0: version "3.0.0" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" From 33eff43313ce363b48e034e8c91476876efab3df Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sat, 7 Dec 2019 21:01:21 +0000 Subject: [PATCH 048/179] Change the (edited) link to an AccessibleButton for a11y Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/messages/TextualBody.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 2680c13512..6e06a6e7b5 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -401,13 +401,18 @@ module.exports = createReactClass({ label={_t("Edited at %(date)s. Click to view edits.", {date: dateString})} />; } + + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return ( -
{editedTooltip}{`(${_t("edited")})`}
+ > + { editedTooltip }{`(${_t("edited")})`} + ); }, From 4c55f3c5b536786d5b088b1c378f1eed8a4309cd Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sun, 8 Dec 2019 12:12:06 +0000 Subject: [PATCH 049/179] Remove unused refs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/GroupView.js | 36 ++++++++++---------- src/components/structures/RoomDirectory.js | 2 +- src/components/structures/RoomView.js | 4 +-- src/components/views/messages/MAudioBody.js | 2 +- src/components/views/messages/MFileBody.js | 2 +- src/components/views/messages/MImageBody.js | 4 +-- src/components/views/messages/MVideoBody.js | 6 ++-- src/components/views/rooms/AuxPanel.js | 5 +-- src/components/views/rooms/RoomDetailList.js | 2 +- src/components/views/rooms/RoomNameEditor.js | 16 ++++----- 10 files changed, 40 insertions(+), 39 deletions(-) diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index a0aa36803f..d52599abe9 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -1214,25 +1214,25 @@ export default createReactClass({ const EditableText = sdk.getComponent("elements.EditableText"); - nameNode = ; + nameNode = ; - shortDescNode = ; + shortDescNode = ; } else { const onGroupHeaderItemClick = this.state.isUserMember ? this._onEditClick : null; const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null; diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index efca8d12a8..c8863773f4 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -572,7 +572,7 @@ module.exports = createReactClass({ if (rows.length === 0 && !this.state.loading) { scrollpanel_content = { _t('No rooms to show') }; } else { - scrollpanel_content =
+ scrollpanel_content =
{ rows } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 5cc1e2b765..679b385c71 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1719,7 +1719,7 @@ module.exports = createReactClass({ aux = ; } else if (this.state.searching) { hideCancel = true; // has own cancel - aux = ; + aux = ; } else if (showRoomUpgradeBar) { aux = ; hideCancel = true; @@ -1775,7 +1775,7 @@ module.exports = createReactClass({ } const auxPanel = ( - + { _t("Error decrypting audio") } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 7f4d76747a..d231a86f36 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -325,7 +325,7 @@ module.exports = createReactClass({ }; return ( - +
{ _t("Decrypt %(text)s", { text: text }) } diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index b12957a7df..3c20aab529 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -459,7 +459,7 @@ export default class MImageBody extends React.Component { if (this.state.error !== null) { return ( - + { _t("Error decrypting image") } @@ -477,7 +477,7 @@ export default class MImageBody extends React.Component { const thumbnail = this._messageContent(contentUrl, thumbUrl, content); const fileBody = this.getFileBody(); - return + return { thumbnail } { fileBody } ; diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index 44954344ff..8366d0dd01 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -132,7 +132,7 @@ module.exports = createReactClass({ if (this.state.error !== null) { return ( - + { _t("Error decrypting video") } @@ -144,8 +144,8 @@ module.exports = createReactClass({ // The attachment is decrypted in componentDidMount. // For now add an img tag with a spinner. return ( - -
+ +
{content.body}
diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index ffb5d9272d..a83160ddbf 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -188,14 +188,15 @@ module.exports = createReactClass({ } const callView = ( - ); - const appsDrawer = { _t('No rooms to show') }; } else { - rooms =
+ rooms =
{ this.getRows() } diff --git a/src/components/views/rooms/RoomNameEditor.js b/src/components/views/rooms/RoomNameEditor.js index 5bdf719e97..375a4b42b1 100644 --- a/src/components/views/rooms/RoomNameEditor.js +++ b/src/components/views/rooms/RoomNameEditor.js @@ -65,14 +65,14 @@ module.exports = createReactClass({ return (
- +
); }, From d22985f12e222f997ef9c118f072d7bbe936194d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Sun, 8 Dec 2019 12:16:17 +0000 Subject: [PATCH 050/179] Migrate string refs over to createRef Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- code_style.md | 8 +- .../views/dialogs/ExportE2eKeysDialog.js | 13 ++-- .../views/dialogs/ImportE2eKeysDialog.js | 23 ++++-- src/components/structures/InteractiveAuth.js | 15 ++-- src/components/structures/LoggedInView.js | 14 ++-- src/components/structures/MessagePanel.js | 75 ++++++++++--------- src/components/structures/RoomSubList.js | 32 ++++---- src/components/structures/RoomView.js | 62 ++++++++------- src/components/structures/ScrollPanel.js | 28 +++---- src/components/structures/SearchBox.js | 22 +++--- src/components/structures/TimelinePanel.js | 61 ++++++++------- src/components/views/auth/CaptchaForm.js | 10 ++- .../auth/InteractiveAuthEntryComponents.js | 10 ++- .../views/dialogs/AddressPickerDialog.js | 22 +++--- src/components/views/dialogs/SetMxIdDialog.js | 17 +++-- src/components/views/dialogs/ShareDialog.js | 8 +- .../views/dialogs/TextInputDialog.js | 18 ++++- src/components/views/elements/AppTile.js | 16 ++-- src/components/views/elements/EditableText.js | 21 +++--- src/components/views/elements/UserSelector.js | 14 ++-- .../views/messages/EditHistoryMessage.js | 12 +-- src/components/views/messages/MFileBody.js | 26 ++++--- src/components/views/messages/MImageBody.js | 12 +-- src/components/views/messages/MessageEvent.js | 11 ++- src/components/views/messages/TextualBody.js | 22 +++--- .../room_settings/RoomProfileSettings.js | 8 +- src/components/views/rooms/EventTile.js | 21 +++--- .../views/rooms/LinkPreviewWidget.js | 14 ++-- src/components/views/rooms/MessageComposer.js | 10 ++- src/components/views/rooms/RoomBreadcrumbs.js | 10 ++- src/components/views/rooms/RoomDetailRow.js | 12 ++- src/components/views/rooms/RoomHeader.js | 12 ++- src/components/views/rooms/SearchBar.js | 16 ++-- .../views/rooms/SlateMessageComposer.js | 10 +-- .../views/settings/ProfileSettings.js | 8 +- .../tabs/room/NotificationSettingsTab.js | 8 +- src/components/views/voip/CallView.js | 12 ++- src/components/views/voip/VideoFeed.js | 12 ++- src/components/views/voip/VideoView.js | 15 ++-- 39 files changed, 438 insertions(+), 302 deletions(-) diff --git a/code_style.md b/code_style.md index 4b2338064c..3ad0d38873 100644 --- a/code_style.md +++ b/code_style.md @@ -174,12 +174,6 @@ React // Best, if onFooClick would do anything other than directly calling doStuff ``` - Not doing so is acceptable in a single case: in function-refs: - - ```jsx - this.component = self}> - ``` - - Prefer classes that extend `React.Component` (or `React.PureComponent`) instead of `React.createClass` - You can avoid the need to bind handler functions by using [property initializers](https://reactjs.org/docs/react-component.html#constructor): @@ -208,3 +202,5 @@ React ``` - Think about whether your component really needs state: are you duplicating information in component state that could be derived from the model? + +- Avoid things marked as Legacy or Deprecated in React 16 (e.g string refs and legacy contexts) diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 0fd412935a..ba2e985889 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -15,7 +15,7 @@ limitations under the License. */ import FileSaver from 'file-saver'; -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; @@ -44,6 +44,9 @@ export default createReactClass({ componentWillMount: function() { this._unmounted = false; + + this._passphrase1 = createRef(); + this._passphrase2 = createRef(); }, componentWillUnmount: function() { @@ -53,8 +56,8 @@ export default createReactClass({ _onPassphraseFormSubmit: function(ev) { ev.preventDefault(); - const passphrase = this.refs.passphrase1.value; - if (passphrase !== this.refs.passphrase2.value) { + const passphrase = this._passphrase1.current.value; + if (passphrase !== this._passphrase2.current.value) { this.setState({errStr: _t('Passphrases must match')}); return false; } @@ -148,7 +151,7 @@ export default createReactClass({
- @@ -161,7 +164,7 @@ export default createReactClass({
- diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 17f3bba117..de9e819f5a 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; @@ -56,6 +56,9 @@ export default createReactClass({ componentWillMount: function() { this._unmounted = false; + + this._file = createRef(); + this._passphrase = createRef(); }, componentWillUnmount: function() { @@ -63,15 +66,15 @@ export default createReactClass({ }, _onFormChange: function(ev) { - const files = this.refs.file.files || []; + const files = this._file.current.files || []; this.setState({ - enableSubmit: (this.refs.passphrase.value !== "" && files.length > 0), + enableSubmit: (this._passphrase.current.value !== "" && files.length > 0), }); }, _onFormSubmit: function(ev) { ev.preventDefault(); - this._startImport(this.refs.file.files[0], this.refs.passphrase.value); + this._startImport(this._file.current.files[0], this._passphrase.current.value); return false; }, @@ -146,7 +149,10 @@ export default createReactClass({
- @@ -159,8 +165,11 @@ export default createReactClass({
-
diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index e1b02f653b..1981310a2f 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -18,7 +18,7 @@ limitations under the License. import Matrix from 'matrix-js-sdk'; const InteractiveAuth = Matrix.InteractiveAuth; -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; @@ -129,6 +129,8 @@ export default createReactClass({ this._authLogic.poll(); }, 2000); } + + this._stageComponent = createRef(); }, componentWillUnmount: function() { @@ -153,8 +155,8 @@ export default createReactClass({ }, tryContinue: function() { - if (this.refs.stageComponent && this.refs.stageComponent.tryContinue) { - this.refs.stageComponent.tryContinue(); + if (this._stageComponent.current && this._stageComponent.current.tryContinue) { + this._stageComponent.current.tryContinue(); } }, @@ -192,8 +194,8 @@ export default createReactClass({ }, _setFocus: function() { - if (this.refs.stageComponent && this.refs.stageComponent.focus) { - this.refs.stageComponent.focus(); + if (this._stageComponent.current && this._stageComponent.current.focus) { + this._stageComponent.current.focus(); } }, @@ -214,7 +216,8 @@ export default createReactClass({ const StageComponent = getEntryComponentForLoginType(stage); return ( - { hr } @@ -829,14 +831,14 @@ export default class MessagePanel extends React.Component { // once dynamic content in the events load, make the scrollPanel check the // scroll offsets. _onHeightChanged = () => { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } }; _onTypingShown = () => { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; // this will make the timeline grow, so checkScroll scrollPanel.checkScroll(); if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { @@ -845,7 +847,7 @@ export default class MessagePanel extends React.Component { }; _onTypingHidden = () => { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; if (scrollPanel) { // as hiding the typing notifications doesn't // update the scrollPanel, we tell it to apply @@ -858,11 +860,11 @@ export default class MessagePanel extends React.Component { }; updateTimelineMinHeight() { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; if (scrollPanel) { const isAtBottom = scrollPanel.isAtBottom(); - const whoIsTyping = this.refs.whoIsTyping; + const whoIsTyping = this._whoIsTyping.current; const isTypingVisible = whoIsTyping && whoIsTyping.isVisible(); // when messages get added to the timeline, // but somebody else is still typing, @@ -875,7 +877,7 @@ export default class MessagePanel extends React.Component { } onTimelineReset() { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; if (scrollPanel) { scrollPanel.clearPreventShrinking(); } @@ -909,19 +911,22 @@ export default class MessagePanel extends React.Component { room={this.props.room} onShown={this._onTypingShown} onHidden={this._onTypingHidden} - ref="whoIsTyping" /> + ref={this._whoIsTyping} /> ); } return ( - + { topSpinner } { this._getEventTiles() } { whoIsTyping } diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 921680b678..fe43e60405 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -82,8 +82,14 @@ const RoomSubList = createReactClass({ }; }, - componentDidMount: function() { + UNSAFE_componentWillMount: function() { + this._header = createRef(); + this._subList = createRef(); + this._scroller = createRef(); this._headerButton = createRef(); + }, + + componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); }, @@ -103,7 +109,7 @@ const RoomSubList = createReactClass({ // The header is collapsible if it is hidden or not stuck // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method isCollapsibleOnClick: function() { - const stuck = this.refs.header.dataset.stuck; + const stuck = this._header.current.dataset.stuck; if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) { return true; } else { @@ -135,7 +141,7 @@ const RoomSubList = createReactClass({ }); } else { // The header is stuck, so the click is to be interpreted as a scroll to the header - this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition); + this.props.onHeaderClick(this.state.hidden, this._header.current.dataset.originalPosition); } }, @@ -159,7 +165,7 @@ const RoomSubList = createReactClass({ this.onClick(); } else if (!this.props.forceExpand) { // sublist is expanded, go to first room - const element = this.refs.subList && this.refs.subList.querySelector(".mx_RoomTile"); + const element = this._subList.current && this._subList.current.querySelector(".mx_RoomTile"); if (element) { element.focus(); } @@ -328,7 +334,7 @@ const RoomSubList = createReactClass({ } return ( -
+
+ this.makeRoomTile(r)); const tiles = roomTiles.concat(this.props.extraTiles); content = ( - + { tiles } ); @@ -418,7 +424,7 @@ const RoomSubList = createReactClass({ return (
{ - const scrollPanel = this.refs.searchResultsPanel; + const scrollPanel = this._searchResultsPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } @@ -1370,28 +1373,28 @@ module.exports = createReactClass({ // jump down to the bottom of this room, where new events are arriving jumpToLiveTimeline: function() { - this.refs.messagePanel.jumpToLiveTimeline(); + this._messagePanel.jumpToLiveTimeline(); dis.dispatch({action: 'focus_composer'}); }, // jump up to wherever our read marker is jumpToReadMarker: function() { - this.refs.messagePanel.jumpToReadMarker(); + this._messagePanel.jumpToReadMarker(); }, // update the read marker to match the read-receipt forgetReadMarker: function(ev) { ev.stopPropagation(); - this.refs.messagePanel.forgetReadMarker(); + this._messagePanel.forgetReadMarker(); }, // decide whether or not the top 'unread messages' bar should be shown _updateTopUnreadMessagesBar: function() { - if (!this.refs.messagePanel) { + if (!this._messagePanel) { return; } - const showBar = this.refs.messagePanel.canJumpToReadMarker(); + const showBar = this._messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { this.setState({showTopUnreadMessagesBar: showBar}); } @@ -1401,7 +1404,7 @@ module.exports = createReactClass({ // restored when we switch back to it. // _getScrollState: function() { - const messagePanel = this.refs.messagePanel; + const messagePanel = this._messagePanel; if (!messagePanel) return null; // if we're following the live timeline, we want to return null; that @@ -1506,10 +1509,10 @@ module.exports = createReactClass({ */ handleScrollKey: function(ev) { let panel; - if (this.refs.searchResultsPanel) { - panel = this.refs.searchResultsPanel; - } else if (this.refs.messagePanel) { - panel = this.refs.messagePanel; + if (this._searchResultsPanel.current) { + panel = this._searchResultsPanel.current; + } else if (this._messagePanel) { + panel = this._messagePanel; } if (panel) { @@ -1530,7 +1533,7 @@ module.exports = createReactClass({ // this has to be a proper method rather than an unnamed function, // otherwise react calls it with null on each update. _gatherTimelinePanelRef: function(r) { - this.refs.messagePanel = r; + this._messagePanel = r; if (r) { console.log("updateTint from RoomView._gatherTimelinePanelRef"); this.updateTint(); @@ -1875,7 +1878,7 @@ module.exports = createReactClass({ searchResultsPanel = (
); } else { searchResultsPanel = ( - +
- = 0; --i) { @@ -756,7 +758,7 @@ module.exports = createReactClass({ }, _getMessagesHeight() { - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const lastNode = itemlist.lastElementChild; const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0; const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0; @@ -765,7 +767,7 @@ module.exports = createReactClass({ }, _topFromBottom(node) { - return this.refs.itemlist.clientHeight - node.offsetTop; + return this._itemlist.current.clientHeight - node.offsetTop; }, /* get the DOM node which has the scrollTop property we care about for our @@ -797,7 +799,7 @@ module.exports = createReactClass({ the same minimum bottom offset, effectively preventing the timeline to shrink. */ preventShrinking: function() { - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const tiles = messageList && messageList.children; if (!messageList) { return; @@ -824,7 +826,7 @@ module.exports = createReactClass({ /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ clearPreventShrinking: function() { - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const balanceElement = messageList && messageList.parentElement; if (balanceElement) balanceElement.style.paddingBottom = null; this.preventShrinkingState = null; @@ -843,7 +845,7 @@ module.exports = createReactClass({ if (this.preventShrinkingState) { const sn = this._getScrollNode(); const scrollState = this.scrollState; - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const {offsetNode, offsetFromBottom} = this.preventShrinkingState; // element used to set paddingBottom to balance the typing notifs disappearing const balanceElement = messageList.parentElement; @@ -879,7 +881,7 @@ module.exports = createReactClass({ onScroll={this.onScroll} className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
-
    +
      { this.props.children }
diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 21613733db..0aa2e15f4c 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { KeyCode } from '../../Keyboard'; @@ -53,6 +53,10 @@ module.exports = createReactClass({ }; }, + UNSAFE_componentWillMount: function() { + this._search = createRef(); + }, + componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); }, @@ -66,26 +70,26 @@ module.exports = createReactClass({ switch (payload.action) { case 'view_room': - if (this.refs.search && payload.clear_search) { + if (this._search.current && payload.clear_search) { this._clearSearch(); } break; case 'focus_room_filter': - if (this.refs.search) { - this.refs.search.focus(); + if (this._search.current) { + this._search.current.focus(); } break; } }, onChange: function() { - if (!this.refs.search) return; - this.setState({ searchTerm: this.refs.search.value }); + if (!this._search.current) return; + this.setState({ searchTerm: this._search.current.value }); this.onSearch(); }, onSearch: throttle(function() { - this.props.onSearch(this.refs.search.value); + this.props.onSearch(this._search.current.value); }, 200, {trailing: true, leading: true}), _onKeyDown: function(ev) { @@ -113,7 +117,7 @@ module.exports = createReactClass({ }, _clearSearch: function(source) { - this.refs.search.value = ""; + this._search.current.value = ""; this.onChange(); if (this.props.onCleared) { this.props.onCleared(source); @@ -146,7 +150,7 @@ module.exports = createReactClass({ { - if (payload.event && this.refs.messagePanel) { - this.refs.messagePanel.scrollToEventIfNeeded( + if (payload.event && this._messagePanel.current) { + this._messagePanel.current.scrollToEventIfNeeded( payload.event.getId(), ); } @@ -442,9 +444,9 @@ const TimelinePanel = createReactClass({ // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; - if (!this.refs.messagePanel.getScrollState().stuckAtBottom) { + if (!this._messagePanel.current.getScrollState().stuckAtBottom) { // we won't load this event now, because we don't want to push any // events off the other end of the timeline. But we need to note // that we can now paginate. @@ -499,7 +501,7 @@ const TimelinePanel = createReactClass({ } this.setState(updatedState, () => { - this.refs.messagePanel.updateTimelineMinHeight(); + this._messagePanel.current.updateTimelineMinHeight(); if (callRMUpdated) { this.props.onReadMarkerUpdated(); } @@ -510,13 +512,13 @@ const TimelinePanel = createReactClass({ onRoomTimelineReset: function(room, timelineSet) { if (timelineSet !== this.props.timelineSet) return; - if (this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) { + if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) { this._loadTimeline(); } }, canResetTimeline: function() { - return this.refs.messagePanel && this.refs.messagePanel.isAtBottom(); + return this._messagePanel.current && this._messagePanel.current.isAtBottom(); }, onRoomRedaction: function(ev, room) { @@ -629,7 +631,7 @@ const TimelinePanel = createReactClass({ sendReadReceipt: function() { if (SettingsStore.getValue("lowBandwidth")) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; if (!this.props.manageReadReceipts) return; // This happens on user_activity_end which is delayed, and it's // very possible have logged out within that timeframe, so check @@ -815,8 +817,8 @@ const TimelinePanel = createReactClass({ if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { this._loadTimeline(); } else { - if (this.refs.messagePanel) { - this.refs.messagePanel.scrollToBottom(); + if (this._messagePanel.current) { + this._messagePanel.current.scrollToBottom(); } } }, @@ -826,7 +828,7 @@ const TimelinePanel = createReactClass({ */ jumpToReadMarker: function() { if (!this.props.manageReadMarkers) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; if (!this.state.readMarkerEventId) return; // we may not have loaded the event corresponding to the read-marker @@ -835,11 +837,11 @@ const TimelinePanel = createReactClass({ // // a quick way to figure out if we've loaded the relevant event is // simply to check if the messagepanel knows where the read-marker is. - const ret = this.refs.messagePanel.getReadMarkerPosition(); + const ret = this._messagePanel.current.getReadMarkerPosition(); if (ret !== null) { // The messagepanel knows where the RM is, so we must have loaded // the relevant event. - this.refs.messagePanel.scrollToEvent(this.state.readMarkerEventId, + this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, 0, 1/3); return; } @@ -874,8 +876,8 @@ const TimelinePanel = createReactClass({ * at the end of the live timeline. */ isAtEndOfLiveTimeline: function() { - return this.refs.messagePanel - && this.refs.messagePanel.isAtBottom() + return this._messagePanel.current + && this._messagePanel.current.isAtBottom() && this._timelineWindow && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); }, @@ -887,8 +889,8 @@ const TimelinePanel = createReactClass({ * returns null if we are not mounted. */ getScrollState: function() { - if (!this.refs.messagePanel) { return null; } - return this.refs.messagePanel.getScrollState(); + if (!this._messagePanel.current) { return null; } + return this._messagePanel.current.getScrollState(); }, // returns one of: @@ -899,9 +901,9 @@ const TimelinePanel = createReactClass({ // +1: read marker is below the window getReadMarkerPosition: function() { if (!this.props.manageReadMarkers) return null; - if (!this.refs.messagePanel) return null; + if (!this._messagePanel.current) return null; - const ret = this.refs.messagePanel.getReadMarkerPosition(); + const ret = this._messagePanel.current.getReadMarkerPosition(); if (ret !== null) { return ret; } @@ -936,7 +938,7 @@ const TimelinePanel = createReactClass({ * We pass it down to the scroll panel. */ handleScrollKey: function(ev) { - if (!this.refs.messagePanel) { return; } + if (!this._messagePanel.current) { return; } // jump to the live timeline on ctrl-end, rather than the end of the // timeline window. @@ -944,7 +946,7 @@ const TimelinePanel = createReactClass({ ev.keyCode == KeyCode.END) { this.jumpToLiveTimeline(); } else { - this.refs.messagePanel.handleScrollKey(ev); + this._messagePanel.current.handleScrollKey(ev); } }, @@ -986,8 +988,8 @@ const TimelinePanel = createReactClass({ const onLoaded = () => { // clear the timeline min-height when // (re)loading the timeline - if (this.refs.messagePanel) { - this.refs.messagePanel.onTimelineReset(); + if (this._messagePanel.current) { + this._messagePanel.current.onTimelineReset(); } this._reloadEvents(); @@ -1002,7 +1004,7 @@ const TimelinePanel = createReactClass({ timelineLoading: false, }, () => { // initialise the scroll state of the message panel - if (!this.refs.messagePanel) { + if (!this._messagePanel.current) { // this shouldn't happen - we know we're mounted because // we're in a setState callback, and we know // timelineLoading is now false, so render() should have @@ -1012,10 +1014,10 @@ const TimelinePanel = createReactClass({ return; } if (eventId) { - this.refs.messagePanel.scrollToEvent(eventId, pixelOffset, + this._messagePanel.current.scrollToEvent(eventId, pixelOffset, offsetBase); } else { - this.refs.messagePanel.scrollToBottom(); + this._messagePanel.current.scrollToBottom(); } this.sendReadReceipt(); @@ -1134,7 +1136,7 @@ const TimelinePanel = createReactClass({ const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; - const messagePanel = this.refs.messagePanel; + const messagePanel = this._messagePanel.current; if (messagePanel === undefined) return null; const EventTile = sdk.getComponent('rooms.EventTile'); @@ -1313,7 +1315,8 @@ const TimelinePanel = createReactClass({ ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) ); return ( -

{_t( "This homeserver would like to make sure you are not a robot.", )}

-
+
{ error }
); diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index cc3f9f96c4..dd661291f3 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import url from 'url'; @@ -581,6 +581,8 @@ export const FallbackAuthEntry = createReactClass({ // the popup if we open it immediately. this._popupWindow = null; window.addEventListener("message", this._onReceiveMessage); + + this._fallbackButton = createRef(); }, componentWillUnmount: function() { @@ -591,8 +593,8 @@ export const FallbackAuthEntry = createReactClass({ }, focus: function() { - if (this.refs.fallbackButton) { - this.refs.fallbackButton.focus(); + if (this._fallbackButton.current) { + this._fallbackButton.current.focus(); } }, @@ -624,7 +626,7 @@ export const FallbackAuthEntry = createReactClass({ } return (
); diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index a40495893d..2be505a798 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -17,7 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; @@ -106,10 +106,14 @@ module.exports = createReactClass({ }; }, + UNSAFE_componentWillMount: function() { + this._textinput = createRef(); + }, + componentDidMount: function() { if (this.props.focus) { // Set the cursor at the end of the text input - this.refs.textinput.value = this.props.value; + this._textinput.current.value = this.props.value; } }, @@ -126,8 +130,8 @@ module.exports = createReactClass({ let selectedList = this.state.selectedList.slice(); // Check the text input field to see if user has an unconverted address // If there is and it's valid add it to the local selectedList - if (this.refs.textinput.value !== '') { - selectedList = this._addAddressesToList([this.refs.textinput.value]); + if (this._textinput.current.value !== '') { + selectedList = this._addAddressesToList([this._textinput.current.value]); if (selectedList === null) return; } this.props.onFinished(true, selectedList); @@ -154,23 +158,23 @@ module.exports = createReactClass({ e.stopPropagation(); e.preventDefault(); if (this.addressSelector) this.addressSelector.chooseSelection(); - } else if (this.refs.textinput.value.length === 0 && this.state.selectedList.length && e.keyCode === 8) { // backspace + } else if (this._textinput.current.value.length === 0 && this.state.selectedList.length && e.keyCode === 8) { // backspace e.stopPropagation(); e.preventDefault(); this.onDismissed(this.state.selectedList.length - 1)(); } else if (e.keyCode === 13) { // enter e.stopPropagation(); e.preventDefault(); - if (this.refs.textinput.value === '') { + if (this._textinput.current.value === '') { // if there's nothing in the input box, submit the form this.onButtonClick(); } else { - this._addAddressesToList([this.refs.textinput.value]); + this._addAddressesToList([this._textinput.current.value]); } } else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab e.stopPropagation(); e.preventDefault(); - this._addAddressesToList([this.refs.textinput.value]); + this._addAddressesToList([this._textinput.current.value]); } }, @@ -647,7 +651,7 @@ module.exports = createReactClass({ onPaste={this._onPaste} rows="1" id="textinput" - ref="textinput" + ref={this._textinput} className="mx_AddressPickerDialog_input" onChange={this.onQueryChanged} placeholder={this.getPlaceholder()} diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index 598d0ce354..0294c1c700 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import sdk from '../../../index'; @@ -62,8 +62,13 @@ export default createReactClass({ }; }, + UNSAFE_componentWillMount: function() { + this._input_value = createRef(); + this._uiAuth = createRef(); + }, + componentDidMount: function() { - this.refs.input_value.select(); + this._input_value.current.select(); this._matrixClient = MatrixClientPeg.get(); }, @@ -102,8 +107,8 @@ export default createReactClass({ }, onSubmit: function(ev) { - if (this.refs.uiAuth) { - this.refs.uiAuth.tryContinue(); + if (this._uiAuth.current) { + this._uiAuth.current.tryContinue(); } this.setState({ doingUIAuth: true, @@ -215,7 +220,7 @@ export default createReactClass({ onAuthFinished={this._onUIAuthFinished} inputs={{}} poll={true} - ref="uiAuth" + ref={this._uiAuth} continueIsManaged={true} />; } @@ -257,7 +262,7 @@ export default createReactClass({ >
- diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 55cb9a5487..9f75e5433b 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -64,6 +64,8 @@ export default class AppTile extends React.Component { this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); this._contextMenuButton = createRef(); + this._appFrame = createRef(); + this._menu_bar = createRef(); } /** @@ -337,14 +339,14 @@ export default class AppTile extends React.Component { // HACK: This is a really dirty way to ensure that Jitsi cleans up // its hold on the webcam. Without this, the widget holds a media // stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351 - if (this.refs.appFrame) { + if (this._appFrame.current) { // In practice we could just do `+= ''` to trick the browser // into thinking the URL changed, however I can foresee this // being optimized out by a browser. Instead, we'll just point // the iframe at a page that is reasonably safe to use in the // event the iframe doesn't wink away. // This is relative to where the Riot instance is located. - this.refs.appFrame.src = 'about:blank'; + this._appFrame.current.src = 'about:blank'; } WidgetUtils.setRoomWidget( @@ -389,7 +391,7 @@ export default class AppTile extends React.Component { // FIXME: There's probably no reason to do this here: it should probably be done entirely // in ActiveWidgetStore. const widgetMessaging = new WidgetMessaging( - this.props.id, this.props.url, this.props.userWidget, this.refs.appFrame.contentWindow); + this.props.id, this.props.url, this.props.userWidget, this._appFrame.current.contentWindow); ActiveWidgetStore.setWidgetMessaging(this.props.id, widgetMessaging); widgetMessaging.getCapabilities().then((requestedCapabilities) => { console.log(`Widget ${this.props.id} requested capabilities: ` + requestedCapabilities); @@ -496,7 +498,7 @@ export default class AppTile extends React.Component { ev.preventDefault(); // Ignore clicks on menu bar children - if (ev.target !== this.refs.menu_bar) { + if (ev.target !== this._menu_bar.current) { return; } @@ -555,7 +557,7 @@ export default class AppTile extends React.Component { _onReloadWidgetClick() { // Reload iframe in this way to avoid cross-origin restrictions - this.refs.appFrame.src = this.refs.appFrame.src; + this._appFrame.current.src = this._appFrame.current.src; } _onContextMenuClick = () => { @@ -626,7 +628,7 @@ export default class AppTile extends React.Component { { this.state.loading && loadingElement }