diff --git a/.eslintrc.js b/.eslintrc.js index 62d24ea707..971809f851 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -47,6 +47,9 @@ module.exports = { }], "react/jsx-key": ["error"], + // Components in JSX should always be defined. + "react/jsx-no-undef": "error", + // Assert no spacing in JSX curly brackets // // diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 99025f0e0a..f7c8c8b1c5 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,4 +1,4 @@ Contributing code to The React SDK ================================== -matrix-react-sdk follows the same pattern as https://github.com/matrix-org/synapse/blob/master/CONTRIBUTING.rst +matrix-react-sdk follows the same pattern as https://github.com/matrix-org/matrix-js-sdk/blob/master/CONTRIBUTING.rst diff --git a/res/css/_common.scss b/res/css/_common.scss index 11e04f5dc0..97ae5412e1 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -34,7 +34,7 @@ body { -webkit-font-smoothing: subpixel-antialiased; } -div.error, div.warning { +.error, .warning { color: $warning-color; } diff --git a/res/css/_components.scss b/res/css/_components.scss index 579856f880..5633b7ec8f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -24,8 +24,10 @@ @import "./structures/_ViewSource.scss"; @import "./structures/login/_Login.scss"; @import "./views/avatars/_BaseAvatar.scss"; +@import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_RoomTileContextMenu.scss"; +@import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @@ -33,7 +35,6 @@ @import "./views/dialogs/_ChatInviteDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; -@import "./views/dialogs/_CreateKeyBackupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @@ -47,6 +48,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/_RestoreKeyBackupDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; diff --git a/res/css/views/dialogs/_CreateKeyBackupDialog.scss b/res/css/views/avatars/_MemberStatusMessageAvatar.scss similarity index 74% rename from res/css/views/dialogs/_CreateKeyBackupDialog.scss rename to res/css/views/avatars/_MemberStatusMessageAvatar.scss index a422cf858c..c857b9807b 100644 --- a/res/css/views/dialogs/_CreateKeyBackupDialog.scss +++ b/res/css/views/avatars/_MemberStatusMessageAvatar.scss @@ -14,12 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_CreateKeyBackupDialog { - padding-right: 40px; -} - -.mx_CreateKeyBackupDialog_recoveryKey { - padding: 20px; - color: $info-plinth-fg-color; - background-color: $info-plinth-bg-color; +.mx_MemberStatusMessageAvatar_hasStatus { + border: 2px solid $accent-color; + border-radius: 40px; } diff --git a/res/css/views/context_menus/_StatusMessageContextMenu.scss b/res/css/views/context_menus/_StatusMessageContextMenu.scss new file mode 100644 index 0000000000..873ad99495 --- /dev/null +++ b/res/css/views/context_menus/_StatusMessageContextMenu.scss @@ -0,0 +1,55 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_StatusMessageContextMenu_message { + display: inline-block; + border-radius: 3px 0 0 3px; + border: 1px solid $input-border-color; + font-size: 13px; + padding: 7px 7px 7px 9px; + width: 135px; + background-color: $primary-bg-color !important; +} + +.mx_StatusMessageContextMenu_submit { + display: inline-block; +} + +.mx_StatusMessageContextMenu_submitFaded { + opacity: 0.5; +} + +.mx_StatusMessageContextMenu_submit img { + vertical-align: middle; + margin-left: 8px; +} + +.mx_StatusMessageContextMenu hr { + border: 0.5px solid $menu-border-color; +} + +.mx_StatusMessageContextMenu_clearIcon { + margin: 5px 15px 5px 5px; + vertical-align: middle; +} + +.mx_StatusMessageContextMenu_clear { + padding: 2px; +} + +.mx_StatusMessageContextMenu_hasStatus .mx_StatusMessageContextMenu_clear { + color: $warning-color; +} diff --git a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss index 2cb6b11c0c..424ffbd0a8 100644 --- a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss +++ b/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss @@ -13,7 +13,11 @@ 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_CreateKeyBackupDialog { + padding-right: 40px; +} + .mx_CreateKeyBackupDialog_primaryContainer { /*FIXME: plinth colour in new theme(s). background-color: $accent-color;*/ padding: 20px @@ -25,9 +29,13 @@ limitations under the License. display: block; } +.mx_CreateKeyBackupDialog_passPhraseContainer { + display: flex; + align-items: start; +} + .mx_CreateKeyBackupDialog_passPhraseHelp { - float: right; - width: 230px; + flex: 1; height: 85px; margin-left: 20px; font-size: 80%; @@ -38,20 +46,36 @@ limitations under the License. } .mx_CreateKeyBackupDialog_passPhraseInput { + flex: none; width: 250px; border: 1px solid $accent-color; border-radius: 5px; padding: 10px; + margin-bottom: 1em; } .mx_CreateKeyBackupDialog_passPhraseMatch { - float: right; + margin-left: 20px; } -.mx_CreateKeyBackupDialog_recoveryKeyButtons { - float: right; +.mx_CreateKeyBackupDialog_recoveryKeyHeader { + margin-bottom: 1em; +} + +.mx_CreateKeyBackupDialog_recoveryKeyContainer { + display: flex; } .mx_CreateKeyBackupDialog_recoveryKey { - width: 300px; + width: 262px; + padding: 20px; + color: $info-plinth-fg-color; + background-color: $info-plinth-bg-color; + margin-right: 12px; +} + +.mx_CreateKeyBackupDialog_recoveryKeyButtons { + flex: 1; + display: flex; + align-items: center; } diff --git a/res/css/views/dialogs/keybackup/_NewRecoveryMethodDialog.scss b/res/css/views/dialogs/keybackup/_NewRecoveryMethodDialog.scss new file mode 100644 index 0000000000..370f82d9ab --- /dev/null +++ b/res/css/views/dialogs/keybackup/_NewRecoveryMethodDialog.scss @@ -0,0 +1,41 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_NewRecoveryMethodDialog .mx_Dialog_title { + margin-bottom: 32px; +} + +.mx_NewRecoveryMethodDialog_title { + position: relative; + padding-left: 45px; + padding-bottom: 10px; + + &:before { + mask: url("../../../img/e2e/lock-warning.svg"); + mask-repeat: no-repeat; + background-color: $primary-fg-color; + content: ""; + position: absolute; + top: -6px; + right: 0; + bottom: 0; + left: 0; + } +} + +.mx_NewRecoveryMethodDialog .mx_Dialog_buttons { + margin-top: 36px; +} diff --git a/res/css/views/rooms/_EntityTile.scss b/res/css/views/rooms/_EntityTile.scss index 031894afde..90d5dc9aa5 100644 --- a/res/css/views/rooms/_EntityTile.scss +++ b/res/css/views/rooms/_EntityTile.scss @@ -111,4 +111,12 @@ limitations under the License. opacity: 0.25; } +.mx_EntityTile_subtext { + font-size: 11px; + opacity: 0.5; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; +} + diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss index 5d47275efe..2270e83743 100644 --- a/res/css/views/rooms/_MemberInfo.scss +++ b/res/css/views/rooms/_MemberInfo.scss @@ -110,3 +110,10 @@ limitations under the License. margin-left: 8px; } +.mx_MemberInfo_statusMessage { + font-size: 11px; + opacity: 0.5; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; +} diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index ccd3afe26c..b5ac9aadc6 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -35,7 +35,19 @@ limitations under the License. .mx_RoomTile_nameContainer { display: inline-block; width: 180px; - height: 24px; + vertical-align: middle; +} + +.mx_RoomTile_subtext { + display: inline-block; + font-size: 11px; + padding: 0 0 0 7px; + margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: clip; + position: relative; + bottom: 4px; } .mx_RoomTile_avatar_container { @@ -49,10 +61,14 @@ limitations under the License. padding-left: 16px; padding-right: 6px; width: 24px; - height: 24px; vertical-align: middle; } +.mx_RoomTile_hasSubtext .mx_RoomTile_avatar { + padding-top: 0; + vertical-align: super; +} + .mx_RoomTile_dm { display: block; position: absolute; diff --git a/res/img/e2e/lock-warning.svg b/res/img/e2e/lock-warning.svg new file mode 100644 index 0000000000..a984ed85a0 --- /dev/null +++ b/res/img/e2e/lock-warning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/icons-checkmark.svg b/res/img/icons-checkmark.svg new file mode 100644 index 0000000000..3c5392003d --- /dev/null +++ b/res/img/icons-checkmark.svg @@ -0,0 +1,17 @@ + + + + Tick + Created with Sketch. + + + + + + + + + + + + diff --git a/src/BasePlatform.js b/src/BasePlatform.js index abc9aa0bed..79f0d69e2c 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -3,6 +3,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2016 OpenMarket Ltd +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. @@ -105,11 +106,6 @@ export default class BasePlatform { return "Not implemented"; } - isElectron(): boolean { return false; } - - setupScreenSharingForIframe() { - } - /** * Restarts the application, without neccessarily reloading * any application code diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 6b115b890f..a097e84cdb 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -92,25 +92,33 @@ export default React.createClass({ }); }, - _createBackup: function() { + _createBackup: async function() { this.setState({ phase: PHASE_BACKINGUP, error: null, }); - this._createBackupPromise = MatrixClientPeg.get().createKeyBackupVersion( - this._keyBackupInfo, - ).then((info) => { - return MatrixClientPeg.get().backupAllGroupSessions(info.version); - }).then(() => { + let info; + try { + info = await MatrixClientPeg.get().createKeyBackupVersion( + this._keyBackupInfo, + ); + await MatrixClientPeg.get().backupAllGroupSessions(info.version); this.setState({ phase: PHASE_DONE, }); - }).catch(e => { + } catch (e) { console.log("Error creating key backup", e); + // TODO: If creating a version succeeds, but backup fails, should we + // delete the version, disable backup, or do nothing? If we just + // disable without deleting, we'll enable on next app reload since + // it is trusted. + if (info) { + MatrixClientPeg.get().deleteKeyBackupVersion(info.version); + } this.setState({ error: e, }); - }); + } }, _onCancel: function() { @@ -231,17 +239,19 @@ export default React.createClass({

