From 878413f6a48a4dcaa7e14873fefc41661236fb0a Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 14 Mar 2017 11:50:13 +0000 Subject: [PATCH 01/28] Support msisdn signin Changes from https://github.com/matrix-org/matrix-react-sdk/pull/742 --- src/HtmlUtils.js | 16 + src/Login.js | 41 +- src/component-index.js | 4 + src/components/structures/login/Login.js | 36 +- .../structures/login/Registration.js | 18 +- .../views/elements/AccessibleButton.js | 4 +- src/components/views/elements/Dropdown.js | 324 +++++ src/components/views/login/CountryDropdown.js | 123 ++ .../login/InteractiveAuthEntryComponents.js | 134 ++ src/components/views/login/PasswordLogin.js | 46 +- .../views/login/RegistrationForm.js | 43 + src/phonenumber.js | 1273 +++++++++++++++++ 12 files changed, 2032 insertions(+), 30 deletions(-) create mode 100644 src/components/views/elements/Dropdown.js create mode 100644 src/components/views/login/CountryDropdown.js create mode 100644 src/phonenumber.js diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index c500076783..f1420d0a22 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -58,6 +58,22 @@ export function unicodeToImage(str) { return str; } +/** + * Given one or more unicode characters (represented by unicode + * character number), return an image node with the corresponding + * emoji. + * + * @param alt {string} String to use for the image alt text + * @param unicode {integer} One or more integers representing unicode characters + * @returns A img node with the corresponding emoji + */ +export function charactersToImageNode(alt, ...unicode) { + const fileName = unicode.map((u) => { + return u.toString(16); + }).join('-'); + return {alt}; +} + export function stripParagraphs(html: string): string { const contentDiv = document.createElement('div'); contentDiv.innerHTML = html; diff --git a/src/Login.js b/src/Login.js index 96f953c130..053f88ce93 100644 --- a/src/Login.js +++ b/src/Login.js @@ -105,21 +105,38 @@ export default class Login { }); } - loginViaPassword(username, pass) { - var self = this; - var isEmail = username.indexOf("@") > 0; - var loginParams = { - password: pass, - initial_device_display_name: this._defaultDeviceDisplayName, - }; - if (isEmail) { - loginParams.medium = 'email'; - loginParams.address = username; + loginViaPassword(username, phoneCountry, phoneNumber, pass) { + const self = this; + + const isEmail = username.indexOf("@") > 0; + + let identifier; + if (phoneCountry && phoneNumber) { + identifier = { + type: 'm.id.phone', + country: phoneCountry, + number: phoneNumber, + }; + } else if (isEmail) { + identifier = { + type: 'm.id.thirdparty', + medium: 'email', + address: username, + }; } else { - loginParams.user = username; + identifier = { + type: 'm.id.user', + user: username, + }; } - var client = this._createTemporaryClient(); + const loginParams = { + password: pass, + identifier: identifier, + initial_device_display_name: this._defaultDeviceDisplayName, + }; + + const client = this._createTemporaryClient(); return client.login('m.login.password', loginParams).then(function(data) { return q({ homeserverUrl: self._hsUrl, diff --git a/src/component-index.js b/src/component-index.js index 2644f1a379..59d3ad53e4 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -109,6 +109,8 @@ import views$elements$DeviceVerifyButtons from './components/views/elements/Devi views$elements$DeviceVerifyButtons && (module.exports.components['views.elements.DeviceVerifyButtons'] = views$elements$DeviceVerifyButtons); import views$elements$DirectorySearchBox from './components/views/elements/DirectorySearchBox'; views$elements$DirectorySearchBox && (module.exports.components['views.elements.DirectorySearchBox'] = views$elements$DirectorySearchBox); +import views$elements$Dropdown from './components/views/elements/Dropdown'; +views$elements$Dropdown && (module.exports.components['views.elements.Dropdown'] = views$elements$Dropdown); import views$elements$EditableText from './components/views/elements/EditableText'; views$elements$EditableText && (module.exports.components['views.elements.EditableText'] = views$elements$EditableText); import views$elements$EditableTextContainer from './components/views/elements/EditableTextContainer'; @@ -131,6 +133,8 @@ import views$login$CaptchaForm from './components/views/login/CaptchaForm'; views$login$CaptchaForm && (module.exports.components['views.login.CaptchaForm'] = views$login$CaptchaForm); import views$login$CasLogin from './components/views/login/CasLogin'; views$login$CasLogin && (module.exports.components['views.login.CasLogin'] = views$login$CasLogin); +import views$login$CountryDropdown from './components/views/login/CountryDropdown'; +views$login$CountryDropdown && (module.exports.components['views.login.CountryDropdown'] = views$login$CountryDropdown); import views$login$CustomServerDialog from './components/views/login/CustomServerDialog'; views$login$CustomServerDialog && (module.exports.components['views.login.CustomServerDialog'] = views$login$CustomServerDialog); import views$login$InteractiveAuthEntryComponents from './components/views/login/InteractiveAuthEntryComponents'; diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 69195fc715..0a1549f75b 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -64,8 +65,10 @@ module.exports = React.createClass({ enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl, enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl, - // used for preserving username when changing homeserver + // used for preserving form values when changing homeserver username: "", + phoneCountry: null, + phoneNumber: "", }; }, @@ -73,20 +76,21 @@ module.exports = React.createClass({ this._initLoginLogic(); }, - onPasswordLogin: function(username, password) { - var self = this; - self.setState({ + onPasswordLogin: function(username, phoneCountry, phoneNumber, password) { + this.setState({ busy: true, errorText: null, loginIncorrect: false, }); - this._loginLogic.loginViaPassword(username, password).then(function(data) { - self.props.onLoggedIn(data); - }, function(error) { - self._setStateFromError(error, true); - }).finally(function() { - self.setState({ + this._loginLogic.loginViaPassword( + username, phoneCountry, phoneNumber, password, + ).then((data) => { + this.props.onLoggedIn(data); + }, (error) => { + this._setStateFromError(error, true); + }).finally(() => { + this.setState({ busy: false }); }).done(); @@ -119,6 +123,14 @@ module.exports = React.createClass({ this.setState({ username: username }); }, + onPhoneCountryChanged: function(phoneCountry) { + this.setState({ phoneCountry: phoneCountry }); + }, + + onPhoneNumberChanged: function(phoneNumber) { + this.setState({ phoneNumber: phoneNumber }); + }, + onHsUrlChanged: function(newHsUrl) { var self = this; this.setState({ @@ -225,7 +237,11 @@ module.exports = React.createClass({ diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index cbc8929158..f4805ef044 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -262,6 +262,9 @@ module.exports = React.createClass({ case "RegistrationForm.ERR_EMAIL_INVALID": errMsg = "This doesn't look like a valid email address"; break; + case "RegistrationForm.ERR_PHONE_NUMBER_INVALID": + errMsg = "This doesn't look like a valid phone number"; + break; case "RegistrationForm.ERR_USERNAME_INVALID": errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores."; break; @@ -296,15 +299,20 @@ module.exports = React.createClass({ guestAccessToken = null; } + // Only send the bind params if we're sending username / pw params + // (Since we need to send no params at all to use the ones saved in the + // session). + const bindThreepids = this.state.formVals.password ? { + email: true, + msisdn: true, + } : {}; + return this._matrixClient.register( this.state.formVals.username, this.state.formVals.password, undefined, // session id: included in the auth dict already auth, - // Only send the bind_email param if we're sending username / pw params - // (Since we need to send no params at all to use the ones saved in the - // session). - Boolean(this.state.formVals.username) || undefined, + bindThreepids, guestAccessToken, ); }, @@ -355,6 +363,8 @@ module.exports = React.createClass({ + {this.props.children} + + } +}; + +MenuOption.propTypes = { + children: React.PropTypes.oneOfType([ + React.PropTypes.arrayOf(React.PropTypes.node), + React.PropTypes.node + ]), + highlighted: React.PropTypes.bool, + dropdownKey: React.PropTypes.string, + onClick: React.PropTypes.func.isRequired, + onMouseEnter: React.PropTypes.func.isRequired, +}; + +/* + * Reusable dropdown select control, akin to react-select, + * but somewhat simpler as react-select is 79KB of minified + * javascript. + * + * TODO: Port NetworkDropdown to use this. + */ +export default class Dropdown extends React.Component { + constructor(props) { + super(props); + + this.dropdownRootElement = null; + this.ignoreEvent = null; + + this._onInputClick = this._onInputClick.bind(this); + this._onRootClick = this._onRootClick.bind(this); + this._onDocumentClick = this._onDocumentClick.bind(this); + this._onMenuOptionClick = this._onMenuOptionClick.bind(this); + this._onInputKeyPress = this._onInputKeyPress.bind(this); + this._onInputKeyUp = this._onInputKeyUp.bind(this); + this._onInputChange = this._onInputChange.bind(this); + this._collectRoot = this._collectRoot.bind(this); + this._collectInputTextBox = this._collectInputTextBox.bind(this); + this._setHighlightedOption = this._setHighlightedOption.bind(this); + + this.inputTextBox = null; + + this._reindexChildren(this.props.children); + + const firstChild = React.Children.toArray(props.children)[0]; + + this.state = { + // True if the menu is dropped-down + expanded: false, + // The key of the highlighted option + // (the option that would become selected if you pressed enter) + highlightedOption: firstChild ? firstChild.key : null, + // the current search query + searchQuery: '', + }; + } + + componentWillMount() { + // Listen for all clicks on the document so we can close the + // menu when the user clicks somewhere else + document.addEventListener('click', this._onDocumentClick, false); + } + + componentWillUnmount() { + document.removeEventListener('click', this._onDocumentClick, false); + } + + componentWillReceiveProps(nextProps) { + this._reindexChildren(nextProps.children); + const firstChild = React.Children.toArray(nextProps.children)[0]; + this.setState({ + highlightedOption: firstChild ? firstChild.key : null, + }); + } + + _reindexChildren(children) { + this.childrenByKey = {}; + React.Children.forEach(children, (child) => { + this.childrenByKey[child.key] = child; + }); + } + + _onDocumentClick(ev) { + // Close the dropdown if the user clicks anywhere that isn't + // within our root element + if (ev !== this.ignoreEvent) { + this.setState({ + expanded: false, + }); + } + } + + _onRootClick(ev) { + // This captures any clicks that happen within our elements, + // such that we can then ignore them when they're seen by the + // click listener on the document handler, ie. not close the + // dropdown immediately after opening it. + // NB. We can't just stopPropagation() because then the event + // doesn't reach the React onClick(). + this.ignoreEvent = ev; + } + + _onInputClick(ev) { + this.setState({ + expanded: !this.state.expanded, + }); + ev.preventDefault(); + } + + _onMenuOptionClick(dropdownKey) { + this.setState({ + expanded: false, + }); + this.props.onOptionChange(dropdownKey); + } + + _onInputKeyPress(e) { + // This needs to be on the keypress event because otherwise + // it can't cancel the form submission + if (e.key == 'Enter') { + this.setState({ + expanded: false, + }); + this.props.onOptionChange(this.state.highlightedOption); + e.preventDefault(); + } + } + + _onInputKeyUp(e) { + // These keys don't generate keypress events and so needs to + // be on keyup + if (e.key == 'Escape') { + this.setState({ + expanded: false, + }); + } else if (e.key == 'ArrowDown') { + this.setState({ + highlightedOption: this._nextOption(this.state.highlightedOption), + }); + } else if (e.key == 'ArrowUp') { + this.setState({ + highlightedOption: this._prevOption(this.state.highlightedOption), + }); + } + } + + _onInputChange(e) { + this.setState({ + searchQuery: e.target.value, + }); + if (this.props.onSearchChange) { + this.props.onSearchChange(e.target.value); + } + } + + _collectRoot(e) { + if (this.dropdownRootElement) { + this.dropdownRootElement.removeEventListener( + 'click', this._onRootClick, false, + ); + } + if (e) { + e.addEventListener('click', this._onRootClick, false); + } + this.dropdownRootElement = e; + } + + _collectInputTextBox(e) { + this.inputTextBox = e; + if (e) e.focus(); + } + + _setHighlightedOption(optionKey) { + this.setState({ + highlightedOption: optionKey, + }); + } + + _nextOption(optionKey) { + const keys = Object.keys(this.childrenByKey); + const index = keys.indexOf(optionKey); + return keys[(index + 1) % keys.length]; + } + + _prevOption(optionKey) { + const keys = Object.keys(this.childrenByKey); + const index = keys.indexOf(optionKey); + return keys[(index - 1) % keys.length]; + } + + _getMenuOptions() { + const options = React.Children.map(this.props.children, (child) => { + return ( + + {child} + + ); + }); + + if (!this.state.searchQuery) { + options.push( +
+ Type to search... +
+ ); + } + return options; + } + + render() { + let currentValue; + + const menuStyle = {}; + if (this.props.menuWidth) menuStyle.width = this.props.menuWidth; + + let menu; + if (this.state.expanded) { + currentValue = ; + menu =
+ {this._getMenuOptions()} +
; + } else { + const selectedChild = this.props.getShortOption ? + this.props.getShortOption(this.props.value) : + this.childrenByKey[this.props.value]; + currentValue =
+ {selectedChild} +
+ } + + const dropdownClasses = { + mx_Dropdown: true, + }; + if (this.props.className) { + dropdownClasses[this.props.className] = true; + } + + // Note the menu sits inside the AccessibleButton div so it's anchored + // to the input, but overflows below it. The root contains both. + return
+ + {currentValue} + + {menu} + +
; + } +} + +Dropdown.propTypes = { + // The width that the dropdown should be. If specified, + // the dropped-down part of the menu will be set to this + // width. + menuWidth: React.PropTypes.number, + // Called when the selected option changes + onOptionChange: React.PropTypes.func.isRequired, + // Called when the value of the search field changes + onSearchChange: React.PropTypes.func, + // Function that, given the key of an option, returns + // a node representing that option to be displayed in the + // box itself as the currently-selected option (ie. as + // opposed to in the actual dropped-down part). If + // unspecified, the appropriate child element is used as + // in the dropped-down menu. + getShortOption: React.PropTypes.func, + value: React.PropTypes.string, +} diff --git a/src/components/views/login/CountryDropdown.js b/src/components/views/login/CountryDropdown.js new file mode 100644 index 0000000000..fc1e89661b --- /dev/null +++ b/src/components/views/login/CountryDropdown.js @@ -0,0 +1,123 @@ +/* +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. +*/ + +import React from 'react'; + +import sdk from '../../../index'; + +import { COUNTRIES } from '../../../phonenumber'; +import { charactersToImageNode } from '../../../HtmlUtils'; + +const COUNTRIES_BY_ISO2 = new Object(null); +for (const c of COUNTRIES) { + COUNTRIES_BY_ISO2[c.iso2] = c; +} + +function countryMatchesSearchQuery(query, country) { + if (country.name.toUpperCase().indexOf(query.toUpperCase()) == 0) return true; + if (country.iso2 == query.toUpperCase()) return true; + if (country.prefix == query) return true; + return false; +} + +const MAX_DISPLAYED_ROWS = 2; + +export default class CountryDropdown extends React.Component { + constructor(props) { + super(props); + this._onSearchChange = this._onSearchChange.bind(this); + + this.state = { + searchQuery: '', + } + + if (!props.value) { + // If no value is given, we start with the first + // country selected, but our parent component + // doesn't know this, therefore we do this. + this.props.onOptionChange(COUNTRIES[0].iso2); + } + } + + _onSearchChange(search) { + this.setState({ + searchQuery: search, + }); + } + + _flagImgForIso2(iso2) { + // Unicode Regional Indicator Symbol letter 'A' + const RIS_A = 0x1F1E6; + const ASCII_A = 65; + return charactersToImageNode(iso2, + RIS_A + (iso2.charCodeAt(0) - ASCII_A), + RIS_A + (iso2.charCodeAt(1) - ASCII_A), + ); + } + + render() { + const Dropdown = sdk.getComponent('elements.Dropdown'); + + let displayedCountries; + if (this.state.searchQuery) { + displayedCountries = COUNTRIES.filter( + countryMatchesSearchQuery.bind(this, this.state.searchQuery), + ); + if ( + this.state.searchQuery.length == 2 && + COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()] + ) { + // exact ISO2 country name match: make the first result the matches ISO2 + const matched = COUNTRIES_BY_ISO2[this.state.searchQuery.toUpperCase()]; + displayedCountries = displayedCountries.filter((c) => { + return c.iso2 != matched.iso2; + }); + displayedCountries.unshift(matched); + } + } else { + displayedCountries = COUNTRIES; + } + + if (displayedCountries.length > MAX_DISPLAYED_ROWS) { + displayedCountries = displayedCountries.slice(0, MAX_DISPLAYED_ROWS); + } + + const options = displayedCountries.map((country) => { + return
+ {this._flagImgForIso2(country.iso2)} + {country.name} +
; + }); + + // default value here too, otherwise we need to handle null / undefined + // values between mounting and the initial value propgating + const value = this.props.value || COUNTRIES[0].iso2; + + return + {options} + + } +} + +CountryDropdown.propTypes = { + className: React.PropTypes.string, + onOptionChange: React.PropTypes.func.isRequired, + value: React.PropTypes.string, +}; diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index e75cb082d4..2d8abf9216 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -16,6 +16,8 @@ limitations under the License. */ import React from 'react'; +import url from 'url'; +import classnames from 'classnames'; import sdk from '../../../index'; @@ -255,6 +257,137 @@ export const EmailIdentityAuthEntry = React.createClass({ }, }); +export const MsisdnAuthEntry = React.createClass({ + displayName: 'MsisdnAuthEntry', + + statics: { + LOGIN_TYPE: "m.login.msisdn", + }, + + propTypes: { + inputs: React.PropTypes.shape({ + phoneCountry: React.PropTypes.string, + phoneNumber: React.PropTypes.string, + }), + fail: React.PropTypes.func, + clientSecret: React.PropTypes.func, + submitAuthDict: React.PropTypes.func.isRequired, + matrixClient: React.PropTypes.object, + submitAuthDict: React.PropTypes.func, + }, + + getInitialState: function() { + return { + token: '', + requestingToken: false, + }; + }, + + componentWillMount: function() { + this._sid = null; + this._msisdn = null; + this._tokenBox = null; + + this.setState({requestingToken: true}); + this._requestMsisdnToken().catch((e) => { + this.props.fail(e); + }).finally(() => { + this.setState({requestingToken: false}); + }).done(); + }, + + /* + * Requests a verification token by SMS. + */ + _requestMsisdnToken: function() { + return this.props.matrixClient.requestRegisterMsisdnToken( + this.props.inputs.phoneCountry, + this.props.inputs.phoneNumber, + this.props.clientSecret, + 1, // TODO: Multiple send attempts? + ).then((result) => { + this._sid = result.sid; + this._msisdn = result.msisdn; + }); + }, + + _onTokenChange: function(e) { + this.setState({ + token: e.target.value, + }); + }, + + _onFormSubmit: function(e) { + e.preventDefault(); + if (this.state.token == '') return; + + this.setState({ + errorText: null, + }); + + this.props.matrixClient.submitMsisdnToken( + this._sid, this.props.clientSecret, this.state.token + ).then((result) => { + if (result.success) { + const idServerParsedUrl = url.parse( + this.props.matrixClient.getIdentityServerUrl(), + ) + this.props.submitAuthDict({ + type: MsisdnAuthEntry.LOGIN_TYPE, + threepid_creds: { + sid: this._sid, + client_secret: this.props.clientSecret, + id_server: idServerParsedUrl.host, + }, + }); + } else { + this.setState({ + errorText: "Token incorrect", + }); + } + }).catch((e) => { + this.props.fail(e); + console.log("Failed to submit msisdn token"); + }).done(); + }, + + render: function() { + if (this.state.requestingToken) { + const Loader = sdk.getComponent("elements.Spinner"); + return ; + } else { + const enableSubmit = Boolean(this.state.token); + const submitClasses = classnames({ + mx_InteractiveAuthEntryComponents_msisdnSubmit: true, + mx_UserSettings_button: true, // XXX button classes + }); + return ( +
+

