diff --git a/package.json b/package.json index 95bb75b35d..7be54d235a 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "@babel/register": "^7.7.4", "@peculiar/webcrypto": "^1.0.22", "@types/classnames": "^2.2.10", + "@types/modernizr": "^3.5.3", "@types/react": "16.9", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", diff --git a/res/css/structures/auth/_Login.scss b/res/css/structures/auth/_Login.scss index 4ce90cc6bd..02436833a2 100644 --- a/res/css/structures/auth/_Login.scss +++ b/res/css/structures/auth/_Login.scss @@ -89,3 +89,13 @@ limitations under the License. .mx_Login_underlinedServerName { border-bottom: 1px dashed $accent-color; } + +div.mx_AccessibleButton_kind_link.mx_Login_forgot { + // style it as a link + font-size: inherit; + padding: 0; + + &.mx_AccessibleButton_disabled { + cursor: not-allowed; + } +} diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 468a4b3d62..4b2d6b1bf1 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -119,6 +119,24 @@ limitations under the License. margin-right: 0; } +.mx_AuthBody_paddedFooter { + height: 80px; // height of the submit button + register link + padding-top: 28px; + text-align: center; + + .mx_AuthBody_paddedFooter_title { + margin-top: 16px; + font-size: $font-15px; + line-height: $font-24px; + } + + .mx_AuthBody_paddedFooter_subtitle { + margin-top: 8px; + font-size: $font-10px; + line-height: $font-14px; + } +} + .mx_AuthBody_changeFlow { display: block; text-align: center; diff --git a/res/css/views/context_menus/_MessageContextMenu.scss b/res/css/views/context_menus/_MessageContextMenu.scss index d15d566bdb..2ecb93e734 100644 --- a/res/css/views/context_menus/_MessageContextMenu.scss +++ b/res/css/views/context_menus/_MessageContextMenu.scss @@ -19,6 +19,7 @@ limitations under the License. } .mx_MessageContextMenu_field { + display: block; padding: 3px 6px 3px 6px; cursor: pointer; white-space: nowrap; diff --git a/res/css/views/directory/_NetworkDropdown.scss b/res/css/views/directory/_NetworkDropdown.scss index 269b507e3c..bd5c67c7ed 100644 --- a/res/css/views/directory/_NetworkDropdown.scss +++ b/res/css/views/directory/_NetworkDropdown.scss @@ -35,6 +35,8 @@ limitations under the License. border-radius: 4px; border: 1px solid $dialog-close-fg-color; background-color: $primary-bg-color; + max-height: calc(100vh - 20px); // allow 10px padding on both top and bottom + overflow-y: auto; } .mx_NetworkDropdown_menu_network { @@ -51,15 +53,16 @@ limitations under the License. font-weight: 600; line-height: $font-20px; margin-bottom: 4px; + position: relative; // remove server button .mx_AccessibleButton { position: absolute; display: inline; - right: 12px; + right: 10px; height: 16px; width: 16px; - margin-top: 4px; + margin-top: 2px; &::after { content: ""; diff --git a/res/css/views/elements/_Dropdown.scss b/res/css/views/elements/_Dropdown.scss index 0dd9656c9c..32a68d5252 100644 --- a/res/css/views/elements/_Dropdown.scss +++ b/res/css/views/elements/_Dropdown.scss @@ -33,6 +33,10 @@ limitations under the License. user-select: none; } +.mx_Dropdown_input.mx_AccessibleButton_disabled { + cursor: not-allowed; +} + .mx_Dropdown_input:focus { border-color: $input-focused-border-color; } diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 0a4ed2a194..983ef074f2 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -37,7 +37,7 @@ limitations under the License. order: 2; /* min-width hack needed for FF */ min-width: 0px; - height: 90%; + max-height: 90%; flex: 15 15 0; display: flex; align-items: center; diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index e3f88cc779..e01b1f8938 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -14,8 +14,11 @@ } a.mx_Pill { - word-break: break-all; - display: inline; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + vertical-align: text-bottom; + max-width: calc(100% - 1ch); } /* More specific to override `.markdown-body a` text-decoration */ diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index e015f30e48..0dc60226b8 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -661,3 +661,23 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { } } } + +.mx_EventTile_tileError { + color: red; + text-align: center; + + // Remove some of the default tile padding so that the error is centered + margin-right: 0; + .mx_EventTile_line { + padding-left: 0; + margin-right: 0; + } + + .mx_EventTile_line span { + padding: 4px 8px; + } + + a { + margin-left: 1em; + } +} diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 7be2a4e3d4..de018bf178 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -24,6 +24,20 @@ limitations under the License. margin: 0; padding: 0 8px 0 10px; position: relative; + + .mx_RoomTile_menuButton { + display: none; + flex: 0 0 16px; + height: 16px; + background-image: url('$(res)/img/icon_context.svg'); + background-repeat: no-repeat; + background-position: center; + } + + .mx_UserOnlineDot { + display: block; + margin-right: 5px; + } } .mx_RoomTile:focus { @@ -31,15 +45,6 @@ limitations under the License. background-color: $roomtile-focused-bg-color; } -.mx_RoomTile_menuButton { - display: none; - flex: 0 0 16px; - height: 16px; - background-image: url('$(res)/img/icon_context.svg'); - background-repeat: no-repeat; - background-position: center; -} - .mx_RoomTile_tooltip { display: inline-block; position: relative; @@ -151,7 +156,10 @@ limitations under the License. } .mx_RoomTile_menuButton { - display: none; //no design for this for now + display: none; // no design for this for now + } + .mx_UserOnlineDot { + display: none; // no design for this for now } } @@ -164,6 +172,9 @@ limitations under the License. .mx_RoomTile_menuButton { display: block; } + .mx_UserOnlineDot { + display: none; + } } .mx_RoomTile_unreadNotify .mx_RoomTile_badge, diff --git a/res/css/views/rooms/_UserOnlineDot.scss b/res/css/views/rooms/_UserOnlineDot.scss index 339e5cc48a..f9da8648ed 100644 --- a/res/css/views/rooms/_UserOnlineDot.scss +++ b/res/css/views/rooms/_UserOnlineDot.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_UserOnlineDot { border-radius: 50%; background-color: $accent-color; - height: 5px; - width: 5px; + height: 6px; + width: 6px; display: inline-block; } diff --git a/res/img/icon_person.svg b/res/img/icon_person.svg deleted file mode 100644 index 4be70df0db..0000000000 --- a/res/img/icon_person.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - 815EF7DE-169A-4322-AE2A-B65CBE91DCED - Created with sketchtool. - - - - - - - - - - - - - - - - - - diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh index 2f907dffa2..1233677db4 100755 --- a/scripts/ci/end-to-end-tests.sh +++ b/scripts/ci/end-to-end-tests.sh @@ -13,7 +13,6 @@ handle_error() { trap 'handle_error' ERR - echo "--- Building Riot" scripts/ci/layered-riot-web.sh cd ../riot-web diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts new file mode 100644 index 0000000000..1931c0b1d0 --- /dev/null +++ b/src/@types/global.d.ts @@ -0,0 +1,40 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import * as ModernizrStatic from "modernizr"; + +declare global { + interface Window { + Modernizr: ModernizrStatic; + Olm: { + init: () => Promise; + }; + } + + // workaround for https://github.com/microsoft/TypeScript/issues/30933 + interface ObjectConstructor { + fromEntries?(xs: [string|number|symbol, any][]): object + } + + interface Document { + // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess + hasStorageAccess?: () => Promise; + } + + interface StorageEstimate { + usageDetails?: {[key: string]: number}; + } +} diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js index 07ec776bd1..c37d0f8bf5 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -51,7 +51,7 @@ async function confirmToDismiss(name) { } else if (name === "m.cross_signing.self_signing") { description = _t("If you cancel now, you won't complete verifying your other session."); } else { - description = _t("If you cancel now, you won't complete your secret storage operation."); + description = _t("If you cancel now, you won't complete your operation."); } const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); diff --git a/src/DeviceListener.js b/src/DeviceListener.js index 21c844e11c..4ec2ec0fa0 100644 --- a/src/DeviceListener.js +++ b/src/DeviceListener.js @@ -52,6 +52,7 @@ export default class DeviceListener { MatrixClientPeg.get().on('userTrustStatusChanged', this._onUserTrustStatusChanged); MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged); MatrixClientPeg.get().on('accountData', this._onAccountData); + MatrixClientPeg.get().on('sync', this._onSync); this._recheck(); } @@ -62,6 +63,7 @@ export default class DeviceListener { MatrixClientPeg.get().removeListener('userTrustStatusChanged', this._onUserTrustStatusChanged); MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged); MatrixClientPeg.get().removeListener('accountData', this._onAccountData); + MatrixClientPeg.get().removeListener('sync', this._onSync); } this._dismissed.clear(); } @@ -109,6 +111,10 @@ export default class DeviceListener { } } + _onSync = (state, prevState) => { + if (state === 'PREPARED' && prevState === null) this._recheck(); + } + // The server doesn't tell us when key backup is set up, so we poll // & cache the result async _getKeyBackupInfo() { @@ -124,11 +130,15 @@ export default class DeviceListener { const cli = MatrixClientPeg.get(); if ( - !SettingsStore.isFeatureEnabled("feature_cross_signing") || + !SettingsStore.getValue("feature_cross_signing") || !await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing") ) return; if (!cli.isCryptoEnabled()) return; + // don't recheck until the initial sync is complete: lots of account data events will fire + // while the initial sync is processing and we don't need to recheck on each one of them + // (we add a listener on sync to do once check after the initial sync is done) + if (!cli.isInitialSyncComplete()) return; const crossSigningReady = await cli.isCrossSigningReady(); diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 30f3b7d50e..ceaff0c54d 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -35,7 +35,7 @@ export default class KeyRequestHandler { handleKeyRequest(keyRequest) { // Ignore own device key requests if cross-signing lab enabled - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { return; } @@ -70,7 +70,7 @@ export default class KeyRequestHandler { handleKeyRequestCancellation(cancellation) { // Ignore own device key requests if cross-signing lab enabled - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { return; } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 4ffd58bad3..f91f5d23d9 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -38,6 +38,8 @@ import {inviteUsersToRoom} from "./RoomInvite"; import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; import { parseFragment as parseHtml } from "parse5"; +import sendBugReport from "./rageshake/submit-rageshake"; +import SdkConfig from "./SdkConfig"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -947,6 +949,27 @@ export const Commands = [ }, category: CommandCategories.advanced, }), + new Command({ + command: "rageshake", + aliases: ["bugreport"], + description: _td("Send a bug report with logs"), + args: "", + runFn: function(roomId, args) { + return success( + sendBugReport(SdkConfig.get().bug_report_endpoint_url, { + userText: args, + sendLogs: true, + }).then(() => { + const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); + Modal.createTrackedDialog('Slash Commands', 'Rageshake sent', InfoDialog, { + title: _t('Logs sent'), + description: _t('Thank you!'), + }); + }), + ); + }, + category: CommandCategories.advanced, + }), // Command definitions for autocompletion ONLY: // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js index 5f24fb10fa..bb2cf7f0b8 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js @@ -140,7 +140,7 @@ export default class ManageEventIndexDialog extends React.Component { crawlerState = _t("Not currently indexing messages for any room."); } else { crawlerState = ( - _t("Currently indexing: %(currentRoom)s.", { currentRoom: this.state.currentRoom }) + _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) ); } diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 3a480a2579..e4e39400f6 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -25,6 +25,7 @@ import { _t } from '../../../../languageHandler'; import { accessSecretStorage } from '../../../../CrossSigningManager'; import SettingsStore from '../../../../settings/SettingsStore'; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; +import {copyNode} from "../../../../utils/strings"; const PHASE_PASSPHRASE = 0; const PHASE_PASSPHRASE_CONFIRM = 1; @@ -37,16 +38,6 @@ 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 an e2e key backup * on the server. @@ -77,7 +68,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { async componentDidMount() { const cli = MatrixClientPeg.get(); const secureSecretStorage = ( - SettingsStore.isFeatureEnabled("feature_cross_signing") && + SettingsStore.getValue("feature_cross_signing") && await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing") ); this.setState({ secureSecretStorage }); @@ -101,8 +92,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { } _onCopyClick = () => { - selectText(this._recoveryKeyNode); - const successful = document.execCommand('copy'); + const successful = copyNode(this._recoveryKeyNode); if (successful) { this.setState({ copied: true, @@ -272,7 +262,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { let helpText; if (this.state.zxcvbnResult) { if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) { - helpText = _t("Great! This passphrase looks strong enough."); + helpText = _t("Great! This recovery passphrase looks strong enough."); } else { const suggestions = []; for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) { @@ -297,7 +287,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { )}

{_t( "We'll store an encrypted copy of your keys on our server. " + - "Protect your backup with a passphrase to keep it secure.", + "Secure your backup with a recovery passphrase.", )}

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