{_t("You'll need it if you log out or lose access to this device.")}

-
- {strengthMeter} - {helpText} +
+ +
+ {strengthMeter} + {helpText} +
-
- {passPhraseMatch} -
- +
+
+ +
+ {passPhraseMatch}
{_t("Make a copy of this Recovery Key and keep it safe.")}

{bodyText}

-

{_t("Your Recovery Key")}
-
- - { - // FIXME REDESIGN: buttons should be adjacent but insufficient room in current design - } -

- +
+ {_t("Your Recovery Key")}
-
- {this._keyBackupInfo.recovery_key} +
+
+ {this._keyBackupInfo.recovery_key} +
+
+ + +


diff --git a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js new file mode 100644 index 0000000000..e88e0444bc --- /dev/null +++ b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js @@ -0,0 +1,110 @@ +/* +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 React from "react"; +import PropTypes from "prop-types"; +import sdk from "../../../../index"; +import MatrixClientPeg from '../../../../MatrixClientPeg'; +import dis from "../../../../dispatcher"; +import { _t } from "../../../../languageHandler"; +import Modal from "../../../../Modal"; + +export default class NewRecoveryMethodDialog extends React.PureComponent { + static propTypes = { + onFinished: PropTypes.func.isRequired, + } + + onGoToSettingsClick = () => { + this.props.onFinished(); + dis.dispatch({ action: 'view_user_settings' }); + } + + 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 + // look for an unverified device and verify it. Note that this means + // we won't restore keys yet; instead we'll only trust the backup for + // sending our own new keys to it. + let backupSigStatus; + try { + const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); + backupSigStatus = await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo); + } catch (e) { + console.log("Unable to fetch key backup status", e); + return; + } + + let unverifiedDevice; + for (const sig of backupSigStatus.sigs) { + if (!sig.device.isVerified()) { + unverifiedDevice = sig.device; + break; + } + } + if (!unverifiedDevice) { + console.log("Unable to find a device to verify."); + return; + } + + const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); + Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, { + userId: MatrixClientPeg.get().credentials.userId, + device: unverifiedDevice, + onFinished: this.props.onFinished, + }); + } + + render() { + const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); + const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); + const title = + {_t("New Recovery Method")} + ; + + 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.", + )}

+ +
+
+ ); + } +} diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index e2fd15aa89..56e6575793 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -1077,6 +1077,7 @@ export default React.createClass({ }, _getJoinableNode: function() { + const InlineSpinner = sdk.getComponent('elements.InlineSpinner'); return this.state.editing ?

{ _t('Who can join this community?') } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 4517304453..fd95276445 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -1430,6 +1430,11 @@ export default React.createClass({ break; } }); + cli.on("crypto.keyBackupFailed", () => { + Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method', + import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'), + ); + }); // Fire the tinter right on startup to ensure the default theme is applied // A later sync can/will correct the tint to be the right value for the user diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 6f932d71e1..b9dbe345c5 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -188,9 +188,11 @@ module.exports = React.createClass({ phase: "UserSettings.LOADING", // LOADING, DISPLAY email_add_pending: false, vectorVersion: undefined, + canSelfUpdate: null, rejectingInvites: false, mediaDevices: null, ignoredUsers: [], + autoLaunchEnabled: null, }; }, @@ -209,6 +211,13 @@ module.exports = React.createClass({ }, (e) => { console.log("Failed to fetch app version", e); }); + + PlatformPeg.get().canSelfUpdate().then((canUpdate) => { + if (this._unmounted) return; + this.setState({ + canSelfUpdate: canUpdate, + }); + }); } this._refreshMediaDevices(); @@ -227,11 +236,12 @@ module.exports = React.createClass({ }); this._refreshFromServer(); - if (PlatformPeg.get().isElectron()) { - const {ipcRenderer} = require('electron'); - - ipcRenderer.on('settings', this._electronSettings); - ipcRenderer.send('settings_get'); + if (PlatformPeg.get().supportsAutoLaunch()) { + PlatformPeg.get().getAutoLaunchEnabled().then(enabled => { + this.setState({ + autoLaunchEnabled: enabled, + }); + }); } this.setState({ @@ -262,11 +272,6 @@ module.exports = React.createClass({ if (cli) { cli.removeListener("RoomMember.membership", this._onInviteStateChange); } - - if (PlatformPeg.get().isElectron()) { - const {ipcRenderer} = require('electron'); - ipcRenderer.removeListener('settings', this._electronSettings); - } }, // `UserSettings` assumes that the client peg will not be null, so give it some @@ -285,10 +290,6 @@ module.exports = React.createClass({ }); }, - _electronSettings: function(ev, settings) { - this.setState({ electron_settings: settings }); - }, - _refreshMediaDevices: function(stream) { if (stream) { // kill stream so that we don't leave it lingering around with webcam enabled etc @@ -967,7 +968,7 @@ module.exports = React.createClass({ _renderCheckUpdate: function() { const platform = PlatformPeg.get(); - if ('canSelfUpdate' in platform && platform.canSelfUpdate() && 'startUpdateCheck' in platform) { + if (this.state.canSelfUpdate) { return

{ _t('Updates') }

@@ -1012,8 +1013,7 @@ module.exports = React.createClass({ }, _renderElectronSettings: function() { - const settings = this.state.electron_settings; - if (!settings) return; + if (!PlatformPeg.get().supportsAutoLaunch()) return; // TODO: This should probably be a granular setting, but it only applies to electron // and ends up being get/set outside of matrix anyways (local system setting). @@ -1023,7 +1023,7 @@ module.exports = React.createClass({
@@ -1033,8 +1033,11 @@ module.exports = React.createClass({ }, _onAutoLaunchChanged: function(e) { - const {ipcRenderer} = require('electron'); - ipcRenderer.send('settings_set', 'auto-launch', e.target.checked); + PlatformPeg.get().setAutoLaunchEnabled(e.target.checked).then(() => { + this.setState({ + autoLaunchEnabled: e.target.checked, + }); + }); }, _mapWebRtcDevicesToSpans: function(devices) { @@ -1393,7 +1396,7 @@ module.exports = React.createClass({ { this._renderBulkOptions() } { this._renderBugReport() } - { PlatformPeg.get().isElectron() && this._renderElectronSettings() } + { this._renderElectronSettings() } { this._renderAnalyticsControl() } diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js new file mode 100644 index 0000000000..aebd1741b7 --- /dev/null +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -0,0 +1,120 @@ +/* +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 React from 'react'; +import PropTypes from 'prop-types'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import AccessibleButton from '../elements/AccessibleButton'; +import MemberAvatar from '../avatars/MemberAvatar'; +import classNames from 'classnames'; +import * as ContextualMenu from "../../structures/ContextualMenu"; +import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; +import SettingsStore from "../../../settings/SettingsStore"; + +export default class MemberStatusMessageAvatar extends React.Component { + static propTypes = { + member: PropTypes.object.isRequired, + width: PropTypes.number, + height: PropTypes.number, + resizeMethod: PropTypes.string, + }; + + static defaultProps = { + width: 40, + height: 40, + resizeMethod: 'crop', + }; + + constructor(props, context) { + super(props, context); + } + + componentWillMount() { + if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) { + throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user"); + } + } + + componentDidMount() { + MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); + + if (this.props.member.user) { + this.setState({message: this.props.member.user._unstable_statusMessage}); + } else { + this.setState({message: ""}); + } + } + + componentWillUnmount() { + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents); + } + } + + _onRoomStateEvents = (ev, state) => { + if (ev.getStateKey() !== MatrixClientPeg.get().getUserId()) return; + if (ev.getType() !== "im.vector.user_status") return; + // TODO: We should be relying on `this.props.member.user._unstable_statusMessage` + // We don't currently because the js-sdk doesn't emit a specific event for this + // change, and we don't want to race it. This should be improved when we rip out + // the im.vector.user_status stuff and replace it with a complete solution. + this.setState({message: ev.getContent()["status"]}); + }; + + _onClick = (e) => { + e.stopPropagation(); + + const elementRect = e.target.getBoundingClientRect(); + + // The window X and Y offsets are to adjust position when zoomed in to page + const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3; + const chevronOffset = 12; + let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset; + y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron + + ContextualMenu.createMenu(StatusMessageContextMenu, { + chevronOffset: chevronOffset, + chevronFace: 'bottom', + left: x, + top: y, + menuWidth: 190, + user: this.props.member.user, + }); + }; + + render() { + if (!SettingsStore.isFeatureEnabled("feature_custom_status")) { + return ; + } + + const hasStatus = this.props.member.user ? !!this.props.member.user._unstable_statusMessage : false; + + const classes = classNames({ + "mx_MemberStatusMessageAvatar": true, + "mx_MemberStatusMessageAvatar_hasStatus": hasStatus, + }); + + return + + ; + } +} diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js new file mode 100644 index 0000000000..f07220db44 --- /dev/null +++ b/src/components/views/context_menus/StatusMessageContextMenu.js @@ -0,0 +1,86 @@ +/* +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 React from 'react'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import AccessibleButton from '../elements/AccessibleButton'; +import classNames from 'classnames'; + +export default class StatusMessageContextMenu extends React.Component { + static propTypes = { + // js-sdk User object. Not required because it might not exist. + user: PropTypes.object, + }; + + constructor(props, context) { + super(props, context); + + this.state = { + message: props.user ? props.user._unstable_statusMessage : "", + }; + } + + _onClearClick = async(e) => { + await MatrixClientPeg.get()._unstable_setStatusMessage(""); + this.setState({message: ""}); + }; + + _onSubmit = (e) => { + e.preventDefault(); + MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message); + }; + + _onStatusChange = (e) => { + this.setState({message: e.target.value}); + }; + + render() { + const formSubmitClasses = classNames({ + "mx_StatusMessageContextMenu_submit": true, + "mx_StatusMessageContextMenu_submitFaded": !this.state.message, // no message == faded + }); + + const form =
+ + + + +
; + + const clearIcon = this.state.message ? "img/cancel-red.svg" : "img/cancel.svg"; + const clearButton = + {_t('Clear + {_t("Clear status")} + ; + + const menuClasses = classNames({ + "mx_StatusMessageContextMenu": true, + "mx_StatusMessageContextMenu_hasStatus": this.state.message, + }); + + return
+ { form } +
+ { clearButton } +
; + } +} diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 8ec417a59b..3e9052cc34 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -57,8 +57,7 @@ export default React.createClass({ className: PropTypes.string, // Title for the dialog. - // (could probably actually be something more complicated than a string if desired) - title: PropTypes.string.isRequired, + title: PropTypes.node.isRequired, // children should be the content of the dialog children: PropTypes.node, diff --git a/src/components/views/dialogs/ChangelogDialog.js b/src/components/views/dialogs/ChangelogDialog.js index b93678b2ab..3c9414fd88 100644 --- a/src/components/views/dialogs/ChangelogDialog.js +++ b/src/components/views/dialogs/ChangelogDialog.js @@ -36,8 +36,12 @@ export default class ChangelogDialog extends React.Component { for (let i=0; i { - if (body == null) return; + const url = `https://api.github.com/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`; + request(url, (err, response, body) => { + if (response.statusCode < 200 || response.statusCode >= 300) { + this.setState({ [REPOS[i]]: response.statusText }); + return; + } this.setState({[REPOS[i]]: JSON.parse(body).commits}); }); } @@ -58,13 +62,20 @@ export default class ChangelogDialog extends React.Component { const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); const logs = REPOS.map(repo => { - if (this.state[repo] == null) return ; + let content; + if (this.state[repo] == null) { + content = ; + } else if (typeof this.state[repo] === "string") { + content = _t("Unable to load commit detail: %(msg)s", { + msg: this.state[repo], + }); + } else { + content = this.state[repo].map(this._elementsForCommit); + } return (

{repo}

-
    - {this.state[repo].map(this._elementsForCommit)} -
+
    {content}
); }); diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 23b24adbb4..f4f929a3c2 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -22,7 +22,6 @@ import qs from 'querystring'; import React from 'react'; import PropTypes from 'prop-types'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import PlatformPeg from '../../../PlatformPeg'; import ScalarAuthClient from '../../../ScalarAuthClient'; import WidgetMessaging from '../../../WidgetMessaging'; import TintableSvgButton from './TintableSvgButton'; @@ -49,7 +48,6 @@ export default class AppTile extends React.Component { this.state = this._getNewState(props); this._onAction = this._onAction.bind(this); - this._onMessage = this._onMessage.bind(this); this._onLoaded = this._onLoaded.bind(this); this._onEditClick = this._onEditClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this); @@ -143,10 +141,6 @@ export default class AppTile extends React.Component { } componentDidMount() { - // Legacy Jitsi widget messaging -- TODO replace this with standard widget - // postMessaging API - window.addEventListener('message', this._onMessage, false); - // Widget action listeners this.dispatcherRef = dis.register(this._onAction); } @@ -155,9 +149,6 @@ export default class AppTile extends React.Component { // Widget action listeners dis.unregister(this.dispatcherRef); - // Jitsi listener - window.removeEventListener('message', this._onMessage); - // if it's not remaining on screen, get rid of the PersistedElement container if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) { ActiveWidgetStore.destroyPersistentWidget(); @@ -233,32 +224,6 @@ export default class AppTile extends React.Component { } } - // Legacy Jitsi widget messaging - // TODO -- This should be replaced with the new widget postMessaging API - _onMessage(event) { - if (this.props.type !== 'jitsi') { - return; - } - if (!event.origin) { - event.origin = event.originalEvent.origin; - } - - const widgetUrlObj = url.parse(this.state.widgetUrl); - const eventOrigin = url.parse(event.origin); - if ( - eventOrigin.protocol !== widgetUrlObj.protocol || - eventOrigin.host !== widgetUrlObj.host - ) { - return; - } - - if (event.data.widgetAction === 'jitsi_iframe_loaded') { - const iframe = this.refs.appFrame.contentWindow - .document.querySelector('iframe[id^="jitsiConferenceFrame"]'); - PlatformPeg.get().setupScreenSharingForIframe(iframe); - } - } - _canUserModify() { // User widgets should always be modifiable by their creator if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) { @@ -544,7 +509,7 @@ export default class AppTile extends React.Component { // Additional iframe feature pemissions // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) - const iframeFeatures = "microphone; camera; encrypted-media;"; + const iframeFeatures = "microphone; camera; encrypted-media; autoplay;"; const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' '); diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js index 6b3264d123..46c5502310 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.js @@ -70,6 +70,7 @@ const EntityTile = React.createClass({ onClick: PropTypes.func, suppressOnHover: PropTypes.bool, showPresence: PropTypes.bool, + subtextLabel: PropTypes.string, }, getDefaultProps: function() { @@ -129,6 +130,9 @@ const EntityTile = React.createClass({ presenceState={this.props.presenceState} />; nameClasses += ' mx_EntityTile_name_hover'; } + if (this.props.subtextLabel) { + presenceLabel = {this.props.subtextLabel}; + } nameEl = (
@@ -138,6 +142,15 @@ const EntityTile = React.createClass({ {presenceLabel}
); + } else if (this.props.subtextLabel) { + nameEl = ( +
+ + {name} + + {this.props.subtextLabel} +
+ ); } else { nameEl = ( { name } diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 17b1311c4f..1829413dfd 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -42,6 +42,7 @@ import AccessibleButton from '../elements/AccessibleButton'; import RoomViewStore from '../../../stores/RoomViewStore'; import SdkConfig from '../../../SdkConfig'; import MultiInviter from "../../../utils/MultiInviter"; +import SettingsStore from "../../../settings/SettingsStore"; module.exports = withMatrixClient(React.createClass({ displayName: 'MemberInfo', @@ -889,11 +890,16 @@ module.exports = withMatrixClient(React.createClass({ let presenceState; let presenceLastActiveAgo; let presenceCurrentlyActive; + let statusMessage; if (this.props.member.user) { presenceState = this.props.member.user.presence; presenceLastActiveAgo = this.props.member.user.lastActiveAgo; presenceCurrentlyActive = this.props.member.user.currentlyActive; + + if (SettingsStore.isFeatureEnabled("feature_custom_status")) { + statusMessage = this.props.member.user._unstable_statusMessage; + } } const room = this.props.matrixClient.getRoom(this.props.member.roomId); @@ -915,6 +921,11 @@ module.exports = withMatrixClient(React.createClass({ presenceState={presenceState} />; } + let statusLabel = null; + if (statusMessage) { + statusLabel = { statusMessage }; + } + let roomMemberDetails = null; if (this.props.member.roomId) { // is in room const PowerSelector = sdk.getComponent('elements.PowerSelector'); @@ -931,6 +942,7 @@ module.exports = withMatrixClient(React.createClass({
{presenceLabel} + {statusLabel}
; } diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index 2359bc242c..ba951792d0 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -16,6 +16,8 @@ limitations under the License. 'use strict'; +import SettingsStore from "../../../settings/SettingsStore"; + const React = require('react'); import PropTypes from 'prop-types'; @@ -85,6 +87,11 @@ module.exports = React.createClass({ const active = -1; const presenceState = member.user ? member.user.presence : null; + let statusMessage = null; + if (member.user && SettingsStore.isFeatureEnabled("feature_custom_status")) { + statusMessage = member.user._unstable_statusMessage; + } + const av = ( ); @@ -106,7 +113,9 @@ module.exports = React.createClass({ presenceLastTs={member.user ? member.user.lastPresenceTs : 0} presenceCurrentlyActive={member.user ? member.user.currentlyActive : false} avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick} - name={name} powerStatus={powerStatus} showPresence={this.props.showPresence} /> + name={name} powerStatus={powerStatus} showPresence={this.props.showPresence} + subtextLabel={statusMessage} + /> ); }, }); diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 3fa0f888df..2fc35d80cc 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -291,7 +291,7 @@ export default class MessageComposer extends React.Component { render() { const uploadInputStyle = {display: 'none'}; - const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); const TintableSvg = sdk.getComponent("elements.TintableSvg"); const MessageComposerInput = sdk.getComponent("rooms.MessageComposerInput"); @@ -300,7 +300,7 @@ export default class MessageComposer extends React.Component { if (this.state.me) { controls.push(
- +
, ); } diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 54044e8d65..a054246b4f 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -30,6 +30,7 @@ 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({ displayName: 'RoomTile', @@ -251,6 +252,17 @@ module.exports = React.createClass({ const mentionBadges = this.props.highlight && this._shouldShowMentionBadge(); const badges = notifBadges || mentionBadges; + const isJoined = this.props.room.getMyMembership() === "join"; + const looksLikeDm = this.props.room.getInvitedAndJoinedMemberCount() === 2; + let subtext = null; + if (!isInvite && isJoined && looksLikeDm && SettingsStore.isFeatureEnabled("feature_custom_status")) { + const selfId = MatrixClientPeg.get().getUserId(); + const otherMember = this.props.room.currentState.getMembersExcept([selfId])[0]; + if (otherMember && otherMember.user && otherMember.user._unstable_statusMessage) { + subtext = otherMember.user._unstable_statusMessage; + } + } + const classes = classNames({ 'mx_RoomTile': true, 'mx_RoomTile_selected': this.state.selected, @@ -261,6 +273,7 @@ module.exports = React.createClass({ 'mx_RoomTile_menuDisplayed': this.state.menuDisplayed, 'mx_RoomTile_noBadges': !badges, 'mx_RoomTile_transparent': this.props.transparent, + 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed, }); const avatarClasses = classNames({ @@ -291,6 +304,7 @@ module.exports = React.createClass({ const EmojiText = sdk.getComponent('elements.EmojiText'); let label; + let subtextLabel; let tooltip; if (!this.props.collapsed) { const nameClasses = classNames({ @@ -299,6 +313,8 @@ module.exports = React.createClass({ 'mx_RoomTile_badgeShown': badges || this.state.badgeHover || this.state.menuDisplayed, }); + subtextLabel = subtext ? { subtext } : null; + if (this.state.selected) { const nameSelected = { name }; @@ -339,6 +355,7 @@ module.exports = React.createClass({
{ label } + { subtextLabel } { badge }
{ /* { incomingCallBox } */ } diff --git a/src/components/views/settings/KeyBackupPanel.js b/src/components/views/settings/KeyBackupPanel.js index b08f4d0e78..03b98d28a0 100644 --- a/src/components/views/settings/KeyBackupPanel.js +++ b/src/components/views/settings/KeyBackupPanel.js @@ -154,6 +154,7 @@ export default class KeyBackupPanel extends React.Component { } let backupSigStatuses = this.state.backupSigStatus.sigs.map((sig, i) => { + const deviceName = sig.device.getDisplayName() || sig.device.deviceId; const sigStatusSubstitutions = { validity: sub => @@ -163,7 +164,7 @@ export default class KeyBackupPanel extends React.Component { {sub} , - device: sub => {sig.device.getDisplayName()}, + device: sub => {deviceName}, }; let sigStatus; if (sig.device.getFingerprint() === MatrixClientPeg.get().getDeviceEd25519Key()) { @@ -174,7 +175,7 @@ export default class KeyBackupPanel extends React.Component { } else if (sig.valid && sig.device.isVerified()) { sigStatus = _t( "Backup has a valid signature from " + - "verified device x", + "verified device ", {}, sigStatusSubstitutions, ); } else if (sig.valid && !sig.device.isVerified()) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 00f781ea5b..81fcd963b3 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -250,11 +250,14 @@ "A word by itself is easy to guess": "A word by itself is easy to guess", "Names and surnames by themselves are easy to guess": "Names and surnames by themselves are easy to guess", "Common names and surnames are easy to guess": "Common names and surnames are easy to guess", + "Straight rows of keys are easy to guess": "Straight rows of keys are easy to guess", + "Short keyboard patterns are easy to guess": "Short keyboard patterns are easy to guess", "There was an error joining the room": "There was an error joining the room", "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", "Failed to join room": "Failed to join room", "Message Pinning": "Message Pinning", + "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", "Disable Emoji suggestions while typing": "Disable Emoji suggestions while typing", @@ -351,7 +354,7 @@ "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", "Backup has a valid signature from this device": "Backup has a valid signature from this device", - "Backup has a valid signature from verified device x": "Backup has a valid signature from verified device x", + "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 ", "Backup has an invalid signature from verified device ": "Backup has an invalid signature from verified device ", "Backup has an invalid signature from unverified device ": "Backup has an invalid signature from unverified device ", @@ -887,6 +890,7 @@ "What GitHub issue are these logs for?": "What GitHub issue are these logs for?", "Notes:": "Notes:", "Send logs": "Send logs", + "Unable to load commit detail: %(msg)s": "Unable to load commit detail: %(msg)s", "Unavailable": "Unavailable", "Changelog": "Changelog", "Create a new chat or reuse an existing one": "Create a new chat or reuse an existing one", @@ -1058,6 +1062,8 @@ "Forget": "Forget", "Low Priority": "Low Priority", "Direct Chat": "Direct Chat", + "Set a new status...": "Set a new status...", + "Clear status": "Clear status", "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.", @@ -1401,6 +1407,12 @@ "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.", + "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", + "Go to Settings": "Go to 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/settings/Settings.js b/src/settings/Settings.js index c9a4ecdebe..1cac8559d1 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -83,6 +83,12 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_custom_status": { + isFeature: true, + displayName: _td("Custom user status messages"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_lazyloading": { isFeature: true, displayName: _td("Increase performance by only loading room members on first view"), diff --git a/src/utils/PasswordScorer.js b/src/utils/PasswordScorer.js index e4bbec1637..545686cdb6 100644 --- a/src/utils/PasswordScorer.js +++ b/src/utils/PasswordScorer.js @@ -52,6 +52,8 @@ _td("This is similar to a commonly used password"); _td("A word by itself is easy to guess"); _td("Names and surnames by themselves are easy to guess"); _td("Common names and surnames are easy to guess"); +_td("Straight rows of keys are easy to guess"); +_td("Short keyboard patterns are easy to guess"); /** * Wrapper around zxcvbn password strength estimation