From 9cd7914ea51dbfb60f8b84a80cb800282476d3e4 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 21 Apr 2017 11:37:08 +0100 Subject: [PATCH] Finishing off the first iteration on login UI This makes the following changes: - Improve CountryDropdown by allowing all countries to be displayed at once and using PNGs for performance (trading of quality - the pngs are scaled down from 32px to 25px) - "I want to sign in with" dropdown to select login method - MXID login field that suffixes HS domain (whether custom or matrix.org) and prefixes "@" - Email field which is secretly the same as the username field but with a different placeholder - No more login flickering when changing ServerConfig (!) fixes https://github.com/vector-im/riot-web/issues/1517 This implements most of the design in https://github.com/vector-im/riot-web/issues/3524 but neglects the phone number login: ![login_with_msisdn](https://cloud.githubusercontent.com/assets/1922197/24864469/30a921fc-1dfc-11e7-95d1-76f619da1402.png) This will be updated in another PR to implement desired things: - Country code visible once a country has been selected (propbably but as a prefix to the phone number input box. - Use square flags - Move CountryDropdown above phone input and make it show the full country name when not expanded - Auto-select country based on IP --- src/HtmlUtils.js | 14 +- src/components/structures/login/Login.js | 85 +++---- src/components/views/elements/Dropdown.js | 13 +- src/components/views/login/CountryDropdown.js | 8 +- src/components/views/login/PasswordLogin.js | 210 +++++++++++------- src/components/views/login/ServerConfig.js | 28 ++- 6 files changed, 207 insertions(+), 151 deletions(-) diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index a8e20f5ec1..96934d205e 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -25,6 +25,9 @@ import emojione from 'emojione'; import classNames from 'classnames'; emojione.imagePathSVG = 'emojione/svg/'; +// Store PNG path for displaying many flags at once (for increased performance over SVG) +emojione.imagePathPNG = 'emojione/png/'; +// Use SVGs for emojis emojione.imageType = 'svg'; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); @@ -64,16 +67,23 @@ export function unicodeToImage(str) { * emoji. * * @param alt {string} String to use for the image alt text + * @param useSvg {boolean} Whether to use SVG image src. If False, PNG will be used. * @param unicode {integer} One or more integers representing unicode characters * @returns A img node with the corresponding emoji */ -export function charactersToImageNode(alt, ...unicode) { +export function charactersToImageNode(alt, useSvg, ...unicode) { const fileName = unicode.map((u) => { return u.toString(16); }).join('-'); - return {alt}; + const path = useSvg ? emojione.imagePathSVG : emojione.imagePathPNG; + const fileType = useSvg ? 'svg' : 'png'; + return {alt}; } + export function stripParagraphs(html: string): string { const contentDiv = document.createElement('div'); contentDiv.innerHTML = html; diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 7e1a5f9d35..d9a7039686 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -17,13 +17,11 @@ limitations under the License. 'use strict'; -var React = require('react'); -var ReactDOM = require('react-dom'); -var sdk = require('../../../index'); -var Login = require("../../../Login"); -var PasswordLogin = require("../../views/login/PasswordLogin"); -var CasLogin = require("../../views/login/CasLogin"); -var ServerConfig = require("../../views/login/ServerConfig"); +import React from 'react'; +import ReactDOM from 'react-dom'; +import url from 'url'; +import sdk from '../../../index'; +import Login from '../../../Login'; /** * A wire component which glues together login UI components and Login logic @@ -67,6 +65,7 @@ module.exports = React.createClass({ username: "", phoneCountry: null, phoneNumber: "", + currentFlow: "m.login.password", }; }, @@ -129,23 +128,19 @@ module.exports = React.createClass({ this.setState({ phoneNumber: phoneNumber }); }, - onHsUrlChanged: function(newHsUrl) { + onServerConfigChange: function(config) { var self = this; - this.setState({ - enteredHomeserverUrl: newHsUrl, + let newState = { errorText: null, // reset err messages - }, function() { - self._initLoginLogic(newHsUrl); - }); - }, - - onIsUrlChanged: function(newIsUrl) { - var self = this; - this.setState({ - enteredIdentityServerUrl: newIsUrl, - errorText: null, // reset err messages - }, function() { - self._initLoginLogic(null, newIsUrl); + }; + if (config.hsUrl !== undefined) { + newState.enteredHomeserverUrl = config.hsUrl; + } + if (config.isUrl !== undefined) { + newState.enteredIdentityServerUrl = config.isUrl; + } + this.setState(newState, function() { + self._initLoginLogic(config.hsUrl || null, config.isUrl); }); }, @@ -161,25 +156,28 @@ module.exports = React.createClass({ }); this._loginLogic = loginLogic; - loginLogic.getFlows().then(function(flows) { - // old behaviour was to always use the first flow without presenting - // options. This works in most cases (we don't have a UI for multiple - // logins so let's skip that for now). - loginLogic.chooseFlow(0); - }, function(err) { - self._setStateFromError(err, false); - }).finally(function() { - self.setState({ - busy: false - }); - }); - this.setState({ enteredHomeserverUrl: hsUrl, enteredIdentityServerUrl: isUrl, busy: true, loginIncorrect: false, }); + + loginLogic.getFlows().then(function(flows) { + // old behaviour was to always use the first flow without presenting + // options. This works in most cases (we don't have a UI for multiple + // logins so let's skip that for now). + loginLogic.chooseFlow(0); + self.setState({ + currentFlow: self._getCurrentFlowStep(), + }); + }, function(err) { + self._setStateFromError(err, false); + }).finally(function() { + self.setState({ + busy: false, + }); + }); }, _getCurrentFlowStep: function() { @@ -231,6 +229,7 @@ module.exports = React.createClass({ componentForStep: function(step) { switch (step) { case 'm.login.password': + const PasswordLogin = sdk.getComponent('login.PasswordLogin'); return ( ); case 'm.login.cas': + const CasLogin = sdk.getComponent('login.CasLogin'); return ( ); @@ -262,10 +263,11 @@ module.exports = React.createClass({ }, render: function() { - var Loader = sdk.getComponent("elements.Spinner"); - var LoginHeader = sdk.getComponent("login.LoginHeader"); - var LoginFooter = sdk.getComponent("login.LoginFooter"); - var loader = this.state.busy ?
: null; + const Loader = sdk.getComponent("elements.Spinner"); + const LoginHeader = sdk.getComponent("login.LoginHeader"); + const LoginFooter = sdk.getComponent("login.LoginFooter"); + const ServerConfig = sdk.getComponent("login.ServerConfig"); + const loader = this.state.busy ?
: null; var loginAsGuestJsx; if (this.props.enableGuest) { @@ -291,15 +293,14 @@ module.exports = React.createClass({

Sign in { loader }

- { this.componentForStep(this._getCurrentFlowStep()) } + { this.componentForStep(this.state.currentFlow) }
{ this.state.errorText } diff --git a/src/components/views/elements/Dropdown.js b/src/components/views/elements/Dropdown.js index 907d4b0905..a9ecf5b669 100644 --- a/src/components/views/elements/Dropdown.js +++ b/src/components/views/elements/Dropdown.js @@ -248,13 +248,10 @@ export default class Dropdown extends React.Component { ); }); - - if (!this.state.searchQuery && this.props.searchEnabled) { - options.push( -
- Type to search... -
- ); + if (options.length === 0) { + return [
+ No results +
]; } return options; } @@ -317,7 +314,7 @@ Dropdown.propTypes = { onOptionChange: React.PropTypes.func.isRequired, // Called when the value of the search field changes onSearchChange: React.PropTypes.func, - searchEnabled: React.PropTypes.boolean, + searchEnabled: React.PropTypes.bool, // 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 diff --git a/src/components/views/login/CountryDropdown.js b/src/components/views/login/CountryDropdown.js index be1ed51b5e..7f6b21650d 100644 --- a/src/components/views/login/CountryDropdown.js +++ b/src/components/views/login/CountryDropdown.js @@ -33,8 +33,6 @@ function countryMatchesSearchQuery(query, country) { return false; } -const MAX_DISPLAYED_ROWS = 2; - export default class CountryDropdown extends React.Component { constructor(props) { super(props); @@ -64,7 +62,7 @@ export default class CountryDropdown extends React.Component { // Unicode Regional Indicator Symbol letter 'A' const RIS_A = 0x1F1E6; const ASCII_A = 65; - return charactersToImageNode(iso2, + return charactersToImageNode(iso2, true, RIS_A + (iso2.charCodeAt(0) - ASCII_A), RIS_A + (iso2.charCodeAt(1) - ASCII_A), ); @@ -93,10 +91,6 @@ export default class CountryDropdown extends React.Component { 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)} diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 002de0c2ba..fc063efbe9 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -25,56 +25,49 @@ import {field_input_incorrect} from '../../../UiEffects'; /** * A pure UI component which displays a username/password form. */ -module.exports = React.createClass({displayName: 'PasswordLogin', - propTypes: { - 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, - }, +class PasswordLogin extends React.Component { + static defaultProps = { + onUsernameChanged: function() {}, + onPasswordChanged: function() {}, + onPhoneCountryChanged: function() {}, + onPhoneNumberChanged: function() {}, + initialUsername: "", + initialPhoneCountry: "", + initialPhoneNumber: "", + initialPassword: "", + loginIncorrect: false, + hsDomain: "", + } - getDefaultProps: function() { - return { - onUsernameChanged: function() {}, - onPasswordChanged: function() {}, - onPhoneCountryChanged: function() {}, - onPhoneNumberChanged: function() {}, - initialUsername: "", - initialPhoneCountry: "", - initialPhoneNumber: "", - initialPassword: "", - loginIncorrect: false, - }; - }, - - getInitialState: function() { - return { + constructor(props) { + super(props); + this.state = { username: this.props.initialUsername, password: this.props.initialPassword, phoneCountry: this.props.initialPhoneCountry, phoneNumber: this.props.initialPhoneNumber, - loginType: "mxid", + loginType: PasswordLogin.LOGIN_FIELD_MXID, }; - }, - componentWillMount: function() { + this.onSubmitForm = this.onSubmitForm.bind(this); + this.onUsernameChanged = this.onUsernameChanged.bind(this); + this.onLoginTypeChange = this.onLoginTypeChange.bind(this); + this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this); + this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this); + this.onPasswordChanged = this.onPasswordChanged.bind(this); + } + + componentWillMount() { this._passwordField = null; - }, + } - componentWillReceiveProps: function(nextProps) { + componentWillReceiveProps(nextProps) { if (!this.props.loginIncorrect && nextProps.loginIncorrect) { field_input_incorrect(this._passwordField); } - }, + } - onSubmitForm: function(ev) { + onSubmitForm(ev) { ev.preventDefault(); this.props.onSubmit( this.state.username, @@ -82,33 +75,87 @@ module.exports = React.createClass({displayName: 'PasswordLogin', this.state.phoneNumber, this.state.password, ); - }, + } - onUsernameChanged: function(ev) { + onUsernameChanged(ev) { this.setState({username: ev.target.value}); this.props.onUsernameChanged(ev.target.value); - }, + } - onLoginTypeChange: function(loginType) { - this.setState({loginType: loginType}); - }, + onLoginTypeChange(loginType) { + this.setState({ + loginType: loginType, + username: "" // Reset because email and username use the same state + }); + } - onPhoneCountryChanged: function(country) { + onPhoneCountryChanged(country) { this.setState({phoneCountry: country}); this.props.onPhoneCountryChanged(country); - }, + } - onPhoneNumberChanged: function(ev) { + onPhoneNumberChanged(ev) { this.setState({phoneNumber: ev.target.value}); this.props.onPhoneNumberChanged(ev.target.value); - }, + } - onPasswordChanged: function(ev) { + onPasswordChanged(ev) { this.setState({password: ev.target.value}); this.props.onPasswordChanged(ev.target.value); - }, + } - render: function() { + renderLoginField(loginType) { + switch(loginType) { + case PasswordLogin.LOGIN_FIELD_EMAIL: + return ; + case PasswordLogin.LOGIN_FIELD_MXID: + return
+
@
+ +
:{this.props.hsDomain}
+
; + case PasswordLogin.LOGIN_FIELD_PHONE: + const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); + return
+ + +
; + } + } + + render() { var forgotPasswordJsx; if (this.props.onForgotPasswordClick) { @@ -124,47 +171,25 @@ module.exports = React.createClass({displayName: 'PasswordLogin', error: this.props.loginIncorrect, }); - const CountryDropdown = sdk.getComponent('views.login.CountryDropdown'); const Dropdown = sdk.getComponent('elements.Dropdown'); - const loginType = { - 'email': - , - 'mxid': - , - 'phone':
- - -
- }[this.state.loginType]; + const loginField = this.renderLoginField(this.state.loginType); return (
- - Matrix ID - Email - Phone + + Matrix ID + Email Address + Phone
- {loginType} + {loginField} {this._passwordField = e;}} type="password" name="password" value={this.state.password} onChange={this.onPasswordChanged} @@ -176,4 +201,25 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
); } -}); +} + +PasswordLogin.LOGIN_FIELD_EMAIL = "login_field_email"; +PasswordLogin.LOGIN_FIELD_MXID = "login_field_mxid"; +PasswordLogin.LOGIN_FIELD_PHONE = "login_field_phone"; + +PasswordLogin.propTypes = { + 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, + hsDomain: React.PropTypes.string, +}; + +module.exports = PasswordLogin; diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js index 4e6ed12f9e..2853945425 100644 --- a/src/components/views/login/ServerConfig.js +++ b/src/components/views/login/ServerConfig.js @@ -27,8 +27,7 @@ module.exports = React.createClass({ displayName: 'ServerConfig', propTypes: { - onHsUrlChanged: React.PropTypes.func, - onIsUrlChanged: React.PropTypes.func, + onServerConfigChange: React.PropTypes.func, // default URLs are defined in config.json (or the hardcoded defaults) // they are used if the user has not overridden them with a custom URL. @@ -50,8 +49,7 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - onHsUrlChanged: function() {}, - onIsUrlChanged: function() {}, + onServerConfigChange: function() {}, customHsUrl: "", customIsUrl: "", withToggleButton: false, @@ -75,7 +73,10 @@ module.exports = React.createClass({ this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() { var hsUrl = this.state.hs_url.trim().replace(/\/$/, ""); if (hsUrl === "") hsUrl = this.props.defaultHsUrl; - this.props.onHsUrlChanged(hsUrl); + this.props.onServerConfigChange({ + hsUrl : this.state.hs_url, + isUrl : this.state.is_url, + }); }); }); }, @@ -85,7 +86,10 @@ module.exports = React.createClass({ this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() { var isUrl = this.state.is_url.trim().replace(/\/$/, ""); if (isUrl === "") isUrl = this.props.defaultIsUrl; - this.props.onIsUrlChanged(isUrl); + this.props.onServerConfigChange({ + hsUrl : this.state.hs_url, + isUrl : this.state.is_url, + }); }); }); }, @@ -102,12 +106,16 @@ module.exports = React.createClass({ configVisible: visible }); if (!visible) { - this.props.onHsUrlChanged(this.props.defaultHsUrl); - this.props.onIsUrlChanged(this.props.defaultIsUrl); + this.props.onServerConfigChange({ + hsUrl : this.props.defaultHsUrl, + isUrl : this.props.defaultIsUrl, + }); } else { - this.props.onHsUrlChanged(this.state.hs_url); - this.props.onIsUrlChanged(this.state.is_url); + this.props.onServerConfigChange({ + hsUrl : this.state.hs_url, + isUrl : this.state.is_url, + }); } },