@@ -307,7 +297,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { onChange={this._onPassPhraseChange} value={this.state.passPhrase} className="mx_CreateKeyBackupDialog_passPhraseInput" - placeholder={_t("Enter a passphrase...")} + placeholder={_t("Enter a recovery passphrase...")} autoFocus={true} />
@@ -364,7 +354,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

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

@@ -373,7 +363,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { onChange={this._onPassPhraseConfirmChange} value={this.state.passPhraseConfirm} className="mx_CreateKeyBackupDialog_passPhraseInput" - placeholder={_t("Repeat your passphrase...")} + placeholder={_t("Repeat your recovery passphrase...")} autoFocus={true} />
@@ -393,7 +383,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { 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.", + "access to your encrypted messages if you forget your recovery passphrase.", )}

{_t( "Keep a copy of it somewhere secure, like a password manager or even a safe.", @@ -487,9 +477,9 @@ export default class CreateKeyBackupDialog extends React.PureComponent { _titleForPhase(phase) { switch (phase) { case PHASE_PASSPHRASE: - return _t('Secure your backup with a passphrase'); + return _t('Secure your backup with a recovery passphrase'); case PHASE_PASSPHRASE_CONFIRM: - return _t('Confirm your passphrase'); + return _t('Confirm your recovery passphrase'); case PHASE_OPTOUT_CONFIRM: return _t('Warning!'); case PHASE_SHOWKEY: diff --git a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js index 6588ff5191..9e2264a960 100644 --- a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js @@ -57,8 +57,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { ; const newMethodDetected =

{_t( - "A new recovery passphrase and key for Secure " + - "Messages have been detected.", + "A new recovery passphrase and key for Secure Messages have been detected.", )}

; const hackWarning =

{_t( diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index d63db617d5..a2aa4f27e8 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -24,6 +24,7 @@ import FileSaver from 'file-saver'; import { _t } from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; +import {copyNode} from "../../../../utils/strings"; const PHASE_LOADING = 0; const PHASE_MIGRATE = 1; @@ -38,16 +39,6 @@ const PHASE_CONFIRM_SKIP = 8; 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. @@ -169,8 +160,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } _onCopyClick = () => { - selectText(this._recoveryKeyNode); - const successful = document.execCommand('copy'); + const successful = copyNode(this._recoveryKeyNode); if (successful) { this.setState({ copied: true, @@ -472,7 +462,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { let helpText; if (this.state.zxcvbnResult) { if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) { - helpText = _t("Great! This passphrase looks strong enough."); + helpText = _t("Great! This recovery passphrase looks strong enough."); } else { // We take the warning from zxcvbn or failing that, the first // suggestion. In practice The first is generally the most relevant @@ -497,12 +487,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return

{_t( - "Set up encryption on this session to allow it to verify other sessions, " + - "granting them access to encrypted messages and marking them as trusted for other users.", - )}

-

{_t( - "Secure your encryption keys with a passphrase. For maximum security " + - "this should be different to your account password:", + "Set a recovery passphrase to secure encrypted information and recover it if you log out. " + + "This should be different to your account password:", )}

@@ -511,7 +497,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { className="mx_CreateSecretStorageDialog_passPhraseField" onChange={this._onPassPhraseChange} value={this.state.passPhrase} - label={_t("Enter a passphrase")} + label={_t("Enter a recovery passphrase")} autoFocus={true} autoComplete="new-password" /> @@ -522,7 +508,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
@@ -579,7 +565,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( - "Enter your passphrase a second time to confirm it.", + "Enter your recovery passphrase a second time to confirm it.", )}

@@ -614,7 +600,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { 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.", + "access to your encrypted messages if you forget your recovery passphrase.", )}

{_t( "Keep a copy of it somewhere secure, like a password manager or even a safe.", @@ -628,7 +614,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent { {this._recoveryKey.encodedPrivateKey}

- + {_t("Copy")} @@ -713,7 +703,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { case PHASE_PASSPHRASE: return _t('Set up encryption'); case PHASE_PASSPHRASE_CONFIRM: - return _t('Confirm passphrase'); + return _t('Confirm recovery passphrase'); case PHASE_CONFIRM_SKIP: return _t('Are you sure?'); case PHASE_SHOWKEY: diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js index b4647a6c30..98b0867ccc 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -245,7 +245,6 @@ export class ContextMenu extends React.Component { } const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; - const padding = 10; const chevronOffset = {}; if (props.chevronFace) { @@ -264,7 +263,8 @@ export class ContextMenu extends React.Component { // If we know the dimensions of the context menu, adjust its position // such that it does not leave the (padded) window. if (contextMenuRect) { - adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding); + const padding = 10; + adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height + padding); } position.top = adjusted; diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index da416142f8..1293ccc7e9 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -1506,7 +1506,7 @@ export default createReactClass({ }); cli.on("crypto.verification.request", request => { - const isFlagOn = SettingsStore.isFeatureEnabled("feature_cross_signing"); + const isFlagOn = SettingsStore.getValue("feature_cross_signing"); if (!isFlagOn && !request.channel.deviceId) { request.cancel({code: "m.invalid_message", reason: "This client has cross-signing disabled"}); @@ -1556,7 +1556,7 @@ export default createReactClass({ // changing colour. More advanced behaviour will come once // we implement more settings. cli.setGlobalErrorOnUnknownDevices( - !SettingsStore.isFeatureEnabled("feature_cross_signing"), + !SettingsStore.getValue("feature_cross_signing"), ); } }, @@ -1902,34 +1902,29 @@ export default createReactClass({ const cli = MatrixClientPeg.get(); // We're checking `isCryptoAvailable` here instead of `isCryptoEnabled` // because the client hasn't been started yet. - if (!isCryptoAvailable()) { + const cryptoAvailable = isCryptoAvailable(); + if (!cryptoAvailable) { this._onLoggedIn(); } - // Test for the master cross-signing key in SSSS as a quick proxy for - // whether cross-signing has been set up on the account. We can't - // really continue until we know whether it's there or not so retry - // if this fails. - let masterKeyInStorage; - while (masterKeyInStorage === undefined) { - try { - masterKeyInStorage = !!await cli.getAccountDataFromServer("m.cross_signing.master"); - } catch (e) { - if (e.errcode === "M_NOT_FOUND") { - masterKeyInStorage = false; - } else { - console.warn("Secret storage account data check failed: retrying...", e); - } - } + this.setState({ pendingInitialSync: true }); + await this.firstSyncPromise.promise; + + if (!cryptoAvailable) { + this.setState({ pendingInitialSync: false }); + return setLoggedInPromise; } + // Test for the master cross-signing key in SSSS as a quick proxy for + // whether cross-signing has been set up on the account. + const masterKeyInStorage = !!cli.getAccountData("m.cross_signing.master"); if (masterKeyInStorage) { // Auto-enable cross-signing for the new session when key found in // secret storage. - SettingsStore.setFeatureEnabled("feature_cross_signing", true); + SettingsStore.setValue("feature_cross_signing", null, SettingLevel.DEVICE, true); this.setStateForNewView({ view: VIEWS.COMPLETE_SECURITY }); } else if ( - SettingsStore.isFeatureEnabled("feature_cross_signing") && + SettingsStore.getValue("feature_cross_signing") && await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing") ) { // This will only work if the feature is set to 'enable' in the config, @@ -1939,6 +1934,7 @@ export default createReactClass({ } else { this._onLoggedIn(); } + this.setState({ pendingInitialSync: false }); return setLoggedInPromise; }, @@ -2060,6 +2056,7 @@ export default createReactClass({ const Login = sdk.getComponent('structures.auth.Login'); view = ( - + + + , ); @@ -757,6 +760,7 @@ export default class MessagePanel extends React.Component { } render() { + const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary'); const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); const WhoIsTypingTile = sdk.getComponent("rooms.WhoIsTypingTile"); const Spinner = sdk.getComponent("elements.Spinner"); @@ -789,22 +793,24 @@ export default class MessagePanel extends React.Component { } return ( - - { topSpinner } - { this._getEventTiles() } - { whoIsTyping } - { bottomSpinner } - + + + { topSpinner } + { this._getEventTiles() } + { whoIsTyping } + { bottomSpinner } + + ); } } diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 3c97d2f4ae..f5bdfdf40d 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -219,7 +219,7 @@ export default class RightPanel extends React.Component { break; case RIGHT_PANEL_PHASES.RoomMemberInfo: case RIGHT_PANEL_PHASES.EncryptionPanel: - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { const onClose = () => { dis.dispatch({ action: "view_user", @@ -246,7 +246,7 @@ export default class RightPanel extends React.Component { panel = ; break; case RIGHT_PANEL_PHASES.GroupMemberInfo: - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { const onClose = () => { dis.dispatch({ action: "view_user", diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 78bd34bf7f..179e0aa2e9 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -822,7 +822,7 @@ export default createReactClass({ }); return; } - if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (!SettingsStore.getValue("feature_cross_signing")) { room.hasUnverifiedDevices().then((hasUnverifiedDevices) => { this.setState({ e2eStatus: hasUnverifiedDevices ? "warning" : "verified", diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.js index 06cece0af2..95128c0be9 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.js @@ -59,17 +59,17 @@ export default class CompleteSecurity extends React.Component { let title; if (phase === PHASE_INTRO) { - icon = ; - title = _t("Complete security"); + icon = ; + title = _t("Verify this session"); } else if (phase === PHASE_DONE) { - icon = ; + icon = ; title = _t("Session verified"); } else if (phase === PHASE_CONFIRM_SKIP) { - icon = ; + icon = ; title = _t("Are you sure?"); } else if (phase === PHASE_BUSY) { - icon = ; - title = _t("Complete security"); + icon = ; + title = _t("Verify this session"); } else { throw new Error(`Unknown phase ${phase}`); } diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index 836e5bd93d..5d3cb69417 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -84,11 +84,13 @@ export default createReactClass({ onServerConfigChange: PropTypes.func.isRequired, serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, + isSyncing: PropTypes.bool, }, getInitialState: function() { return { busy: false, + busyLoggingIn: null, errorText: null, loginIncorrect: false, canTryLogin: true, // can we attempt to log in or are there validation errors? @@ -169,6 +171,7 @@ export default createReactClass({ const componentState = AutoDiscoveryUtils.authComponentStateForError(e); this.setState({ busy: false, + busyLoggingIn: false, ...componentState, }); aliveAgain = !componentState.serverErrorIsFatal; @@ -182,6 +185,7 @@ export default createReactClass({ this.setState({ busy: true, + busyLoggingIn: true, errorText: null, loginIncorrect: false, }); @@ -250,6 +254,7 @@ export default createReactClass({ this.setState({ busy: false, + busyLoggingIn: false, errorText: errorText, // 401 would be the sensible status code for 'incorrect password' // but the login API gives a 403 https://matrix.org/jira/browse/SYN-744 @@ -594,6 +599,7 @@ export default createReactClass({ loginIncorrect={this.state.loginIncorrect} serverConfig={this.props.serverConfig} disableSubmit={this.isBusy()} + busy={this.props.isSyncing || this.state.busyLoggingIn} /> ); }, @@ -629,9 +635,11 @@ export default createReactClass({ render: function() { const Loader = sdk.getComponent("elements.Spinner"); + const InlineSpinner = sdk.getComponent("elements.InlineSpinner"); const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); - const loader = this.isBusy() ?
: null; + const loader = this.isBusy() && !this.state.busyLoggingIn ? +
: null; const errorText = this.state.errorText; @@ -658,9 +666,28 @@ export default createReactClass({ ); } + let footer; + if (this.props.isSyncing || this.state.busyLoggingIn) { + footer =
+
+ + { this.props.isSyncing ? _t("Syncing...") : _t("Signing In...") } +
+ { this.props.isSyncing &&
+ {_t("If you've joined lots of rooms, this might take a while")} +
} +
; + } else { + footer = ( + + { _t('Create account') } + + ); + } + return ( - +

{_t('Sign in')} @@ -670,9 +697,7 @@ export default createReactClass({ { serverDeadSection } { this.renderServerComponent() } { this.renderLoginComponentForStep() } - - { _t('Create account') } - + { footer } ); diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.js index a982957ed0..e6302a4685 100644 --- a/src/components/structures/auth/SetupEncryptionBody.js +++ b/src/components/structures/auth/SetupEncryptionBody.js @@ -116,7 +116,7 @@ export default class SetupEncryptionBody extends React.Component { "granting it access to encrypted messages.", )}

{_t( - "If you can’t access one, ", + "If you can’t access one, ", {}, { button: sub => - +

); }, diff --git a/src/components/views/auth/LanguageSelector.js b/src/components/views/auth/LanguageSelector.js index 99578d4504..83db5d225b 100644 --- a/src/components/views/auth/LanguageSelector.js +++ b/src/components/views/auth/LanguageSelector.js @@ -28,12 +28,14 @@ function onChange(newLang) { } } -export default function LanguageSelector() { +export default function LanguageSelector({disabled}) { if (SdkConfig.get()['disable_login_language_selector']) return
; const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); - return ; } diff --git a/src/components/views/auth/PasswordLogin.js b/src/components/views/auth/PasswordLogin.js index e64b8360c3..790c837497 100644 --- a/src/components/views/auth/PasswordLogin.js +++ b/src/components/views/auth/PasswordLogin.js @@ -23,6 +23,7 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AccessibleButton from "../elements/AccessibleButton"; /** * A pure UI component which displays a username/password form. @@ -44,6 +45,7 @@ export default class PasswordLogin extends React.Component { loginIncorrect: PropTypes.bool, disableSubmit: PropTypes.bool, serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, + busy: PropTypes.bool, }; static defaultProps = { @@ -183,7 +185,7 @@ export default class PasswordLogin extends React.Component { this.props.onPasswordChanged(ev.target.value); } - renderLoginField(loginType) { + renderLoginField(loginType, autoFocus) { const Field = sdk.getComponent('elements.Field'); const classes = {}; @@ -202,7 +204,7 @@ export default class PasswordLogin extends React.Component { onChange={this.onUsernameChanged} onBlur={this.onUsernameBlur} disabled={this.props.disableSubmit} - autoFocus + autoFocus={autoFocus} />; case PasswordLogin.LOGIN_FIELD_MXID: classes.error = this.props.loginIncorrect && !this.state.username; @@ -216,7 +218,7 @@ export default class PasswordLogin extends React.Component { onChange={this.onUsernameChanged} onBlur={this.onUsernameBlur} disabled={this.props.disableSubmit} - autoFocus + autoFocus={autoFocus} />; case PasswordLogin.LOGIN_FIELD_PHONE: { const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); @@ -240,7 +242,7 @@ export default class PasswordLogin extends React.Component { onChange={this.onPhoneNumberChanged} onBlur={this.onPhoneNumberBlur} disabled={this.props.disableSubmit} - autoFocus + autoFocus={autoFocus} />; } } @@ -265,12 +267,16 @@ export default class PasswordLogin extends React.Component { if (this.props.onForgotPasswordClick) { forgotPasswordJsx = {_t('Not sure of your password? Set a new one', {}, { - a: sub => - {sub} - , + a: sub => ( + + {sub} + + ), })} ; } @@ -279,7 +285,10 @@ export default class PasswordLogin extends React.Component { error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field }); - const loginField = this.renderLoginField(this.state.loginType); + // If login is empty, autoFocus login, otherwise autoFocus password. + // this is for when auto server discovery remounts us when the user tries to tab from username to password + const autoFocusPassword = !this.isLoginEmpty(); + const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); let loginType; if (!SdkConfig.get().disable_3pid_login) { @@ -330,13 +339,14 @@ export default class PasswordLogin extends React.Component { value={this.state.password} onChange={this.onPasswordChanged} disabled={this.props.disableSubmit} + autoFocus={autoFocusPassword} /> {forgotPasswordJsx} - + /> }
); diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.js index 6e337d53dc..9bb716fe3f 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.js @@ -137,12 +137,20 @@ export default class BugReportDialog extends React.Component { ); } + let warning; + if (window.Modernizr && Object.values(window.Modernizr).some(support => support === false)) { + warning =

+ { _t("Reminder: Your browser is unsupported, so your experience may be unpredictable.") } +

; + } + return (
+ { warning }

{ _t( "Debug logs contain application usage data including your " + diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index f18e28b85e..74e006354b 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -65,7 +65,7 @@ export default createReactClass({ createOpts.creation_content = {'m.federate': false}; } - if (!this.state.isPublic && SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (!this.state.isPublic && SettingsStore.getValue("feature_cross_signing")) { opts.encryption = this.state.isEncrypted; } @@ -192,9 +192,14 @@ export default createReactClass({ } let e2eeSection; - if (!this.state.isPublic && SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (!this.state.isPublic && SettingsStore.getValue("feature_cross_signing")) { e2eeSection = - +

{ _t("You can’t disable this later. Bridges & most bots won’t work yet.") }

; } diff --git a/src/components/views/dialogs/DeviceVerifyDialog.js b/src/components/views/dialogs/DeviceVerifyDialog.js index 39e391269c..a3f9430476 100644 --- a/src/components/views/dialogs/DeviceVerifyDialog.js +++ b/src/components/views/dialogs/DeviceVerifyDialog.js @@ -131,7 +131,7 @@ export default class DeviceVerifyDialog extends React.Component { } else { this._verifier = request.verifier; } - } else if (verifyingOwnDevice && SettingsStore.isFeatureEnabled("feature_cross_signing")) { + } else if (verifyingOwnDevice && SettingsStore.getValue("feature_cross_signing")) { this._request = await client.requestVerification(this.props.userId, [ verificationMethods.SAS, SHOW_QR_CODE_METHOD, diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index f0d5443cac..a46fa0df07 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -574,7 +574,7 @@ export default class InviteDialog extends React.PureComponent { const createRoomOptions = {inlineErrors: true}; - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { // Check whether all users have uploaded device keys before. // If so, enable encryption in the new room. const client = MatrixClientPeg.get(); diff --git a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js index a04c4a389f..e59f77fce9 100644 --- a/src/components/views/dialogs/KeySignatureUploadFailedDialog.js +++ b/src/components/views/dialogs/KeySignatureUploadFailedDialog.js @@ -85,7 +85,7 @@ export default function KeySignatureUploadFailedDialog({ {_t("Upload completed")} : cancelled ? {_t("Cancelled signature upload")} : - {_t("Unabled to upload")}} + {_t("Unable to upload")}} void; + target: Room | User | Group | RoomMember | MatrixEvent; + permalinkCreator: RoomPermalinkCreator; +} + +interface IState { + linkSpecificEvent: boolean; + permalinkCreator: RoomPermalinkCreator; +} + +export default class ShareDialog extends React.PureComponent { static propTypes = { onFinished: PropTypes.func.isRequired, target: PropTypes.oneOfType([ @@ -64,6 +81,8 @@ export default class ShareDialog extends React.Component { ]).isRequired, }; + protected closeCopiedTooltip: () => void; + constructor(props) { super(props); @@ -81,45 +100,26 @@ export default class ShareDialog extends React.Component { linkSpecificEvent: this.props.target instanceof MatrixEvent, permalinkCreator, }; - - this._link = createRef(); - } - - static _selectText(target) { - const range = document.createRange(); - range.selectNodeContents(target); - - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); } static onLinkClick(e) { e.preventDefault(); - const {target} = e; - ShareDialog._selectText(target); + selectText(e.target); } - onCopyClick(e) { + async onCopyClick(e) { e.preventDefault(); + const target = e.target; // copy target before we go async and React throws it away - ShareDialog._selectText(this._link.current); - - let successful; - try { - successful = document.execCommand('copy'); - } catch (err) { - console.error('Failed to copy: ', err); - } - - const buttonRect = e.target.getBoundingClientRect(); + const successful = await copyPlaintext(this.getUrl()); + const buttonRect = target.getBoundingClientRect(); const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); const {close} = ContextMenu.createMenu(GenericTextContextMenu, { ...toRightOf(buttonRect, 2), message: successful ? _t('Copied!') : _t('Failed to copy'), }); // Drop a reference to this close handler for componentWillUnmount - this.closeCopiedTooltip = e.target.onmouseleave = close; + this.closeCopiedTooltip = target.onmouseleave = close; } onLinkSpecificEventCheckboxClick() { @@ -134,10 +134,32 @@ export default class ShareDialog extends React.Component { if (this.closeCopiedTooltip) this.closeCopiedTooltip(); } - render() { - let title; + getUrl() { let matrixToUrl; + if (this.props.target instanceof Room) { + if (this.state.linkSpecificEvent) { + const events = this.props.target.getLiveTimeline().getEvents(); + matrixToUrl = this.state.permalinkCreator.forEvent(events[events.length - 1].getId()); + } else { + matrixToUrl = this.state.permalinkCreator.forRoom(); + } + } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { + matrixToUrl = makeUserPermalink(this.props.target.userId); + } else if (this.props.target instanceof Group) { + matrixToUrl = makeGroupPermalink(this.props.target.groupId); + } else if (this.props.target instanceof MatrixEvent) { + if (this.state.linkSpecificEvent) { + matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId()); + } else { + matrixToUrl = this.props.permalinkCreator.forRoom(); + } + } + return matrixToUrl; + } + + render() { + let title; let checkbox; if (this.props.target instanceof Room) { @@ -155,18 +177,10 @@ export default class ShareDialog extends React.Component {
; } - - if (this.state.linkSpecificEvent) { - matrixToUrl = this.state.permalinkCreator.forEvent(events[events.length - 1].getId()); - } else { - matrixToUrl = this.state.permalinkCreator.forRoom(); - } } else if (this.props.target instanceof User || this.props.target instanceof RoomMember) { title = _t('Share User'); - matrixToUrl = makeUserPermalink(this.props.target.userId); } else if (this.props.target instanceof Group) { title = _t('Share Community'); - matrixToUrl = makeGroupPermalink(this.props.target.groupId); } else if (this.props.target instanceof MatrixEvent) { title = _t('Share Room Message'); checkbox =
@@ -178,14 +192,9 @@ export default class ShareDialog extends React.Component { { _t('Link to selected message') }
; - - if (this.state.linkSpecificEvent) { - matrixToUrl = this.props.permalinkCreator.forEvent(this.props.target.getId()); - } else { - matrixToUrl = this.props.permalinkCreator.forRoom(); - } } + const matrixToUrl = this.getUrl(); const encodedUrl = encodeURIComponent(matrixToUrl); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -196,8 +205,7 @@ export default class ShareDialog extends React.Component { >
diff --git a/src/components/views/dialogs/VerificationRequestDialog.js b/src/components/views/dialogs/VerificationRequestDialog.js index 7ff2cb8f50..3a6e9a2d10 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.js +++ b/src/components/views/dialogs/VerificationRequestDialog.js @@ -48,7 +48,7 @@ export default class VerificationRequestDialog extends React.Component { const member = this.props.member || otherUserId && MatrixClientPeg.get().getUser(otherUserId); const title = request && request.isSelfVerification ? - _t("Verify this session") : _t("Verification Request"); + _t("Verify other session") : _t("Verification Request"); return

{_t( - "Backup could not be decrypted with this key: " + + "Backup could not be decrypted with this recovery key: " + "please verify that you entered the correct recovery key.", )}

; @@ -291,7 +291,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent { title = _t("Incorrect recovery passphrase"); content =

{_t( - "Backup could not be decrypted with this passphrase: " + + "Backup could not be decrypted with this recovery passphrase: " + "please verify that you entered the correct recovery passphrase.", )}

; diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js index e3a7d7f532..7d7edffcbf 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js @@ -119,14 +119,14 @@ export default class AccessSecretStorageDialog extends React.PureComponent { if (hasPassphrase && !this.state.forceRecoveryKey) { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - title = _t("Enter secret storage passphrase"); + title = _t("Enter recovery 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.", + "Unable to access secret storage. " + + "Please verify that you entered the correct recovery passphrase.", )}
; } else { @@ -135,13 +135,12 @@ export default class AccessSecretStorageDialog extends React.PureComponent { content =

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

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

@@ -164,7 +163,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { />
{_t( - "If you've forgotten your passphrase you can "+ + "If you've forgotten your recovery passphrase you can "+ "use your recovery key or " + "set up new recovery options." , {}, { @@ -183,7 +182,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { })}
; } else { - title = _t("Enter secret storage recovery key"); + title = _t("Enter recovery key"); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -193,8 +192,8 @@ export default class AccessSecretStorageDialog extends React.PureComponent { } else if (this.state.keyMatches === false) { keyStatus =
{"\uD83D\uDC4E "}{_t( - "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.", )}
; } else if (this.state.recoveryKeyValid) { @@ -209,8 +208,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent { content =

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

{_t( diff --git a/src/components/views/elements/ButtonPlaceholder.js b/src/components/views/elements/ButtonPlaceholder.js index 9c40df9db6..6501429e65 100644 --- a/src/components/views/elements/ButtonPlaceholder.js +++ b/src/components/views/elements/ButtonPlaceholder.js @@ -15,5 +15,5 @@ limitations under the License. */ export default function ButtonPlaceholder(props) { - return

{props.children}
; + return
{props.children}
; } diff --git a/src/components/views/elements/LabelledToggleSwitch.js b/src/components/views/elements/LabelledToggleSwitch.js index ecd4d39bf8..78beb2aa91 100644 --- a/src/components/views/elements/LabelledToggleSwitch.js +++ b/src/components/views/elements/LabelledToggleSwitch.js @@ -35,6 +35,9 @@ export default class LabelledToggleSwitch extends React.Component { // True to put the toggle in front of the label // Default false. toggleInFront: PropTypes.bool, + + // Additional class names to append to the switch. Optional. + className: PropTypes.string, }; render() { @@ -50,8 +53,9 @@ export default class LabelledToggleSwitch extends React.Component { secondPart = temp; } + const classes = `mx_SettingsFlag ${this.props.className || ""}`; return ( -
+
{firstPart} {secondPart}
diff --git a/src/components/views/elements/LanguageDropdown.js b/src/components/views/elements/LanguageDropdown.js index 18a7e95e85..e37109caff 100644 --- a/src/components/views/elements/LanguageDropdown.js +++ b/src/components/views/elements/LanguageDropdown.js @@ -114,6 +114,7 @@ export default class LanguageDropdown extends React.Component { searchEnabled={true} value={value} label={_t("Language Dropdown")} + disabled={this.props.disabled} > { options } ; diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index fbc864caf2..0cde90e417 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -26,6 +26,7 @@ import Modal from '../../../Modal'; import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import RoomContext from "../../../contexts/RoomContext"; +import SettingsStore from '../../../settings/SettingsStore'; const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -48,7 +49,7 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo }; let e2eInfoCallback = null; - if (mxEvent.isEncrypted()) { + if (mxEvent.isEncrypted() && !SettingsStore.getValue("feature_cross_signing")) { e2eInfoCallback = onCryptoClick; } diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 27514d0e23..882e331675 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -34,6 +34,7 @@ import {pillifyLinks, unmountPills} from '../../../utils/pillify'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import {isPermalinkHost} from "../../../utils/permalinks/Permalinks"; import {toRightOf} from "../../structures/ContextMenu"; +import {copyPlaintext} from "../../../utils/strings"; export default createReactClass({ displayName: 'TextualBody', @@ -69,23 +70,6 @@ export default createReactClass({ }; }, - copyToClipboard: function(text) { - const textArea = document.createElement("textarea"); - textArea.value = text; - document.body.appendChild(textArea); - textArea.select(); - - let successful = false; - try { - successful = document.execCommand('copy'); - } catch (err) { - console.log('Unable to copy'); - } - - document.body.removeChild(textArea); - return successful; - }, - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs UNSAFE_componentWillMount: function() { this._content = createRef(); @@ -277,17 +261,17 @@ export default createReactClass({ Array.from(ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre')).forEach((p) => { const button = document.createElement("span"); button.className = "mx_EventTile_copyButton"; - button.onclick = (e) => { + button.onclick = async () => { const copyCode = button.parentNode.getElementsByTagName("pre")[0]; - const successful = this.copyToClipboard(copyCode.textContent); + const successful = await copyPlaintext(copyCode.textContent); - const buttonRect = e.target.getBoundingClientRect(); + const buttonRect = button.getBoundingClientRect(); const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); const {close} = ContextMenu.createMenu(GenericTextContextMenu, { ...toRightOf(buttonRect, 2), message: successful ? _t('Copied!') : _t('Failed to copy'), }); - e.target.onmouseleave = close; + button.onmouseleave = close; }; // Wrap a div around
 so that the copy button can be correctly positioned
diff --git a/src/components/views/messages/TileErrorBoundary.js b/src/components/views/messages/TileErrorBoundary.js
new file mode 100644
index 0000000000..e42ddab16a
--- /dev/null
+++ b/src/components/views/messages/TileErrorBoundary.js
@@ -0,0 +1,72 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import classNames from 'classnames';
+import { _t } from '../../../languageHandler';
+import * as sdk from '../../../index';
+import Modal from '../../../Modal';
+
+export default class TileErrorBoundary extends React.Component {
+    constructor(props) {
+        super(props);
+
+        this.state = {
+            error: null,
+        };
+    }
+
+    static getDerivedStateFromError(error) {
+        // Side effects are not permitted here, so we only update the state so
+        // that the next render shows an error message.
+        return { error };
+    }
+
+    _onBugReport = () => {
+        const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
+        if (!BugReportDialog) {
+            return;
+        }
+        Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {
+            label: 'react-soft-crash-tile',
+        });
+    };
+
+    render() {
+        if (this.state.error) {
+            const { mxEvent } = this.props;
+            const classes = {
+                mx_EventTile: true,
+                mx_EventTile_info: true,
+                mx_EventTile_content: true,
+                mx_EventTile_tileError: true,
+            };
+            return (
+
+ + {_t("Can't load this message")} + { mxEvent && ` (${mxEvent.getType()})` } + + {_t("Submit logs")} + + +
+
); + } + + return this.props.children; + } +} diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.js index abe54b355e..979bac23e6 100644 --- a/src/components/views/right_panel/UserInfo.js +++ b/src/components/views/right_panel/UserInfo.js @@ -63,7 +63,7 @@ const _disambiguateDevices = (devices) => { }; export const getE2EStatus = (cli, userId, devices) => { - if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (!SettingsStore.getValue("feature_cross_signing")) { const hasUnverifiedDevice = devices.some((device) => device.isUnverified()); return hasUnverifiedDevice ? "warning" : "verified"; } @@ -111,7 +111,7 @@ async function openDMForUser(matrixClient, userId) { dmUserId: userId, }; - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { // Check whether all users have uploaded device keys before. // If so, enable encryption in the new room. const usersToDevicesMap = await matrixClient.downloadKeys([userId]); @@ -166,7 +166,7 @@ function DeviceItem({userId, device}) { // cross-signing so that other users can then safely trust you. // For other people's devices, the more general verified check that // includes locally verified devices can be used. - const isVerified = (isMe && SettingsStore.isFeatureEnabled("feature_cross_signing")) ? + const isVerified = (isMe && SettingsStore.getValue("feature_cross_signing")) ? deviceTrust.isCrossSigningVerified() : deviceTrust.isVerified(); @@ -237,7 +237,7 @@ function DevicesSection({devices, userId, loading}) { // cross-signing so that other users can then safely trust you. // For other people's devices, the more general verified check that // includes locally verified devices can be used. - const isVerified = (isMe && SettingsStore.isFeatureEnabled("feature_cross_signing")) ? + const isVerified = (isMe && SettingsStore.getValue("feature_cross_signing")) ? deviceTrust.isCrossSigningVerified() : deviceTrust.isVerified(); @@ -1298,7 +1298,7 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { const userTrust = cli.checkUserTrust(member.userId); const userVerified = userTrust.isCrossSigningVerified(); const isMe = member.userId === cli.getUserId(); - const canVerify = SettingsStore.isFeatureEnabled("feature_cross_signing") && + const canVerify = SettingsStore.getValue("feature_cross_signing") && homeserverSupportsCrossSigning && !userVerified && !isMe; const setUpdating = (updating) => { @@ -1308,8 +1308,9 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => { useHasCrossSigningKeys(cli, member, canVerify, setUpdating ); if (canVerify) { + // Note: mx_UserInfo_verifyButton is for the end-to-end tests verifyButton = ( - { + { if (hasCrossSigningKeys) { verifyUser(member); } else { diff --git a/src/components/views/right_panel/VerificationPanel.js b/src/components/views/right_panel/VerificationPanel.js index b60cc234eb..67efd29d27 100644 --- a/src/components/views/right_panel/VerificationPanel.js +++ b/src/components/views/right_panel/VerificationPanel.js @@ -123,10 +123,17 @@ export default class VerificationPanel extends React.PureComponent { const sasLabel = showQR ? _t("If you can't scan the code above, verify by comparing unique emoji.") : _t("Verify by comparing unique emoji."); + + // Note: mx_VerificationPanel_verifyByEmojiButton is for the end-to-end tests sasBlock =

{_t("Verify by emoji")}

{sasLabel}

- + {_t("Verify by emoji")}
; diff --git a/src/components/views/room_settings/RoomPublishSetting.js b/src/components/views/room_settings/RoomPublishSetting.js index bac2dfc656..6cc3ce26ba 100644 --- a/src/components/views/room_settings/RoomPublishSetting.js +++ b/src/components/views/room_settings/RoomPublishSetting.js @@ -18,7 +18,9 @@ import React from 'react'; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import {_t} from "../../../languageHandler"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +@replaceableComponent("views.room_settings.RoomPublishSetting") export default class RoomPublishSetting extends React.PureComponent { constructor(props) { super(props); diff --git a/src/components/views/rooms/E2EIcon.js b/src/components/views/rooms/E2EIcon.js index a2c99fad99..5e74656920 100644 --- a/src/components/views/rooms/E2EIcon.js +++ b/src/components/views/rooms/E2EIcon.js @@ -20,7 +20,7 @@ import PropTypes from "prop-types"; import classNames from 'classnames'; import {_t, _td} from '../../../languageHandler'; -import {useFeatureEnabled} from "../../../hooks/useSettings"; +import {useSettingValue} from "../../../hooks/useSettings"; import AccessibleButton from "../elements/AccessibleButton"; import Tooltip from "../elements/Tooltip"; @@ -62,7 +62,7 @@ const E2EIcon = ({isUser, status, className, size, onClick, hideTooltip}) => { }, className); let e2eTitle; - const crossSigning = useFeatureEnabled("feature_cross_signing"); + const crossSigning = useSettingValue("feature_cross_signing"); if (crossSigning && isUser) { e2eTitle = crossSigningUserTitles[status]; } else if (crossSigning && !isUser) { diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 75fbe5caa3..f67877373e 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -323,7 +323,7 @@ export default createReactClass({ // If cross-signing is off, the old behaviour is to scream at the user // as if they've done something wrong, which they haven't - if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (!SettingsStore.getValue("feature_cross_signing")) { this.setState({ verified: E2E_STATE.WARNING, }, this.props.onHeightChanged); diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index bf2a1bee23..d830624f8a 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -56,7 +56,7 @@ export default createReactClass({ } } - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { const { roomId } = this.props.member; if (roomId) { const isRoomEncrypted = cli.isRoomEncrypted(roomId); diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index c86bcb2ff0..4749742a7d 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -270,7 +270,7 @@ export default class MessageComposer extends React.Component { } renderPlaceholderText() { - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { if (this.state.isQuoting) { if (this.props.e2eStatus) { return _t('Send an encrypted reply…'); diff --git a/src/components/views/rooms/RoomBreadcrumbs.js b/src/components/views/rooms/RoomBreadcrumbs.js index 1d433c9a40..86c0d7ca96 100644 --- a/src/components/views/rooms/RoomBreadcrumbs.js +++ b/src/components/views/rooms/RoomBreadcrumbs.js @@ -363,17 +363,6 @@ export default class RoomBreadcrumbs extends React.Component { badge =
{r.formattedCount}
; } - let dmIndicator; - if (this._isDmRoom(r.room) && !SettingsStore.isFeatureEnabled("feature_cross_signing")) { - dmIndicator = {_t("Direct; - } - return ( {badge} - {dmIndicator} {tooltip} ); diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 71f774248f..17495e6299 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -168,7 +168,7 @@ export default createReactClass({ const joinRule = joinRules && joinRules.getContent().join_rule; let privateIcon; // Don't show an invite-only icon for DMs. Users know they're invite-only. - if (!dmUserId && SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (!dmUserId && SettingsStore.getValue("feature_cross_signing")) { if (joinRule == "invite") { privateIcon = ; } diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index d264b087a0..f157ee0df7 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -155,7 +155,7 @@ export default createReactClass({ if (!cli.isRoomEncrypted(this.props.room.roomId)) { return; } - if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (!SettingsStore.getValue("feature_cross_signing")) { return; } @@ -484,26 +484,10 @@ export default createReactClass({ let ariaLabel = name; - let dmIndicator; let dmOnline; - /* Post-cross-signing we don't show DM indicators at all, instead relying on user - context to let them know when that is. */ - if (dmUserId && !SettingsStore.isFeatureEnabled("feature_cross_signing")) { - dmIndicator = dm; - } - const { room } = this.props; const member = room.getMember(dmUserId); - if ( - member && member.membership === "join" && room.getJoinedMemberCount() === 2 && - SettingsStore.isFeatureEnabled("feature_presence_in_room_list") - ) { + if (member && member.membership === "join" && room.getJoinedMemberCount() === 2) { const UserOnlineDot = sdk.getComponent('rooms.UserOnlineDot'); dmOnline = ; } @@ -532,7 +516,7 @@ export default createReactClass({ } let privateIcon = null; - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { if (this.state.joinRule == "invite" && !dmUserId) { privateIcon = ; } @@ -562,7 +546,6 @@ export default createReactClass({
- { dmIndicator } { e2eIcon }
diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js index 9d60ed1188..fa3fa03c74 100644 --- a/src/components/views/settings/KeyBackupPanel.js +++ b/src/components/views/settings/KeyBackupPanel.js @@ -75,7 +75,7 @@ export default class KeyBackupPanel extends React.PureComponent { async _checkKeyBackupStatus() { try { const {backupInfo, trustInfo} = await MatrixClientPeg.get().checkKeyBackup(); - const backupKeyStored = await MatrixClientPeg.get().isKeyBackupKeyStored(); + const backupKeyStored = Boolean(await MatrixClientPeg.get().isKeyBackupKeyStored()); this.setState({ backupInfo, backupSigStatus: trustInfo, @@ -326,7 +326,7 @@ export default class KeyBackupPanel extends React.PureComponent {
); - if (this.state.backupKeyStored && !SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (this.state.backupKeyStored && !SettingsStore.getValue("feature_cross_signing")) { buttonRow =

⚠️ {_t( "Backup key stored in secret storage, but this feature is not " + "enabled on this session. Please enable cross-signing in Labs to " + diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 3dca6e2490..1cde5d6f87 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -270,7 +270,7 @@ export default class SecurityUserSettingsTab extends React.Component { // can remove this. const CrossSigningPanel = sdk.getComponent('views.settings.CrossSigningPanel'); let crossSigning; - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { crossSigning = (

{_t("Cross-signing")} diff --git a/src/components/views/verification/VerificationShowSas.js b/src/components/views/verification/VerificationShowSas.js index 0a8947f2c2..edf860c4c2 100644 --- a/src/components/views/verification/VerificationShowSas.js +++ b/src/components/views/verification/VerificationShowSas.js @@ -20,6 +20,7 @@ import { _t, _td } from '../../../languageHandler'; import {PendingActionSpinner} from "../right_panel/EncryptionInfo"; import AccessibleButton from "../elements/AccessibleButton"; import DialogButtons from "../elements/DialogButtons"; +import { fixupColorFonts } from '../../../utils/FontManager'; function capFirst(s) { return s.charAt(0).toUpperCase() + s.slice(1); @@ -44,6 +45,13 @@ export default class VerificationShowSas extends React.Component { }; } + componentWillMount() { + // As this component is also used before login (during complete security), + // also make sure we have a working emoji font to display the SAS emojis here. + // This is also done from LoggedInView. + fixupColorFonts(); + } + onMatchClick = () => { this.setState({ pending: true }); this.props.onDone(); diff --git a/src/createRoom.js b/src/createRoom.js index 66d4d1908e..a39d2c2216 100644 --- a/src/createRoom.js +++ b/src/createRoom.js @@ -227,7 +227,7 @@ export async function ensureDMExists(client, userId) { roomId = existingDMRoom.roomId; } else { let encryption; - if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (SettingsStore.getValue("feature_cross_signing")) { encryption = canEncryptToAllUsers(client, [userId]); } roomId = await createRoom({encryption, dmUserId: userId, spinner: false, andView: false}); diff --git a/src/editor/deserialize.js b/src/editor/deserialize.ts similarity index 84% rename from src/editor/deserialize.js rename to src/editor/deserialize.ts index 190963f357..48d1d98ae4 100644 --- a/src/editor/deserialize.js +++ b/src/editor/deserialize.ts @@ -1,6 +1,6 @@ /* Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,11 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + import { walkDOMDepthFirst } from "./dom"; import { checkBlockNode } from "../HtmlUtils"; -import {getPrimaryPermalinkEntity} from "../utils/permalinks/Permalinks"; +import { getPrimaryPermalinkEntity } from "../utils/permalinks/Permalinks"; +import { PartCreator } from "./parts"; -function parseAtRoomMentions(text, partCreator) { +function parseAtRoomMentions(text: string, partCreator: PartCreator) { const ATROOM = "@room"; const parts = []; text.split(ATROOM).forEach((textPart, i, arr) => { @@ -37,7 +40,7 @@ function parseAtRoomMentions(text, partCreator) { return parts; } -function parseLink(a, partCreator) { +function parseLink(a: HTMLAnchorElement, partCreator: PartCreator) { const {href} = a; const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID const prefix = resourceId ? resourceId[0] : undefined; // First character of ID @@ -50,17 +53,17 @@ function parseLink(a, partCreator) { if (href === a.textContent) { return partCreator.plain(a.textContent); } else { - return partCreator.plain(`[${a.textContent}](${href})`); + return partCreator.plain(`[${a.textContent.replace(/[[\\\]]/g, c => "\\" + c)}](${href})`); } } } } -function parseCodeBlock(n, partCreator) { +function parseCodeBlock(n: HTMLElement, partCreator: PartCreator) { const parts = []; let language = ""; if (n.firstChild && n.firstChild.nodeName === "CODE") { - for (const className of n.firstChild.classList) { + for (const className of (n.firstChild).classList) { if (className.startsWith("language-")) { language = className.substr("language-".length); break; @@ -77,12 +80,17 @@ function parseCodeBlock(n, partCreator) { return parts; } -function parseHeader(el, partCreator) { +function parseHeader(el: HTMLElement, partCreator: PartCreator) { const depth = parseInt(el.nodeName.substr(1), 10); return partCreator.plain("#".repeat(depth) + " "); } -function parseElement(n, partCreator, lastNode, state) { +interface IState { + listIndex: number[]; + listDepth?: number; +} + +function parseElement(n: HTMLElement, partCreator: PartCreator, lastNode: HTMLElement | undefined, state: IState) { switch (n.nodeName) { case "H1": case "H2": @@ -92,7 +100,7 @@ function parseElement(n, partCreator, lastNode, state) { case "H6": return parseHeader(n, partCreator); case "A": - return parseLink(n, partCreator); + return parseLink(n, partCreator); case "BR": return partCreator.newline(); case "EM": @@ -123,11 +131,11 @@ function parseElement(n, partCreator, lastNode, state) { break; } case "OL": - state.listIndex.push(n.start || 1); - // fallthrough + state.listIndex.push((n).start || 1); + /* falls through */ case "UL": state.listDepth = (state.listDepth || 0) + 1; - // fallthrough + /* falls through */ default: // don't textify block nodes we'll descend into if (!checkDescendInto(n)) { @@ -174,7 +182,7 @@ function prefixQuoteLines(isFirstNode, parts, partCreator) { } } -function parseHtmlMessage(html, partCreator, isQuotedMessage) { +function parseHtmlMessage(html: string, partCreator: PartCreator, isQuotedMessage: boolean) { // no nodes from parsing here should be inserted in the document, // as scripts in event handlers, etc would be executed then. // we're only taking text, so that is fine @@ -182,7 +190,7 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) { const parts = []; let lastNode; let inQuote = isQuotedMessage; - const state = { + const state: IState = { listIndex: [], }; @@ -236,7 +244,7 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) { break; case "OL": state.listIndex.pop(); - // fallthrough + /* falls through */ case "UL": state.listDepth -= 1; break; @@ -249,9 +257,9 @@ function parseHtmlMessage(html, partCreator, isQuotedMessage) { return parts; } -export function parsePlainTextMessage(body, partCreator, isQuotedMessage) { +export function parsePlainTextMessage(body: string, partCreator: PartCreator, isQuotedMessage: boolean) { const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n - const parts = lines.reduce((parts, line, i) => { + return lines.reduce((parts, line, i) => { if (isQuotedMessage) { parts.push(partCreator.plain(QUOTE_LINE_PREFIX)); } @@ -262,10 +270,9 @@ export function parsePlainTextMessage(body, partCreator, isQuotedMessage) { } return parts; }, []); - return parts; } -export function parseEvent(event, partCreator, {isQuotedMessage = false} = {}) { +export function parseEvent(event: MatrixEvent, partCreator: PartCreator, {isQuotedMessage = false} = {}) { const content = event.getContent(); let parts; if (content.format === "org.matrix.custom.html") { diff --git a/src/editor/serialize.js b/src/editor/serialize.ts similarity index 78% rename from src/editor/serialize.js rename to src/editor/serialize.ts index ba380f2809..4d0b8cd03a 100644 --- a/src/editor/serialize.js +++ b/src/editor/serialize.ts @@ -1,6 +1,6 @@ /* Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,8 +17,9 @@ limitations under the License. import Markdown from '../Markdown'; import {makeGenericPermalink} from "../utils/permalinks/Permalinks"; +import EditorModel from "./model"; -export function mdSerialize(model) { +export function mdSerialize(model: EditorModel) { return model.parts.reduce((html, part) => { switch (part.type) { case "newline": @@ -30,12 +31,12 @@ export function mdSerialize(model) { return html + part.text; case "room-pill": case "user-pill": - return html + `[${part.text}](${makeGenericPermalink(part.resourceId)})`; + return html + `[${part.text.replace(/[[\\\]]/g, c => "\\" + c)}](${makeGenericPermalink(part.resourceId)})`; } }, ""); } -export function htmlSerializeIfNeeded(model, {forceHTML = false} = {}) { +export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} = {}) { const md = mdSerialize(model); const parser = new Markdown(md); if (!parser.isPlainText() || forceHTML) { @@ -43,7 +44,7 @@ export function htmlSerializeIfNeeded(model, {forceHTML = false} = {}) { } } -export function textSerialize(model) { +export function textSerialize(model: EditorModel) { return model.parts.reduce((text, part) => { switch (part.type) { case "newline": @@ -60,11 +61,11 @@ export function textSerialize(model) { }, ""); } -export function containsEmote(model) { +export function containsEmote(model: EditorModel) { return startsWith(model, "/me "); } -export function startsWith(model, prefix) { +export function startsWith(model: EditorModel, prefix: string) { const firstPart = model.parts[0]; // part type will be "plain" while editing, // and "command" while composing a message. @@ -73,18 +74,18 @@ export function startsWith(model, prefix) { firstPart.text.startsWith(prefix); } -export function stripEmoteCommand(model) { +export function stripEmoteCommand(model: EditorModel) { // trim "/me " return stripPrefix(model, "/me "); } -export function stripPrefix(model, prefix) { +export function stripPrefix(model: EditorModel, prefix: string) { model = model.clone(); model.removeText({index: 0, offset: 0}, prefix.length); return model; } -export function unescapeMessage(model) { +export function unescapeMessage(model: EditorModel) { const {parts} = model; if (parts.length) { const firstPart = parts[0]; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c77ab9ac71..1b3becdbd5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -70,7 +70,7 @@ "Failure to create room": "Failure to create room", "If you cancel now, you won't complete verifying the other user.": "If you cancel now, you won't complete verifying the other user.", "If you cancel now, you won't complete verifying your other session.": "If you cancel now, you won't complete verifying your other session.", - "If you cancel now, you won't complete your secret storage operation.": "If you cancel now, you won't complete your secret storage operation.", + "If you cancel now, you won't complete your operation.": "If you cancel now, you won't complete your operation.", "Cancel entering passphrase?": "Cancel entering passphrase?", "Enter passphrase": "Enter passphrase", "Cancel": "Cancel", @@ -217,6 +217,9 @@ "Sends the given emote coloured as a rainbow": "Sends the given emote coloured as a rainbow", "Displays list of commands with usages and descriptions": "Displays list of commands with usages and descriptions", "Displays information about a user": "Displays information about a user", + "Send a bug report with logs": "Send a bug report with logs", + "Logs sent": "Logs sent", + "Thank you!": "Thank you!", "Displays action": "Displays action", "Reason": "Reason", "%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.", @@ -398,9 +401,8 @@ "Render simple counters in room header": "Render simple counters in room header", "Multiple integration managers": "Multiple integration managers", "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", - "Show a presence dot next to DMs in the room list": "Show a presence dot next to DMs in the room list", "Support adding custom themes": "Support adding custom themes", - "Enable cross-signing to verify per-user instead of per-session (in development)": "Enable cross-signing to verify per-user instead of per-session (in development)", + "Enable cross-signing to verify per-user instead of per-session": "Enable cross-signing to verify per-user instead of per-session", "Enable local event indexing and E2EE search (requires restart)": "Enable local event indexing and E2EE search (requires restart)", "Show info about bridges in room settings": "Show info about bridges in room settings", "Show padlocks on invite only rooms": "Show padlocks on invite only rooms", @@ -445,7 +447,7 @@ "Send read receipts for messages (requires compatible homeserver to disable)": "Send read receipts for messages (requires compatible homeserver to disable)", "Show previews/thumbnails for images": "Show previews/thumbnails for images", "Enable message search in encrypted rooms": "Enable message search in encrypted rooms", - "Keep secret storage passphrase in memory for this session": "Keep secret storage passphrase in memory for this session", + "Keep recovery passphrase in memory for this session": "Keep recovery passphrase in memory for this session", "How fast should messages be downloaded.": "How fast should messages be downloaded.", "Manually verify all remote sessions": "Manually verify all remote sessions", "Collecting app version information": "Collecting app version information", @@ -1087,7 +1089,6 @@ "Seen by %(userName)s at %(dateTime)s": "Seen by %(userName)s at %(dateTime)s", "Seen by %(displayName)s (%(userName)s) at %(dateTime)s": "Seen by %(displayName)s (%(userName)s) at %(dateTime)s", "Replying": "Replying", - "Direct Chat": "Direct Chat", "Room %(name)s": "Room %(name)s", "Recent rooms": "Recent rooms", "No rooms to show": "No rooms to show", @@ -1339,6 +1340,8 @@ "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?", "Edited at %(date)s. Click to view edits.": "Edited at %(date)s. Click to view edits.", "edited": "edited", + "Can't load this message": "Can't load this message", + "Submit logs": "Submit logs", "Removed or unknown message type": "Removed or unknown message type", "Message removed by %(userId)s": "Message removed by %(userId)s", "Message removed": "Message removed", @@ -1528,9 +1531,8 @@ "Close dialog": "Close dialog", "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.": "Please tell us what went wrong or, better, create a GitHub issue that describes the problem.", "Preparing to send logs": "Preparing to send logs", - "Logs sent": "Logs sent", - "Thank you!": "Thank you!", "Failed to send logs: ": "Failed to send logs: ", + "Reminder: Your browser is unsupported, so your experience may be unpredictable.": "Reminder: Your browser is unsupported, so your experience may be unpredictable.", "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.": "Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited and the usernames of other users. They do not contain messages.", "Before submitting logs, you must create a GitHub issue to describe your problem.": "Before submitting logs, you must create a GitHub issue to describe your problem.", "GitHub issue": "GitHub issue", @@ -1657,7 +1659,7 @@ "Riot encountered an error during upload of:": "Riot encountered an error during upload of:", "Upload completed": "Upload completed", "Cancelled signature upload": "Cancelled signature upload", - "Unabled to upload": "Unabled to upload", + "Unable to upload": "Unable to upload", "Signature upload success": "Signature upload success", "Signature upload failed": "Signature upload failed", "You've previously used Riot on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, Riot needs to resync your account.": "You've previously used Riot on %(host)s with lazy loading of members enabled. In this version lazy loading is disabled. As the local cache is not compatible between these two settings, Riot needs to resync your account.", @@ -1773,18 +1775,19 @@ "Upload %(count)s other files|one": "Upload %(count)s other file", "Cancel All": "Cancel All", "Upload Error": "Upload Error", + "Verify other session": "Verify other session", "Verification Request": "Verification Request", "A widget would like to verify your identity": "A widget would like to verify your identity", "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.": "A widget located at %(widgetUrl)s would like to verify your identity. By allowing this, the widget will be able to verify your user ID, but not perform actions as you.", "Remember my selection for this widget": "Remember my selection for this widget", "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 sessions by entering your passphrase.": "Access your secure message history and your cross-signing identity for verifying other sessions 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", + "Enter recovery passphrase": "Enter recovery passphrase", + "Unable to access secret storage. Please verify that you entered the correct recovery passphrase.": "Unable to access secret storage. Please verify that you entered the correct recovery passphrase.", + "Warning: You should only do this on a trusted computer.": "Warning: You should only do this on a trusted computer.", + "Access your secure message history and your cross-signing identity for verifying other sessions by entering your recovery passphrase.": "Access your secure message history and your cross-signing identity for verifying other sessions 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", "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.", "This looks like a valid recovery key!": "This looks like a valid recovery key!", "Not a valid recovery key": "Not a valid recovery key", @@ -1792,19 +1795,17 @@ "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.", + "Backup could not be decrypted with this recovery key: please verify that you entered the correct recovery key.": "Backup could not be decrypted with this recovery key: please verify that you entered the correct recovery key.", "Incorrect recovery passphrase": "Incorrect recovery passphrase", - "Backup could not be decrypted with this passphrase: please verify that you entered the correct recovery passphrase.": "Backup could not be decrypted with this passphrase: please verify that you entered the correct recovery passphrase.", + "Backup could not be decrypted with this recovery passphrase: please verify that you entered the correct recovery passphrase.": "Backup could not be decrypted with this recovery passphrase: please verify that you entered the correct recovery passphrase.", "Unable to restore backup": "Unable to restore backup", "No backup found!": "No backup found!", "Backup restored": "Backup restored", "Failed to decrypt %(failedCount)s sessions!": "Failed to decrypt %(failedCount)s sessions!", "Restored %(sessionCount)s session keys": "Restored %(sessionCount)s session keys", - "Enter recovery passphrase": "Enter recovery passphrase", "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 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", "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 key you can ": "If you've forgotten your recovery key you can ", @@ -1840,6 +1841,7 @@ "Forget": "Forget", "Favourite": "Favourite", "Low Priority": "Low Priority", + "Direct Chat": "Direct Chat", "Clear status": "Clear status", "Update status": "Update status", "Set status": "Set status", @@ -2065,7 +2067,6 @@ "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", "Could not load user profile": "Could not load user profile", - "Complete security": "Complete security", "Session verified": "Session verified", "Failed to send email": "Failed to send email", "The email address linked to your account must be entered.": "The email address linked to your account must be entered.", @@ -2103,6 +2104,9 @@ "Error: Problem communicating with the given homeserver.": "Error: Problem communicating with the given homeserver.", "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.": "Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts.", "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.": "Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.", + "Syncing...": "Syncing...", + "Signing In...": "Signing In...", + "If you've joined lots of rooms, this might take a while": "If you've joined lots of rooms, this might take a while", "Create account": "Create account", "Failed to fetch avatar URL": "Failed to fetch avatar URL", "Set a display name:": "Set a display name:", @@ -2117,7 +2121,7 @@ "Registration Successful": "Registration Successful", "Create your account": "Create your account", "Use an existing session to verify this one, granting it access to encrypted messages.": "Use an existing session to verify this one, granting it access to encrypted messages.", - "If you can’t access one, ": "If you can’t access one, ", + "If you can’t access one, ": "If you can’t access one, ", "Use your other device to continue…": "Use your other device to continue…", "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.": "Your new session is now verified. It has access to your encrypted messages, and other users will see it as trusted.", "Your new session is now verified. Other users will see it as trusted.": "Your new session is now verified. Other users will see it as trusted.", @@ -2180,18 +2184,17 @@ "Restore": "Restore", "You'll need to authenticate with the server to confirm the upgrade.": "You'll need to authenticate with the server to confirm the upgrade.", "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Upgrade this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.", - "Great! This passphrase looks strong enough.": "Great! This passphrase looks strong enough.", - "Set up encryption on this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.": "Set up encryption on this session to allow it to verify other sessions, granting them access to encrypted messages and marking them as trusted for other users.", - "Secure your encryption keys with a passphrase. For maximum security this should be different to your account password:": "Secure your encryption keys with a passphrase. For maximum security this should be different to your account password:", - "Enter a passphrase": "Enter a passphrase", - "Back up my encryption keys, securing them with the same passphrase": "Back up my encryption keys, securing them with the same passphrase", + "Great! This recovery passphrase looks strong enough.": "Great! This recovery passphrase looks strong enough.", + "Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to your account password:": "Set a recovery passphrase to secure encrypted information and recover it if you log out. This should be different to your account password:", + "Enter a recovery passphrase": "Enter a recovery passphrase", + "Back up encrypted message keys": "Back up encrypted message keys", "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.", - "Enter your passphrase a second time to confirm it.": "Enter your passphrase a second time to confirm it.", - "Confirm your passphrase": "Confirm 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.": "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your passphrase.", + "Enter your recovery passphrase a second time to confirm it.": "Enter your recovery passphrase a second time to confirm it.", + "Confirm your recovery passphrase": "Confirm your recovery passphrase", + "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your recovery passphrase.": "Your recovery key is a safety net - you can use it to restore access to your encrypted messages if you forget your recovery passphrase.", "Keep a copy of it somewhere secure, like a password manager or even a safe.": "Keep a copy of it somewhere secure, like a password manager or even a safe.", "Your recovery key": "Your recovery key", "Copy": "Copy", @@ -2203,19 +2206,20 @@ "Copy it to your personal cloud storage": "Copy it to your personal cloud storage", "You can now verify your other devices, and other users to keep your chats safe.": "You can now verify your other devices, and other users to keep your chats safe.", "Upgrade your encryption": "Upgrade your encryption", + "Confirm recovery passphrase": "Confirm recovery passphrase", "Make a copy of your recovery key": "Make a copy of your recovery key", "You're done!": "You're done!", "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.", + "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.": "We'll store an encrypted copy of your keys on our server. Secure your backup with a recovery passphrase.", "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...", - "Please enter your passphrase a second time to confirm.": "Please enter your passphrase a second time to confirm.", - "Repeat your passphrase...": "Repeat your passphrase...", + "Enter a recovery passphrase...": "Enter a recovery passphrase...", + "Please enter your recovery passphrase a second time to confirm.": "Please enter your recovery passphrase a second time to confirm.", + "Repeat your recovery passphrase...": "Repeat your recovery passphrase...", "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 session.": "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another session.", "Set up Secure Message Recovery": "Set up Secure Message Recovery", - "Secure your backup with a passphrase": "Secure your backup with a passphrase", + "Secure your backup with a recovery passphrase": "Secure your backup with a recovery passphrase", "Starting backup...": "Starting backup...", "Success!": "Success!", "Create key backup": "Create key backup", @@ -2237,7 +2241,7 @@ "If disabled, messages from encrypted rooms won't appear in search results.": "If disabled, messages from encrypted rooms won't appear in search results.", "Disable": "Disable", "Not currently indexing messages for any room.": "Not currently indexing messages for any room.", - "Currently indexing: %(currentRoom)s.": "Currently indexing: %(currentRoom)s.", + "Currently indexing: %(currentRoom)s": "Currently indexing: %(currentRoom)s", "Riot is securely caching encrypted messages locally for them to appear in search results:": "Riot is securely caching encrypted messages locally for them to appear in search results:", "Space used:": "Space used:", "Indexed messages:": "Indexed messages:", diff --git a/src/indexing/EventIndex.js b/src/indexing/EventIndex.js index 1226b84b5b..a6bbbb5f96 100644 --- a/src/indexing/EventIndex.js +++ b/src/indexing/EventIndex.js @@ -275,6 +275,7 @@ export default class EventIndex extends EventEmitter { const validEventType = isUsefulType && !ev.isRedacted() && !ev.isDecryptionFailure(); let validMsgType = true; + let hasContentValue = true; if (ev.getType() === "m.room.message" && !ev.isRedacted()) { // Expand this if there are more invalid msgtypes. @@ -282,9 +283,15 @@ export default class EventIndex extends EventEmitter { if (!msgtype) validMsgType = false; else validMsgType = !msgtype.startsWith("m.key.verification"); + + if (!ev.getContent().body) hasContentValue = false; + } else if (ev.getType() === "m.room.topic" && !ev.isRedacted()) { + if (!ev.getContent().topic) hasContentValue = false; + } else if (ev.getType() === "m.room.name" && !ev.isRedacted()) { + if (!ev.getContent().name) hasContentValue = false; } - return validEventType && validMsgType; + return validEventType && validMsgType && hasContentValue; } /** @@ -383,7 +390,7 @@ export default class EventIndex extends EventEmitter { // We have a checkpoint, let us fetch some messages, again, very // conservatively to not bother our homeserver too much. - const eventMapper = client.getEventMapper(); + const eventMapper = client.getEventMapper({preventReEmit: true}); // TODO we need to ensure to use member lazy loading with this // request so we get the correct profiles. let res; diff --git a/src/rageshake/submit-rageshake.js b/src/rageshake/submit-rageshake.ts similarity index 76% rename from src/rageshake/submit-rageshake.js rename to src/rageshake/submit-rageshake.ts index 00ef87f89c..921f3fbf40 100644 --- a/src/rageshake/submit-rageshake.js +++ b/src/rageshake/submit-rageshake.ts @@ -33,6 +33,13 @@ if (!TextEncoder) { TextEncoder = TextEncodingUtf8.TextEncoder; } +interface IOpts { + label?: string; + userText?: string; + sendLogs?: boolean; + progressCallback?: (string) => void; +} + /** * Send a bug report. * @@ -48,7 +55,7 @@ if (!TextEncoder) { * * @return {Promise} Resolved when the bug report is sent. */ -export default async function sendBugReport(bugReportEndpoint, opts) { +export default async function sendBugReport(bugReportEndpoint: string, opts: IOpts) { if (!bugReportEndpoint) { throw new Error("No bug report endpoint has been set."); } @@ -70,13 +77,13 @@ export default async function sendBugReport(bugReportEndpoint, opts) { let installedPWA = "UNKNOWN"; try { // Known to work at least for desktop Chrome - installedPWA = window.matchMedia('(display-mode: standalone)').matches; - } catch (e) { } + installedPWA = String(window.matchMedia('(display-mode: standalone)').matches); + } catch (e) {} let touchInput = "UNKNOWN"; try { // MDN claims broad support across browsers - touchInput = window.matchMedia('(pointer: coarse)').matches; + touchInput = String(window.matchMedia('(pointer: coarse)').matches); } catch (e) { } const client = MatrixClientPeg.get(); @@ -96,12 +103,14 @@ export default async function sendBugReport(bugReportEndpoint, opts) { body.append('device_id', client.deviceId); } - const keys = [`ed25519:${client.getDeviceEd25519Key()}`]; - if (client.getDeviceCurve25519Key) { - keys.push(`curve25519:${client.getDeviceCurve25519Key()}`); + if (client.isCryptoEnabled()) { + const keys = [`ed25519:${client.getDeviceEd25519Key()}`]; + if (client.getDeviceCurve25519Key) { + keys.push(`curve25519:${client.getDeviceCurve25519Key()}`); + } + body.append('device_keys', keys.join(', ')); + body.append('cross_signing_key', client.getCrossSigningId()); } - body.append('device_keys', keys.join(', ')); - body.append('cross_signing_key', client.getCrossSigningId()); if (opts.label) { body.append('label', opts.label); @@ -116,26 +125,33 @@ export default async function sendBugReport(bugReportEndpoint, opts) { // add storage persistence/quota information if (navigator.storage && navigator.storage.persisted) { try { - body.append("storageManager_persisted", await navigator.storage.persisted()); + body.append("storageManager_persisted", String(await navigator.storage.persisted())); } catch (e) {} } else if (document.hasStorageAccess) { // Safari try { - body.append("storageManager_persisted", await document.hasStorageAccess()); + body.append("storageManager_persisted", String(await document.hasStorageAccess())); } catch (e) {} } if (navigator.storage && navigator.storage.estimate) { try { const estimate = await navigator.storage.estimate(); - body.append("storageManager_quota", estimate.quota); - body.append("storageManager_usage", estimate.usage); + body.append("storageManager_quota", String(estimate.quota)); + body.append("storageManager_usage", String(estimate.usage)); if (estimate.usageDetails) { Object.keys(estimate.usageDetails).forEach(k => { - body.append(`storageManager_usage_${k}`, estimate.usageDetails[k]); + body.append(`storageManager_usage_${k}`, String(estimate.usageDetails[k])); }); } } catch (e) {} } + if (window.Modernizr) { + const missingFeatures = Object.keys(window.Modernizr).filter(key => window.Modernizr[key] === false); + if (missingFeatures.length > 0) { + body.append("modernizr_missing_features", missingFeatures.join(", ")); + } + } + if (opts.sendLogs) { progressCallback(_t("Collecting logs")); const logs = await rageshake.getLogsForReport(); @@ -154,7 +170,7 @@ export default async function sendBugReport(bugReportEndpoint, opts) { await _submitReport(bugReportEndpoint, body, progressCallback); } -function _submitReport(endpoint, body, progressCallback) { +function _submitReport(endpoint: string, body: FormData, progressCallback: (string) => void) { return new Promise((resolve, reject) => { const req = new XMLHttpRequest(); req.open("POST", endpoint); diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 0d72017878..5e57c27c9d 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -131,12 +131,6 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_presence_in_room_list": { - isFeature: true, - displayName: _td("Show a presence dot next to DMs in the room list"), - supportedLevels: LEVELS_FEATURE, - default: false, - }, "feature_custom_themes": { isFeature: true, displayName: _td("Support adding custom themes"), @@ -152,10 +146,11 @@ export const SETTINGS = { default: null, }, "feature_cross_signing": { - isFeature: true, - displayName: _td("Enable cross-signing to verify per-user instead of per-session (in development)"), - supportedLevels: LEVELS_FEATURE, - default: false, + // XXX: We shouldn't be using the feature prefix for non-feature settings. There is an exception + // for this case though as we're converting a feature to a setting for a temporary safety net. + displayName: _td("Enable cross-signing to verify per-user instead of per-session"), + supportedLevels: ['device', 'config'], // we shouldn't use LEVELS_FEATURE for non-features, so copy it here. + default: true, }, "feature_event_indexing": { isFeature: true, @@ -516,7 +511,7 @@ export const SETTINGS = { }, "keepSecretStoragePassphraseForSession": { supportedLevels: ['device', 'config'], - displayName: _td("Keep secret storage passphrase in memory for this session"), + displayName: _td("Keep recovery passphrase in memory for this session"), default: false, }, "crawlerSleepTime": { diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index b32e088a76..841734dfb7 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -159,7 +159,7 @@ class RoomViewStore extends Store { } case 'sync_state': this._setState({ - matrixClientIsReady: MatrixClientPeg.get().isInitialSyncComplete(), + matrixClientIsReady: MatrixClientPeg.get() && MatrixClientPeg.get().isInitialSyncComplete(), }); break; } diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 0000000000..5856682445 --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,75 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Copy plaintext to user's clipboard + * It will overwrite user's selection range + * In certain browsers it may only work if triggered by a user action or may ask user for permissions + * Tries to use new async clipboard API if available + * @param text the plaintext to put in the user's clipboard + */ +export async function copyPlaintext(text: string): Promise { + try { + if (navigator && navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + return true; + } else { + const textArea = document.createElement("textarea"); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.position = "fixed"; + + document.body.appendChild(textArea); + const selection = document.getSelection(); + const range = document.createRange(); + // range.selectNodeContents(textArea); + range.selectNode(textArea); + selection.removeAllRanges(); + selection.addRange(range); + + const successful = document.execCommand("copy"); + selection.removeAllRanges(); + document.body.removeChild(textArea); + return successful; + } + } catch (e) { + console.error("copyPlaintext failed", e); + } + return false; +} + +export function selectText(target: Element) { + const range = document.createRange(); + range.selectNodeContents(target); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/** + * Copy rich text to user's clipboard + * It will overwrite user's selection range + * In certain browsers it may only work if triggered by a user action or may ask user for permissions + * @param ref pointer to the node to copy + */ +export function copyNode(ref: Element): boolean { + selectText(ref); + return document.execCommand('copy'); +} diff --git a/src/verification.js b/src/verification.js index e00e5e05fa..ca839940e5 100644 --- a/src/verification.js +++ b/src/verification.js @@ -27,7 +27,7 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto'; async function enable4SIfNeeded() { const cli = MatrixClientPeg.get(); - if (!cli.isCryptoEnabled() || !SettingsStore.isFeatureEnabled("feature_cross_signing")) { + if (!cli.isCryptoEnabled() || !SettingsStore.getValue("feature_cross_signing")) { return false; } const usk = cli.getCrossSigningId("user_signing"); diff --git a/test/components/views/dialogs/AccessSecretStorageDialog-test.js b/test/components/views/dialogs/AccessSecretStorageDialog-test.js index 30512ca4dd..c754a4b607 100644 --- a/test/components/views/dialogs/AccessSecretStorageDialog-test.js +++ b/test/components/views/dialogs/AccessSecretStorageDialog-test.js @@ -100,7 +100,7 @@ describe("AccessSecretStorageDialog", function() { }); expect(notification.props.children).toEqual( ["\uD83D\uDC4E ", "Unable to access secret storage. Please verify that you " + - "entered the correct passphrase."]); + "entered the correct recovery passphrase."]); done(); }); }); diff --git a/test/editor/deserialize-test.js b/test/editor/deserialize-test.js index 1c58a6c40b..2bd5d7e4c6 100644 --- a/test/editor/deserialize-test.js +++ b/test/editor/deserialize-test.js @@ -148,6 +148,30 @@ describe('editor/deserialize', function() { expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice", resourceId: "@alice:hs.tld"}); expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); }); + it('user pill with displayname containing backslash', function() { + const html = "Hi Alice\\!"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice\\", resourceId: "@alice:hs.tld"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + }); + it('user pill with displayname containing opening square bracket', function() { + const html = "Hi Alice[[!"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice[[", resourceId: "@alice:hs.tld"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + }); + it('user pill with displayname containing closing square bracket', function() { + const html = "Hi Alice]!"; + const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); + expect(parts.length).toBe(3); + expect(parts[0]).toStrictEqual({type: "plain", text: "Hi "}); + expect(parts[1]).toStrictEqual({type: "user-pill", text: "Alice]", resourceId: "@alice:hs.tld"}); + expect(parts[2]).toStrictEqual({type: "plain", text: "!"}); + }); it('room pill', function() { const html = "Try #room:hs.tld?"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js index 7517e46437..bd26ae91bb 100644 --- a/test/editor/serialize-test.js +++ b/test/editor/serialize-test.js @@ -43,4 +43,22 @@ describe('editor/serialize', function() { const html = htmlSerializeIfNeeded(model, {}); expect(html).toBe("hello world"); }); + it('displaynames ending in a backslash work', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname\\"); + }); + it('displaynames containing an opening square bracket work', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname[[", "@user:server")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname[["); + }); + it('displaynames containing a closing square bracket work', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname]", "@user:server")]); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname]"); + }); }); diff --git a/test/end-to-end-tests/Windows.md b/test/end-to-end-tests/Windows.md index dee4fabb3f..39b06a9a62 100644 --- a/test/end-to-end-tests/Windows.md +++ b/test/end-to-end-tests/Windows.md @@ -8,7 +8,7 @@ and start following these steps to get going: 3. Run `dos2unix ./test/end-to-end-tests/*.sh ./test/end-to-end-tests/synapse/*.sh ./test/end-to-end-tests/riot/*.sh` 4. Install NodeJS for ubuntu: ```bash - curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - + curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash - sudo apt-get update sudo apt-get install nodejs ``` @@ -24,6 +24,7 @@ and start following these steps to get going: ```bash cd ./test/end-to-end-tests ./synapse/install.sh + ./install.sh ./run.sh --riot-url http://localhost:8080 --no-sandbox ``` diff --git a/test/end-to-end-tests/src/scenarios/directory.js b/test/end-to-end-tests/src/scenarios/directory.js index ca2f99f192..b5be9ed4f4 100644 --- a/test/end-to-end-tests/src/scenarios/directory.js +++ b/test/end-to-end-tests/src/scenarios/directory.js @@ -20,7 +20,7 @@ const join = require('../usecases/join'); const sendMessage = require('../usecases/send-message'); const {receiveMessage} = require('../usecases/timeline'); const {createRoom} = require('../usecases/create-room'); -const changeRoomSettings = require('../usecases/room-settings'); +const {changeRoomSettings} = require('../usecases/room-settings'); module.exports = async function roomDirectoryScenarios(alice, bob) { console.log(" creating a public room and join through directory:"); diff --git a/test/end-to-end-tests/src/scenarios/e2e-encryption.js b/test/end-to-end-tests/src/scenarios/e2e-encryption.js index 2f08acf417..d31d2c0d57 100644 --- a/test/end-to-end-tests/src/scenarios/e2e-encryption.js +++ b/test/end-to-end-tests/src/scenarios/e2e-encryption.js @@ -1,6 +1,6 @@ /* Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,26 +17,23 @@ limitations under the License. const sendMessage = require('../usecases/send-message'); const acceptInvite = require('../usecases/accept-invite'); -const invite = require('../usecases/invite'); const {receiveMessage} = require('../usecases/timeline'); -const {createRoom} = require('../usecases/create-room'); -const changeRoomSettings = require('../usecases/room-settings'); +const {createDm} = require('../usecases/create-room'); +const {checkRoomSettings} = require('../usecases/room-settings'); const {startSasVerifcation, acceptSasVerification} = require('../usecases/verify'); const assert = require('assert'); module.exports = async function e2eEncryptionScenarios(alice, bob) { - console.log(" creating an e2e encrypted room and join through invite:"); - const room = "secrets"; - await createRoom(bob, room); - await changeRoomSettings(bob, {encryption: true}); - // await cancelKeyBackup(bob); - await invite(bob, "@alice:localhost"); - await acceptInvite(alice, room); + console.log(" creating an e2e encrypted DM and join through invite:"); + await createDm(bob, ['@alice:localhost']); + await checkRoomSettings(bob, {encryption: true}); // for sanity, should be e2e-by-default + await acceptInvite(alice, 'bob'); // do sas verifcation bob.log.step(`starts SAS verification with ${alice.username}`); const bobSasPromise = startSasVerifcation(bob, alice.username); const aliceSasPromise = acceptSasVerification(alice, bob.username); // wait in parallel, so they don't deadlock on each other + // the logs get a bit messy here, but that's fine enough for debugging (hopefully) const [bobSas, aliceSas] = await Promise.all([bobSasPromise, aliceSasPromise]); assert.deepEqual(bobSas, aliceSas); bob.log.done(`done (match for ${bobSas.join(", ")})`); diff --git a/test/end-to-end-tests/src/scenarios/lazy-loading.js b/test/end-to-end-tests/src/scenarios/lazy-loading.js index 0c45b0d083..6d321dc737 100644 --- a/test/end-to-end-tests/src/scenarios/lazy-loading.js +++ b/test/end-to-end-tests/src/scenarios/lazy-loading.js @@ -25,7 +25,7 @@ const { } = require('../usecases/timeline'); const {createRoom} = require('../usecases/create-room'); const {getMembersInMemberlist} = require('../usecases/memberlist'); -const changeRoomSettings = require('../usecases/room-settings'); +const {changeRoomSettings} = require('../usecases/room-settings'); const assert = require('assert'); module.exports = async function lazyLoadingScenarios(alice, bob, charlies) { diff --git a/test/end-to-end-tests/src/session.js b/test/end-to-end-tests/src/session.js index f25c5056ad..55c2ed440c 100644 --- a/test/end-to-end-tests/src/session.js +++ b/test/end-to-end-tests/src/session.js @@ -75,6 +75,10 @@ module.exports = class RiotSession { return this.getElementProperty(field, 'outerHTML'); } + isChecked(field) { + return this.getElementProperty(field, 'checked'); + } + consoleLogs() { return this.consoleLog.buffer; } diff --git a/test/end-to-end-tests/src/usecases/create-room.js b/test/end-to-end-tests/src/usecases/create-room.js index 140748bca7..7e219fd159 100644 --- a/test/end-to-end-tests/src/usecases/create-room.js +++ b/test/end-to-end-tests/src/usecases/create-room.js @@ -20,23 +20,27 @@ async function openRoomDirectory(session) { await roomDirectoryButton.click(); } -async function createRoom(session, roomName) { +async function createRoom(session, roomName, encrypted=false) { session.log.step(`creates room "${roomName}"`); const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer'); const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h))); const roomsIndex = roomListHeaderLabels.findIndex(l => l.toLowerCase().includes("rooms")); if (roomsIndex === -1) { - throw new Error("could not find room list section that contains rooms in header"); + throw new Error("could not find room list section that contains 'rooms' in header"); } const roomsHeader = roomListHeaders[roomsIndex]; const addRoomButton = await roomsHeader.$(".mx_RoomSubList_addRoom"); await addRoomButton.click(); - const roomNameInput = await session.query('.mx_CreateRoomDialog_name input'); await session.replaceInputText(roomNameInput, roomName); + if (!encrypted) { + const encryptionToggle = await session.query('.mx_CreateRoomDialog_e2eSwitch .mx_ToggleSwitch'); + await encryptionToggle.click(); + } + const createButton = await session.query('.mx_Dialog_primary'); await createButton.click(); @@ -44,4 +48,39 @@ async function createRoom(session, roomName) { session.log.done(); } -module.exports = {openRoomDirectory, createRoom}; +async function createDm(session, invitees) { + session.log.step(`creates DM with ${JSON.stringify(invitees)}`); + + const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer'); + const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h))); + const dmsIndex = roomListHeaderLabels.findIndex(l => l.toLowerCase().includes('direct messages')); + if (dmsIndex === -1) { + throw new Error("could not find room list section that contains 'direct messages' in header"); + } + const dmsHeader = roomListHeaders[dmsIndex]; + const startChatButton = await dmsHeader.$(".mx_RoomSubList_addRoom"); + await startChatButton.click(); + + const inviteesEditor = await session.query('.mx_InviteDialog_editor textarea'); + for (const target of invitees) { + await session.replaceInputText(inviteesEditor, target); + await session.delay(1000); // give it a moment to figure out a suggestion + // find the suggestion and accept it + const suggestions = await session.queryAll('.mx_InviteDialog_roomTile_userId'); + const suggestionTexts = await Promise.all(suggestions.map(s => session.innerText(s))); + const suggestionIndex = suggestionTexts.indexOf(target); + if (suggestionIndex === -1) { + throw new Error(`failed to find a suggestion in the DM dialog to invite ${target} with`); + } + await suggestions[suggestionIndex].click(); + } + + // press the go button and hope for the best + const goButton = await session.query('.mx_InviteDialog_goButton'); + await goButton.click(); + + await session.query('.mx_MessageComposer'); + session.log.done(); +} + +module.exports = {openRoomDirectory, createRoom, createDm}; diff --git a/test/end-to-end-tests/src/usecases/room-settings.js b/test/end-to-end-tests/src/usecases/room-settings.js index ab6d66ea6d..b705463965 100644 --- a/test/end-to-end-tests/src/usecases/room-settings.js +++ b/test/end-to-end-tests/src/usecases/room-settings.js @@ -30,18 +30,102 @@ async function setSettingsToggle(session, toggle, enabled) { } } -module.exports = async function changeRoomSettings(session, settings) { - session.log.startGroup(`changes the room settings`); +async function checkSettingsToggle(session, toggle, shouldBeEnabled) { + const className = await session.getElementProperty(toggle, "className"); + const checked = className.includes("mx_ToggleSwitch_on"); + if (checked === shouldBeEnabled) { + session.log.done('set as expected'); + } else { + // other logs in the area should give more context as to what this means. + throw new Error("settings toggle value didn't match expectation"); + } +} + +async function findTabs(session) { /// XXX delay is needed here, possibly because the header is being rerendered /// click doesn't do anything otherwise await session.delay(1000); const settingsButton = await session.query(".mx_RoomHeader .mx_AccessibleButton[title=Settings]"); await settingsButton.click(); + //find tabs const tabButtons = await session.queryAll(".mx_RoomSettingsDialog .mx_TabbedView_tabLabel"); const tabLabels = await Promise.all(tabButtons.map(t => session.innerText(t))); const securityTabButton = tabButtons[tabLabels.findIndex(l => l.toLowerCase().includes("security"))]; + return {securityTabButton}; +} + +async function checkRoomSettings(session, expectedSettings) { + session.log.startGroup(`checks the room settings`); + + const {securityTabButton} = await findTabs(session); + const generalSwitches = await session.queryAll(".mx_RoomSettingsDialog .mx_ToggleSwitch"); + const isDirectory = generalSwitches[0]; + + if (typeof expectedSettings.directory === 'boolean') { + session.log.step(`checks directory listing is ${expectedSettings.directory}`); + await checkSettingsToggle(session, isDirectory, expectedSettings.directory); + } + + if (expectedSettings.alias) { + session.log.step(`checks for local alias of ${expectedSettings.alias}`); + const summary = await session.query(".mx_RoomSettingsDialog .mx_AliasSettings summary"); + await summary.click(); + const localAliases = await session.query('.mx_RoomSettingsDialog .mx_AliasSettings .mx_EditableItem_item'); + const localAliasTexts = await Promise.all(localAliases.map(a => session.innerText(a))); + if (localAliasTexts.find(a => a.includes(expectedSettings.alias))) { + session.log.done("present"); + } else { + throw new Error(`could not find local alias ${expectedSettings.alias}`); + } + } + + securityTabButton.click(); + await session.delay(500); + const securitySwitches = await session.queryAll(".mx_RoomSettingsDialog .mx_ToggleSwitch"); + const e2eEncryptionToggle = securitySwitches[0]; + + if (typeof expectedSettings.encryption === "boolean") { + session.log.step(`checks room e2e encryption is ${expectedSettings.encryption}`); + await checkSettingsToggle(session, e2eEncryptionToggle, expectedSettings.encryption); + } + + if (expectedSettings.visibility) { + session.log.step(`checks visibility is ${expectedSettings.visibility}`); + const radios = await session.queryAll(".mx_RoomSettingsDialog input[type=radio]"); + assert.equal(radios.length, 7); + const inviteOnly = radios[0]; + const publicNoGuests = radios[1]; + const publicWithGuests = radios[2]; + + let expectedRadio = null; + if (expectedSettings.visibility === "invite_only") { + expectedRadio = inviteOnly; + } else if (expectedSettings.visibility === "public_no_guests") { + expectedRadio = publicNoGuests; + } else if (expectedSettings.visibility === "public_with_guests") { + expectedRadio = publicWithGuests; + } else { + throw new Error(`unrecognized room visibility setting: ${expectedSettings.visibility}`); + } + if (await session.isChecked(expectedRadio)) { + session.log.done(); + } else { + throw new Error("room visibility is not as expected"); + } + } + + const closeButton = await session.query(".mx_RoomSettingsDialog .mx_Dialog_cancelButton"); + await closeButton.click(); + + session.log.endGroup(); +} + +async function changeRoomSettings(session, settings) { + session.log.startGroup(`changes the room settings`); + + const {securityTabButton} = await findTabs(session); const generalSwitches = await session.queryAll(".mx_RoomSettingsDialog .mx_ToggleSwitch"); const isDirectory = generalSwitches[0]; @@ -100,4 +184,6 @@ module.exports = async function changeRoomSettings(session, settings) { await closeButton.click(); session.log.endGroup(); -}; +} + +module.exports = {checkRoomSettings, changeRoomSettings}; diff --git a/test/end-to-end-tests/src/usecases/signup.js b/test/end-to-end-tests/src/usecases/signup.js index ef8a259091..aa9f6b7efa 100644 --- a/test/end-to-end-tests/src/usecases/signup.js +++ b/test/end-to-end-tests/src/usecases/signup.js @@ -79,6 +79,35 @@ module.exports = async function signup(session, username, password, homeserver) const acceptButton = await session.query('.mx_InteractiveAuthEntryComponents_termsSubmit'); await acceptButton.click(); + //plow through cross-signing setup by entering arbitrary details + //TODO: It's probably important for the tests to know the passphrase + const xsigningPassphrase = 'a7eaXcjpa9!Yl7#V^h$B^%dovHUVX'; // https://xkcd.com/221/ + let passphraseField = await session.query('.mx_CreateSecretStorageDialog_passPhraseField input'); + await session.replaceInputText(passphraseField, xsigningPassphrase); + await session.delay(1000); // give it a second to analyze our passphrase for security + let xsignContButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_buttons .mx_Dialog_primary'); + await xsignContButton.click(); + + //repeat passphrase entry + passphraseField = await session.query('.mx_CreateSecretStorageDialog_passPhraseField input'); + await session.replaceInputText(passphraseField, xsigningPassphrase); + await session.delay(1000); // give it a second to analyze our passphrase for security + xsignContButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_buttons .mx_Dialog_primary'); + await xsignContButton.click(); + + //ignore the recovery key + //TODO: It's probably important for the tests to know the recovery key + const copyButton = await session.query('.mx_CreateSecretStorageDialog_recoveryKeyButtons_copyBtn'); + await copyButton.click(); + + //acknowledge that we copied the recovery key to a safe place + const copyContinueButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_primary'); + await copyContinueButton.click(); + + //acknowledge that we're done cross-signing setup and our keys are safe + const doneOkButton = await session.query('.mx_CreateSecretStorageDialog .mx_Dialog_primary'); + await doneOkButton.click(); + //wait for registration to finish so the hash gets set //onhashchange better? diff --git a/test/end-to-end-tests/src/usecases/verify.js b/test/end-to-end-tests/src/usecases/verify.js index 5f507f96e6..98e73ad6b7 100644 --- a/test/end-to-end-tests/src/usecases/verify.js +++ b/test/end-to-end-tests/src/usecases/verify.js @@ -1,6 +1,6 @@ /* Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,18 +17,21 @@ limitations under the License. const assert = require('assert'); const {openMemberInfo} = require("./memberlist"); -const {assertDialog, acceptDialog} = require("./dialog"); - -async function assertVerified(session) { - const dialogSubTitle = await session.innerText(await session.query(".mx_Dialog h2")); - assert(dialogSubTitle, "Verified!"); -} async function startVerification(session, name) { + session.log.step("opens their opponent's profile and starts verification"); await openMemberInfo(session, name); // click verify in member info - const firstVerifyButton = await session.query(".mx_MemberDeviceInfo_verify"); + const firstVerifyButton = await session.query(".mx_UserInfo_verifyButton"); await firstVerifyButton.click(); + + // wait for the animation to finish + await session.delay(1000); + + // click 'start verification' + const startVerifyButton = await session.query('.mx_UserInfo_container .mx_AccessibleButton_kind_primary'); + await startVerifyButton.click(); + session.log.done(); } async function getSasCodes(session) { @@ -38,33 +41,73 @@ async function getSasCodes(session) { return sasLabels; } -module.exports.startSasVerifcation = async function(session, name) { - await startVerification(session, name); - // expect "Verify device" dialog and click "Begin Verification" - await assertDialog(session, "Verify device"); - // click "Begin Verification" - await acceptDialog(session); +async function doSasVerification(session) { + session.log.step("hunts for the emoji to yell at their opponent"); const sasCodes = await getSasCodes(session); - // click "Verify" - await acceptDialog(session); - await assertVerified(session); - // click "Got it" when verification is done - await acceptDialog(session); + session.log.done(sasCodes); + + // Assume they match + session.log.step("assumes the emoji match"); + const matchButton = await session.query(".mx_VerificationShowSas .mx_AccessibleButton_kind_primary"); + await matchButton.click(); + session.log.done(); + + // Wait for a big green shield (universal sign that it worked) + session.log.step("waits for a green shield"); + await session.query(".mx_VerificationPanel_verified_section .mx_E2EIcon_verified"); + session.log.done(); + + // Click 'Got It' + session.log.step("confirms the green shield"); + const doneButton = await session.query(".mx_VerificationPanel_verified_section .mx_AccessibleButton_kind_primary"); + await doneButton.click(); + session.log.done(); + + // Wait a bit for the animation + session.log.step("confirms their opponent has a green shield"); + await session.delay(1000); + + // Verify that we now have a green shield in their name (proving it still works) + await session.query('.mx_UserInfo_profile .mx_E2EIcon_verified'); + session.log.done(); + + return sasCodes; +} + +module.exports.startSasVerifcation = async function(session, name) { + session.log.startGroup("starts verification"); + await startVerification(session, name); + + // expect to be waiting (the presence of a spinner is a good thing) + await session.query('.mx_UserInfo_container .mx_EncryptionInfo_spinner'); + + const sasCodes = await doSasVerification(session); + session.log.endGroup(); return sasCodes; }; module.exports.acceptSasVerification = async function(session, name) { - await assertDialog(session, "Incoming Verification Request"); - const opponentLabelElement = await session.query(".mx_IncomingSasDialog_opponentProfile h2"); - const opponentLabel = await session.innerText(opponentLabelElement); - assert(opponentLabel, name); - // click "Continue" button - await acceptDialog(session); - const sasCodes = await getSasCodes(session); - // click "Verify" - await acceptDialog(session); - await assertVerified(session); - // click "Got it" when verification is done - await acceptDialog(session); + session.log.startGroup("accepts verification"); + const requestToast = await session.query('.mx_Toast_icon_verification'); + + // verify the toast is for verification + const toastHeader = await requestToast.$("h2"); + const toastHeaderText = await session.innerText(toastHeader); + assert.equal(toastHeaderText, 'Verification Request'); + const toastDescription = await requestToast.$(".mx_Toast_description"); + const toastDescText = await session.innerText(toastDescription); + assert.equal(toastDescText.startsWith(name), true, + `verification opponent mismatch: expected to start with '${name}', got '${toastDescText}'`); + + // accept the verification + const acceptButton = await requestToast.$(".mx_AccessibleButton_kind_primary"); + await acceptButton.click(); + + // find the emoji button + const startEmojiButton = await session.query(".mx_VerificationPanel_verifyByEmojiButton"); + await startEmojiButton.click(); + + const sasCodes = await doSasVerification(session); + session.log.endGroup(); return sasCodes; }; diff --git a/test/end-to-end-tests/start.js b/test/end-to-end-tests/start.js index 83bc186356..6c80608903 100644 --- a/test/end-to-end-tests/start.js +++ b/test/end-to-end-tests/start.js @@ -93,7 +93,13 @@ async function writeLogs(sessions, dir) { for (let i = 0; i < sessions.length; ++i) { const session = sessions[i]; const userLogDir = `${dir}/${session.username}`; - fs.mkdirSync(userLogDir); + try { + fs.mkdirSync(userLogDir); + } catch (e) { + // typically this will be EEXIST. If it's something worse, the next few + // lines will fail too. + console.warn(`non-fatal error creating ${userLogDir} :`, e.message); + } const consoleLogName = `${userLogDir}/console.log`; const networkLogName = `${userLogDir}/network.log`; const appHtmlName = `${userLogDir}/app.html`; diff --git a/test/end-to-end-tests/yarn.lock b/test/end-to-end-tests/yarn.lock index 4379b24946..c26dde0f97 100644 --- a/test/end-to-end-tests/yarn.lock +++ b/test/end-to-end-tests/yarn.lock @@ -7,10 +7,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-11.12.1.tgz#d90123f6c61fdf2f7cddd286ddae891586dd3488" integrity sha512-sKDlqv6COJrR7ar0+GqqhrXQDzQlMcqMnF2iEU6m9hLo8kxozoAGUazwPyELHlRVmjsbvlnGXjnzyptSXVmceA== -agent-base@^4.1.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" - integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg== +agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== dependencies: es6-promisify "^5.0.0" @@ -233,9 +233,9 @@ entities@^1.1.1, entities@~1.1.1: integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== es6-promise@^4.0.3: - version "4.2.6" - resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.6.tgz#b685edd8258886365ea62b57d30de28fadcd974f" - integrity sha512-aRVgGdnmW2OiySVPUC9e6m+plolMAJKjZnQlCwNSuK5yQ0JN61DZSO1X1Ufd1foqWRAlig0rhduTCHe7sVtK5Q== + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== es6-promisify@^5.0.0: version "5.0.0" @@ -359,11 +359,11 @@ http-signature@~1.2.0: sshpk "^1.7.0" https-proxy-agent@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0" - integrity sha512-HPCTS1LW51bcyMYbxUIOO4HEOlQ1/1qRaFWcyxvwaqUS9TY88aoEuHUY33kuAh1YhVVaDQhLZsnPd+XNARWZlQ== + version "2.2.4" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" + integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg== dependencies: - agent-base "^4.1.0" + agent-base "^4.3.0" debug "^3.1.0" inflight@^1.0.4: @@ -471,9 +471,9 @@ ms@2.0.0: integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= ms@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" - integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== nth-check@~1.0.1: version "1.0.2" diff --git a/yarn.lock b/yarn.lock index e392bf2872..d92da5a76f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1257,6 +1257,11 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/modernizr@^3.5.3": + version "3.5.3" + resolved "https://registry.yarnpkg.com/@types/modernizr/-/modernizr-3.5.3.tgz#8ef99e6252191c1d88647809109dc29884ba6d7a" + integrity sha512-jhMOZSS0UGYTS9pqvt6q3wtT3uvOSve5piTEmTMx3zzTuBLvSIMxSIBIc3d5lajVD5h4xc41AMZD2M5orN3PxA== + "@types/node@*": version "13.11.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.0.tgz#390ea202539c61c8fa6ba4428b57e05bc36dc47b"