A text message has been sent to +{this._msisdn}

+

Please enter the code it contains:

+
+
+ +
+ +
+
+ {this.state.errorText} +
+
+
+ ); + } + }, +}); + export const FallbackAuthEntry = React.createClass({ displayName: 'FallbackAuthEntry', @@ -313,6 +446,7 @@ const AuthEntryComponents = [ PasswordAuthEntry, RecaptchaAuthEntry, EmailIdentityAuthEntry, + MsisdnAuthEntry, ]; export function getEntryComponentForLoginType(loginType) { diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 6f6081858b..61cb3da652 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -17,6 +18,7 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; +import sdk from '../../../index'; import {field_input_incorrect} from '../../../UiEffects'; @@ -28,8 +30,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin', onSubmit: React.PropTypes.func.isRequired, // fn(username, password) onForgotPasswordClick: React.PropTypes.func, // fn() initialUsername: React.PropTypes.string, + initialPhoneCountry: React.PropTypes.string, + initialPhoneNumber: React.PropTypes.string, initialPassword: React.PropTypes.string, onUsernameChanged: React.PropTypes.func, + onPhoneCountryChanged: React.PropTypes.func, + onPhoneNumberChanged: React.PropTypes.func, onPasswordChanged: React.PropTypes.func, loginIncorrect: React.PropTypes.bool, }, @@ -38,7 +44,11 @@ module.exports = React.createClass({displayName: 'PasswordLogin', return { onUsernameChanged: function() {}, onPasswordChanged: function() {}, + onPhoneCountryChanged: function() {}, + onPhoneNumberChanged: function() {}, initialUsername: "", + initialPhoneCountry: "", + initialPhoneNumber: "", initialPassword: "", loginIncorrect: false, }; @@ -48,6 +58,8 @@ module.exports = React.createClass({displayName: 'PasswordLogin', return { username: this.props.initialUsername, password: this.props.initialPassword, + phoneCountry: this.props.initialPhoneCountry, + phoneNumber: this.props.initialPhoneNumber, }; }, @@ -63,7 +75,12 @@ module.exports = React.createClass({displayName: 'PasswordLogin', onSubmitForm: function(ev) { ev.preventDefault(); - this.props.onSubmit(this.state.username, this.state.password); + this.props.onSubmit( + this.state.username, + this.state.phoneCountry, + this.state.phoneNumber, + this.state.password, + ); }, onUsernameChanged: function(ev) { @@ -71,6 +88,16 @@ module.exports = React.createClass({displayName: 'PasswordLogin', this.props.onUsernameChanged(ev.target.value); }, + onPhoneCountryChanged: function(country) { + this.setState({phoneCountry: country}); + this.props.onPhoneCountryChanged(country); + }, + + onPhoneNumberChanged: function(ev) { + this.setState({phoneNumber: ev.target.value}); + this.props.onPhoneNumberChanged(ev.target.value); + }, + onPasswordChanged: function(ev) { this.setState({password: ev.target.value}); this.props.onPasswordChanged(ev.target.value); @@ -92,13 +119,28 @@ module.exports = React.createClass({displayName: 'PasswordLogin', error: this.props.loginIncorrect, }); + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); return (
- + or +
+ + +

{this._passwordField = e;}} type="password" name="password" diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 93e3976834..4868c9de63 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -19,9 +19,12 @@ import React from 'react'; import { field_input_incorrect } from '../../../UiEffects'; import sdk from '../../../index'; import Email from '../../../email'; +import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; import Modal from '../../../Modal'; const FIELD_EMAIL = 'field_email'; +const FIELD_PHONE_COUNTRY = 'field_phone_country'; +const FIELD_PHONE_NUMBER = 'field_phone_number'; const FIELD_USERNAME = 'field_username'; const FIELD_PASSWORD = 'field_password'; const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; @@ -35,6 +38,8 @@ module.exports = React.createClass({ propTypes: { // Values pre-filled in the input boxes when the component loads defaultEmail: React.PropTypes.string, + defaultPhoneCountry: React.PropTypes.string, + defaultPhoneNumber: React.PropTypes.string, defaultUsername: React.PropTypes.string, defaultPassword: React.PropTypes.string, teamsConfig: React.PropTypes.shape({ @@ -71,6 +76,8 @@ module.exports = React.createClass({ return { fieldValid: {}, selectedTeam: null, + // The ISO2 country code selected in the phone number entry + phoneCountry: this.props.defaultPhoneCountry, }; }, @@ -85,6 +92,7 @@ module.exports = React.createClass({ this.validateField(FIELD_PASSWORD_CONFIRM); this.validateField(FIELD_PASSWORD); this.validateField(FIELD_USERNAME); + this.validateField(FIELD_PHONE_NUMBER); this.validateField(FIELD_EMAIL); var self = this; @@ -118,6 +126,8 @@ module.exports = React.createClass({ username: this.refs.username.value.trim() || this.props.guestUsername, password: this.refs.password.value.trim(), email: email, + phoneCountry: this.state.phoneCountry, + phoneNumber: this.refs.phoneNumber.value.trim(), }); if (promise) { @@ -174,6 +184,11 @@ module.exports = React.createClass({ const emailValid = email === '' || Email.looksValid(email); this.markFieldValid(field_id, emailValid, "RegistrationForm.ERR_EMAIL_INVALID"); break; + case FIELD_PHONE_NUMBER: + const phoneNumber = this.refs.phoneNumber.value; + const phoneNumberValid = phoneNumber === '' || phoneNumberLooksValid(phoneNumber); + this.markFieldValid(field_id, phoneNumberValid, "RegistrationForm.ERR_PHONE_NUMBER_INVALID"); + break; case FIELD_USERNAME: // XXX: SPEC-1 var username = this.refs.username.value.trim() || this.props.guestUsername; @@ -233,6 +248,8 @@ module.exports = React.createClass({ switch (field_id) { case FIELD_EMAIL: return this.refs.email; + case FIELD_PHONE_NUMBER: + return this.refs.phoneNumber; case FIELD_USERNAME: return this.refs.username; case FIELD_PASSWORD: @@ -251,6 +268,12 @@ module.exports = React.createClass({ return cls; }, + _onPhoneCountryChange(newVal) { + this.setState({ + phoneCountry: newVal, + }); + }, + render: function() { var self = this; @@ -286,6 +309,25 @@ module.exports = React.createClass({ } } + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + const phoneSection = ( +
+ + +
+ ); + const registerButton = ( ); @@ -300,6 +342,7 @@ module.exports = React.createClass({ {emailSection} {belowEmailSection} + {phoneSection} Date: Tue, 14 Mar 2017 14:37:18 +0000 Subject: [PATCH 02/28] Send legacy parameters on login call To support login on old HSes --- src/Login.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Login.js b/src/Login.js index 053f88ce93..107a8825e9 100644 --- a/src/Login.js +++ b/src/Login.js @@ -111,23 +111,32 @@ export default class Login { const isEmail = username.indexOf("@") > 0; let identifier; + let legacyParams; // parameters added to support old HSes if (phoneCountry && phoneNumber) { identifier = { type: 'm.id.phone', country: phoneCountry, number: phoneNumber, }; + // No legacy support for phone number login } else if (isEmail) { identifier = { type: 'm.id.thirdparty', medium: 'email', address: username, }; + legacyParams = { + medium: 'email', + address: username, + }; } else { identifier = { type: 'm.id.user', user: username, }; + legacyParams = { + user: username, + }; } const loginParams = { @@ -135,6 +144,7 @@ export default class Login { identifier: identifier, initial_device_display_name: this._defaultDeviceDisplayName, }; + Object.assign(loginParams, legacyParams); const client = this._createTemporaryClient(); return client.login('m.login.password', loginParams).then(function(data) { From d292a627d8e1f4cb7b8aee4d3488e0b06da4d096 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 15 Mar 2017 16:44:56 +0000 Subject: [PATCH 03/28] Handle no-auth-flow error from js-sdk --- src/components/structures/InteractiveAuth.js | 1 - src/components/structures/login/Registration.js | 13 ++++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 71fee883be..a58ad9aaa4 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -107,7 +107,6 @@ export default React.createClass({ return; } - const msg = error.message || error.toString(); this.setState({ errorText: msg }); diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index f4805ef044..a878657de9 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -155,10 +155,21 @@ module.exports = React.createClass({ _onUIAuthFinished: function(success, response, extra) { if (!success) { + let msg = response.message || response.toString(); + // can we give a better error message? + if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { + let msisdn_available = false; + for (const flow of response.available_flows) { + msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1; + } + if (!msisdn_available) { + msg = "This server does not support authentication with a phone number"; + } + } this.setState({ busy: false, doingUIAuth: false, - errorText: response.message || response.toString(), + errorText: msg, }); return; } From 67757a16f368d5ca5ba60d1fbd4b6ac240229c54 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 16 Mar 2017 12:54:18 +0000 Subject: [PATCH 04/28] Don't remove the line that gets the error message --- src/components/structures/InteractiveAuth.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index a58ad9aaa4..71fee883be 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -107,6 +107,7 @@ export default React.createClass({ return; } + const msg = error.message || error.toString(); this.setState({ errorText: msg }); From 544a6593e1ab91e60efd30a0085a4c3b25c2beae Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Thu, 16 Mar 2017 14:19:17 +0000 Subject: [PATCH 05/28] Unregister the UploadBar event listener on unmount --- src/components/structures/UploadBar.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 8266a11bc8..01a879fd1b 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -25,12 +25,13 @@ module.exports = React.createClass({displayName: 'UploadBar', }, componentDidMount: function() { - dis.register(this.onAction); + this.dispatcherRef = dis.register(this.onAction); this.mounted = true; }, componentWillUnmount: function() { this.mounted = false; + dis.unregister(this.dispatcherRef); }, onAction: function(payload) { From af8c3edba6d7cdd3bb0acb1f741f7eeaf27020a6 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 16 Mar 2017 14:56:26 +0000 Subject: [PATCH 06/28] Support adding phone numbers in UserSettings --- src/AddThreepid.js | 52 +++++++- src/components/structures/UserSettings.js | 146 +++++++++++++++++++--- 2 files changed, 181 insertions(+), 17 deletions(-) diff --git a/src/AddThreepid.js b/src/AddThreepid.js index d6a1d58aa0..44d709371b 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +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. @@ -51,11 +52,35 @@ class AddThreepid { }); } + /** + * Attempt to add a msisdn threepid. This will trigger a side-effect of + * sending a test message to the provided phone number. + * @param {string} emailAddress The email address to add + * @param {boolean} bind If True, bind this phone number to this mxid on the Identity Server + * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). + */ + addMsisdn(phoneCountry, phoneNumber, bind) { + this.bind = bind; + return MatrixClientPeg.get().requestAdd3pidMsisdnToken( + phoneCountry, phoneNumber, this.clientSecret, 1, + ).then((res) => { + this.sessionId = res.sid; + return res; + }, function(err) { + if (err.errcode == 'M_THREEPID_IN_USE') { + err.message = "This phone number is already in use"; + } else if (err.httpStatus) { + err.message = err.message + ` (Status ${err.httpStatus})`; + } + throw err; + }); + } + /** * Checks if the email link has been clicked by attempting to add the threepid - * @return {Promise} Resolves if the password was reset. Rejects with an object + * @return {Promise} Resolves if the email address was added. Rejects with an object * with a "message" property which contains a human-readable message detailing why - * the reset failed, e.g. "There is no mapped matrix user ID for the given email address". + * the request failed. */ checkEmailLinkClicked() { var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; @@ -73,6 +98,29 @@ class AddThreepid { throw err; }); } + + /** + * Takes a phone number verification code as entered by the user and validates + * it with the ID server, then if successful, adds the phone number. + * @return {Promise} Resolves if the email address was added. Rejects with an object + * with a "message" property which contains a human-readable message detailing why + * the request failed. + */ + haveMsisdnToken(token) { + return MatrixClientPeg.get().submitMsisdnToken( + this.sessionId, this.clientSecret, token, + ).then((result) => { + if (result.errcode) { + throw result; + } + const identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; + return MatrixClientPeg.get().addThreePid({ + sid: this.sessionId, + client_secret: this.clientSecret, + id_server: identityServerDomain + }, this.bind); + }); + } } module.exports = AddThreepid; diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index febdccd9c3..ed8a271241 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -132,13 +132,17 @@ module.exports = React.createClass({ threePids: [], phase: "UserSettings.LOADING", // LOADING, DISPLAY email_add_pending: false, + msisdn_add_pending: false, vectorVersion: null, rejectingInvites: false, + phoneCountry: null, + phoneNumber: "", }; }, componentWillMount: function() { this._unmounted = false; + this._addThreepid = null; if (PlatformPeg.get()) { q().then(() => { @@ -214,6 +218,14 @@ module.exports = React.createClass({ }); }, + _onPhoneCountryChange: function(phoneCountry) { + this.setState({ phoneCountry: phoneCountry }); + }, + + _onPhoneNumberChange: function(ev) { + this.setState({ phoneNumber: ev.target.value }); + }, + onAction: function(payload) { if (payload.action === "notifier_enabled") { this.forceUpdate(); @@ -315,12 +327,26 @@ module.exports = React.createClass({ UserSettingsStore.setEnableNotifications(event.target.checked); }, - onAddThreepidClicked: function(value, shouldSubmit) { + _onAddEmailEditFinished: function(value, shouldSubmit) { if (!shouldSubmit) return; + this._addEmail(); + }, + + _onAddMsisdnEditFinished: function(value, shouldSubmit) { + if (!shouldSubmit) return; + this._addMsisdn(); + }, + + _onAddMsisdnSubmit: function(ev) { + ev.preventDefault(); + this._addMsisdn(); + }, + + _addEmail: function() { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - var email_address = this.refs.add_threepid_input.value; + var email_address = this.refs.add_email_input.value; if (!Email.looksValid(email_address)) { Modal.createDialog(ErrorDialog, { title: "Invalid Email Address", @@ -328,10 +354,10 @@ module.exports = React.createClass({ }); return; } - this.add_threepid = new AddThreepid(); + this._addThreepid = new AddThreepid(); // we always bind emails when registering, so let's do the // same here. - this.add_threepid.addEmailAddress(email_address, true).done(() => { + this._addThreepid.addEmailAddress(email_address, true).done(() => { Modal.createDialog(QuestionDialog, { title: "Verification Pending", description: "Please check your email and click on the link it contains. Once this is done, click continue.", @@ -346,10 +372,69 @@ module.exports = React.createClass({ description: "Unable to add email address" }); }); - ReactDOM.findDOMNode(this.refs.add_threepid_input).blur(); + ReactDOM.findDOMNode(this.refs.add_email_input).blur(); this.setState({email_add_pending: true}); }, + _addMsisdn: function() { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + this._addThreepid = new AddThreepid(); + // we always phone numbers when registering, so let's do the + // same here. + this._addThreepid.addMsisdn(this.state.phoneCountry, this.state.phoneNumber, true).then((resp) => { + this._promptForMsisdnVerificationCode(resp.msisdn); + }).catch((err) => { + console.error("Unable to add phone number: " + err); + let msg = err.message; + Modal.createDialog(ErrorDialog, { + title: "Error", + description: msg, + }); + }).finally(() => { + this.setState({msisdn_add_pending: false}); + }).done();; + ReactDOM.findDOMNode(this.refs.add_msisdn_input).blur(); + this.setState({msisdn_add_pending: true}); + }, + + _promptForMsisdnVerificationCode(msisdn, err) { + const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); + let msgElements = [ +
A text message has been sent to +{msisdn}. + Please enter the verification code it contains
+ ]; + if (err) { + let msg = err.error; + if (err.errcode == 'M_THREEPID_AUTH_FAILED') { + msg = "Incorrect verification code"; + } + msgElements.push(
{msg}
); + } + Modal.createDialog(TextInputDialog, { + title: "Enter Code", + description:
{msgElements}
, + button: "Submit", + onFinished: (should_verify, token) => { + if (!should_verify) { + this._addThreepid = null; + return; + } + this.setState({msisdn_add_pending: true}); + this._addThreepid.haveMsisdnToken(token).then(() => { + this._addThreepid = null; + this.setState({phoneNumber: ''}); + return this._refreshFromServer(); + }).catch((err) => { + this._promptForMsisdnVerificationCode(msisdn, err); + }).finally(() => { + this.setState({msisdn_add_pending: false}); + }).done(); + } + }); + }, + onRemoveThreepidClicked: function(threepid) { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createDialog(QuestionDialog, { @@ -385,8 +470,8 @@ module.exports = React.createClass({ }, verifyEmailAddress: function() { - this.add_threepid.checkEmailLinkClicked().done(() => { - this.add_threepid = undefined; + this._addThreepid.checkEmailLinkClicked().done(() => { + this._addThreepid = null; this.setState({ phase: "UserSettings.LOADING", }); @@ -795,30 +880,61 @@ module.exports = React.createClass({
); }); - var addThreepidSection; + let addEmailSection; + let addMsisdnSection; if (this.state.email_add_pending) { - addThreepidSection = ; + addEmailSection = ; } else if (!MatrixClientPeg.get().isGuest()) { - addThreepidSection = ( -
+ addEmailSection = ( +
+ onValueChanged={ this._onAddEmailEditFinished } />
- Add + Add
); } - threepidsSection.push(addThreepidSection); + if (this.state.msisdn_add_pending) { + addMsisdnSection = ; + } else if (!MatrixClientPeg.get().isGuest()) { + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + addMsisdnSection = ( +
+
+
+
+ + + + +
+
+ Add +
+
+ ); + } + threepidsSection.push(addEmailSection); + threepidsSection.push(addMsisdnSection); var accountJsx; From b06111202da17763b3b807b3bf04412540217968 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 16 Mar 2017 15:16:24 +0000 Subject: [PATCH 07/28] Display threepids slightly prettier ie. Put a + on the front of msisdns. --- src/components/structures/UserSettings.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index febdccd9c3..8b30d0f497 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -755,6 +755,14 @@ module.exports = React.createClass({ return medium[0].toUpperCase() + medium.slice(1); }, + presentableTextForThreepid: function(threepid) { + if (threepid.medium == 'msisdn') { + return '+' + threepid.address; + } else { + return threepid.address; + } + }, + render: function() { var Loader = sdk.getComponent("elements.Spinner"); switch (this.state.phase) { @@ -787,7 +795,9 @@ module.exports = React.createClass({
- +
Remove From 375ae8fb04d8775951569d9a75027553bcbf82e8 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 16 Mar 2017 17:26:42 +0000 Subject: [PATCH 08/28] Fix password UI auth test By adding a way to wait a short time for a component to appear in the DOM, so we don't get flakey failures like this when we change something to returning a promise that needs to resolve before the component actually appears. --- .../dialogs/InteractiveAuthDialog-test.js | 75 ++++++++++--------- test/test-utils.js | 50 +++++++++++-- 2 files changed, 83 insertions(+), 42 deletions(-) diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js index da8fc17001..50500ba6e3 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.js +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -68,50 +68,51 @@ describe('InteractiveAuthDialog', function () { onFinished={onFinished} />, parentDiv); - // at this point there should be a password box and a submit button - const formNode = ReactTestUtils.findRenderedDOMComponentWithTag(dlg, "form"); - const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag( - dlg, "input" - ); - let passwordNode; - let submitNode; - for (const node of inputNodes) { - if (node.type == 'password') { - passwordNode = node; - } else if (node.type == 'submit') { - submitNode = node; + // wait for a password box and a submit button + test_utils.waitForRenderedDOMComponentWithTag(dlg, "form").then((formNode) => { + const inputNodes = ReactTestUtils.scryRenderedDOMComponentsWithTag( + dlg, "input" + ); + let passwordNode; + let submitNode; + for (const node of inputNodes) { + if (node.type == 'password') { + passwordNode = node; + } else if (node.type == 'submit') { + submitNode = node; + } } - } - expect(passwordNode).toExist(); - expect(submitNode).toExist(); + expect(passwordNode).toExist(); + expect(submitNode).toExist(); - // submit should be disabled - expect(submitNode.disabled).toBe(true); + // submit should be disabled + expect(submitNode.disabled).toBe(true); - // put something in the password box, and hit enter; that should - // trigger a request - passwordNode.value = "s3kr3t"; - ReactTestUtils.Simulate.change(passwordNode); - expect(submitNode.disabled).toBe(false); - ReactTestUtils.Simulate.submit(formNode, {}); + // put something in the password box, and hit enter; that should + // trigger a request + passwordNode.value = "s3kr3t"; + ReactTestUtils.Simulate.change(passwordNode); + expect(submitNode.disabled).toBe(false); + ReactTestUtils.Simulate.submit(formNode, {}); - expect(doRequest.callCount).toEqual(1); - expect(doRequest.calledWithExactly({ - session: "sess", - type: "m.login.password", - password: "s3kr3t", - user: "@user:id", - })).toBe(true); + expect(doRequest.callCount).toEqual(1); + expect(doRequest.calledWithExactly({ + session: "sess", + type: "m.login.password", + password: "s3kr3t", + user: "@user:id", + })).toBe(true); - // there should now be a spinner - ReactTestUtils.findRenderedComponentWithType( - dlg, sdk.getComponent('elements.Spinner'), - ); + // there should now be a spinner + ReactTestUtils.findRenderedComponentWithType( + dlg, sdk.getComponent('elements.Spinner'), + ); - // let the request complete - q.delay(1).then(() => { + // let the request complete + return q.delay(1); + }).then(() => { expect(onFinished.callCount).toEqual(1); expect(onFinished.calledWithExactly(true, {a:1})).toBe(true); - }).done(done, done); + }).done(done); }); }); diff --git a/test/test-utils.js b/test/test-utils.js index aca91ad399..5209465362 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -1,11 +1,51 @@ "use strict"; -var sinon = require('sinon'); -var q = require('q'); +import sinon from 'sinon'; +import q from 'q'; +import ReactTestUtils from 'react-addons-test-utils'; -var peg = require('../src/MatrixClientPeg.js'); -var jssdk = require('matrix-js-sdk'); -var MatrixEvent = jssdk.MatrixEvent; +import peg from '../src/MatrixClientPeg.js'; +import jssdk from 'matrix-js-sdk'; +const MatrixEvent = jssdk.MatrixEvent; + +/** + * Wrapper around window.requestAnimationFrame that returns a promise + * @private + */ +function _waitForFrame() { + const def = q.defer(); + window.requestAnimationFrame(() => { + def.resolve(); + }); + return def.promise; +} + +/** + * Waits a small number of animation frames for a component to appear + * in the DOM. Like findRenderedDOMComponentWithTag(), but allows + * for the element to appear a short time later, eg. if a promise needs + * to resolve first. + * @return a promise that resolves once the component appears, or rejects + * if it doesn't appear after a nominal number of animation frames. + */ +export function waitForRenderedDOMComponentWithTag(tree, tag, attempts) { + if (attempts === undefined) { + // Let's start by assuming we'll only need to wait a single frame, and + // we can try increasing this if necessary. + attempts = 1; + } else if (attempts == 0) { + return q.reject("Gave up waiting for component with tag: " + tag); + } + + return _waitForFrame().then(() => { + const result = ReactTestUtils.scryRenderedDOMComponentsWithTag(tree, tag); + if (result.length > 0) { + return result[0]; + } else { + return waitForRenderedDOMComponentWithTag(tree, tag, attempts - 1); + } + }); +} /** * Perform common actions before each test case, e.g. printing the test case From 23c38bd8a3370304d8678b8ea11aade4fff8dc91 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 16 Mar 2017 17:47:15 +0000 Subject: [PATCH 09/28] Put back both done's mocha takes the exception arg and does the right thing --- test/components/views/dialogs/InteractiveAuthDialog-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js index 50500ba6e3..b8a8e49769 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.js +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -113,6 +113,6 @@ describe('InteractiveAuthDialog', function () { }).then(() => { expect(onFinished.callCount).toEqual(1); expect(onFinished.calledWithExactly(true, {a:1})).toBe(true); - }).done(done); + }).done(done, done); }); }); From 25a4f4e3b6d16c4b661e3cfb4d19fb8134e3e858 Mon Sep 17 00:00:00 2001 From: Keyvan Fatehi Date: Sat, 18 Mar 2017 18:58:28 -0700 Subject: [PATCH 10/28] Add ConfirmRedactDialog component Signed-off-by: Keyvan Fatehi --- src/component-index.js | 2 + .../views/dialogs/ConfirmRedactDialog.js | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/components/views/dialogs/ConfirmRedactDialog.js diff --git a/src/component-index.js b/src/component-index.js index 2644f1a379..04cb746163 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -79,6 +79,8 @@ import views$dialogs$ChatCreateOrReuseDialog from './components/views/dialogs/Ch views$dialogs$ChatCreateOrReuseDialog && (module.exports.components['views.dialogs.ChatCreateOrReuseDialog'] = views$dialogs$ChatCreateOrReuseDialog); import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog'; views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); +import views$dialogs$ConfirmRedactDialog from './components/views/dialogs/ConfirmRedactDialog'; +views$dialogs$ConfirmRedactDialog && (module.exports.components['views.dialogs.ConfirmRedactDialog'] = views$dialogs$ConfirmRedactDialog); import views$dialogs$ConfirmUserActionDialog from './components/views/dialogs/ConfirmUserActionDialog'; views$dialogs$ConfirmUserActionDialog && (module.exports.components['views.dialogs.ConfirmUserActionDialog'] = views$dialogs$ConfirmUserActionDialog); import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js new file mode 100644 index 0000000000..ad1c73eb96 --- /dev/null +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -0,0 +1,73 @@ +/* +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. +*/ + +import React from 'react'; +import sdk from '../../../index'; +import classnames from 'classnames'; + +/* + * A dialog for confirming a redaction. + */ +export default React.createClass({ + displayName: 'ConfirmRedactDialog', + propTypes: { + onFinished: React.PropTypes.func.isRequired, + }, + + defaultProps: { + danger: false, + }, + + onOk: function() { + this.props.onFinished(true); + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + const title = "Confirm Redaction"; + + const confirmButtonClass = classnames({ + 'mx_Dialog_primary': true, + 'danger': false, + }); + + return ( + +
+ Are you sure you wish to redact this event? + Note that if you redact a room name or topic change, it could undo the change. +
+
+ + + +
+
+ ); + }, +}); From df63c779dd71793487e6c3b0e6a6c146b5dc2eee Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sun, 19 Mar 2017 02:34:25 +0000 Subject: [PATCH 11/28] clarify that redact === delete --- src/components/views/dialogs/ConfirmRedactDialog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js index ad1c73eb96..fc9e55f666 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -55,7 +55,7 @@ export default React.createClass({ title={title} >
- Are you sure you wish to redact this event? + Are you sure you wish to redact (delete) this event? Note that if you redact a room name or topic change, it could undo the change.
From 7891f9b246756415103c056e82cf8fea2b6eeeab Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Sun, 19 Mar 2017 15:18:19 +0530 Subject: [PATCH 12/28] UnknownBody: add explanatory title --- src/components/views/messages/UnknownBody.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/messages/UnknownBody.js b/src/components/views/messages/UnknownBody.js index a0fe8fdf74..9b1bd74087 100644 --- a/src/components/views/messages/UnknownBody.js +++ b/src/components/views/messages/UnknownBody.js @@ -24,7 +24,7 @@ module.exports = React.createClass({ render: function() { const text = this.props.mxEvent.getContent().body; return ( - + {text} ); From c697b48f99f21f1da8427d38ff961becffb73d54 Mon Sep 17 00:00:00 2001 From: Lieuwe Rooijakkers Date: Sun, 19 Mar 2017 21:52:24 +0100 Subject: [PATCH 13/28] fix leading extraneous space in emotes --- src/components/views/rooms/MessageComposerInput.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index d702b7558d..60088ddd6f 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -541,7 +541,7 @@ export default class MessageComposerInput extends React.Component { let sendTextFn = this.client.sendTextMessage; if (contentText.startsWith('/me')) { - contentText = contentText.replace('/me', ''); + contentText = contentText.replace('/me ', ''); // bit of a hack, but the alternative would be quite complicated if (contentHTML) contentHTML = contentHTML.replace('/me', ''); sendHtmlFn = this.client.sendHtmlEmote; From bf8973ad33b62b9ef88007d6a1d13a1408130e37 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Sun, 19 Mar 2017 21:33:18 +0000 Subject: [PATCH 14/28] avoid leading space in HTML /me too --- src/components/views/rooms/MessageComposerInput.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 60088ddd6f..51c9ba881b 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -543,7 +543,7 @@ export default class MessageComposerInput extends React.Component { if (contentText.startsWith('/me')) { contentText = contentText.replace('/me ', ''); // bit of a hack, but the alternative would be quite complicated - if (contentHTML) contentHTML = contentHTML.replace('/me', ''); + if (contentHTML) contentHTML = contentHTML.replace('/me ', ''); sendHtmlFn = this.client.sendHtmlEmote; sendTextFn = this.client.sendEmoteMessage; } From ec63e18b42f3b5b195150c99060ae257ff84ed30 Mon Sep 17 00:00:00 2001 From: David Baker Date: Tue, 21 Mar 2017 18:40:41 +0000 Subject: [PATCH 15/28] Show spinner whilst processing recaptcha response The fact that we showed no feedback whilst submitting the captcha response was causing confusion on slower connections where this took a nontrivial amount of time. Takes a new flag from the js-sdk that indicates whether the request being made is a background request, presenting a spinner appropriately. Requires https://github.com/matrix-org/matrix-js-sdk/pull/396 --- src/components/structures/InteractiveAuth.js | 12 +++++++----- .../views/login/InteractiveAuthEntryComponents.js | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 71fee883be..3dd34f51b4 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -140,9 +140,9 @@ export default React.createClass({ }); }, - _requestCallback: function(auth) { + _requestCallback: function(auth, background) { this.setState({ - busy: true, + busy: !background, errorText: null, stageErrorText: null, }); @@ -150,9 +150,11 @@ export default React.createClass({ if (this._unmounted) { return; } - this.setState({ - busy: false, - }); + if (background) { + this.setState({ + busy: false, + }); + } }); }, diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index 2d8abf9216..c4084facb2 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -160,6 +160,7 @@ export const RecaptchaAuthEntry = React.createClass({ submitAuthDict: React.PropTypes.func.isRequired, stageParams: React.PropTypes.object.isRequired, errorText: React.PropTypes.string, + busy: React.PropTypes.bool, }, _onCaptchaResponse: function(response) { @@ -170,6 +171,11 @@ export const RecaptchaAuthEntry = React.createClass({ }, render: function() { + if (this.props.busy) { + const Loader = sdk.getComponent("elements.Spinner"); + return ; + } + const CaptchaForm = sdk.getComponent("views.login.CaptchaForm"); var sitePublicKey = this.props.stageParams.public_key; return ( From e5a5ca9efcd8de1513fe927516c4e8eaaad69d2e Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 10:53:15 +0000 Subject: [PATCH 16/28] Don't set busy state at all for background request --- src/components/structures/InteractiveAuth.js | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 3dd34f51b4..d520f4dff9 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -141,16 +141,20 @@ export default React.createClass({ }, _requestCallback: function(auth, background) { - this.setState({ - busy: !background, - errorText: null, - stageErrorText: null, - }); + // only set the busy flag if this is a non-background request + if (!background) { + this.setState({ + busy: true, + errorText: null, + stageErrorText: null, + }); + } return this.props.makeRequest(auth).finally(() => { if (this._unmounted) { return; } - if (background) { + // only unset the busy flag if this is a non-background request + if (!background) { this.setState({ busy: false, }); From 5ae7d5e4b218c633664ce093cdbdc803b0649b67 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 11:13:00 +0000 Subject: [PATCH 17/28] More comments --- src/components/structures/InteractiveAuth.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index d520f4dff9..fe7552d20f 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -141,7 +141,9 @@ export default React.createClass({ }, _requestCallback: function(auth, background) { - // only set the busy flag if this is a non-background request + // only set the busy flag if this is a non-background request, + // otherwise, the user initiated a request, so make the busy + // spinner appear and clear and existing error messages. if (!background) { this.setState({ busy: true, From 6a5682897446e362ce9d86867cb2f2010d5d965f Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 11:25:33 +0000 Subject: [PATCH 18/28] Just return the promise if it's a bg request This makes the code a bit neater. --- src/components/structures/InteractiveAuth.js | 33 ++++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index fe7552d20f..7c8a5b8065 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -141,26 +141,25 @@ export default React.createClass({ }, _requestCallback: function(auth, background) { - // only set the busy flag if this is a non-background request, - // otherwise, the user initiated a request, so make the busy - // spinner appear and clear and existing error messages. - if (!background) { - this.setState({ - busy: true, - errorText: null, - stageErrorText: null, - }); - } - return this.props.makeRequest(auth).finally(() => { + const makeRequestPromise = this.props.makeRequest(auth); + + // if it's a background request, just do it: we don't want + // it to affect the state of our UI. + if (background) return makeRequestPromise; + + // otherwise, manage the state of the spinner and error messages + this.setState({ + busy: true, + errorText: null, + stageErrorText: null, + }); + return makeRequestPromise.finally(() => { if (this._unmounted) { return; } - // only unset the busy flag if this is a non-background request - if (!background) { - this.setState({ - busy: false, - }); - } + this.setState({ + busy: false, + }); }); }, From 6a37fc432544c3680a03a9f08834103e13268341 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 12:00:16 +0000 Subject: [PATCH 19/28] Comment typos --- src/AddThreepid.js | 5 +++-- src/components/structures/UserSettings.js | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/AddThreepid.js b/src/AddThreepid.js index 44d709371b..c89de4f5fa 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -55,7 +55,8 @@ class AddThreepid { /** * Attempt to add a msisdn threepid. This will trigger a side-effect of * sending a test message to the provided phone number. - * @param {string} emailAddress The email address to add + * @param {string} phoneCountry The ISO 2 letter code of the country to resolve phoneNumber in + * @param {string} phoneNumber The national or international formatted phone number to add * @param {boolean} bind If True, bind this phone number to this mxid on the Identity Server * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). */ @@ -102,7 +103,7 @@ class AddThreepid { /** * Takes a phone number verification code as entered by the user and validates * it with the ID server, then if successful, adds the phone number. - * @return {Promise} Resolves if the email address was added. Rejects with an object + * @return {Promise} Resolves if the phone number was added. Rejects with an object * with a "message" property which contains a human-readable message detailing why * the request failed. */ diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 6626d2c400..b50c3318ce 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -387,7 +387,7 @@ module.exports = React.createClass({ const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); this._addThreepid = new AddThreepid(); - // we always phone numbers when registering, so let's do the + // we always bind phone numbers when registering, so let's do the // same here. this._addThreepid.addMsisdn(this.state.phoneCountry, this.state.phoneNumber, true).then((resp) => { this._promptForMsisdnVerificationCode(resp.msisdn); From 4cebded04f159c154d526a81cdf70b30593f3b95 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Wed, 22 Mar 2017 15:06:52 +0000 Subject: [PATCH 20/28] Add canResetTimeline callback and thread it through to TimelinePanel --- src/components/structures/LoggedInView.js | 7 +++++++ src/components/structures/MatrixChat.js | 24 +++++++++++++++++++++- src/components/structures/RoomView.js | 7 +++++++ src/components/structures/TimelinePanel.js | 4 ++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index c2243820cd..6e2f0a3a5b 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -81,6 +81,13 @@ export default React.createClass({ return this._scrollStateMap[roomId]; }, + canResetTimelineInRoom: function(roomId) { + if (!this.refs.roomView) { + return true; + } + return this.refs.roomView.canResetTimeline(); + }, + _onKeyDown: function(ev) { /* // Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 2337d62fd8..9b51e7f3fb 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -806,9 +806,31 @@ module.exports = React.createClass({ * (useful for setting listeners) */ _onWillStartClient() { + var self = this; var cli = MatrixClientPeg.get(); - var self = this; + // Allow the JS SDK to reap timeline events. This reduces the amount of + // memory consumed as the JS SDK stores multiple distinct copies of room + // state (each of which can be 10s of MBs) for each DISJOINT timeline. This is + // particularly noticeable when there are lots of 'limited' /sync responses + // such as when laptops unsleep. + // https://github.com/vector-im/riot-web/issues/3307#issuecomment-282895568 + cli.setCanResetTimelineCallback(function(roomId) { + console.log("Request to reset timeline in room ", roomId, " viewing:", self.state.currentRoomId); + if (roomId !== self.state.currentRoomId) { + // It is safe to remove events from rooms we are not viewing. + return true; + } + // We are viewing the room which we want to reset. It is only safe to do + // this if we are not scrolled up in the view. To find out, delegate to + // the timeline panel. If the timeline panel doesn't exist, then we assume + // it is safe to reset the timeline. + if (!self.refs.loggedInView) { + return true; + } + return self.refs.loggedInView.canResetTimelineInRoom(roomId); + }); + cli.on('sync', function(state, prevState) { self.updateStatusIndicator(state, prevState); if (state === "SYNCING" && prevState === "SYNCING") { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 345d0f6b80..b22d867acf 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -490,6 +490,13 @@ module.exports = React.createClass({ } }, + canResetTimeline: function() { + if (!this.refs.messagePanel) { + return true; + } + return this.refs.messagePanel.canResetTimeline(); + }, + // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). _onRoomLoaded: function(room) { diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index cb42f701a3..8ef0e7631f 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -431,6 +431,10 @@ var TimelinePanel = React.createClass({ } }, + canResetTimeline: function() { + return this.refs.messagePanel && this.refs.messagePanel.isAtBottom(); + }, + onRoomRedaction: function(ev, room) { if (this.unmounted) return; From 4cd24d15d453b749ebcc967e30d15207fd10ebc3 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 15:18:27 +0000 Subject: [PATCH 21/28] Factor out AddPhoneNumber to a separate component --- src/component-index.js | 2 + src/components/structures/UserSettings.js | 114 +----------- .../views/settings/AddPhoneNumber.js | 170 ++++++++++++++++++ 3 files changed, 177 insertions(+), 109 deletions(-) create mode 100644 src/components/views/settings/AddPhoneNumber.js diff --git a/src/component-index.js b/src/component-index.js index c83c0dbb11..d6873c6dfd 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -229,6 +229,8 @@ import views$rooms$TopUnreadMessagesBar from './components/views/rooms/TopUnread views$rooms$TopUnreadMessagesBar && (module.exports.components['views.rooms.TopUnreadMessagesBar'] = views$rooms$TopUnreadMessagesBar); import views$rooms$UserTile from './components/views/rooms/UserTile'; views$rooms$UserTile && (module.exports.components['views.rooms.UserTile'] = views$rooms$UserTile); +import views$settings$AddPhoneNumber from './components/views/settings/AddPhoneNumber'; +views$settings$AddPhoneNumber && (module.exports.components['views.settings.AddPhoneNumber'] = views$settings$AddPhoneNumber); import views$settings$ChangeAvatar from './components/views/settings/ChangeAvatar'; views$settings$ChangeAvatar && (module.exports.components['views.settings.ChangeAvatar'] = views$settings$ChangeAvatar); import views$settings$ChangeDisplayName from './components/views/settings/ChangeDisplayName'; diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index b50c3318ce..5633bd0bc7 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -135,8 +136,6 @@ module.exports = React.createClass({ msisdn_add_pending: false, vectorVersion: null, rejectingInvites: false, - phoneCountry: null, - phoneNumber: "", }; }, @@ -218,14 +217,6 @@ module.exports = React.createClass({ }); }, - _onPhoneCountryChange: function(phoneCountry) { - this.setState({ phoneCountry: phoneCountry }); - }, - - _onPhoneNumberChange: function(ev) { - this.setState({ phoneNumber: ev.target.value }); - }, - onAction: function(payload) { if (payload.action === "notifier_enabled") { this.forceUpdate(); @@ -338,16 +329,6 @@ module.exports = React.createClass({ this._addEmail(); }, - _onAddMsisdnEditFinished: function(value, shouldSubmit) { - if (!shouldSubmit) return; - this._addMsisdn(); - }, - - _onAddMsisdnSubmit: function(ev) { - ev.preventDefault(); - this._addMsisdn(); - }, - _addEmail: function() { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); @@ -382,65 +363,6 @@ module.exports = React.createClass({ this.setState({email_add_pending: true}); }, - _addMsisdn: function() { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - - this._addThreepid = new AddThreepid(); - // we always bind phone numbers when registering, so let's do the - // same here. - this._addThreepid.addMsisdn(this.state.phoneCountry, this.state.phoneNumber, true).then((resp) => { - this._promptForMsisdnVerificationCode(resp.msisdn); - }).catch((err) => { - console.error("Unable to add phone number: " + err); - let msg = err.message; - Modal.createDialog(ErrorDialog, { - title: "Error", - description: msg, - }); - }).finally(() => { - this.setState({msisdn_add_pending: false}); - }).done();; - ReactDOM.findDOMNode(this.refs.add_msisdn_input).blur(); - this.setState({msisdn_add_pending: true}); - }, - - _promptForMsisdnVerificationCode(msisdn, err) { - const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); - let msgElements = [ -
A text message has been sent to +{msisdn}. - Please enter the verification code it contains
- ]; - if (err) { - let msg = err.error; - if (err.errcode == 'M_THREEPID_AUTH_FAILED') { - msg = "Incorrect verification code"; - } - msgElements.push(
{msg}
); - } - Modal.createDialog(TextInputDialog, { - title: "Enter Code", - description:
{msgElements}
, - button: "Submit", - onFinished: (should_verify, token) => { - if (!should_verify) { - this._addThreepid = null; - return; - } - this.setState({msisdn_add_pending: true}); - this._addThreepid.haveMsisdnToken(token).then(() => { - this._addThreepid = null; - this.setState({phoneNumber: ''}); - return this._refreshFromServer(); - }).catch((err) => { - this._promptForMsisdnVerificationCode(msisdn, err); - }).finally(() => { - this.setState({msisdn_add_pending: false}); - }).done(); - } - }); - }, - onRemoveThreepidClicked: function(threepid) { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createDialog(QuestionDialog, { @@ -897,7 +819,6 @@ module.exports = React.createClass({ ); }); let addEmailSection; - let addMsisdnSection; if (this.state.email_add_pending) { addEmailSection = ; } else if (!MatrixClientPeg.get().isGuest()) { @@ -920,35 +841,10 @@ module.exports = React.createClass({
); } - if (this.state.msisdn_add_pending) { - addMsisdnSection = ; - } else if (!MatrixClientPeg.get().isGuest()) { - const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); - addMsisdnSection = ( -
-
-
-
-
- - - -
-
- Add -
-
- ); - } + const AddPhoneNumber = sdk.getComponent('views.settings.AddPhoneNumber'); + const addMsisdnSection = ( + + ); threepidsSection.push(addEmailSection); threepidsSection.push(addMsisdnSection); diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js new file mode 100644 index 0000000000..905a21f61d --- /dev/null +++ b/src/components/views/settings/AddPhoneNumber.js @@ -0,0 +1,170 @@ +/* +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. +*/ + +import React from 'react'; + +import sdk from '../../../index'; +import AddThreepid from '../../../AddThreepid'; +import WithMatrixClient from '../../../wrappers/WithMatrixClient'; +import Modal from '../../../Modal'; + + +class AddPhoneNumber extends React.Component { + constructor(props, context) { + super(props, context); + + this._addThreepid = null; + this._addMsisdnInput = null; + + this.state = { + busy: false, + phoneCountry: null, + phoneNumber: "", + }; + + this._onPhoneCountryChange = this._onPhoneCountryChange.bind(this); + this._onPhoneNumberChange = this._onPhoneNumberChange.bind(this); + this._onAddMsisdnEditFinished = this._onAddMsisdnEditFinished.bind(this); + this._onAddMsisdnSubmit = this._onAddMsisdnSubmit.bind(this); + this._collectAddMsisdnInput = this._collectAddMsisdnInput.bind(this); + this._addMsisdn = this._addMsisdn.bind(this); + this._promptForMsisdnVerificationCode = this._promptForMsisdnVerificationCode.bind(this); + } + + _onPhoneCountryChange(phoneCountry) { + this.setState({ phoneCountry: phoneCountry }); + } + + _onPhoneNumberChange(ev) { + this.setState({ phoneNumber: ev.target.value }); + } + + _onAddMsisdnEditFinished(value, shouldSubmit) { + if (!shouldSubmit) return; + this._addMsisdn(); + } + + _onAddMsisdnSubmit(ev) { + ev.preventDefault(); + this._addMsisdn(); + } + + _collectAddMsisdnInput(e) { + this._addMsisdnInput = e; + } + + _addMsisdn() { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + this._addThreepid = new AddThreepid(); + // we always bind phone numbers when registering, so let's do the + // same here. + this._addThreepid.addMsisdn(this.state.phoneCountry, this.state.phoneNumber, true).then((resp) => { + this._promptForMsisdnVerificationCode(resp.msisdn); + }).catch((err) => { + console.error("Unable to add phone number: " + err); + let msg = err.message; + Modal.createDialog(ErrorDialog, { + title: "Error", + description: msg, + }); + }).finally(() => { + this.setState({msisdn_add_pending: false}); + }).done();; + this._addMsisdnInput.blur(); + this.setState({msisdn_add_pending: true}); + } + + _promptForMsisdnVerificationCode(msisdn, err) { + const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); + let msgElements = [ +
A text message has been sent to +{msisdn}. + Please enter the verification code it contains
+ ]; + if (err) { + let msg = err.error; + if (err.errcode == 'M_THREEPID_AUTH_FAILED') { + msg = "Incorrect verification code"; + } + msgElements.push(
{msg}
); + } + Modal.createDialog(TextInputDialog, { + title: "Enter Code", + description:
{msgElements}
, + button: "Submit", + onFinished: (should_verify, token) => { + if (!should_verify) { + this._addThreepid = null; + return; + } + this.setState({msisdn_add_pending: true}); + this._addThreepid.haveMsisdnToken(token).then(() => { + this._addThreepid = null; + this.setState({phoneNumber: ''}); + if (this.props.onThreepidAdded) this.props.onThreepidAdded(); + }).catch((err) => { + this._promptForMsisdnVerificationCode(msisdn, err); + }).finally(() => { + this.setState({msisdn_add_pending: false}); + }).done(); + } + }); + } + + render() { + const Loader = sdk.getComponent("elements.Spinner"); + if (this.state.msisdn_add_pending) { + return ; + } else if (!this.props.matrixClient.isGuest()) { + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + // XXX: This CSS relies on the CSS surrounding it in UserSettings as its in + // a tabular format to align the submit buttons + return ( +
+
+
+
+
+ + + +
+
+ Add +
+
+ ); + } + } +} + +AddPhoneNumber.propTypes = { + matrixClient: React.PropTypes.object.isRequired, + onThreepidAdded: React.PropTypes.func, +}; + +AddPhoneNumber = WithMatrixClient(AddPhoneNumber); +export default AddPhoneNumber; From cca607d4694256fc0a0f701e563db7456acfb6fe Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 15:39:09 +0000 Subject: [PATCH 22/28] Make phone number form a bit more semantic --- src/components/views/settings/AddPhoneNumber.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js index 905a21f61d..e058fce0f2 100644 --- a/src/components/views/settings/AddPhoneNumber.js +++ b/src/components/views/settings/AddPhoneNumber.js @@ -134,11 +134,11 @@ class AddPhoneNumber extends React.Component { // XXX: This CSS relies on the CSS surrounding it in UserSettings as its in // a tabular format to align the submit buttons return ( -
+
- +
- +
- Add +
-
+ ); } } From e39979a61f284aa873f38f659ab3a57d7f58f8d0 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 16:15:45 +0000 Subject: [PATCH 23/28] Convert to old style react class --- .../views/settings/AddPhoneNumber.js | 67 ++++++++----------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js index e058fce0f2..c64ed4b545 100644 --- a/src/components/views/settings/AddPhoneNumber.js +++ b/src/components/views/settings/AddPhoneNumber.js @@ -22,51 +22,50 @@ import WithMatrixClient from '../../../wrappers/WithMatrixClient'; import Modal from '../../../Modal'; -class AddPhoneNumber extends React.Component { - constructor(props, context) { - super(props, context); +export default WithMatrixClient(React.createClass({ + displayName: 'AddPhoneNumber', - this._addThreepid = null; - this._addMsisdnInput = null; + propTypes: { + matrixClient: React.PropTypes.object.isRequired, + onThreepidAdded: React.PropTypes.func, + }, - this.state = { + getInitialState: function() { + return { busy: false, phoneCountry: null, phoneNumber: "", }; + }, - this._onPhoneCountryChange = this._onPhoneCountryChange.bind(this); - this._onPhoneNumberChange = this._onPhoneNumberChange.bind(this); - this._onAddMsisdnEditFinished = this._onAddMsisdnEditFinished.bind(this); - this._onAddMsisdnSubmit = this._onAddMsisdnSubmit.bind(this); - this._collectAddMsisdnInput = this._collectAddMsisdnInput.bind(this); - this._addMsisdn = this._addMsisdn.bind(this); - this._promptForMsisdnVerificationCode = this._promptForMsisdnVerificationCode.bind(this); - } + componentWillMount: function() { + this._addThreepid = null; + this._addMsisdnInput = null; + }, - _onPhoneCountryChange(phoneCountry) { + _onPhoneCountryChange: function(phoneCountry) { this.setState({ phoneCountry: phoneCountry }); - } + }, - _onPhoneNumberChange(ev) { + _onPhoneNumberChange: function(ev) { this.setState({ phoneNumber: ev.target.value }); - } + }, - _onAddMsisdnEditFinished(value, shouldSubmit) { + _onAddMsisdnEditFinished: function(value, shouldSubmit) { if (!shouldSubmit) return; this._addMsisdn(); - } + }, - _onAddMsisdnSubmit(ev) { + _onAddMsisdnSubmit: function(ev) { ev.preventDefault(); this._addMsisdn(); - } + }, - _collectAddMsisdnInput(e) { + _collectAddMsisdnInput: function(e) { this._addMsisdnInput = e; - } + }, - _addMsisdn() { + _addMsisdn: function() { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); @@ -87,9 +86,9 @@ class AddPhoneNumber extends React.Component { }).done();; this._addMsisdnInput.blur(); this.setState({msisdn_add_pending: true}); - } + }, - _promptForMsisdnVerificationCode(msisdn, err) { + _promptForMsisdnVerificationCode:function (msisdn, err) { const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); let msgElements = [
A text message has been sent to +{msisdn}. @@ -123,9 +122,9 @@ class AddPhoneNumber extends React.Component { }).done(); } }); - } + }, - render() { + render: function() { const Loader = sdk.getComponent("elements.Spinner"); if (this.state.msisdn_add_pending) { return ; @@ -159,12 +158,4 @@ class AddPhoneNumber extends React.Component { ); } } -} - -AddPhoneNumber.propTypes = { - matrixClient: React.PropTypes.object.isRequired, - onThreepidAdded: React.PropTypes.func, -}; - -AddPhoneNumber = WithMatrixClient(AddPhoneNumber); -export default AddPhoneNumber; +})) From 6b78440466234c6cc378e6baecd7c986333cc2e5 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 16:36:42 +0000 Subject: [PATCH 24/28] Unmounted guard --- src/components/views/settings/AddPhoneNumber.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js index c64ed4b545..83c331dd33 100644 --- a/src/components/views/settings/AddPhoneNumber.js +++ b/src/components/views/settings/AddPhoneNumber.js @@ -41,6 +41,11 @@ export default WithMatrixClient(React.createClass({ componentWillMount: function() { this._addThreepid = null; this._addMsisdnInput = null; + this._unmounted = false; + }, + + componentWillUnmount: function() { + this._unmounted = true; }, _onPhoneCountryChange: function(phoneCountry) { @@ -67,7 +72,6 @@ export default WithMatrixClient(React.createClass({ _addMsisdn: function() { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); this._addThreepid = new AddThreepid(); // we always bind phone numbers when registering, so let's do the @@ -89,6 +93,7 @@ export default WithMatrixClient(React.createClass({ }, _promptForMsisdnVerificationCode:function (msisdn, err) { + if (this._unmounted) return; const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); let msgElements = [
A text message has been sent to +{msisdn}. From b58d8bffe1c784485f529525020a2582fc5db6df Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 16:41:08 +0000 Subject: [PATCH 25/28] More PR feedback Unmounted guards, extra semicolon, return early to lose indent level, add keys. --- .../views/settings/AddPhoneNumber.js | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js index 83c331dd33..9680bdd12d 100644 --- a/src/components/views/settings/AddPhoneNumber.js +++ b/src/components/views/settings/AddPhoneNumber.js @@ -86,8 +86,9 @@ export default WithMatrixClient(React.createClass({ description: msg, }); }).finally(() => { + if (this._unmounted) return; this.setState({msisdn_add_pending: false}); - }).done();; + }).done(); this._addMsisdnInput.blur(); this.setState({msisdn_add_pending: true}); }, @@ -96,7 +97,7 @@ export default WithMatrixClient(React.createClass({ if (this._unmounted) return; const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); let msgElements = [ -
A text message has been sent to +{msisdn}. +
A text message has been sent to +{msisdn}. Please enter the verification code it contains
]; if (err) { @@ -104,7 +105,7 @@ export default WithMatrixClient(React.createClass({ if (err.errcode == 'M_THREEPID_AUTH_FAILED') { msg = "Incorrect verification code"; } - msgElements.push(
{msg}
); + msgElements.push(
{msg}
); } Modal.createDialog(TextInputDialog, { title: "Enter Code", @@ -123,6 +124,7 @@ export default WithMatrixClient(React.createClass({ }).catch((err) => { this._promptForMsisdnVerificationCode(msisdn, err); }).finally(() => { + if (this._unmounted) return; this.setState({msisdn_add_pending: false}); }).done(); } @@ -133,34 +135,36 @@ export default WithMatrixClient(React.createClass({ const Loader = sdk.getComponent("elements.Spinner"); if (this.state.msisdn_add_pending) { return ; - } else if (!this.props.matrixClient.isGuest()) { - const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); - // XXX: This CSS relies on the CSS surrounding it in UserSettings as its in - // a tabular format to align the submit buttons - return ( -
-
-
-
-
- - -
-
-
- -
-
- ); + } else if (this.props.matrixClient.isGuest()) { + return null; } + + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + // XXX: This CSS relies on the CSS surrounding it in UserSettings as its in + // a tabular format to align the submit buttons + return ( +
+
+
+
+
+ + +
+
+
+ +
+
+ ); } })) From d5272149f6ab734da3b683fffe3e513b987787be Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 22 Mar 2017 16:42:44 +0000 Subject: [PATCH 26/28] Another unmounted guard --- src/components/views/settings/AddPhoneNumber.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js index 9680bdd12d..bb5ecd2694 100644 --- a/src/components/views/settings/AddPhoneNumber.js +++ b/src/components/views/settings/AddPhoneNumber.js @@ -116,6 +116,7 @@ export default WithMatrixClient(React.createClass({ this._addThreepid = null; return; } + if (this._unmounted) return; this.setState({msisdn_add_pending: true}); this._addThreepid.haveMsisdnToken(token).then(() => { this._addThreepid = null; From 707fd6062446d86bcbf2ed7b8452e3e8996d8c95 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 23 Mar 2017 10:38:00 +0000 Subject: [PATCH 27/28] Prevent crash on login of no guest session The bound functions are only set when the Notifier is started, so if stop() was called without start() having been called, the listener function would be null which would throw an exception. --- src/Notifier.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Notifier.js b/src/Notifier.js index 67642e734a..7fc7d3e338 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -14,8 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - var MatrixClientPeg = require("./MatrixClientPeg"); var PlatformPeg = require("./PlatformPeg"); var TextForEvent = require('./TextForEvent'); @@ -103,7 +102,7 @@ var Notifier = { }, stop: function() { - if (MatrixClientPeg.get()) { + if (MatrixClientPeg.get() && this.boundOnRoomTimeline) { MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); MatrixClientPeg.get().removeListener("Room.receipt", this.boundOnRoomReceipt); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); From 5e3b991ec23deabb10bd8e78f6280ef46b3ed237 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 24 Mar 2017 10:45:38 +0000 Subject: [PATCH 28/28] PR feedback fixes --- src/components/structures/UserSettings.js | 1 - src/components/views/settings/AddPhoneNumber.js | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 5633bd0bc7..0cb120019e 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -133,7 +133,6 @@ module.exports = React.createClass({ threePids: [], phase: "UserSettings.LOADING", // LOADING, DISPLAY email_add_pending: false, - msisdn_add_pending: false, vectorVersion: null, rejectingInvites: false, }; diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js index bb5ecd2694..3a348393aa 100644 --- a/src/components/views/settings/AddPhoneNumber.js +++ b/src/components/views/settings/AddPhoneNumber.js @@ -35,6 +35,7 @@ export default WithMatrixClient(React.createClass({ busy: false, phoneCountry: null, phoneNumber: "", + msisdn_add_pending: false, }; }, @@ -137,7 +138,7 @@ export default WithMatrixClient(React.createClass({ if (this.state.msisdn_add_pending) { return ; } else if (this.props.matrixClient.isGuest()) { - return null; + return
; } const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');