diff --git a/src/AddThreepid.js b/src/AddThreepid.js index d6a1d58aa0..c89de4f5fa 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,36 @@ 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} 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(). + */ + 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 +99,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 phone number 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/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..107a8825e9 100644 --- a/src/Login.js +++ b/src/Login.js @@ -105,21 +105,48 @@ 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; + 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 { - loginParams.user = username; + identifier = { + type: 'm.id.user', + user: username, + }; + legacyParams = { + user: username, + }; } - var client = this._createTemporaryClient(); + const loginParams = { + password: pass, + 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) { return q({ homeserverUrl: self._hsUrl, 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); diff --git a/src/component-index.js b/src/component-index.js index 2644f1a379..d6873c6dfd 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'; @@ -109,6 +111,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 +135,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'; @@ -223,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/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 71fee883be..7c8a5b8065 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -140,13 +140,20 @@ export default React.createClass({ }); }, - _requestCallback: function(auth) { + _requestCallback: function(auth, background) { + 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 this.props.makeRequest(auth).finally(() => { + return makeRequestPromise.finally(() => { if (this._unmounted) { return; } 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; 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) { diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 9e6d454fe9..0cb120019e 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. @@ -139,6 +140,7 @@ module.exports = React.createClass({ componentWillMount: function() { this._unmounted = false; + this._addThreepid = null; if (PlatformPeg.get()) { q().then(() => { @@ -321,12 +323,16 @@ module.exports = React.createClass({ UserSettingsStore.setEnableNotifications(event.target.checked); }, - onAddThreepidClicked: function(value, shouldSubmit) { + _onAddEmailEditFinished: function(value, shouldSubmit) { if (!shouldSubmit) return; + this._addEmail(); + }, + + _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", @@ -334,10 +340,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.", @@ -352,7 +358,7 @@ 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}); }, @@ -391,8 +397,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", }); @@ -761,6 +767,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) { @@ -793,7 +807,9 @@ module.exports = React.createClass({
- +
Remove @@ -801,30 +817,35 @@ module.exports = React.createClass({
); }); - var addThreepidSection; + let addEmailSection; if (this.state.email_add_pending) { - addThreepidSection = ; + addEmailSection = ; } else if (!MatrixClientPeg.get().isGuest()) { - addThreepidSection = ( -
+ addEmailSection = ( +
+ onValueChanged={ this._onAddEmailEditFinished } />
- Add + Add
); } - threepidsSection.push(addThreepidSection); + const AddPhoneNumber = sdk.getComponent('views.settings.AddPhoneNumber'); + const addMsisdnSection = ( + + ); + threepidsSection.push(addEmailSection); + threepidsSection.push(addMsisdnSection); var accountJsx; 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 5f62407e65..4e0d61e716 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; } @@ -261,6 +272,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; @@ -295,15 +309,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, ); }, @@ -354,6 +373,8 @@ module.exports = React.createClass({ +
+ 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. +
+
+ + + +
+ + ); + }, +}); diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js index ffea8e1ba7..2c23c0d208 100644 --- a/src/components/views/elements/AccessibleButton.js +++ b/src/components/views/elements/AccessibleButton.js @@ -27,8 +27,8 @@ import React from 'react'; export default function AccessibleButton(props) { const {element, onClick, children, ...restProps} = props; restProps.onClick = onClick; - restProps.onKeyDown = function(e) { - if (e.keyCode == 13 || e.keyCode == 32) return onClick(); + restProps.onKeyUp = function(e) { + if (e.keyCode == 13 || e.keyCode == 32) return onClick(e); }; restProps.tabIndex = restProps.tabIndex || "0"; restProps.role = "button"; diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.js new file mode 100644 index 0000000000..3b34d3cac1 --- /dev/null +++ b/src/components/views/elements/Dropdown.js @@ -0,0 +1,324 @@ +/* +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 classnames from 'classnames'; +import AccessibleButton from './AccessibleButton'; + +class MenuOption extends React.Component { + constructor(props) { + super(props); + this._onMouseEnter = this._onMouseEnter.bind(this); + this._onClick = this._onClick.bind(this); + } + + _onMouseEnter() { + this.props.onMouseEnter(this.props.dropdownKey); + } + + _onClick(e) { + e.preventDefault(); + e.stopPropagation(); + this.props.onClick(this.props.dropdownKey); + } + + render() { + const optClasses = classnames({ + mx_Dropdown_option: true, + mx_Dropdown_option_highlight: this.props.highlighted, + }); + + return
+ {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..c4084facb2 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'; @@ -158,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) { @@ -168,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 ( @@ -255,6 +263,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 +452,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} + {text} ); diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index d702b7558d..51c9ba881b 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -541,9 +541,9 @@ 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', ''); + if (contentHTML) contentHTML = contentHTML.replace('/me ', ''); sendHtmlFn = this.client.sendHtmlEmote; sendTextFn = this.client.sendEmoteMessage; } diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js new file mode 100644 index 0000000000..3a348393aa --- /dev/null +++ b/src/components/views/settings/AddPhoneNumber.js @@ -0,0 +1,172 @@ +/* +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'; + + +export default WithMatrixClient(React.createClass({ + displayName: 'AddPhoneNumber', + + propTypes: { + matrixClient: React.PropTypes.object.isRequired, + onThreepidAdded: React.PropTypes.func, + }, + + getInitialState: function() { + return { + busy: false, + phoneCountry: null, + phoneNumber: "", + msisdn_add_pending: false, + }; + }, + + componentWillMount: function() { + this._addThreepid = null; + this._addMsisdnInput = null; + this._unmounted = false; + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + _onPhoneCountryChange: function(phoneCountry) { + this.setState({ phoneCountry: phoneCountry }); + }, + + _onPhoneNumberChange: function(ev) { + this.setState({ phoneNumber: ev.target.value }); + }, + + _onAddMsisdnEditFinished: function(value, shouldSubmit) { + if (!shouldSubmit) return; + this._addMsisdn(); + }, + + _onAddMsisdnSubmit: function(ev) { + ev.preventDefault(); + this._addMsisdn(); + }, + + _collectAddMsisdnInput: function(e) { + this._addMsisdnInput = e; + }, + + _addMsisdn: function() { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + 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(() => { + if (this._unmounted) return; + this.setState({msisdn_add_pending: false}); + }).done(); + this._addMsisdnInput.blur(); + this.setState({msisdn_add_pending: true}); + }, + + _promptForMsisdnVerificationCode:function (msisdn, err) { + if (this._unmounted) return; + 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; + } + if (this._unmounted) 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(() => { + if (this._unmounted) return; + this.setState({msisdn_add_pending: false}); + }).done(); + } + }); + }, + + render: function() { + const Loader = sdk.getComponent("elements.Spinner"); + if (this.state.msisdn_add_pending) { + return ; + } else if (this.props.matrixClient.isGuest()) { + return
; + } + + 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 ( + +
+
+
+
+ + +
+
+
+ +
+ + ); + } +})) diff --git a/src/phonenumber.js b/src/phonenumber.js new file mode 100644 index 0000000000..aaf018ba26 --- /dev/null +++ b/src/phonenumber.js @@ -0,0 +1,1273 @@ +/* +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. +*/ + +const PHONE_NUMBER_REGEXP = /^[0-9 -\.]+$/; + +/* + * Do basic validation to determine if the given input could be + * a valid phone number. + * + * @param {String} phoneNumber The string to validate. This could be + * either an international format number (MSISDN or e.164) or + * a national-format number. + * @return True if the number could be a valid phone number, otherwise false. + */ +export function looksValid(phoneNumber) { + return PHONE_NUMBER_REGEXP.test(phoneNumber); +} + +export const COUNTRIES = [ + { + "iso2": "GB", + "name": "United Kingdom", + "prefix": "44", + }, + { + "iso2": "US", + "name": "United States", + "prefix": "1", + }, + { + "iso2": "AF", + "name": "Afghanistan", + "prefix": "93", + }, + { + "iso2": "AX", + "name": "\u00c5land Islands", + "prefix": "358", + }, + { + "iso2": "AL", + "name": "Albania", + "prefix": "355", + }, + { + "iso2": "DZ", + "name": "Algeria", + "prefix": "213", + }, + { + "iso2": "AS", + "name": "American Samoa", + "prefix": "1", + }, + { + "iso2": "AD", + "name": "Andorra", + "prefix": "376", + }, + { + "iso2": "AO", + "name": "Angola", + "prefix": "244", + }, + { + "iso2": "AI", + "name": "Anguilla", + "prefix": "1", + }, + { + "iso2": "AQ", + "name": "Antarctica", + "prefix": "672", + }, + { + "iso2": "AG", + "name": "Antigua & Barbuda", + "prefix": "1", + }, + { + "iso2": "AR", + "name": "Argentina", + "prefix": "54", + }, + { + "iso2": "AM", + "name": "Armenia", + "prefix": "374", + }, + { + "iso2": "AW", + "name": "Aruba", + "prefix": "297", + }, + { + "iso2": "AU", + "name": "Australia", + "prefix": "61", + }, + { + "iso2": "AT", + "name": "Austria", + "prefix": "43", + }, + { + "iso2": "AZ", + "name": "Azerbaijan", + "prefix": "994", + }, + { + "iso2": "BS", + "name": "Bahamas", + "prefix": "1", + }, + { + "iso2": "BH", + "name": "Bahrain", + "prefix": "973", + }, + { + "iso2": "BD", + "name": "Bangladesh", + "prefix": "880", + }, + { + "iso2": "BB", + "name": "Barbados", + "prefix": "1", + }, + { + "iso2": "BY", + "name": "Belarus", + "prefix": "375", + }, + { + "iso2": "BE", + "name": "Belgium", + "prefix": "32", + }, + { + "iso2": "BZ", + "name": "Belize", + "prefix": "501", + }, + { + "iso2": "BJ", + "name": "Benin", + "prefix": "229", + }, + { + "iso2": "BM", + "name": "Bermuda", + "prefix": "1", + }, + { + "iso2": "BT", + "name": "Bhutan", + "prefix": "975", + }, + { + "iso2": "BO", + "name": "Bolivia", + "prefix": "591", + }, + { + "iso2": "BA", + "name": "Bosnia", + "prefix": "387", + }, + { + "iso2": "BW", + "name": "Botswana", + "prefix": "267", + }, + { + "iso2": "BV", + "name": "Bouvet Island", + "prefix": "47", + }, + { + "iso2": "BR", + "name": "Brazil", + "prefix": "55", + }, + { + "iso2": "IO", + "name": "British Indian Ocean Territory", + "prefix": "246", + }, + { + "iso2": "VG", + "name": "British Virgin Islands", + "prefix": "1", + }, + { + "iso2": "BN", + "name": "Brunei", + "prefix": "673", + }, + { + "iso2": "BG", + "name": "Bulgaria", + "prefix": "359", + }, + { + "iso2": "BF", + "name": "Burkina Faso", + "prefix": "226", + }, + { + "iso2": "BI", + "name": "Burundi", + "prefix": "257", + }, + { + "iso2": "KH", + "name": "Cambodia", + "prefix": "855", + }, + { + "iso2": "CM", + "name": "Cameroon", + "prefix": "237", + }, + { + "iso2": "CA", + "name": "Canada", + "prefix": "1", + }, + { + "iso2": "CV", + "name": "Cape Verde", + "prefix": "238", + }, + { + "iso2": "BQ", + "name": "Caribbean Netherlands", + "prefix": "599", + }, + { + "iso2": "KY", + "name": "Cayman Islands", + "prefix": "1", + }, + { + "iso2": "CF", + "name": "Central African Republic", + "prefix": "236", + }, + { + "iso2": "TD", + "name": "Chad", + "prefix": "235", + }, + { + "iso2": "CL", + "name": "Chile", + "prefix": "56", + }, + { + "iso2": "CN", + "name": "China", + "prefix": "86", + }, + { + "iso2": "CX", + "name": "Christmas Island", + "prefix": "61", + }, + { + "iso2": "CC", + "name": "Cocos (Keeling) Islands", + "prefix": "61", + }, + { + "iso2": "CO", + "name": "Colombia", + "prefix": "57", + }, + { + "iso2": "KM", + "name": "Comoros", + "prefix": "269", + }, + { + "iso2": "CG", + "name": "Congo - Brazzaville", + "prefix": "242", + }, + { + "iso2": "CD", + "name": "Congo - Kinshasa", + "prefix": "243", + }, + { + "iso2": "CK", + "name": "Cook Islands", + "prefix": "682", + }, + { + "iso2": "CR", + "name": "Costa Rica", + "prefix": "506", + }, + { + "iso2": "HR", + "name": "Croatia", + "prefix": "385", + }, + { + "iso2": "CU", + "name": "Cuba", + "prefix": "53", + }, + { + "iso2": "CW", + "name": "Cura\u00e7ao", + "prefix": "599", + }, + { + "iso2": "CY", + "name": "Cyprus", + "prefix": "357", + }, + { + "iso2": "CZ", + "name": "Czech Republic", + "prefix": "420", + }, + { + "iso2": "CI", + "name": "C\u00f4te d\u2019Ivoire", + "prefix": "225", + }, + { + "iso2": "DK", + "name": "Denmark", + "prefix": "45", + }, + { + "iso2": "DJ", + "name": "Djibouti", + "prefix": "253", + }, + { + "iso2": "DM", + "name": "Dominica", + "prefix": "1", + }, + { + "iso2": "DO", + "name": "Dominican Republic", + "prefix": "1", + }, + { + "iso2": "EC", + "name": "Ecuador", + "prefix": "593", + }, + { + "iso2": "EG", + "name": "Egypt", + "prefix": "20", + }, + { + "iso2": "SV", + "name": "El Salvador", + "prefix": "503", + }, + { + "iso2": "GQ", + "name": "Equatorial Guinea", + "prefix": "240", + }, + { + "iso2": "ER", + "name": "Eritrea", + "prefix": "291", + }, + { + "iso2": "EE", + "name": "Estonia", + "prefix": "372", + }, + { + "iso2": "ET", + "name": "Ethiopia", + "prefix": "251", + }, + { + "iso2": "FK", + "name": "Falkland Islands", + "prefix": "500", + }, + { + "iso2": "FO", + "name": "Faroe Islands", + "prefix": "298", + }, + { + "iso2": "FJ", + "name": "Fiji", + "prefix": "679", + }, + { + "iso2": "FI", + "name": "Finland", + "prefix": "358", + }, + { + "iso2": "FR", + "name": "France", + "prefix": "33", + }, + { + "iso2": "GF", + "name": "French Guiana", + "prefix": "594", + }, + { + "iso2": "PF", + "name": "French Polynesia", + "prefix": "689", + }, + { + "iso2": "TF", + "name": "French Southern Territories", + "prefix": "262", + }, + { + "iso2": "GA", + "name": "Gabon", + "prefix": "241", + }, + { + "iso2": "GM", + "name": "Gambia", + "prefix": "220", + }, + { + "iso2": "GE", + "name": "Georgia", + "prefix": "995", + }, + { + "iso2": "DE", + "name": "Germany", + "prefix": "49", + }, + { + "iso2": "GH", + "name": "Ghana", + "prefix": "233", + }, + { + "iso2": "GI", + "name": "Gibraltar", + "prefix": "350", + }, + { + "iso2": "GR", + "name": "Greece", + "prefix": "30", + }, + { + "iso2": "GL", + "name": "Greenland", + "prefix": "299", + }, + { + "iso2": "GD", + "name": "Grenada", + "prefix": "1", + }, + { + "iso2": "GP", + "name": "Guadeloupe", + "prefix": "590", + }, + { + "iso2": "GU", + "name": "Guam", + "prefix": "1", + }, + { + "iso2": "GT", + "name": "Guatemala", + "prefix": "502", + }, + { + "iso2": "GG", + "name": "Guernsey", + "prefix": "44", + }, + { + "iso2": "GN", + "name": "Guinea", + "prefix": "224", + }, + { + "iso2": "GW", + "name": "Guinea-Bissau", + "prefix": "245", + }, + { + "iso2": "GY", + "name": "Guyana", + "prefix": "592", + }, + { + "iso2": "HT", + "name": "Haiti", + "prefix": "509", + }, + { + "iso2": "HM", + "name": "Heard & McDonald Islands", + "prefix": "672", + }, + { + "iso2": "HN", + "name": "Honduras", + "prefix": "504", + }, + { + "iso2": "HK", + "name": "Hong Kong", + "prefix": "852", + }, + { + "iso2": "HU", + "name": "Hungary", + "prefix": "36", + }, + { + "iso2": "IS", + "name": "Iceland", + "prefix": "354", + }, + { + "iso2": "IN", + "name": "India", + "prefix": "91", + }, + { + "iso2": "ID", + "name": "Indonesia", + "prefix": "62", + }, + { + "iso2": "IR", + "name": "Iran", + "prefix": "98", + }, + { + "iso2": "IQ", + "name": "Iraq", + "prefix": "964", + }, + { + "iso2": "IE", + "name": "Ireland", + "prefix": "353", + }, + { + "iso2": "IM", + "name": "Isle of Man", + "prefix": "44", + }, + { + "iso2": "IL", + "name": "Israel", + "prefix": "972", + }, + { + "iso2": "IT", + "name": "Italy", + "prefix": "39", + }, + { + "iso2": "JM", + "name": "Jamaica", + "prefix": "1", + }, + { + "iso2": "JP", + "name": "Japan", + "prefix": "81", + }, + { + "iso2": "JE", + "name": "Jersey", + "prefix": "44", + }, + { + "iso2": "JO", + "name": "Jordan", + "prefix": "962", + }, + { + "iso2": "KZ", + "name": "Kazakhstan", + "prefix": "7", + }, + { + "iso2": "KE", + "name": "Kenya", + "prefix": "254", + }, + { + "iso2": "KI", + "name": "Kiribati", + "prefix": "686", + }, + { + "iso2": "KW", + "name": "Kuwait", + "prefix": "965", + }, + { + "iso2": "KG", + "name": "Kyrgyzstan", + "prefix": "996", + }, + { + "iso2": "LA", + "name": "Laos", + "prefix": "856", + }, + { + "iso2": "LV", + "name": "Latvia", + "prefix": "371", + }, + { + "iso2": "LB", + "name": "Lebanon", + "prefix": "961", + }, + { + "iso2": "LS", + "name": "Lesotho", + "prefix": "266", + }, + { + "iso2": "LR", + "name": "Liberia", + "prefix": "231", + }, + { + "iso2": "LY", + "name": "Libya", + "prefix": "218", + }, + { + "iso2": "LI", + "name": "Liechtenstein", + "prefix": "423", + }, + { + "iso2": "LT", + "name": "Lithuania", + "prefix": "370", + }, + { + "iso2": "LU", + "name": "Luxembourg", + "prefix": "352", + }, + { + "iso2": "MO", + "name": "Macau", + "prefix": "853", + }, + { + "iso2": "MK", + "name": "Macedonia", + "prefix": "389", + }, + { + "iso2": "MG", + "name": "Madagascar", + "prefix": "261", + }, + { + "iso2": "MW", + "name": "Malawi", + "prefix": "265", + }, + { + "iso2": "MY", + "name": "Malaysia", + "prefix": "60", + }, + { + "iso2": "MV", + "name": "Maldives", + "prefix": "960", + }, + { + "iso2": "ML", + "name": "Mali", + "prefix": "223", + }, + { + "iso2": "MT", + "name": "Malta", + "prefix": "356", + }, + { + "iso2": "MH", + "name": "Marshall Islands", + "prefix": "692", + }, + { + "iso2": "MQ", + "name": "Martinique", + "prefix": "596", + }, + { + "iso2": "MR", + "name": "Mauritania", + "prefix": "222", + }, + { + "iso2": "MU", + "name": "Mauritius", + "prefix": "230", + }, + { + "iso2": "YT", + "name": "Mayotte", + "prefix": "262", + }, + { + "iso2": "MX", + "name": "Mexico", + "prefix": "52", + }, + { + "iso2": "FM", + "name": "Micronesia", + "prefix": "691", + }, + { + "iso2": "MD", + "name": "Moldova", + "prefix": "373", + }, + { + "iso2": "MC", + "name": "Monaco", + "prefix": "377", + }, + { + "iso2": "MN", + "name": "Mongolia", + "prefix": "976", + }, + { + "iso2": "ME", + "name": "Montenegro", + "prefix": "382", + }, + { + "iso2": "MS", + "name": "Montserrat", + "prefix": "1", + }, + { + "iso2": "MA", + "name": "Morocco", + "prefix": "212", + }, + { + "iso2": "MZ", + "name": "Mozambique", + "prefix": "258", + }, + { + "iso2": "MM", + "name": "Myanmar", + "prefix": "95", + }, + { + "iso2": "NA", + "name": "Namibia", + "prefix": "264", + }, + { + "iso2": "NR", + "name": "Nauru", + "prefix": "674", + }, + { + "iso2": "NP", + "name": "Nepal", + "prefix": "977", + }, + { + "iso2": "NL", + "name": "Netherlands", + "prefix": "31", + }, + { + "iso2": "NC", + "name": "New Caledonia", + "prefix": "687", + }, + { + "iso2": "NZ", + "name": "New Zealand", + "prefix": "64", + }, + { + "iso2": "NI", + "name": "Nicaragua", + "prefix": "505", + }, + { + "iso2": "NE", + "name": "Niger", + "prefix": "227", + }, + { + "iso2": "NG", + "name": "Nigeria", + "prefix": "234", + }, + { + "iso2": "NU", + "name": "Niue", + "prefix": "683", + }, + { + "iso2": "NF", + "name": "Norfolk Island", + "prefix": "672", + }, + { + "iso2": "KP", + "name": "North Korea", + "prefix": "850", + }, + { + "iso2": "MP", + "name": "Northern Mariana Islands", + "prefix": "1", + }, + { + "iso2": "NO", + "name": "Norway", + "prefix": "47", + }, + { + "iso2": "OM", + "name": "Oman", + "prefix": "968", + }, + { + "iso2": "PK", + "name": "Pakistan", + "prefix": "92", + }, + { + "iso2": "PW", + "name": "Palau", + "prefix": "680", + }, + { + "iso2": "PS", + "name": "Palestine", + "prefix": "970", + }, + { + "iso2": "PA", + "name": "Panama", + "prefix": "507", + }, + { + "iso2": "PG", + "name": "Papua New Guinea", + "prefix": "675", + }, + { + "iso2": "PY", + "name": "Paraguay", + "prefix": "595", + }, + { + "iso2": "PE", + "name": "Peru", + "prefix": "51", + }, + { + "iso2": "PH", + "name": "Philippines", + "prefix": "63", + }, + { + "iso2": "PN", + "name": "Pitcairn Islands", + "prefix": "870", + }, + { + "iso2": "PL", + "name": "Poland", + "prefix": "48", + }, + { + "iso2": "PT", + "name": "Portugal", + "prefix": "351", + }, + { + "iso2": "PR", + "name": "Puerto Rico", + "prefix": "1", + }, + { + "iso2": "QA", + "name": "Qatar", + "prefix": "974", + }, + { + "iso2": "RO", + "name": "Romania", + "prefix": "40", + }, + { + "iso2": "RU", + "name": "Russia", + "prefix": "7", + }, + { + "iso2": "RW", + "name": "Rwanda", + "prefix": "250", + }, + { + "iso2": "RE", + "name": "R\u00e9union", + "prefix": "262", + }, + { + "iso2": "WS", + "name": "Samoa", + "prefix": "685", + }, + { + "iso2": "SM", + "name": "San Marino", + "prefix": "378", + }, + { + "iso2": "SA", + "name": "Saudi Arabia", + "prefix": "966", + }, + { + "iso2": "SN", + "name": "Senegal", + "prefix": "221", + }, + { + "iso2": "RS", + "name": "Serbia", + "prefix": "381 p", + }, + { + "iso2": "SC", + "name": "Seychelles", + "prefix": "248", + }, + { + "iso2": "SL", + "name": "Sierra Leone", + "prefix": "232", + }, + { + "iso2": "SG", + "name": "Singapore", + "prefix": "65", + }, + { + "iso2": "SX", + "name": "Sint Maarten", + "prefix": "1", + }, + { + "iso2": "SK", + "name": "Slovakia", + "prefix": "421", + }, + { + "iso2": "SI", + "name": "Slovenia", + "prefix": "386", + }, + { + "iso2": "SB", + "name": "Solomon Islands", + "prefix": "677", + }, + { + "iso2": "SO", + "name": "Somalia", + "prefix": "252", + }, + { + "iso2": "ZA", + "name": "South Africa", + "prefix": "27", + }, + { + "iso2": "GS", + "name": "South Georgia & South Sandwich Islands", + "prefix": "500", + }, + { + "iso2": "KR", + "name": "South Korea", + "prefix": "82", + }, + { + "iso2": "SS", + "name": "South Sudan", + "prefix": "211", + }, + { + "iso2": "ES", + "name": "Spain", + "prefix": "34", + }, + { + "iso2": "LK", + "name": "Sri Lanka", + "prefix": "94", + }, + { + "iso2": "BL", + "name": "St. Barth\u00e9lemy", + "prefix": "590", + }, + { + "iso2": "SH", + "name": "St. Helena", + "prefix": "290 n", + }, + { + "iso2": "KN", + "name": "St. Kitts & Nevis", + "prefix": "1", + }, + { + "iso2": "LC", + "name": "St. Lucia", + "prefix": "1", + }, + { + "iso2": "MF", + "name": "St. Martin", + "prefix": "590", + }, + { + "iso2": "PM", + "name": "St. Pierre & Miquelon", + "prefix": "508", + }, + { + "iso2": "VC", + "name": "St. Vincent & Grenadines", + "prefix": "1", + }, + { + "iso2": "SD", + "name": "Sudan", + "prefix": "249", + }, + { + "iso2": "SR", + "name": "Suriname", + "prefix": "597", + }, + { + "iso2": "SJ", + "name": "Svalbard & Jan Mayen", + "prefix": "47", + }, + { + "iso2": "SZ", + "name": "Swaziland", + "prefix": "268", + }, + { + "iso2": "SE", + "name": "Sweden", + "prefix": "46", + }, + { + "iso2": "CH", + "name": "Switzerland", + "prefix": "41", + }, + { + "iso2": "SY", + "name": "Syria", + "prefix": "963", + }, + { + "iso2": "ST", + "name": "S\u00e3o Tom\u00e9 & Pr\u00edncipe", + "prefix": "239", + }, + { + "iso2": "TW", + "name": "Taiwan", + "prefix": "886", + }, + { + "iso2": "TJ", + "name": "Tajikistan", + "prefix": "992", + }, + { + "iso2": "TZ", + "name": "Tanzania", + "prefix": "255", + }, + { + "iso2": "TH", + "name": "Thailand", + "prefix": "66", + }, + { + "iso2": "TL", + "name": "Timor-Leste", + "prefix": "670", + }, + { + "iso2": "TG", + "name": "Togo", + "prefix": "228", + }, + { + "iso2": "TK", + "name": "Tokelau", + "prefix": "690", + }, + { + "iso2": "TO", + "name": "Tonga", + "prefix": "676", + }, + { + "iso2": "TT", + "name": "Trinidad & Tobago", + "prefix": "1", + }, + { + "iso2": "TN", + "name": "Tunisia", + "prefix": "216", + }, + { + "iso2": "TR", + "name": "Turkey", + "prefix": "90", + }, + { + "iso2": "TM", + "name": "Turkmenistan", + "prefix": "993", + }, + { + "iso2": "TC", + "name": "Turks & Caicos Islands", + "prefix": "1", + }, + { + "iso2": "TV", + "name": "Tuvalu", + "prefix": "688", + }, + { + "iso2": "VI", + "name": "U.S. Virgin Islands", + "prefix": "1", + }, + { + "iso2": "UG", + "name": "Uganda", + "prefix": "256", + }, + { + "iso2": "UA", + "name": "Ukraine", + "prefix": "380", + }, + { + "iso2": "AE", + "name": "United Arab Emirates", + "prefix": "971", + }, + { + "iso2": "UY", + "name": "Uruguay", + "prefix": "598", + }, + { + "iso2": "UZ", + "name": "Uzbekistan", + "prefix": "998", + }, + { + "iso2": "VU", + "name": "Vanuatu", + "prefix": "678", + }, + { + "iso2": "VA", + "name": "Vatican City", + "prefix": "39", + }, + { + "iso2": "VE", + "name": "Venezuela", + "prefix": "58", + }, + { + "iso2": "VN", + "name": "Vietnam", + "prefix": "84", + }, + { + "iso2": "WF", + "name": "Wallis & Futuna", + "prefix": "681", + }, + { + "iso2": "EH", + "name": "Western Sahara", + "prefix": "212", + }, + { + "iso2": "YE", + "name": "Yemen", + "prefix": "967", + }, + { + "iso2": "ZM", + "name": "Zambia", + "prefix": "260", + }, + { + "iso2": "ZW", + "name": "Zimbabwe", + "prefix": "263", + }, +]; diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js index da8fc17001..b8a8e49769 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.js +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -68,48 +68,49 @@ 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); 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