diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 0b4266c0b5..2b57d4e9e2 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,8 +1,5 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. -src/autocomplete/AutocompleteProvider.js -src/autocomplete/Autocompleter.js -src/autocomplete/UserProvider.js src/component-index.js src/components/structures/BottomLeftMenu.js src/components/structures/CompatibilityPage.js @@ -18,7 +15,6 @@ src/components/structures/ScrollPanel.js src/components/structures/SearchBox.js src/components/structures/TimelinePanel.js src/components/structures/UploadBar.js -src/components/structures/UserSettings.js src/components/views/avatars/BaseAvatar.js src/components/views/avatars/MemberAvatar.js src/components/views/create_room/RoomAlias.js @@ -54,7 +50,6 @@ src/components/views/rooms/MemberInfo.js src/components/views/rooms/MemberList.js src/components/views/rooms/MemberTile.js src/components/views/rooms/MessageComposer.js -src/components/views/rooms/MessageComposerInput.js src/components/views/rooms/PinnedEventTile.js src/components/views/rooms/RoomList.js src/components/views/rooms/RoomPreviewBar.js diff --git a/.travis.yml b/.travis.yml index 0def6d50f7..0746cc0dff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,8 +14,22 @@ node_js: addons: chrome: stable install: - - npm install -# install synapse prerequisites for end to end tests - - sudo apt-get install build-essential python2.7-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev -script: - ./scripts/travis.sh + - ./scripts/travis/install-deps.sh +matrix: + include: + - name: Linting Checks + script: + # run the linter, but exclude any files known to have errors or warnings. + - npm run lintwithexclusions + - name: End-to-End Tests + if: branch = develop + install: + - sudo apt-get install build-essential python2.7-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev + script: + - ./scripts/travis/end-to-end-tests.sh + - name: Unit Tests + script: + - ./scripts/travis/unit-tests.sh + - name: Riot-web Unit Tests + script: + - ./scripts/travis/riot-unit-tests.sh diff --git a/package.json b/package.json index 155d3d1b23..7b55a09948 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "start:init": "babel src -d lib --source-maps --copy-files", "lint": "eslint src/", "lintall": "eslint src/ test/", - "lintwithexclusions": "eslint --max-warnings 16 --ignore-path .eslintignore.errorfiles src test", + "lintwithexclusions": "eslint --max-warnings 18 --ignore-path .eslintignore.errorfiles src test", "clean": "rimraf lib", "prepublish": "npm run clean && npm run build && git rev-parse HEAD > git-revision.txt", "test": "karma start --single-run=true --browsers ChromeHeadless", @@ -118,7 +118,7 @@ "babel-preset-react": "^6.24.1", "chokidar": "^1.6.1", "concurrently": "^4.0.1", - "eslint": "^5.8.0", + "eslint": "^5.12.0", "eslint-config-google": "^0.7.1", "eslint-plugin-babel": "^5.2.1", "eslint-plugin-flowtype": "^2.30.0", diff --git a/res/css/_components.scss b/res/css/_components.scss index d8f966603d..1e2d7ae156 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -5,6 +5,7 @@ @import "./structures/_ContextualMenu.scss"; @import "./structures/_CreateRoom.scss"; @import "./structures/_FilePanel.scss"; +@import "./structures/_GroupGridView.scss"; @import "./structures/_GroupView.scss"; @import "./structures/_HomePage.scss"; @import "./structures/_LeftPanel.scss"; @@ -50,7 +51,7 @@ @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_UnknownDeviceDialog.scss"; @import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; -@import "./views/dialogs/keybackup/_NewRecoveryMethodDialog.scss"; +@import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; @import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; @@ -91,6 +92,7 @@ @import "./views/messages/_UnknownBody.scss"; @import "./views/rooms/_AppsDrawer.scss"; @import "./views/rooms/_Autocomplete.scss"; +@import "./views/rooms/_AuxPanel.scss"; @import "./views/rooms/_EntityTile.scss"; @import "./views/rooms/_EventTile.scss"; @import "./views/rooms/_LinkPreviewWidget.scss"; diff --git a/res/css/structures/_GroupGridView.scss b/res/css/structures/_GroupGridView.scss new file mode 100644 index 0000000000..541052175d --- /dev/null +++ b/res/css/structures/_GroupGridView.scss @@ -0,0 +1,130 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_GroupGridView { + display: flex; + flex-direction: column; +} + +.mx_GroupGridView_rooms { + display: grid; + grid-template-columns: repeat(3, calc(100% / 3)); + grid-template-rows: repeat(2, calc(100% / 2)); + flex: 1 1 0; + min-width: 0; +} + +.mx_GroupGridView_rightPanel { + display: flex; + flex-direction: column; + + .mx_GroupGridView_tabs { + flex: 0 0 52px; + border-bottom: 1px solid $primary-hairline-color; + display: flex; + align-items: center; + + > div { + justify-content: flex-end; + width: 100%; + margin-right: 10px; + } + } + + .mx_RightPanel { + flex: 1 0 auto !important; + } +} + + +.mx_GroupGridView > .mx_MainSplit { + flex: 1 1 0; + display: flex; +} + +.mx_GroupGridView_emptyTile { + display: block; + margin-top: 100px; + text-align: center; + user-select: none; +} + +.mx_GroupGridView_tile { + border-right: 1px solid $panel-divider-color; + border-bottom: 1px solid $panel-divider-color; +} + +.mx_GroupGridView_activeTile { + position: relative; +} + +.mx_GroupGridView_activeTile:before, +.mx_GroupGridView_activeTile:after { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + content: ""; + pointer-events: none; + z-index: 3500; +} + +.mx_GroupGridView_activeTile:before { + border-radius: 14px; + border: 8px solid $gridview-focus-border-glow-color; + margin: -8px; +} + +.mx_GroupGridView_activeTile:after { + border-radius: 8px; + border: 2px solid $gridview-focus-border-color; + margin: -2px; +} + +.mx_GroupGridView_tile > .mx_RoomView { + height: 100%; +} + +.mx_GroupGridView_rooms > *:nth-child(1) { + grid-column: 1; + grid-row: 1; +} + +.mx_GroupGridView_rooms > *:nth-child(2) { + grid-column: 2; + grid-row: 1; +} + +.mx_GroupGridView_rooms > *:nth-child(3) { + grid-column: 3; + grid-row: 1; +} + +.mx_GroupGridView_rooms > *:nth-child(4) { + grid-column: 1; + grid-row: 2; +} + +.mx_GroupGridView_rooms > *:nth-child(5) { + grid-column: 2; + grid-row: 2; +} + +.mx_GroupGridView_rooms > *:nth-child(6) { + grid-column: 3; + grid-row: 2; +} diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index 1ccbd19391..6d8b79ecb2 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -73,14 +73,16 @@ limitations under the License. .mx_MatrixChat > :not(.mx_LeftPanel_container):not(.mx_ResizeHandle) { background-color: $primary-bg-color; - flex: 1; + flex: 1 1 0; + min-width: 0; /* Experimental fix for https://github.com/vector-im/vector-web/issues/947 and https://github.com/vector-im/vector-web/issues/946. Empirically this stops the MessagePanel's width exploding outwards when gemini is in 'prevented' mode */ - overflow-x: auto; + // disabling this for now as it clips the active room rect on the grid view + // overflow-x: auto; /* To fix https://github.com/vector-im/riot-web/issues/3298 where Safari needed height 100% all the way down to the HomePage. Height does not diff --git a/res/css/views/dialogs/keybackup/_NewRecoveryMethodDialog.scss b/res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss similarity index 82% rename from res/css/views/dialogs/keybackup/_NewRecoveryMethodDialog.scss rename to res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss index 370f82d9ab..4a050b6fc4 100644 --- a/res/css/views/dialogs/keybackup/_NewRecoveryMethodDialog.scss +++ b/res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss @@ -14,17 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_NewRecoveryMethodDialog .mx_Dialog_title { +.mx_KeyBackupFailedDialog .mx_Dialog_title { margin-bottom: 32px; } -.mx_NewRecoveryMethodDialog_title { +.mx_KeyBackupFailedDialog_title { position: relative; padding-left: 45px; padding-bottom: 10px; &:before { - mask: url("../../../img/e2e/lock-warning.svg"); + mask: url("../../img/e2e/lock-warning.svg"); mask-repeat: no-repeat; background-color: $primary-fg-color; content: ""; @@ -36,6 +36,6 @@ limitations under the License. } } -.mx_NewRecoveryMethodDialog .mx_Dialog_buttons { +.mx_KeyBackupFailedDialog .mx_Dialog_buttons { margin-top: 36px; } diff --git a/res/css/views/rooms/_AuxPanel.scss b/res/css/views/rooms/_AuxPanel.scss new file mode 100644 index 0000000000..34ef5e01d4 --- /dev/null +++ b/res/css/views/rooms/_AuxPanel.scss @@ -0,0 +1,50 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.m_RoomView_auxPanel_stateViews { + padding: 5px; + padding-left: 19px; + border-bottom: 1px solid #e5e5e5; +} + +.m_RoomView_auxPanel_stateViews_span a { + text-decoration: none; + color: inherit; +} + +.m_RoomView_auxPanel_stateViews_span[data-severity=warning] { + font-weight: bold; + color: orange; +} + +.m_RoomView_auxPanel_stateViews_span[data-severity=alert] { + font-weight: bold; + color: red; +} + +.m_RoomView_auxPanel_stateViews_span[data-severity=normal] { + font-weight: normal; +} + +.m_RoomView_auxPanel_stateViews_span[data-severity=notice] { + font-weight: normal; + color: $settings-grey-fg-color; +} + +.m_RoomView_auxPanel_stateViews_delim { + padding: 0 5px; + color: $settings-grey-fg-color; +} diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index 6f9491b22f..567727fb64 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -53,6 +53,10 @@ limitations under the License. .mx_MemberList_query, .mx_GroupMemberList_query, .mx_GroupRoomList_query { + flex: 0 0 auto; +} + +.mx_MemberList .gm-scrollbar-container { flex: 1 1 0; } diff --git a/res/img/feather-icons/toggle-right-panel.svg b/res/img/feather-icons/toggle-right-panel.svg new file mode 100644 index 0000000000..4cadf89564 --- /dev/null +++ b/res/img/feather-icons/toggle-right-panel.svg @@ -0,0 +1,17 @@ + + + + Group 2 + Created with Sketch. + + + + + + + + + + + + \ No newline at end of file diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 997a74e6aa..257b723ccf 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -162,6 +162,10 @@ $lightbox-bg-color: #454545; $lightbox-fg-color: #ffffff; $lightbox-border-color: #ffffff; +/*** GroupGridView ***/ +$gridview-focus-border-glow-color: rgba(134, 193, 165, 0.5); +$gridview-focus-border-color: rgba(134, 193, 165, 1); + $imagebody-giflabel: rgba(1, 1, 1, 0.7); $imagebody-giflabel-border: rgba(1, 1, 1, 0.2); $imagebody-giflabel-color: rgba(0, 0, 0, 1); diff --git a/res/themes/dharma/css/_dharma.scss b/res/themes/dharma/css/_dharma.scss index 54ccbc7dd0..732cabf494 100644 --- a/res/themes/dharma/css/_dharma.scss +++ b/res/themes/dharma/css/_dharma.scss @@ -184,6 +184,9 @@ $lightbox-bg-color: #454545; $lightbox-fg-color: #ffffff; $lightbox-border-color: #ffffff; +/*** GroupGridView ***/ +$gridview-focus-border-glow-color: rgba(134, 193, 165, 0.5); +$gridview-focus-border-color: rgba(134, 193, 165, 1); // unused? $progressbar-color: #000; diff --git a/res/themes/light/css/_base.scss b/res/themes/light/css/_base.scss index bd445bbc05..10a8fcd1e5 100644 --- a/res/themes/light/css/_base.scss +++ b/res/themes/light/css/_base.scss @@ -175,6 +175,10 @@ $lightbox-bg-color: #454545; $lightbox-fg-color: #ffffff; $lightbox-border-color: #ffffff; +/*** GroupGridView ***/ +$gridview-focus-border-glow-color: rgba(134, 193, 165, 0.5); +$gridview-focus-border-color: rgba(134, 193, 165, 1); + $imagebody-giflabel: rgba(0, 0, 0, 0.7); $imagebody-giflabel-border: rgba(0, 0, 0, 0.2); $imagebody-giflabel-color: rgba(255, 255, 255, 1); diff --git a/.travis-test-riot.sh b/scripts/travis/build.sh similarity index 61% rename from .travis-test-riot.sh rename to scripts/travis/build.sh index d1c2804b2a..a353e38a06 100755 --- a/.travis-test-riot.sh +++ b/scripts/travis/build.sh @@ -24,18 +24,4 @@ rm -r node_modules/matrix-react-sdk ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk npm run build -npm run test popd - -if [ "$TRAVIS_BRANCH" = "develop" ] -then - # run end to end tests - scripts/fetchdep.sh matrix-org matrix-react-end-to-end-tests master - pushd matrix-react-end-to-end-tests - ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web - # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh - # CHROME_PATH=$(which google-chrome-stable) ./run.sh - ./install.sh - ./run.sh --travis - popd -fi diff --git a/scripts/travis/end-to-end-tests.sh b/scripts/travis/end-to-end-tests.sh new file mode 100755 index 0000000000..361b053d2b --- /dev/null +++ b/scripts/travis/end-to-end-tests.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# +# script which is run by the travis build (after `npm run test`). +# +# clones riot-web develop and runs the tests against our version of react-sdk. + +set -ev + +RIOT_WEB_DIR=riot-web +REACT_SDK_DIR=`pwd` + +scripts/travis/build.sh +# run end to end tests +scripts/fetchdep.sh matrix-org matrix-react-end-to-end-tests master +pushd matrix-react-end-to-end-tests +ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web +# PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh +# CHROME_PATH=$(which google-chrome-stable) ./run.sh +./install.sh +./run.sh --travis +popd diff --git a/scripts/travis.sh b/scripts/travis/install-deps.sh similarity index 57% rename from scripts/travis.sh rename to scripts/travis/install-deps.sh index 48410ea904..04cd728157 100755 --- a/scripts/travis.sh +++ b/scripts/travis/install-deps.sh @@ -1,7 +1,7 @@ #!/bin/sh set -ex - +npm install scripts/fetchdep.sh matrix-org matrix-js-sdk rm -r node_modules/matrix-js-sdk || true ln -s ../matrix-js-sdk node_modules/matrix-js-sdk @@ -9,9 +9,3 @@ ln -s ../matrix-js-sdk node_modules/matrix-js-sdk cd matrix-js-sdk npm install cd .. - -npm run test -./.travis-test-riot.sh - -# run the linter, but exclude any files known to have errors or warnings. -npm run lintwithexclusions diff --git a/scripts/travis/riot-unit-tests.sh b/scripts/travis/riot-unit-tests.sh new file mode 100755 index 0000000000..a2f0d61112 --- /dev/null +++ b/scripts/travis/riot-unit-tests.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# +# script which is run by the travis build (after `npm run test`). +# +# clones riot-web develop and runs the tests against our version of react-sdk. + +set -ev + +RIOT_WEB_DIR=riot-web + +scripts/travis/build.sh +pushd "$RIOT_WEB_DIR" +npm run test +popd diff --git a/scripts/travis/unit-tests.sh b/scripts/travis/unit-tests.sh new file mode 100755 index 0000000000..a8e0a63b31 --- /dev/null +++ b/scripts/travis/unit-tests.sh @@ -0,0 +1,10 @@ +#!/bin/bash +# +# script which is run by the travis build (after `npm run test`). +# +# clones riot-web develop and runs the tests against our version of react-sdk. + +set -ev + +scripts/travis/build.sh +npm run test diff --git a/src/ActiveRoomObserver.js b/src/ActiveRoomObserver.js index d6fbb460b5..c276cccb5d 100644 --- a/src/ActiveRoomObserver.js +++ b/src/ActiveRoomObserver.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import RoomViewStore from './stores/RoomViewStore'; +import OpenRoomsStore from './stores/OpenRoomsStore'; /** - * Consumes changes from the RoomViewStore and notifies specific things + * Consumes changes from the OpenRoomsStore and notifies specific things * about when the active room changes. Unlike listening for RoomViewStore * changes, you can subscribe to only changes relevant to a particular * room. @@ -28,11 +28,15 @@ import RoomViewStore from './stores/RoomViewStore'; class ActiveRoomObserver { constructor() { this._listeners = {}; - - this._activeRoomId = RoomViewStore.getRoomId(); + const roomStore = OpenRoomsStore.getActiveRoomStore(); + this._activeRoomId = roomStore && roomStore.getRoomId(); // TODO: We could self-destruct when the last listener goes away, or at least // stop listening. - this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this)); + this._roomStoreToken = OpenRoomsStore.addListener(this._onOpenRoomsStoreUpdate.bind(this)); + } + + getActiveRoomId() { + return this._activeRoomId; } addListener(roomId, listener) { @@ -51,23 +55,23 @@ class ActiveRoomObserver { } } - _emit(roomId) { + _emit(roomId, newActiveRoomId) { if (!this._listeners[roomId]) return; for (const l of this._listeners[roomId]) { - l.call(); + l.call(l, newActiveRoomId); } } - _onRoomViewStoreUpdate() { + _onOpenRoomsStoreUpdate() { + const activeRoomStore = OpenRoomsStore.getActiveRoomStore(); + const newActiveRoomId = activeRoomStore && activeRoomStore.getRoomId(); // emit for the old room ID - if (this._activeRoomId) this._emit(this._activeRoomId); - + if (this._activeRoomId) this._emit(this._activeRoomId, newActiveRoomId); // update our cache - this._activeRoomId = RoomViewStore.getRoomId(); - + this._activeRoomId = newActiveRoomId; // and emit for the new one - if (this._activeRoomId) this._emit(this._activeRoomId); + if (this._activeRoomId) this._emit(this._activeRoomId, this._activeRoomId); } } diff --git a/src/PageTypes.js b/src/PageTypes.js index 60111723fb..e4e1916c8b 100644 --- a/src/PageTypes.js +++ b/src/PageTypes.js @@ -19,6 +19,7 @@ limitations under the License. export default { HomePage: "home_page", RoomView: "room_view", + GroupGridView: "group_grid_view", UserSettings: "user_settings", RoomDirectory: "room_directory", UserView: "user_view", diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 96cccf07fb..2a37295f83 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -129,6 +129,11 @@ function textForRoomNameEvent(ev) { }); } +function textForTombstoneEvent(ev) { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + return _t('%(senderDisplayName)s upgraded this room.', {senderDisplayName}); +} + function textForServerACLEvent(ev) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const prevContent = ev.getPrevContent(); @@ -433,6 +438,7 @@ const stateHandlers = { 'm.room.power_levels': textForPowerEvent, 'm.room.pinned_events': textForPinnedEvent, 'm.room.server_acl': textForServerACLEvent, + 'm.room.tombstone': textForTombstoneEvent, 'im.vector.modular.widgets': textForWidgetEvent, }; diff --git a/src/UserActivity.js b/src/UserActivity.js index 4e3667274c..145b23e36e 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -44,6 +44,7 @@ class UserActivity { * Can be called multiple times with the same already running timer, which is a NO-OP. * Can be called before the user becomes active, in which case it is only started * later on when the user does become active. + * @param {Timer} timer the timer to use */ timeWhileActive(timer) { // important this happens first diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index c593a9b3ea..4fb5df214b 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018, 2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ import { scorePassword } from '../../../../utils/PasswordScorer'; import FileSaver from 'file-saver'; -import { _t, _td } from '../../../../languageHandler'; +import { _t } from '../../../../languageHandler'; const PHASE_PASSPHRASE = 0; const PHASE_PASSPHRASE_CONFIRM = 1; @@ -32,6 +32,7 @@ const PHASE_DONE = 5; const PHASE_OPTOUT_CONFIRM = 6; const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. +const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms. // XXX: copied from ShareDialog: factor out into utils function selectText(target) { @@ -63,6 +64,13 @@ export default React.createClass({ componentWillMount: function() { this._recoveryKeyNode = null; this._keyBackupInfo = null; + this._setZxcvbnResultTimeout = null; + }, + + componentWillUnmount: function() { + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } }, _collectRecoveryKeyNode: function(n) { @@ -102,7 +110,7 @@ export default React.createClass({ info = await MatrixClientPeg.get().createKeyBackupVersion( this._keyBackupInfo, ); - await MatrixClientPeg.get().backupAllGroupSessions(info.version); + await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup(); this.setState({ phase: PHASE_DONE, }); @@ -150,9 +158,23 @@ export default React.createClass({ this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); }, - _onPassPhraseKeyPress: function(e) { - if (e.key === 'Enter' && this._passPhraseIsValid()) { - this._onPassPhraseNextClick(); + _onPassPhraseKeyPress: async function(e) { + if (e.key === 'Enter') { + // If we're waiting for the timeout before updating the result at this point, + // skip ahead and do it now, otherwise we'll deny the attempt to proceed + // even if the user entered a valid passphrase + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + this._setZxcvbnResultTimeout = null; + await new Promise((resolve) => { + this.setState({ + zxcvbnResult: scorePassword(this.state.passPhrase), + }, resolve); + }); + } + if (this._passPhraseIsValid()) { + this._onPassPhraseNextClick(); + } } }, @@ -177,6 +199,7 @@ export default React.createClass({ passPhrase: '', passPhraseConfirm: '', phase: PHASE_PASSPHRASE, + zxcvbnResult: null, }); }, @@ -189,11 +212,20 @@ export default React.createClass({ _onPassPhraseChange: function(e) { this.setState({ passPhrase: e.target.value, - // precompute this and keep it in state: zxcvbn is fast but - // we use it in a couple of different places so no point recomputing - // it unnecessarily. - zxcvbnResult: scorePassword(e.target.value), }); + + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + this._setZxcvbnResultTimeout = setTimeout(() => { + this._setZxcvbnResultTimeout = null; + this.setState({ + // precompute this and keep it in state: zxcvbn is fast but + // we use it in a couple of different places so no point recomputing + // it unnecessarily. + zxcvbnResult: scorePassword(this.state.passPhrase), + }); + }, PASSPHRASE_FEEDBACK_DELAY); }, _onPassPhraseConfirmChange: function(e) { @@ -246,6 +278,7 @@ export default React.createClass({ value={this.state.passPhrase} className="mx_CreateKeyBackupDialog_passPhraseInput" placeholder={_t("Enter a passphrase...")} + autoFocus={true} />
{strengthMeter} @@ -294,14 +327,22 @@ export default React.createClass({ _renderPhasePassPhraseConfirm: function() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + let matchText; + if (this.state.passPhraseConfirm === this.state.passPhrase) { + matchText = _t("That matches!"); + } else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) { + // only tell them they're wrong if they've actually gone wrong. + // Security concious readers will note that if you left riot-web unattended + // on this screen, this would make it easy for a malicious person to guess + // your passphrase one letter at a time, but they could get this faster by + // just opening the browser's developer tools and reading it. + // Note that not having typed anything at all will not hit this clause and + // fall through so empty box === no hint. + matchText = _t("That doesn't match."); + } + let passPhraseMatch = null; - if (this.state.passPhraseConfirm.length > 0) { - let matchText; - if (this.state.passPhraseConfirm === this.state.passPhrase) { - matchText = _t("That matches!"); - } else { - matchText = _t("That doesn't match."); - } + if (matchText) { passPhraseMatch =
{matchText}
@@ -344,7 +385,10 @@ export default React.createClass({ _renderPhaseShowKey: function() { let bodyText; if (this.state.setPassPhrase) { - bodyText = _t("As a safety net, you can use it to restore your encrypted message history if you forget your Recovery Passphrase."); + bodyText = _t( + "As a safety net, you can use it to restore your encrypted message " + + "history if you forget your Recovery Passphrase.", + ); } else { bodyText = _t("As a safety net, you can use it to restore your encrypted message history."); } @@ -352,7 +396,7 @@ export default React.createClass({ return

{_t("Make a copy of this Recovery Key and keep it safe.")}

{bodyText}

-

+

{_t("Your Recovery Key")}
@@ -369,7 +413,7 @@ export default React.createClass({
-

+
; }, @@ -405,7 +449,6 @@ export default React.createClass({ _renderBusyPhase: function(text) { const Spinner = sdk.getComponent('views.elements.Spinner'); return
-

{_t(text)}

; }, @@ -413,8 +456,10 @@ export default React.createClass({ _renderPhaseDone: function() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
-

{_t("Backup created")}

-

{_t("Your encryption keys are now being backed up to your Homeserver.")}

+

{_t( + "Your encryption keys are now being backed up in the background " + + "to your Homeserver. The initial backup could take several minutes. " + + "You can view key backup upload progress in Settings.")}

{content} diff --git a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js index e88e0444bc..db86178b5a 100644 --- a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018-2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,15 +24,21 @@ import Modal from "../../../../Modal"; export default class NewRecoveryMethodDialog extends React.PureComponent { static propTypes = { + // As returned by js-sdk getKeyBackupVersion() + newVersionInfo: PropTypes.object, onFinished: PropTypes.func.isRequired, } + onOkClick = () => { + this.props.onFinished(); + } + onGoToSettingsClick = () => { this.props.onFinished(); dis.dispatch({ action: 'view_user_settings' }); } - onSetupClick = async() => { + onSetupClick = async () => { // TODO: Should change to a restore key backup flow that checks the // recovery passphrase while at the same time also cross-signing the // device as well in a single flow. Since we don't have that yet, we'll @@ -41,8 +47,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { // sending our own new keys to it. let backupSigStatus; try { - const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); - backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo); + backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(this.props.newVersionInfo); } catch (e) { console.log("Unable to fetch key backup status", e); return; @@ -71,39 +76,62 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { render() { const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); - const title = + + const title = {_t("New Recovery Method")} ; + const newMethodDetected =

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

; + + const hackWarning =

{_t( + "If you didn't set the new recovery method, an " + + "attacker may be trying to access your account. " + + "Change your account password and set a new recovery " + + "method immediately in Settings.", + )}

; + + let content; + if (MatrixClientPeg.get().getKeyBackupEnabled()) { + content =
+ {newMethodDetected} +

{_t( + "This device is encrypting history using the new recovery method.", + )}

+ {hackWarning} + +
; + } else { + content =
+ {newMethodDetected} +

{_t( + "Setting up Secure Messages on this device " + + "will re-encrypt this device's message history with " + + "the new recovery method.", + )}

+ {hackWarning} + +
; + } + return ( - -
-

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

-

{_t( - "Setting up Secure Messages on this device " + - "will re-encrypt this device's message history with " + - "the new recovery method.", - )}

-

{_t( - "If you didn't set the new recovery method, an " + - "attacker may be trying to access your account. " + - "Change your account password and set a new recovery " + - "method immediately in Settings.", - )}

- -
+ {content}
); } diff --git a/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js new file mode 100644 index 0000000000..1975fbe6d6 --- /dev/null +++ b/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js @@ -0,0 +1,80 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import PropTypes from "prop-types"; +import sdk from "../../../../index"; +import dis from "../../../../dispatcher"; +import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; + +export default class RecoveryMethodRemovedDialog extends React.PureComponent { + static propTypes = { + onFinished: PropTypes.func.isRequired, + } + + onGoToSettingsClick = () => { + this.props.onFinished(); + dis.dispatch({ action: 'view_user_settings' }); + } + + onSetupClick = () => { + this.props.onFinished(); + Modal.createTrackedDialogAsync("Key Backup", "Key Backup", + import("./CreateKeyBackupDialog"), + ); + } + + render() { + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); + + const title = + {_t("Recovery Method Removed")} + ; + + return ( + +
+

{_t( + "This device has detected that your recovery passphrase and key " + + "for Secure Messages have been removed.", + )}

+

{_t( + "If you did this accidentally, you can setup Secure Messages on " + + "this device which will re-encrypt this device's message " + + "history with a new recovery method.", + )}

+

{_t( + "If you didn't remove the recovery method, an " + + "attacker may be trying to access your account. " + + "Change your account password and set a new recovery " + + "method immediately in Settings.", + )}

+ +
+
+ ); + } +} diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index f9fb61d3a3..98ae83c526 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -41,8 +41,12 @@ export default class AutocompleteProvider { /** * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. + * @param {string} query The query string + * @param {SelectionRange} selection Selection to search + * @param {boolean} force True if the user is forcing completion + * @return {object} { command, range } where both objects fields are null if no match */ - getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false): ?string { + getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false) { let commandRegex = this.commandRegex; if (force && this.shouldForceComplete()) { diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index e7b89fe576..af2744950f 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -60,8 +60,8 @@ const PROVIDER_COMPLETION_TIMEOUT = 3000; export default class Autocompleter { constructor(room: Room) { this.room = room; - this.providers = PROVIDERS.map((p) => { - return new p(room); + this.providers = PROVIDERS.map((Prov) => { + return new Prov(room); }); } diff --git a/src/autocomplete/CommunityProvider.js b/src/autocomplete/CommunityProvider.js index b85c09b320..d164fab46a 100644 --- a/src/autocomplete/CommunityProvider.js +++ b/src/autocomplete/CommunityProvider.js @@ -61,7 +61,7 @@ export default class CommunityProvider extends AutocompleteProvider { if (command) { const joinedGroups = cli.getGroups().filter(({myMembership}) => myMembership === 'join'); - const groups = (await Promise.all(joinedGroups.map(async({groupId}) => { + const groups = (await Promise.all(joinedGroups.map(async ({groupId}) => { try { return FlairStore.getGroupProfileCached(cli, groupId); } catch (e) { // if FlairStore failed, fall back to just groupId diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 2eae053d72..d4a5ec5e74 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -41,7 +41,7 @@ export default class UserProvider extends AutocompleteProvider { users: Array = null; room: Room = null; - constructor(room) { + constructor(room: Room) { super(USER_REGEX, FORCED_USER_REGEX); this.room = room; this.matcher = new QueryMatcher([], { diff --git a/src/components/structures/GroupGridView.js b/src/components/structures/GroupGridView.js new file mode 100644 index 0000000000..a1a9e1b183 --- /dev/null +++ b/src/components/structures/GroupGridView.js @@ -0,0 +1,127 @@ +/* +Copyright 2017 Vector Creations Ltd. +Copyright 2017, 2018 New Vector Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import OpenRoomsStore from '../../stores/OpenRoomsStore'; +import dis from '../../dispatcher'; +import {_t} from '../../languageHandler'; +import RoomView from './RoomView'; +import classNames from 'classnames'; +import MainSplit from './MainSplit'; +import RightPanel from './RightPanel'; +import RoomHeaderButtons from '../views/right_panel/RoomHeaderButtons'; + +export default class RoomGridView extends React.Component { + constructor(props) { + super(props); + this.state = { + roomStores: OpenRoomsStore.getRoomStores(), + activeRoomStore: OpenRoomsStore.getActiveRoomStore(), + }; + this.onRoomsChanged = this.onRoomsChanged.bind(this); + } + + componentDidUpdate(_, prevState) { + const store = this.state.activeRoomStore; + if (store) { + store.getDispatcher().dispatch({action: 'focus_composer'}); + } + } + + componentDidMount() { + this.componentDidUpdate(); + } + + componentWillMount() { + this._unmounted = false; + this._openRoomsStoreRegistration = OpenRoomsStore.addListener(this.onRoomsChanged); + } + + componentWillUnmount() { + this._unmounted = true; + if (this._openRoomsStoreRegistration) { + this._openRoomsStoreRegistration.remove(); + } + } + + onRoomsChanged() { + if (this._unmounted) return; + this.setState({ + roomStores: OpenRoomsStore.getRoomStores(), + activeRoomStore: OpenRoomsStore.getActiveRoomStore(), + }); + } + + _setActive(i) { + const store = OpenRoomsStore.getRoomStoreAt(i); + if (store !== this.state.activeRoomStore) { + dis.dispatch({ + action: 'group_grid_set_active', + room_id: store.getRoomId(), + }); + } + } + + render() { + let roomStores = this.state.roomStores.slice(0, 6); + const emptyCount = 6 - roomStores.length; + if (emptyCount) { + const emptyTiles = Array.from({length: emptyCount}, () => null); + roomStores = roomStores.concat(emptyTiles); + } + const activeRoomId = this.state.activeRoomStore && this.state.activeRoomStore.getRoomId(); + let rightPanel; + if (activeRoomId) { + rightPanel = ( +
+
+ +
+ ); + } + + return (
+ +
+ { roomStores.map((roomStore, i) => { + if (roomStore) { + const isActive = roomStore === this.state.activeRoomStore; + const tileClasses = classNames({ + "mx_GroupGridView_tile": true, + "mx_GroupGridView_activeTile": isActive, + }); + return (
{this._setActive(i);}} + key={roomStore.getRoomId()} + className={tileClasses} + > + +
); + } else { + return (
{_t("No room in this tile yet.")}
); + } + }) } +
+
+
); + } +} diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 937e07d31e..834fcd2340 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -781,7 +781,7 @@ export default React.createClass({ ), button: _t("Leave"), danger: this.state.isUserPrivileged, - onFinished: async(confirmed) => { + onFinished: async (confirmed) => { if (!confirmed) return; this.setState({membershipBusy: true}); diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 0433ce25b3..67d7d41701 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -31,6 +31,7 @@ import sessionStore from '../../stores/SessionStore'; import MatrixClientPeg from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; import RoomListStore from "../../stores/RoomListStore"; +import OpenRoomsStore from "../../stores/OpenRoomsStore"; import TagOrderActions from '../../actions/TagOrderActions'; import RoomListActions from '../../actions/RoomListActions'; @@ -416,6 +417,7 @@ const LoggedInView = React.createClass({ const RoomDirectory = sdk.getComponent('structures.RoomDirectory'); const HomePage = sdk.getComponent('structures.HomePage'); const GroupView = sdk.getComponent('structures.GroupView'); + const GroupGridView = sdk.getComponent('structures.GroupGridView'); const MyGroups = sdk.getComponent('structures.MyGroups'); const MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); const CookieBar = sdk.getComponent('globals.CookieBar'); @@ -428,7 +430,14 @@ const LoggedInView = React.createClass({ switch (this.props.page_type) { case PageTypes.RoomView: + if (!OpenRoomsStore.getActiveRoomStore()) { + console.warn(`LoggedInView: getCurrentRoomStore not set!`); + } + else if (OpenRoomsStore.getActiveRoomStore().getRoomId() !== this.props.currentRoomId) { + console.warn(`LoggedInView: room id in store not the same as in props: ${OpenRoomsStore.getActiveRoomStore().getRoomId()} & ${this.props.currentRoomId}`); + } page_element = ; break; - + case PageTypes.GroupGridView: + page_element = ; + break; case PageTypes.UserSettings: page_element = { - Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method', - import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'), - ); + cli.on("crypto.keyBackupFailed", async (errcode) => { + let haveNewVersion; + let newVersionInfo; + // if key backup is still enabled, there must be a new backup in place + if (MatrixClientPeg.get().getKeyBackupEnabled()) { + haveNewVersion = true; + } else { + // otherwise check the server to see if there's a new one + try { + newVersionInfo = await MatrixClientPeg.get().getKeyBackupVersion(); + if (newVersionInfo !== null) haveNewVersion = true; + } catch (e) { + console.error("Saw key backup error but failed to check backup version!", e); + return; + } + } + + if (haveNewVersion) { + Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method', + import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'), + { newVersionInfo }, + ); + } else { + Modal.createTrackedDialogAsync('Recovery Method Removed', 'Recovery Method Removed', + import('../../async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog'), + ); + } }); // Fire the tinter right on startup to ensure the default theme is applied diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index be40957673..925b90c6d8 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -165,7 +165,7 @@ export default class RightPanel extends React.Component { } else if (this.state.phase === RightPanel.Phase.GroupRoomList) { panel = ; } else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) { - panel = ; + panel = ; } else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) { panel = { - dis.dispatch({action: 'start_registration'}); + this.props.roomViewStore.getDispatcher().dispatch({action: 'start_registration'}); close(); }, onLoginClick: (ev) => { - dis.dispatch({action: 'start_login'}); + this.props.roomViewStore.getDispatcher().dispatch({action: 'start_login'}); close(); }, }).close; @@ -929,7 +921,7 @@ module.exports = React.createClass({ Promise.resolve().then(() => { const signUrl = this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : undefined; - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'join_room', opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, }); @@ -994,10 +986,10 @@ module.exports = React.createClass({ }, uploadFile: async function(file) { - dis.dispatch({action: 'focus_composer'}); + this.props.roomViewStore.getDispatcher().dispatch({action: 'focus_composer'}); if (MatrixClientPeg.get().isGuest()) { - dis.dispatch({action: 'require_registration'}); + this.props.roomViewStore.getDispatcher().dispatch({action: 'require_registration'}); return; } @@ -1021,14 +1013,14 @@ module.exports = React.createClass({ } // Send message_sent callback, for things like _checkIfAlone because after all a file is still a message. - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'message_sent', }); }, injectSticker: function(url, info, text) { if (MatrixClientPeg.get().isGuest()) { - dis.dispatch({action: 'require_registration'}); + this.props.roomViewStore.getDispatcher().dispatch({action: 'require_registration'}); return; } @@ -1229,7 +1221,7 @@ module.exports = React.createClass({ }, onSettingsClick: function() { - dis.dispatch({ action: 'open_room_settings' }); + this.props.roomViewStore.getDispatcher().dispatch({ action: 'open_room_settings' }); }, onSettingsSaveClick: function() { @@ -1262,31 +1254,31 @@ module.exports = React.createClass({ }); // still editing room settings } else { - dis.dispatch({ action: 'close_settings' }); + this.props.roomViewStore.getDispatcher().dispatch({ action: 'close_settings' }); } }).finally(() => { this.setState({ uploadingRoomSettings: false, }); - dis.dispatch({ action: 'close_settings' }); + this.props.roomViewStore.getDispatcher().dispatch({ action: 'close_settings' }); }).done(); }, onCancelClick: function() { console.log("updateTint from onCancelClick"); this.updateTint(); - dis.dispatch({ action: 'close_settings' }); + this.props.roomViewStore.getDispatcher().dispatch({ action: 'close_settings' }); if (this.state.forwardingEvent) { - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'forward_event', event: null, }); } - dis.dispatch({action: 'focus_composer'}); + this.props.roomViewStore.getDispatcher().dispatch({action: 'focus_composer'}); }, onLeaveClick: function() { - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'leave_room', room_id: this.state.room.roomId, }); @@ -1294,7 +1286,7 @@ module.exports = React.createClass({ onForgetClick: function() { MatrixClientPeg.get().forget(this.state.room.roomId).done(function() { - dis.dispatch({ action: 'view_next_room' }); + this.props.roomViewStore.getDispatcher().dispatch({ action: 'view_next_room' }); }, function(err) { const errCode = err.errcode || _t("unknown error code"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -1311,7 +1303,7 @@ module.exports = React.createClass({ rejecting: true, }); MatrixClientPeg.get().leave(this.state.roomId).done(function() { - dis.dispatch({ action: 'view_next_room' }); + this.props.roomViewStore.getDispatcher().dispatch({ action: 'view_next_room' }); self.setState({ rejecting: false, }); @@ -1337,7 +1329,7 @@ module.exports = React.createClass({ // using /leave rather than /join. In the short term though, we // just ignore them. // https://github.com/vector-im/vector-web/issues/1134 - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'view_room_directory', }); }, @@ -1356,7 +1348,7 @@ module.exports = React.createClass({ // jump down to the bottom of this room, where new events are arriving jumpToLiveTimeline: function() { this.refs.messagePanel.jumpToLiveTimeline(); - dis.dispatch({action: 'focus_composer'}); + this.props.roomViewStore.getDispatcher().dispatch({action: 'focus_composer'}); }, // jump up to wherever our read marker is @@ -1446,7 +1438,7 @@ module.exports = React.createClass({ }, onFullscreenClick: function() { - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'video_fullscreen', fullscreen: true, }, true); @@ -1571,6 +1563,7 @@ module.exports = React.createClass({
@@ -1617,6 +1610,7 @@ module.exports = React.createClass({
@@ -1704,7 +1698,7 @@ module.exports = React.createClass({ aux = ; hideCancel = true; } else if (showRoomRecoveryReminder) { - aux = ; + aux = ; hideCancel = true; } else if (this.state.showingPinned) { hideCancel = true; // has own cancel @@ -1758,7 +1752,9 @@ module.exports = React.createClass({ if (canSpeak) { messageComposer = : undefined; + const rightPanel = this.state.room && !this.props.isGrid ? + : + undefined; return (
{ // TODO: this ought to be a separate component so that we don't need // to rebind the onChange each time we render - const onChange = async(e) => { + const onChange = async (e) => { const checked = e.target.checked; if (featureId === "feature_lazyloading") { const confirmed = await this._onLazyLoadChanging(checked); diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 2dd611843a..e30acca16d 100644 --- a/src/components/views/context_menus/GroupInviteTileContextMenu.js +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -48,7 +48,7 @@ export default class GroupInviteTileContextMenu extends React.Component { Modal.createTrackedDialog('Reject community invite', '', QuestionDialog, { title: _t('Reject invitation'), description: _t('Are you sure you want to reject the invitation?'), - onFinished: async(shouldLeave) => { + onFinished: async (shouldLeave) => { if (!shouldLeave) return; // FIXME: controller shouldn't be loading a view :( diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js index f07220db44..d062fc2a3e 100644 --- a/src/components/views/context_menus/StatusMessageContextMenu.js +++ b/src/components/views/context_menus/StatusMessageContextMenu.js @@ -35,7 +35,7 @@ export default class StatusMessageContextMenu extends React.Component { }; } - _onClearClick = async(e) => { + _onClearClick = async (e) => { await MatrixClientPeg.get()._unstable_setStatusMessage(""); this.setState({message: ""}); }; diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js index 32f5365b82..8ce09bdb2c 100644 --- a/src/components/views/context_menus/TagTileContextMenu.js +++ b/src/components/views/context_menus/TagTileContextMenu.js @@ -21,6 +21,7 @@ import dis from '../../../dispatcher'; import TagOrderActions from '../../../actions/TagOrderActions'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; +import SettingsStore from "../../../settings/SettingsStore"; export default class TagTileContextMenu extends React.Component { static propTypes = { @@ -34,6 +35,7 @@ export default class TagTileContextMenu extends React.Component { this._onViewCommunityClick = this._onViewCommunityClick.bind(this); this._onRemoveClick = this._onRemoveClick.bind(this); + this._onViewAsGridClick = this._onViewAsGridClick.bind(this); } _onViewCommunityClick() { @@ -53,8 +55,28 @@ export default class TagTileContextMenu extends React.Component { this.props.onFinished(); } + _onViewAsGridClick() { + dis.dispatch({ + action: 'group_grid_view', + group_id: this.props.tag, + }); + this.props.onFinished(); + } + render() { const TintableSvg = sdk.getComponent("elements.TintableSvg"); + let gridViewOption; + if (SettingsStore.isFeatureEnabled("feature_gridview")) { + gridViewOption = (
+ + { _t('View as Grid') } +
); + } return
{ _t('View Community') }
+ { gridViewOption }
diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index cbe80763a6..6276a45839 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -389,6 +389,17 @@ module.exports = React.createClass({ const suggestedList = []; results.forEach((result) => { if (result.room_id) { + const client = MatrixClientPeg.get(); + const room = client.getRoom(result.room_id); + if (room) { + const tombstone = room.currentState.getStateEvents('m.room.tombstone', ''); + if (tombstone && tombstone.getContent() && tombstone.getContent()["replacement_room"]) { + const replacementRoom = client.getRoom(tombstone.getContent()["replacement_room"]); + + // Skip rooms with tombstones where we are also aware of the replacement room. + if (replacementRoom) return; + } + } suggestedList.push({ addressType: 'mx-room-id', address: result.room_id, diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.js new file mode 100644 index 0000000000..5c61c3a694 --- /dev/null +++ b/src/components/views/dialogs/AskInviteAnywayDialog.js @@ -0,0 +1,81 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import {SettingLevel} from "../../../settings/SettingsStore"; +import SettingsStore from "../../../settings/SettingsStore"; + +export default React.createClass({ + propTypes: { + unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ] + onInviteAnyways: PropTypes.func.isRequired, + onGiveUp: PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, + }, + + _onInviteClicked: function() { + this.props.onInviteAnyways(); + this.props.onFinished(true); + }, + + _onInviteNeverWarnClicked: function() { + SettingsStore.setValue("alwaysInviteUnknownUsers", null, SettingLevel.ACCOUNT, true); + this.props.onInviteAnyways(); + this.props.onFinished(true); + }, + + _onGiveUpClicked: function() { + this.props.onGiveUp(); + this.props.onFinished(false); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + const errorList = this.props.unknownProfileUsers + .map(address =>
  • {address.userId}: {address.errorText}
  • ); + + return ( + +
    +

    {_t("The following users may not exist - would you like to invite them anyways?")}

    +
      + { errorList } +
    +
    + +
    + + + +
    +
    + ); + }, +}); diff --git a/src/components/views/right_panel/HeaderButtons.js b/src/components/views/right_panel/HeaderButtons.js index f0479eb8be..3f5f58121d 100644 --- a/src/components/views/right_panel/HeaderButtons.js +++ b/src/components/views/right_panel/HeaderButtons.js @@ -78,7 +78,6 @@ export default class HeaderButtons extends React.Component { // till show_right_panel, just without the fromHeader flag // as that would hide the right panel again dis.dispatch(Object.assign({}, payload, {fromHeader: false})); - } this.setState({ phase: payload.phase, diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index 64c0478d41..5370b4d8b5 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -24,6 +24,8 @@ import ObjectUtils from '../../../ObjectUtils'; import AppsDrawer from './AppsDrawer'; import { _t } from '../../../languageHandler'; import classNames from 'classnames'; +import RateLimitedFunc from '../../../ratelimitedfunc'; +import SettingsStore from "../../../settings/SettingsStore"; module.exports = React.createClass({ @@ -60,6 +62,22 @@ module.exports = React.createClass({ hideAppsDrawer: false, }, + getInitialState: function() { + return { counters: this._computeCounters() }; + }, + + componentDidMount: function() { + const cli = MatrixClientPeg.get(); + cli.on("RoomState.events", this._rateLimitedUpdate); + }, + + componentWillUnmount: function() { + const cli = MatrixClientPeg.get(); + if (cli) { + cli.removeListener("RoomState.events", this._rateLimitedUpdate); + } + }, + shouldComponentUpdate: function(nextProps, nextState) { return (!ObjectUtils.shallowEqual(this.props, nextProps) || !ObjectUtils.shallowEqual(this.state, nextState)); @@ -82,6 +100,43 @@ module.exports = React.createClass({ ev.preventDefault(); }, + _rateLimitedUpdate: new RateLimitedFunc(function() { + if (SettingsStore.isFeatureEnabled("feature_state_counters")) { + this.setState({counters: this._computeCounters()}); + } + }, 500), + + _computeCounters: function() { + let counters = []; + + if (this.props.room && SettingsStore.isFeatureEnabled("feature_state_counters")) { + const stateEvs = this.props.room.currentState.getStateEvents('re.jki.counter'); + stateEvs.sort((a, b) => { + return a.getStateKey() < b.getStateKey(); + }); + + stateEvs.forEach((ev, idx) => { + const title = ev.getContent().title; + const value = ev.getContent().value; + const link = ev.getContent().link; + const severity = ev.getContent().severity || "normal"; + const stateKey = ev.getStateKey(); + + if (title && value && severity) { + counters.push({ + "title": title, + "value": value, + "link": link, + "severity": severity, + "stateKey": stateKey + }) + } + }); + } + + return counters; + }, + render: function() { const CallView = sdk.getComponent("voip.CallView"); const TintableSvg = sdk.getComponent("elements.TintableSvg"); @@ -145,6 +200,58 @@ module.exports = React.createClass({ hide={this.props.hideAppsDrawer} />; + let stateViews = null; + if (this.state.counters && SettingsStore.isFeatureEnabled("feature_state_counters")) { + let counters = []; + + this.state.counters.forEach((counter, idx) => { + const title = counter.title; + const value = counter.value; + const link = counter.link; + const severity = counter.severity; + const stateKey = counter.stateKey; + + if (title && value && severity) { + let span = { title }: { value } + + if (link) { + span = ( + + { span } + + ); + } + + span = ( + + {span} + + ); + + counters.push(span); + counters.push( + ─ + ); + } + }); + + if (counters.length > 0) { + counters.pop(); // remove last deliminator + stateViews = ( +
    + { counters } +
    + ); + } + } + const classes = classNames({ "mx_RoomView_auxPanel": true, "mx_RoomView_auxPanel_fullHeight": this.props.fullHeight, @@ -156,6 +263,7 @@ module.exports = React.createClass({ return (
    + { stateViews } { appsDrawer } { fileDropTarget } { callView } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index e978bf438a..692111361a 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -62,6 +62,7 @@ const stateEventTileTypes = { 'm.room.pinned_events': 'messages.TextualEvent', 'm.room.server_acl': 'messages.TextualEvent', 'im.vector.modular.widgets': 'messages.TextualEvent', + 'm.room.tombstone': 'messages.TextualEvent', }; function getHandlerTile(ev) { diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 12dc2117a0..2b50ff5e48 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -39,7 +39,6 @@ import Unread from '../../../Unread'; import { findReadReceiptFromUserId } from '../../../utils/Receipt'; import withMatrixClient from '../../../wrappers/withMatrixClient'; import AccessibleButton from '../elements/AccessibleButton'; -import RoomViewStore from '../../../stores/RoomViewStore'; import SdkConfig from '../../../SdkConfig'; import MultiInviter from "../../../utils/MultiInviter"; import SettingsStore from "../../../settings/SettingsStore"; @@ -50,6 +49,7 @@ module.exports = withMatrixClient(React.createClass({ propTypes: { matrixClient: PropTypes.object.isRequired, member: PropTypes.object.isRequired, + roomId: PropTypes.string, }, getInitialState: function() { @@ -713,8 +713,8 @@ module.exports = withMatrixClient(React.createClass({ } if (!member || !member.membership || member.membership === 'leave') { - const roomId = member && member.roomId ? member.roomId : RoomViewStore.getRoomId(); - const onInviteUserButton = async() => { + const roomId = member && member.roomId ? member.roomId : this.props.roomId; + const onInviteUserButton = async () => { try { // We use a MultiInviter to re-use the invite logic, even though // we're only inviting one user. diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index d4b607a93a..c648ca615b 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -22,7 +22,6 @@ import MatrixClientPeg from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import sdk from '../../../index'; import dis from '../../../dispatcher'; -import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import Stickerpicker from './Stickerpicker'; import { makeRoomPermalink } from '../../../matrix-to'; @@ -63,7 +62,7 @@ export default class MessageComposer extends React.Component { isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'), }, showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'), - isQuoting: Boolean(RoomViewStore.getQuotingEvent()), + isQuoting: Boolean(this.props.roomViewStore.getQuotingEvent()), tombstone: this._getRoomTombstone(), }; } @@ -75,7 +74,7 @@ export default class MessageComposer extends React.Component { // XXX: fragile as all hell - fixme somehow, perhaps with a dedicated Room.encryption event or something. MatrixClientPeg.get().on("event", this.onEvent); MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); - this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); + this._roomStoreToken = this.props.roomViewStore.addListener(this._onRoomViewStoreUpdate); this._waitForOwnMember(); } @@ -124,14 +123,14 @@ export default class MessageComposer extends React.Component { } _onRoomViewStoreUpdate() { - const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); + const isQuoting = Boolean(this.props.roomViewStore.getQuotingEvent()); if (this.state.isQuoting === isQuoting) return; this.setState({ isQuoting }); } onUploadClick(ev) { if (MatrixClientPeg.get().isGuest()) { - dis.dispatch({action: 'require_registration'}); + this.props.roomViewStore.getDispatcher().dispatch({action: 'require_registration'}); return; } @@ -165,7 +164,7 @@ export default class MessageComposer extends React.Component { } } - const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); + const isQuoting = Boolean(this.props.roomViewStore.getQuotingEvent()); let replyToWarning = null; if (isQuoting) { replyToWarning =

    { @@ -229,7 +228,7 @@ export default class MessageComposer extends React.Component { if (!call) { return; } - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'hangup', // hangup the call for this room, which may not be the room in props // (e.g. conferences which will hangup the 1:1 room instead) @@ -238,7 +237,7 @@ export default class MessageComposer extends React.Component { } onCallClick(ev) { - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'place_call', type: ev.shiftKey ? "screensharing" : "video", room_id: this.props.room.roomId, @@ -246,7 +245,7 @@ export default class MessageComposer extends React.Component { } onVoiceCallClick(ev) { - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'place_call', type: "voice", room_id: this.props.room.roomId, @@ -282,10 +281,22 @@ export default class MessageComposer extends React.Component { ev.preventDefault(); const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; - dis.dispatch({ + const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId); + let createEventId = null; + if (replacementRoom) { + const createEvent = replacementRoom.currentState.getStateEvents('m.room.create', ''); + if (createEvent && createEvent.getId()) createEventId = createEvent.getId(); + } + + this.props.roomViewStore.getDispatcher().dispatch({ action: 'view_room', highlighted: true, + event_id: createEventId, room_id: replacementRoomId, + + // Try to join via the server that sent the event. This converts $something:example.org + // into a server domain by splitting on colons and ignoring the first entry ("$something"). + via_servers: [this.state.tombstone.getId().split(':').splice(1).join(':')], }); } @@ -421,8 +432,10 @@ export default class MessageComposer extends React.Component { controls.push( this.messageComposerInput = c} key="controls_input" + isGrid={this.props.isGrid} onResize={this.props.onResize} room={this.props.room} placeholder={placeholderText} @@ -529,5 +542,6 @@ MessageComposer.propTypes = { uploadAllowed: PropTypes.func.isRequired, // string representing the current room app drawer state - showApps: PropTypes.bool + showApps: PropTypes.bool, + roomViewStore: PropTypes.object.isRequired, }; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 14d394ab41..4c800ad8d2 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -15,13 +15,11 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; -import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent'; import { Editor } from 'slate-react'; import { getEventTransfer } from 'slate-react'; -import { Value, Document, Block, Inline, Text, Range, Node } from 'slate'; +import { Value, Block, Inline, Range } from 'slate'; import type { Change } from 'slate'; import Html from 'slate-html-serializer'; @@ -30,7 +28,6 @@ import Plain from 'slate-plain-serializer'; import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer"; import classNames from 'classnames'; -import Promise from 'bluebird'; import MatrixClientPeg from '../../../MatrixClientPeg'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; @@ -38,11 +35,9 @@ import {processCommandInput} from '../../../SlashCommands'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard'; import Modal from '../../../Modal'; import sdk from '../../../index'; -import { _t, _td } from '../../../languageHandler'; +import { _t } from '../../../languageHandler'; import Analytics from '../../../Analytics'; -import dis from '../../../dispatcher'; - import * as RichText from '../../../RichText'; import * as HtmlUtils from '../../../HtmlUtils'; import Autocomplete from './Autocomplete'; @@ -51,28 +46,24 @@ import Markdown from '../../../Markdown'; import ComposerHistoryManager from '../../../ComposerHistoryManager'; import MessageComposerStore from '../../../stores/MessageComposerStore'; -import {MATRIXTO_MD_LINK_PATTERN, MATRIXTO_URL_PATTERN} from '../../../linkify-matrix'; -const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g'); +import {MATRIXTO_URL_PATTERN} from '../../../linkify-matrix'; -import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione'; +import { + asciiRegexp, unicodeRegexp, shortnameToUnicode, + asciiList, mapUnicodeToShort, toShort, +} from 'emojione'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import {makeUserPermalink} from "../../../matrix-to"; import ReplyPreview from "./ReplyPreview"; -import RoomViewStore from '../../../stores/RoomViewStore'; import ReplyThread from "../elements/ReplyThread"; import {ContentHelpers} from 'matrix-js-sdk'; -const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort(); const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$'); const EMOJI_REGEX = new RegExp(unicodeRegexp, 'g'); const TYPING_USER_TIMEOUT = 10000; const TYPING_SERVER_TIMEOUT = 30000; -const ENTITY_TYPES = { - AT_ROOM_PILL: 'ATROOMPILL', -}; - // the Slate node type to default to for unstyled text const DEFAULT_NODE = 'paragraph'; @@ -117,15 +108,6 @@ const SLATE_SCHEMA = { }, }; -function onSendMessageFailed(err, room) { - // XXX: temporary logging to try to diagnose - // https://github.com/vector-im/riot-web/issues/3148 - console.log('MessageComposer got send failure: ' + err.name + '('+err+')'); - dis.dispatch({ - action: 'message_send_failed', - }); -} - function rangeEquals(a: Range, b: Range): boolean { return (a.anchor.key === b.anchor.key && a.anchor.offset === b.anchorOffset @@ -135,6 +117,18 @@ function rangeEquals(a: Range, b: Range): boolean { && a.isBackward === b.isBackward); } +class NoopHistoryManager { + getItem() {} + save() {} + + get currentIndex() { return 0; } + set currentIndex(_) {} + + get history() { return []; } + set history(_) {} +} + + /* * The textInput part of the MessageComposer */ @@ -150,6 +144,7 @@ export default class MessageComposerInput extends React.Component { onFilesPasted: PropTypes.func, onInputStateChanged: PropTypes.func, + roomViewStore: PropTypes.object.isRequired, }; client: MatrixClient; @@ -344,20 +339,32 @@ export default class MessageComposerInput extends React.Component { } componentWillMount() { - this.dispatcherRef = dis.register(this.onAction); - this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); + this.dispatcherRef = this.props.roomViewStore.getDispatcher().register(this.onAction); + if (this.props.isGrid) { + this.historyManager = new NoopHistoryManager(); + } else { + this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); + } } componentWillUnmount() { - dis.unregister(this.dispatcherRef); + this.props.roomViewStore.getDispatcher().unregister(this.dispatcherRef); } _collectEditor = (e) => { this._editor = e; } + onSendMessageFailed = (err, room) => { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('MessageComposer got send failure: ' + err.name + '('+err+')'); + this.props.roomViewStore.getDispatcher().dispatch({ + action: 'message_send_failed', + }); + } + onAction = (payload) => { - const editor = this._editor; const editorState = this.state.editorState; switch (payload.action) { @@ -854,7 +861,7 @@ export default class MessageComposerInput extends React.Component { return true; } - const newState: ?Value = null; + //const newState: ?Value = null; // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. if (this.state.isRichTextEnabled) { @@ -1105,7 +1112,9 @@ export default class MessageComposerInput extends React.Component { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Server error', '', ErrorDialog, { title: _t("Server error"), - description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")), + description: ((err && err.message) ? err.message : _t( + "Server unavailable, overloaded, or something else went wrong.", + )), }); }); } else if (cmd.error) { @@ -1120,7 +1129,7 @@ export default class MessageComposerInput extends React.Component { return true; } - const replyingToEv = RoomViewStore.getQuotingEvent(); + const replyingToEv = this.props.roomViewStore.getQuotingEvent(); const mustSendHTML = Boolean(replyingToEv); if (this.state.isRichTextEnabled) { @@ -1208,18 +1217,18 @@ export default class MessageComposerInput extends React.Component { // Clear reply_to_event as we put the message into the queue // if the send fails, retry will handle resending. - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'reply_to_event', event: null, }); } this.client.sendMessage(this.props.room.roomId, content).then((res) => { - dis.dispatch({ + this.props.roomViewStore.getDispatcher().dispatch({ action: 'message_sent', }); }).catch((e) => { - onSendMessageFailed(e, this.props.room); + this.onSendMessageFailed(e, this.props.room); }); this.setState({ @@ -1260,7 +1269,7 @@ export default class MessageComposerInput extends React.Component { } }; - selectHistory = async(up) => { + selectHistory = async (up) => { const delta = up ? -1 : 1; // True if we are not currently selecting history, but composing a message @@ -1308,7 +1317,7 @@ export default class MessageComposerInput extends React.Component { return true; }; - onTab = async(e) => { + onTab = async (e) => { this.setState({ someCompletions: null, }); @@ -1330,7 +1339,7 @@ export default class MessageComposerInput extends React.Component { up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow(); }; - onEscape = async(e) => { + onEscape = async (e) => { e.preventDefault(); if (this.autocomplete) { this.autocomplete.onEscape(e); @@ -1349,7 +1358,7 @@ export default class MessageComposerInput extends React.Component { /* If passed null, restores the original editor content from state.originalEditorState. * If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState. */ - setDisplayedCompletion = async(displayedCompletion: ?Completion): boolean => { + setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => { const activeEditorState = this.state.originalEditorState || this.state.editorState; if (displayedCompletion == null) { @@ -1484,7 +1493,9 @@ export default class MessageComposerInput extends React.Component { }); const style = {}; if (props.selected) style.border = '1px solid blue'; - return {; + return {; } } }; @@ -1538,7 +1549,6 @@ export default class MessageComposerInput extends React.Component { getSelectionRange(editorState: Value) { let beginning = false; - const query = this.getAutocompleteQuery(editorState); const firstChild = editorState.document.nodes.get(0); const firstGrandChild = firstChild && firstChild.nodes.get(0); beginning = (firstChild && firstGrandChild && @@ -1589,7 +1599,7 @@ export default class MessageComposerInput extends React.Component { return (

    - + this.autocomplete = e} room={this.props.room} diff --git a/src/components/views/rooms/ReplyPreview.js b/src/components/views/rooms/ReplyPreview.js index 46e2826634..04ff9d0778 100644 --- a/src/components/views/rooms/ReplyPreview.js +++ b/src/components/views/rooms/ReplyPreview.js @@ -18,7 +18,6 @@ import React from 'react'; import dis from '../../../dispatcher'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore from "../../../settings/SettingsStore"; function cancelQuoting() { @@ -38,7 +37,7 @@ export default class ReplyPreview extends React.Component { this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); - this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); + this._roomStoreToken = this.props.roomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(); } @@ -50,7 +49,7 @@ export default class ReplyPreview extends React.Component { } _onRoomViewStoreUpdate() { - const event = RoomViewStore.getQuotingEvent(); + const event = this.props.roomViewStore.getQuotingEvent(); if (this.state.event !== event) { this.setState({ event }); } diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 4292fa6a4d..91ca73dd59 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -24,6 +24,7 @@ import { _t } from '../../../languageHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; import Modal from "../../../Modal"; import RateLimitedFunc from '../../../ratelimitedfunc'; +import dis from '../../../dispatcher'; import * as linkify from 'linkifyjs'; import linkifyElement from 'linkifyjs/element'; @@ -152,6 +153,14 @@ module.exports = React.createClass({ }); }, + onToggleRightPanelClick: function(ev) { + if (this.props.collapsedRhs) { + dis.dispatch({action: "show_right_panel"}); + } else { + dis.dispatch({action: "hide_right_panel"}); + } + }, + _hasUnreadPins: function() { const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", ''); if (!currentPinEvent) return false; @@ -409,6 +418,17 @@ module.exports = React.createClass({
    ; } + let toggleRightPanelButton; + if (this.props.isGrid) { + toggleRightPanelButton = + + + ; + } + return (
    @@ -419,7 +439,8 @@ module.exports = React.createClass({ { saveButton } { cancelButton } { rightRow } - + { !this.props.isGrid ? : undefined } + { toggleRightPanelButton }
    ); diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index cb3cb49cd4..1a714e3a84 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -102,6 +102,7 @@ module.exports = React.createClass({ cli.on("Event.decrypted", this.onEventDecrypted); cli.on("accountData", this.onAccountData); cli.on("Group.myMembership", this._onGroupMyMembership); + cli.on("RoomState.events", this.onRoomStateEvents); const dmRoomMap = DMRoomMap.shared(); // A map between tags which are group IDs and the room IDs of rooms that should be kept @@ -230,6 +231,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().removeListener("Event.decrypted", this.onEventDecrypted); MatrixClientPeg.get().removeListener("accountData", this.onAccountData); MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership); + MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); } if (this._tagStoreToken) { @@ -253,6 +255,12 @@ module.exports = React.createClass({ this.updateVisibleRooms(); }, + onRoomStateEvents: function(ev, state) { + if (ev.getType() === "m.room.create" || ev.getType() === "m.room.tombstone") { + this.updateVisibleRooms(); + } + }, + onDeleteRoom: function(roomId) { this.updateVisibleRooms(); }, diff --git a/src/components/views/rooms/RoomRecoveryReminder.js b/src/components/views/rooms/RoomRecoveryReminder.js index d03c5fc96d..01447012e6 100644 --- a/src/components/views/rooms/RoomRecoveryReminder.js +++ b/src/components/views/rooms/RoomRecoveryReminder.js @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018, 2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,10 +20,16 @@ import sdk from "../../../index"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import MatrixClientPeg from "../../../MatrixClientPeg"; +import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; export default class RoomRecoveryReminder extends React.PureComponent { static propTypes = { - onFinished: PropTypes.func.isRequired, + // called if the user sets the option to suppress this reminder in the future + onDontAskAgainSet: PropTypes.func, + } + + static defaultProps = { + onDontAskAgainSet: function() {}, } constructor(props) { @@ -82,7 +88,6 @@ export default class RoomRecoveryReminder extends React.PureComponent { Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, { userId: MatrixClientPeg.get().credentials.userId, device: this.state.unverifiedDevice, - onFinished: this.props.onFinished, }); return; } @@ -91,9 +96,6 @@ export default class RoomRecoveryReminder extends React.PureComponent { // we'll show the create key backup flow. Modal.createTrackedDialogAsync("Key Backup", "Key Backup", import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"), - { - onFinished: this.props.onFinished, - }, ); } @@ -103,10 +105,14 @@ export default class RoomRecoveryReminder extends React.PureComponent { Modal.createTrackedDialogAsync("Ignore Recovery Reminder", "Ignore Recovery Reminder", import("../../../async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog"), { - onDontAskAgain: () => { - // Report false to the caller, who should prevent the - // reminder from appearing in the future. - this.props.onFinished(false); + onDontAskAgain: async () => { + await SettingsStore.setValue( + "showRoomRecoveryReminder", + null, + SettingLevel.ACCOUNT, + false, + ); + this.props.onDontAskAgainSet(); }, onSetup: () => { this.showSetupDialog(); diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index bce4d15f16..95073b7be8 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -29,7 +29,6 @@ import * as RoomNotifs from '../../../RoomNotifs'; import * as FormattingUtils from '../../../utils/FormattingUtils'; import AccessibleButton from '../elements/AccessibleButton'; import ActiveRoomObserver from '../../../ActiveRoomObserver'; -import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore from "../../../settings/SettingsStore"; module.exports = React.createClass({ @@ -62,7 +61,7 @@ module.exports = React.createClass({ roomName: this.props.room.name, notifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId), notificationCount: this.props.room.getUnreadNotificationCount(), - selected: this.props.room.roomId === RoomViewStore.getRoomId(), + selected: this.props.room.roomId === ActiveRoomObserver.getActiveRoomId(), }); }, @@ -117,9 +116,9 @@ module.exports = React.createClass({ } }, - _onActiveRoomChange: function() { + _onActiveRoomChange: function(activeRoomId) { this.setState({ - selected: this.props.room.roomId === RoomViewStore.getRoomId(), + selected: this.props.room.roomId === activeRoomId, }); }, diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js index 03b98d28a0..b1c94c3067 100644 --- a/src/components/views/settings/KeyBackupPanel.js +++ b/src/components/views/settings/KeyBackupPanel.js @@ -21,13 +21,15 @@ import MatrixClientPeg from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -export default class KeyBackupPanel extends React.Component { +export default class KeyBackupPanel extends React.PureComponent { constructor(props) { super(props); this._startNewBackup = this._startNewBackup.bind(this); this._deleteBackup = this._deleteBackup.bind(this); this._verifyDevice = this._verifyDevice.bind(this); + this._onKeyBackupSessionsRemaining = + this._onKeyBackupSessionsRemaining.bind(this); this._onKeyBackupStatus = this._onKeyBackupStatus.bind(this); this._restoreBackup = this._restoreBackup.bind(this); @@ -36,6 +38,7 @@ export default class KeyBackupPanel extends React.Component { loading: true, error: null, backupInfo: null, + sessionsRemaining: 0, }; } @@ -43,6 +46,10 @@ export default class KeyBackupPanel extends React.Component { this._loadBackupStatus(); MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatus); + MatrixClientPeg.get().on( + 'crypto.keyBackupSessionsRemaining', + this._onKeyBackupSessionsRemaining, + ); } componentWillUnmount() { @@ -50,9 +57,19 @@ export default class KeyBackupPanel extends React.Component { if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatus); + MatrixClientPeg.get().removeListener( + 'crypto.keyBackupSessionsRemaining', + this._onKeyBackupSessionsRemaining, + ); } } + _onKeyBackupSessionsRemaining(sessionsRemaining) { + this.setState({ + sessionsRemaining, + }); + } + _onKeyBackupStatus() { this._loadBackupStatus(); } @@ -144,57 +161,70 @@ export default class KeyBackupPanel extends React.Component { } else if (this.state.backupInfo) { let clientBackupStatus; if (MatrixClientPeg.get().getKeyBackupEnabled()) { - clientBackupStatus = _t("This device is uploading keys to this backup"); + clientBackupStatus = _t("This device is using key backup"); } else { // XXX: display why and how to fix it clientBackupStatus = _t( - "This device is not uploading keys to this backup", {}, + "This device is not using key backup", {}, {b: x => {x}}, ); } + let uploadStatus; + const { sessionsRemaining } = this.state; + if (!MatrixClientPeg.get().getKeyBackupEnabled()) { + // No upload status to show when backup disabled. + uploadStatus = ""; + } else if (sessionsRemaining > 0) { + uploadStatus =
    + {_t("Backing up %(sessionsRemaining)s keys...", { sessionsRemaining })}
    +
    ; + } else { + uploadStatus =
    + {_t("All keys backed up")}
    +
    ; + } + let backupSigStatuses = this.state.backupSigStatus.sigs.map((sig, i) => { const deviceName = sig.device.getDisplayName() || sig.device.deviceId; - const sigStatusSubstitutions = { - validity: sub => - - {sub} - , - verify: sub => - - {sub} - , - device: sub => {deviceName}, - }; + const validity = sub => + + {sub} + ; + const verify = sub => + + {sub} + ; + const device = sub => {deviceName}; let sigStatus; if (sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()) { sigStatus = _t( "Backup has a valid signature from this device", - {}, sigStatusSubstitutions, + {}, { validity }, ); } else if (sig.valid && sig.device.isVerified()) { sigStatus = _t( "Backup has a valid signature from " + "verified device ", - {}, sigStatusSubstitutions, + {}, { validity, verify, device }, ); } else if (sig.valid && !sig.device.isVerified()) { sigStatus = _t( "Backup has a valid signature from " + "unverified device ", - {}, sigStatusSubstitutions, + {}, { validity, verify, device }, ); } else if (!sig.valid && sig.device.isVerified()) { sigStatus = _t( "Backup has an invalid signature from " + "verified device ", - {}, sigStatusSubstitutions, + {}, { validity, verify, device }, ); } else if (!sig.valid && !sig.device.isVerified()) { sigStatus = _t( "Backup has an invalid signature from " + "unverified device ", - {}, sigStatusSubstitutions, + {}, { validity, verify, device }, ); } @@ -219,6 +249,7 @@ export default class KeyBackupPanel extends React.Component { {_t("Backup version: ")}{this.state.backupInfo.version}
    {_t("Algorithm: ")}{this.state.backupInfo.algorithm}
    {clientBackupStatus}
    + {uploadStatus}
    {backupSigStatuses}


    {...}. - * @param {boolean=} sync Optional. Pass true to dispatch - * synchronously. This is useful for anything triggering - * an operation that the browser requires user interaction - * for. - */ - dispatch(payload, sync) { - // Allow for asynchronous dispatching by accepting payloads that have the - // type `function (dispatch) {...}` - if (typeof payload === 'function') { - payload((action) => { - this.dispatch(action, sync); - }); - return; - } - - if (sync) { - super.dispatch(payload); - } else { - // Unless the caller explicitly asked for us to dispatch synchronously, - // we always set a timeout to do this: The flux dispatcher complains - // if you dispatch from within a dispatch, so rather than action - // handlers having to worry about not calling anything that might - // then dispatch, we just do dispatches asynchronously. - setTimeout(super.dispatch.bind(this, payload), 0); - } - } -} +import MatrixDispatcher from "./matrix-dispatcher"; if (global.mxDispatcher === undefined) { global.mxDispatcher = new MatrixDispatcher(); } + module.exports = global.mxDispatcher; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ef1b2e9162..0086ed0378 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -183,6 +183,7 @@ "%(senderDisplayName)s changed the topic to \"%(topic)s\".": "%(senderDisplayName)s changed the topic to \"%(topic)s\".", "%(senderDisplayName)s removed the room name.": "%(senderDisplayName)s removed the room name.", "%(senderDisplayName)s changed the room name to %(roomName)s.": "%(senderDisplayName)s changed the room name to %(roomName)s.", + "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.", "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", "%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|other": "%(senderName)s added %(addedAddresses)s as addresses for this room.", "%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.|one": "%(senderName)s added %(addedAddresses)s as an address for this room.", @@ -223,8 +224,10 @@ "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", "Not a valid Riot keyfile": "Not a valid Riot keyfile", "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", + "Unrecognised address": "Unrecognised address", "You do not have permission to invite people to this room.": "You do not have permission to invite people to this room.", "User %(user_id)s does not exist": "User %(user_id)s does not exist", + "User %(user_id)s may or may not exist": "User %(user_id)s may or may not exist", "Unknown server error": "Unknown server error", "Use a few words, avoid common phrases": "Use a few words, avoid common phrases", "No need for symbols, digits, or uppercase letters": "No need for symbols, digits, or uppercase letters", @@ -261,6 +264,8 @@ "Custom user status messages": "Custom user status messages", "Increase performance by only loading room members on first view": "Increase performance by only loading room members on first view", "Backup of encryption keys to server": "Backup of encryption keys to server", + "Render simple counters in room header": "Render simple counters in room header", + "Allow up to 6 rooms in a community to be shown simultaneously in a grid via the context menu": "Allow up to 6 rooms in a community to be shown simultaneously in a grid via the context menu", "Disable Emoji suggestions while typing": "Disable Emoji suggestions while typing", "Use compact timeline layout": "Use compact timeline layout", "Hide removed messages": "Hide removed messages", @@ -292,6 +297,7 @@ "Pin unread rooms to the top of the room list": "Pin unread rooms to the top of the room list", "Enable widget screenshots on supported widgets": "Enable widget screenshots on supported widgets", "Show empty room list headings": "Show empty room list headings", + "Always invite users which may not exist": "Always invite users which may not exist", "Show developer tools": "Show developer tools", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", @@ -352,8 +358,10 @@ "Delete your backed up encryption keys from the server? You will no longer be able to use your recovery key to read encrypted message history": "Delete your backed up encryption keys from the server? You will no longer be able to use your recovery key to read encrypted message history", "Delete backup": "Delete backup", "Unable to load key backup status": "Unable to load key backup status", - "This device is uploading keys to this backup": "This device is uploading keys to this backup", - "This device is not uploading keys to this backup": "This device is not uploading keys to this backup", + "This device is using key backup": "This device is using key backup", + "This device is not using key backup": "This device is not using key backup", + "Backing up %(sessionsRemaining)s keys...": "Backing up %(sessionsRemaining)s keys...", + "All keys backed up": "All keys backed up", "Backup has a valid signature from this device": "Backup has a valid signature from this device", "Backup has a valid signature from verified device ": "Backup has a valid signature from verified device ", "Backup has a valid signature from unverified device ": "Backup has a valid signature from unverified device ", @@ -535,6 +543,7 @@ "Forget room": "Forget room", "Search": "Search", "Share room": "Share room", + "Toggle right panel": "Toggle right panel", "Drop here to favourite": "Drop here to favourite", "Drop here to tag direct chat": "Drop here to tag direct chat", "Drop here to restore": "Drop here to restore", @@ -883,6 +892,10 @@ "That doesn't look like a valid email address": "That doesn't look like a valid email address", "You have entered an invalid address.": "You have entered an invalid address.", "Try using one of the following valid address types: %(validTypesList)s.": "Try using one of the following valid address types: %(validTypesList)s.", + "The following users may not exist": "The following users may not exist", + "The following users may not exist - would you like to invite them anyways?": "The following users may not exist - would you like to invite them anyways?", + "Invite anyways and never warn me again": "Invite anyways and never warn me again", + "Invite anyways": "Invite anyways", "Preparing to send logs": "Preparing to send logs", "Logs sent": "Logs sent", "Thank you!": "Thank you!", @@ -1077,6 +1090,7 @@ "Direct Chat": "Direct Chat", "Set a new status...": "Set a new status...", "Clear status": "Clear status", + "View as Grid": "View as Grid", "View Community": "View Community", "Sorry, your browser is not able to run Riot.": "Sorry, your browser is not able to run Riot.", "Riot uses many advanced browser features, some of which are not available or experimental in your current browser.": "Riot uses many advanced browser features, some of which are not available or experimental in your current browser.", @@ -1087,6 +1101,7 @@ "You must register to use this functionality": "You must register to use this functionality", "You must join the room to see its files": "You must join the room to see its files", "There are no visible files in this room": "There are no visible files in this room", + "No room in this tile yet.": "No room in this tile yet.", "

    HTML for your community's page

    \n

    \n Use the long description to introduce new members to the community, or distribute\n some important links\n

    \n

    \n You can even use 'img' tags\n

    \n": "

    HTML for your community's page

    \n

    \n Use the long description to introduce new members to the community, or distribute\n some important links\n

    \n

    \n You can even use 'img' tags\n

    \n", "Add rooms to the community summary": "Add rooms to the community summary", "Which rooms would you like to add to this summary?": "Which rooms would you like to add to this summary?", @@ -1386,26 +1401,31 @@ "Print it and store it somewhere safe": "Print it and store it somewhere safe", "Save it on a USB key or backup drive": "Save it on a USB key or backup drive", "Copy it to your personal cloud storage": "Copy it to your personal cloud storage", - "Backup created": "Backup created", - "Your encryption keys are now being backed up to your Homeserver.": "Your encryption keys are now being backed up to your Homeserver.", + "Your encryption keys are now being backed up in the background to your Homeserver. The initial backup could take several minutes. You can view key backup upload progress in Settings.": "Your encryption keys are now being backed up in the background to your Homeserver. The initial backup could take several minutes. You can view key backup upload progress in Settings.", "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.": "Without setting up Secure Message Recovery, you won't be able to restore your encrypted message history if you log out or use another device.", "Set up Secure Message Recovery": "Set up Secure Message Recovery", "Create a Recovery Passphrase": "Create a Recovery Passphrase", "Confirm Recovery Passphrase": "Confirm Recovery Passphrase", "Recovery Key": "Recovery Key", "Keep it safe": "Keep it safe", - "Backing up...": "Backing up...", + "Starting backup...": "Starting backup...", + "Backup Started": "Backup Started", "Create Key Backup": "Create Key Backup", "Unable to create key backup": "Unable to create key backup", "Retry": "Retry", "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.": "Without setting up Secure Message Recovery, you'll lose your secure message history when you log out.", "If you don't want to set this up now, you can later in Settings.": "If you don't want to set this up now, you can later in Settings.", "New Recovery Method": "New Recovery Method", - "A new recovery passphrase and key for Secure Messages has been detected.": "A new recovery passphrase and key for Secure Messages has been detected.", - "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.": "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.", + "A new recovery passphrase and key for Secure Messages have been detected.": "A new recovery passphrase and key for Secure Messages have been detected.", "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't set the new recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.", - "Set up Secure Messages": "Set up Secure Messages", + "This device is encrypting history using the new recovery method.": "This device is encrypting history using the new recovery method.", "Go to Settings": "Go to Settings", + "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.": "Setting up Secure Messages on this device will re-encrypt this device's message history with the new recovery method.", + "Set up Secure Messages": "Set up Secure Messages", + "Recovery Method Removed": "Recovery Method Removed", + "This device has detected that your recovery passphrase and key for Secure Messages have been removed.": "This device has detected that your recovery passphrase and key for Secure Messages have been removed.", + "If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.": "If you did this accidentally, you can setup Secure Messages on this device which will re-encrypt this device's message history with a new recovery method.", + "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.": "If you didn't remove the recovery method, an attacker may be trying to access your account. Change your account password and set a new recovery method immediately in Settings.", "Failed to set direct chat tag": "Failed to set direct chat tag", "Failed to remove tag %(tagName)s from room": "Failed to remove tag %(tagName)s from room", "Failed to add tag %(tagName)s to room": "Failed to add tag %(tagName)s to room" diff --git a/src/matrix-dispatcher.js b/src/matrix-dispatcher.js new file mode 100644 index 0000000000..fb81ed837f --- /dev/null +++ b/src/matrix-dispatcher.js @@ -0,0 +1,53 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +const flux = require("flux"); + +export default class MatrixDispatcher extends flux.Dispatcher { + /** + * @param {Object|function} payload Required. The payload to dispatch. + * If an Object, must contain at least an 'action' key. + * If a function, must have the signature (dispatch) => {...}. + * @param {boolean=} sync Optional. Pass true to dispatch + * synchronously. This is useful for anything triggering + * an operation that the browser requires user interaction + * for. + */ + dispatch(payload, sync) { + // Allow for asynchronous dispatching by accepting payloads that have the + // type `function (dispatch) {...}` + if (typeof payload === 'function') { + payload((action) => { + this.dispatch(action, sync); + }); + return; + } + + if (sync) { + super.dispatch(payload); + } else { + // Unless the caller explicitly asked for us to dispatch synchronously, + // we always set a timeout to do this: The flux dispatcher complains + // if you dispatch from within a dispatch, so rather than action + // handlers having to worry about not calling anything that might + // then dispatch, we just do dispatches asynchronously. + setTimeout(super.dispatch.bind(this, payload), 0); + } + } +} diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 14f4bdc6dd..a04301e31e 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -102,6 +102,18 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_state_counters": { + isFeature: true, + displayName: _td("Render simple counters in room header"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, + "feature_gridview": { + isFeature: true, + displayName: _td("Allow up to 6 rooms in a community to be shown simultaneously in a grid via the context menu"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "MessageComposerInput.dontSuggestEmoji": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Disable Emoji suggestions while typing'), @@ -317,6 +329,11 @@ export const SETTINGS = { displayName: _td('Show empty room list headings'), default: true, }, + "alwaysInviteUnknownUsers": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td('Always invite users which may not exist'), + default: false, + }, "showDeveloperTools": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Show developer tools'), diff --git a/src/stores/OpenRoomsStore.js b/src/stores/OpenRoomsStore.js new file mode 100644 index 0000000000..21f02fe28d --- /dev/null +++ b/src/stores/OpenRoomsStore.js @@ -0,0 +1,277 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import MatrixDispatcher from '../matrix-dispatcher'; +import dis from '../dispatcher'; +import {RoomViewStore} from './RoomViewStore'; +import GroupStore from './GroupStore'; +import {Store} from 'flux/utils'; +import MatrixClientPeg from '../MatrixClientPeg'; + + +function matchesRoom(payload, roomStore) { + if (!roomStore) { + return false; + } + if (payload.room_alias) { + return payload.room_alias === roomStore.getRoomAlias(); + } + return payload.room_id === roomStore.getRoomId(); +} + +/** + * A class for keeping track of the RoomViewStores of the rooms shown on the screen. + * Routes the dispatcher actions to the store of currently active room. + */ +class OpenRoomsStore extends Store { + constructor() { + super(dis); + + // Initialise state + this._state = { + rooms: [], + currentIndex: null, + group_id: null, + }; + + this._forwardingEvent = null; + } + + getRoomStores() { + return this._state.rooms.map((r) => r.store); + } + + getActiveRoomStore() { + const openRoom = this._getActiveOpenRoom(); + if (openRoom) { + return openRoom.store; + } + } + + getRoomStoreAt(index) { + if (index >= 0 && index < this._state.rooms.length) { + return this._state.rooms[index].store; + } + } + + _getActiveOpenRoom() { + const index = this._state.currentIndex; + if (index !== null && index < this._state.rooms.length) { + return this._state.rooms[index]; + } + } + + _setState(newState) { + this._state = Object.assign(this._state, newState); + this.__emitChange(); + } + + _hasRoom(payload) { + return this._roomIndex(payload) !== -1; + } + + _roomIndex(payload) { + return this._state.rooms.findIndex((r) => matchesRoom(payload, r.store)); + } + + _cleanupOpenRooms() { + this._state.rooms.forEach((room) => { + room.dispatcher.unregister(room.dispatcherRef); + room.dispatcher.unregister(room.store.getDispatchToken()); + }); + this._setState({ + rooms: [], + group_id: null, + currentIndex: null, + }); + } + + _createOpenRoom(roomId, roomAlias) { + const dispatcher = new MatrixDispatcher(); + // forward all actions coming from the room dispatcher + // to the global one + const dispatcherRef = dispatcher.register((payload) => { + // block a view_room action for the same room because it will switch to + // single room mode in MatrixChat + if (payload.action === 'view_room' && roomId === payload.room_id) { + return; + } + payload.grid_src_room_id = roomId; + payload.grid_src_room_alias = roomAlias; + this.getDispatcher().dispatch(payload); + }); + const openRoom = { + store: new RoomViewStore(dispatcher), + dispatcher, + dispatcherRef, + }; + + dispatcher.dispatch({ + action: 'view_room', + room_id: roomId, + room_alias: roomAlias, + }, true); + + return openRoom; + } + + _setSingleOpenRoom(payload) { + this._setState({ + rooms: [this._createOpenRoom(payload.room_id, payload.room_alias)], + currentIndex: 0, + }); + } + + _setGroupOpenRooms(groupId) { + this._cleanupOpenRooms(); + // TODO: register to GroupStore updates + const rooms = GroupStore.getGroupRooms(groupId); + const openRooms = rooms.map((room) => { + return this._createOpenRoom(room.roomId); + }); + this._setState({ + rooms: openRooms, + group_id: groupId, + currentIndex: 0, + }); + } + + _forwardAction(payload) { + // don't forward an event to a room dispatcher + // if the event originated from that dispatcher, as this + // would cause the event to be observed twice in that + // dispatcher + if (payload.grid_src_room_id || payload.grid_src_room_alias) { + const srcPayload = { + room_id: payload.grid_src_room_id, + room_alias: payload.grid_src_room_alias, + }; + const srcIndex = this._roomIndex(srcPayload); + if (srcIndex === this._state.currentIndex) { + return; + } + } + const currentRoom = this._getActiveOpenRoom(); + if (currentRoom) { + currentRoom.dispatcher.dispatch(payload, true); + } + } + + async _resolveRoomAlias(payload) { + try { + const result = await MatrixClientPeg.get() + .getRoomIdForAlias(payload.room_alias); + this.getDispatcher().dispatch({ + action: 'view_room', + room_id: result.room_id, + event_id: payload.event_id, + highlighted: payload.highlighted, + room_alias: payload.room_alias, + auto_join: payload.auto_join, + oob_data: payload.oob_data, + }); + } catch (err) { + this._forwardAction({ + action: 'view_room_error', + room_id: null, + room_alias: payload.room_alias, + err: err, + }); + } + } + + _viewRoom(payload) { + console.log("!!! OpenRoomsStore: view_room", payload); + if (!payload.room_id && payload.room_alias) { + this._resolveRoomAlias(payload); + } + const currentStore = this.getActiveRoomStore(); + if (!matchesRoom(payload, currentStore)) { + if (this._hasRoom(payload)) { + const roomIndex = this._roomIndex(payload); + this._setState({currentIndex: roomIndex}); + } else { + this._cleanupOpenRooms(); + } + } + if (!this.getActiveRoomStore()) { + console.log("OpenRoomsStore: _setSingleOpenRoom"); + this._setSingleOpenRoom(payload); + } + console.log("OpenRoomsStore: _forwardAction"); + this._forwardAction(payload); + if (this._forwardingEvent) { + this.getDispatcher().dispatch({ + action: 'send_event', + room_id: payload.room_id, + event: this._forwardingEvent, + }); + this._forwardingEvent = null; + } + } + + __onDispatch(payload) { + let proposedIndex; + switch (payload.action) { + // view_room: + // - room_alias: '#somealias:matrix.org' + // - room_id: '!roomid123:matrix.org' + // - event_id: '$213456782:matrix.org' + // - event_offset: 100 + // - highlighted: true + case 'view_room': + this._viewRoom(payload); + break; + case 'view_my_groups': + case 'view_group': + this._forwardAction(payload); + this._cleanupOpenRooms(); + break; + case 'will_join': + case 'cancel_join': + case 'join_room': + case 'join_room_error': + case 'on_logged_out': + case 'reply_to_event': + case 'open_room_settings': + case 'close_settings': + case 'focus_composer': + this._forwardAction(payload); + break; + case 'forward_event': + this._forwardingEvent = payload.event; + break; + case 'group_grid_set_active': + proposedIndex = this._roomIndex(payload); + if (proposedIndex !== -1) { + this._setState({ + currentIndex: proposedIndex, + }); + } + break; + case 'group_grid_view': + if (payload.group_id !== this._state.group_id) { + this._setGroupOpenRooms(payload.group_id); + } + break; + } + } +} + +let singletonOpenRoomsStore = null; +if (!singletonOpenRoomsStore) { + singletonOpenRoomsStore = new OpenRoomsStore(); +} +module.exports = singletonOpenRoomsStore; diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index 9e048e5d8e..a0b831ad17 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -14,7 +14,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import dis from '../dispatcher'; import {Store} from 'flux/utils'; import MatrixClientPeg from '../MatrixClientPeg'; import sdk from '../index'; @@ -53,12 +52,12 @@ const INITIAL_STATE = { * with a subset of the js-sdk. * ``` */ -class RoomViewStore extends Store { - constructor() { - super(dis); +export class RoomViewStore extends Store { + constructor(dispatcher) { + super(dispatcher); // Initialise state - this._state = INITIAL_STATE; + this._state = Object.assign({}, INITIAL_STATE); } _setState(newState) { @@ -85,6 +84,8 @@ class RoomViewStore extends Store { }); break; case 'view_room_error': + // should not go over dispatcher anymore + // but be internal to RoomViewStore this._viewRoomError(payload); break; case 'will_join': @@ -150,22 +151,11 @@ class RoomViewStore extends Store { // pull the user out of Room Settings isEditingSettings: false, }; - - if (this._state.forwardingEvent) { - dis.dispatch({ - action: 'send_event', - room_id: newState.roomId, - event: this._state.forwardingEvent, - }); - } - this._setState(newState); - if (payload.auto_join) { this._joinRoom(payload); } } else if (payload.room_alias) { - // Resolve the alias and then do a second dispatch with the room ID acquired this._setState({ roomId: null, initialEventId: null, @@ -175,25 +165,6 @@ class RoomViewStore extends Store { roomLoading: true, roomLoadError: null, }); - MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias).done( - (result) => { - dis.dispatch({ - action: 'view_room', - room_id: result.room_id, - event_id: payload.event_id, - highlighted: payload.highlighted, - room_alias: payload.room_alias, - auto_join: payload.auto_join, - oob_data: payload.oob_data, - }); - }, (err) => { - dis.dispatch({ - action: 'view_room_error', - room_id: null, - room_alias: payload.room_alias, - err: err, - }); - }); } } @@ -219,7 +190,7 @@ class RoomViewStore extends Store { // stream yet, and that's the point at which we'd consider // the user joined to the room. }, (err) => { - dis.dispatch({ + this.getDispatcher().dispatch({ action: 'join_room_error', err: err, }); @@ -335,8 +306,7 @@ class RoomViewStore extends Store { } } -let singletonRoomViewStore = null; -if (!singletonRoomViewStore) { - singletonRoomViewStore = new RoomViewStore(); -} -module.exports = singletonRoomViewStore; +const MatrixDispatcher = require("../matrix-dispatcher"); +const backwardsCompatInstance = new RoomViewStore(new MatrixDispatcher()); + +export default backwardsCompatInstance; diff --git a/src/utils/MultiInviter.js b/src/utils/MultiInviter.js index ad10f28edf..b5f4f960a9 100644 --- a/src/utils/MultiInviter.js +++ b/src/utils/MultiInviter.js @@ -15,11 +15,15 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React from "react"; import MatrixClientPeg from '../MatrixClientPeg'; import {getAddressType} from '../UserAddress'; import GroupStore from '../stores/GroupStore'; import Promise from 'bluebird'; import {_t} from "../languageHandler"; +import sdk from "../index"; +import Modal from "../Modal"; +import SettingsStore from "../settings/SettingsStore"; /** * Invites multiple addresses to a room or group, handling rate limiting from the server @@ -41,7 +45,7 @@ export default class MultiInviter { this.addrs = []; this.busy = false; this.completionStates = {}; // State of each address (invited or error) - this.errorTexts = {}; // Textual error per address + this.errors = {}; // { address: {errorText, errcode} } this.deferred = null; } @@ -61,7 +65,10 @@ export default class MultiInviter { for (const addr of this.addrs) { if (getAddressType(addr) === null) { this.completionStates[addr] = 'error'; - this.errorTexts[addr] = 'Unrecognised address'; + this.errors[addr] = { + errcode: 'M_INVALID', + errorText: _t('Unrecognised address'), + }; } } this.deferred = Promise.defer(); @@ -85,18 +92,28 @@ export default class MultiInviter { } getErrorText(addr) { - return this.errorTexts[addr]; + return this.errors[addr] ? this.errors[addr].errorText : null; } - async _inviteToRoom(roomId, addr) { + async _inviteToRoom(roomId, addr, ignoreProfile) { const addrType = getAddressType(addr); if (addrType === 'email') { return MatrixClientPeg.get().inviteByEmail(roomId, addr); } else if (addrType === 'mx-user-id') { - const profile = await MatrixClientPeg.get().getProfileInfo(addr); - if (!profile) { - return Promise.reject({errcode: "M_NOT_FOUND", error: "User does not have a profile."}); + if (!ignoreProfile && !SettingsStore.getValue("alwaysInviteUnknownUsers", this.roomId)) { + try { + const profile = await MatrixClientPeg.get().getProfileInfo(addr); + if (!profile) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("User has no profile"); + } + } catch (e) { + throw { + errcode: "RIOT.USER_NOT_FOUND", + error: "User does not have a profile or does not exist." + }; + } } return MatrixClientPeg.get().invite(roomId, addr); @@ -105,14 +122,109 @@ export default class MultiInviter { } } + _doInvite(address, ignoreProfile) { + return new Promise((resolve, reject) => { + console.log(`Inviting ${address}`); - _inviteMore(nextIndex) { + let doInvite; + if (this.groupId !== null) { + doInvite = GroupStore.inviteUserToGroup(this.groupId, address); + } else { + doInvite = this._inviteToRoom(this.roomId, address, ignoreProfile); + } + + doInvite.then(() => { + if (this._canceled) { + return; + } + + this.completionStates[address] = 'invited'; + delete this.errors[address]; + + resolve(); + }).catch((err) => { + if (this._canceled) { + return; + } + + let errorText; + let fatal = false; + if (err.errcode === 'M_FORBIDDEN') { + fatal = true; + errorText = _t('You do not have permission to invite people to this room.'); + } else if (err.errcode === 'M_LIMIT_EXCEEDED') { + // we're being throttled so wait a bit & try again + setTimeout(() => { + this._doInvite(address, ignoreProfile).then(resolve, reject); + }, 5000); + return; + } else if (['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'RIOT.USER_NOT_FOUND'].includes(err.errcode)) { + errorText = _t("User %(user_id)s does not exist", {user_id: address}); + } else if (err.errcode === 'M_PROFILE_UNDISCLOSED') { + errorText = _t("User %(user_id)s may or may not exist", {user_id: address}); + } else if (err.errcode === 'M_PROFILE_NOT_FOUND' && !ignoreProfile) { + // Invite without the profile check + console.warn(`User ${address} does not have a profile - inviting anyways automatically`); + this._doInvite(address, true).then(resolve, reject); + } else { + errorText = _t('Unknown server error'); + } + + this.completionStates[address] = 'error'; + this.errors[address] = {errorText, errcode: err.errcode}; + + this.busy = !fatal; + this.fatal = fatal; + + if (fatal) { + reject(); + } else { + resolve(); + } + }); + }); + } + + _inviteMore(nextIndex, ignoreProfile) { if (this._canceled) { return; } if (nextIndex === this.addrs.length) { this.busy = false; + if (Object.keys(this.errors).length > 0 && !this.groupId) { + // There were problems inviting some people - see if we can invite them + // without caring if they exist or not. + const unknownProfileErrors = ['M_NOT_FOUND', 'M_USER_NOT_FOUND', 'M_PROFILE_UNDISCLOSED', 'M_PROFILE_NOT_FOUND', 'RIOT.USER_NOT_FOUND']; + const unknownProfileUsers = Object.keys(this.errors).filter(a => unknownProfileErrors.includes(this.errors[a].errcode)); + + if (unknownProfileUsers.length > 0) { + const inviteUnknowns = () => { + const promises = unknownProfileUsers.map(u => this._doInvite(u, true)); + Promise.all(promises).then(() => this.deferred.resolve(this.completionStates)); + }; + + if (SettingsStore.getValue("alwaysInviteUnknownUsers", this.roomId)) { + inviteUnknowns(); + return; + } + + const AskInviteAnywayDialog = sdk.getComponent("dialogs.AskInviteAnywayDialog"); + console.log("Showing failed to invite dialog..."); + Modal.createTrackedDialog('Failed to invite the following users to the room', '', AskInviteAnywayDialog, { + unknownProfileUsers: unknownProfileUsers.map(u => {return {userId: u, errorText: this.errors[u].errorText};}), + onInviteAnyways: () => inviteUnknowns(), + onGiveUp: () => { + // Fake all the completion states because we already warned the user + for (const addr of unknownProfileUsers) { + this.completionStates[addr] = 'invited'; + } + this.deferred.resolve(this.completionStates); + }, + }); + return; + } + } this.deferred.resolve(this.completionStates); return; } @@ -134,48 +246,8 @@ export default class MultiInviter { return; } - let doInvite; - if (this.groupId !== null) { - doInvite = GroupStore.inviteUserToGroup(this.groupId, addr); - } else { - doInvite = this._inviteToRoom(this.roomId, addr); - } - - doInvite.then(() => { - if (this._canceled) { return; } - - this.completionStates[addr] = 'invited'; - - this._inviteMore(nextIndex + 1); - }).catch((err) => { - if (this._canceled) { return; } - - let errorText; - let fatal = false; - if (err.errcode === 'M_FORBIDDEN') { - fatal = true; - errorText = _t('You do not have permission to invite people to this room.'); - } else if (err.errcode === 'M_LIMIT_EXCEEDED') { - // we're being throttled so wait a bit & try again - setTimeout(() => { - this._inviteMore(nextIndex); - }, 5000); - return; - } else if(err.errcode === "M_NOT_FOUND") { - errorText = _t("User %(user_id)s does not exist", {user_id: addr}); - } else { - errorText = _t('Unknown server error'); - } - this.completionStates[addr] = 'error'; - this.errorTexts[addr] = errorText; - this.busy = !fatal; - this.fatal = fatal; - - if (!fatal) { - this._inviteMore(nextIndex + 1); - } else { - this.deferred.resolve(this.completionStates); - } - }); + this._doInvite(addr, ignoreProfile).then(() => { + this._inviteMore(nextIndex + 1, ignoreProfile); + }).catch(() => this.deferred.resolve(this.completionStates)); } } diff --git a/src/utils/Timer.js b/src/utils/Timer.js index aeac0887c9..ca06237fbf 100644 --- a/src/utils/Timer.js +++ b/src/utils/Timer.js @@ -26,7 +26,6 @@ Once a timer is finished or aborted, it can't be started again a new one through `clone()` or `cloneIfRun()`. */ export default class Timer { - constructor(timeout) { this._timeout = timeout; this._onTimeout = this._onTimeout.bind(this); @@ -70,6 +69,7 @@ export default class Timer { /** * if not started before, starts the timer. + * @returns {Timer} the same timer */ start() { if (!this.isRunning()) { @@ -81,6 +81,7 @@ export default class Timer { /** * (re)start the timer. If it's running, reset the timeout. If not, start it. + * @returns {Timer} the same timer */ restart() { if (this.isRunning()) { @@ -98,6 +99,7 @@ export default class Timer { /** * if the timer is running, abort it, * and reject the promise for this timer. + * @returns {Timer} the same timer */ abort() { if (this.isRunning()) {