Merge branch 'develop' into feature-change-password-validation
This commit is contained in:
commit
eb64e5b2af
61 changed files with 2079 additions and 1439 deletions
|
@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import withValidation, {IFieldState, IValidationResult} from "../elements/Validation";
|
||||
import {_t, _td} from "../../../languageHandler";
|
||||
import Field from "../elements/Field";
|
||||
import Field, {IInputProps} from "../elements/Field";
|
||||
|
||||
interface IProps {
|
||||
interface IProps extends Omit<IInputProps, "onValidate"> {
|
||||
autoFocus?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
|
|
|
@ -1,377 +0,0 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2019 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import * as sdk from '../../../index';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
/**
|
||||
* A pure UI component which displays a username/password form.
|
||||
*/
|
||||
export default class PasswordLogin extends React.Component {
|
||||
static propTypes = {
|
||||
onSubmit: PropTypes.func.isRequired, // fn(username, password)
|
||||
onError: PropTypes.func,
|
||||
onEditServerDetailsClick: PropTypes.func,
|
||||
onForgotPasswordClick: PropTypes.func, // fn()
|
||||
initialUsername: PropTypes.string,
|
||||
initialPhoneCountry: PropTypes.string,
|
||||
initialPhoneNumber: PropTypes.string,
|
||||
initialPassword: PropTypes.string,
|
||||
onUsernameChanged: PropTypes.func,
|
||||
onPhoneCountryChanged: PropTypes.func,
|
||||
onPhoneNumberChanged: PropTypes.func,
|
||||
onPasswordChanged: PropTypes.func,
|
||||
loginIncorrect: PropTypes.bool,
|
||||
disableSubmit: PropTypes.bool,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
busy: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onError: function() {},
|
||||
onEditServerDetailsClick: null,
|
||||
onUsernameChanged: function() {},
|
||||
onUsernameBlur: function() {},
|
||||
onPasswordChanged: function() {},
|
||||
onPhoneCountryChanged: function() {},
|
||||
onPhoneNumberChanged: function() {},
|
||||
onPhoneNumberBlur: function() {},
|
||||
initialUsername: "",
|
||||
initialPhoneCountry: "",
|
||||
initialPhoneNumber: "",
|
||||
initialPassword: "",
|
||||
loginIncorrect: false,
|
||||
disableSubmit: false,
|
||||
};
|
||||
|
||||
static LOGIN_FIELD_EMAIL = "login_field_email";
|
||||
static LOGIN_FIELD_MXID = "login_field_mxid";
|
||||
static LOGIN_FIELD_PHONE = "login_field_phone";
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
username: this.props.initialUsername,
|
||||
password: this.props.initialPassword,
|
||||
phoneCountry: this.props.initialPhoneCountry,
|
||||
phoneNumber: this.props.initialPhoneNumber,
|
||||
loginType: PasswordLogin.LOGIN_FIELD_MXID,
|
||||
};
|
||||
|
||||
this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this);
|
||||
this.onSubmitForm = this.onSubmitForm.bind(this);
|
||||
this.onUsernameChanged = this.onUsernameChanged.bind(this);
|
||||
this.onUsernameBlur = this.onUsernameBlur.bind(this);
|
||||
this.onLoginTypeChange = this.onLoginTypeChange.bind(this);
|
||||
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
|
||||
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
|
||||
this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this);
|
||||
this.onPasswordChanged = this.onPasswordChanged.bind(this);
|
||||
this.isLoginEmpty = this.isLoginEmpty.bind(this);
|
||||
}
|
||||
|
||||
onForgotPasswordClick(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onForgotPasswordClick();
|
||||
}
|
||||
|
||||
onSubmitForm(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
let username = ''; // XXX: Synapse breaks if you send null here:
|
||||
let phoneCountry = null;
|
||||
let phoneNumber = null;
|
||||
let error;
|
||||
|
||||
switch (this.state.loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
username = this.state.username;
|
||||
if (!username) {
|
||||
error = _t('The email field must not be blank.');
|
||||
}
|
||||
break;
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
username = this.state.username;
|
||||
if (!username) {
|
||||
error = _t('The username field must not be blank.');
|
||||
}
|
||||
break;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE:
|
||||
phoneCountry = this.state.phoneCountry;
|
||||
phoneNumber = this.state.phoneNumber;
|
||||
if (!phoneNumber) {
|
||||
error = _t('The phone number field must not be blank.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this.props.onError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.state.password) {
|
||||
this.props.onError(_t('The password field must not be blank.'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onSubmit(
|
||||
username,
|
||||
phoneCountry,
|
||||
phoneNumber,
|
||||
this.state.password,
|
||||
);
|
||||
}
|
||||
|
||||
onUsernameChanged(ev) {
|
||||
this.setState({username: ev.target.value});
|
||||
this.props.onUsernameChanged(ev.target.value);
|
||||
}
|
||||
|
||||
onUsernameFocus() {
|
||||
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_focus");
|
||||
}
|
||||
}
|
||||
|
||||
onUsernameBlur(ev) {
|
||||
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_MXID) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_blur");
|
||||
}
|
||||
this.props.onUsernameBlur(ev.target.value);
|
||||
}
|
||||
|
||||
onLoginTypeChange(ev) {
|
||||
const loginType = ev.target.value;
|
||||
this.props.onError(null); // send a null error to clear any error messages
|
||||
this.setState({
|
||||
loginType: loginType,
|
||||
username: "", // Reset because email and username use the same state
|
||||
});
|
||||
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
|
||||
}
|
||||
|
||||
onPhoneCountryChanged(country) {
|
||||
this.setState({
|
||||
phoneCountry: country.iso2,
|
||||
phonePrefix: country.prefix,
|
||||
});
|
||||
this.props.onPhoneCountryChanged(country.iso2);
|
||||
}
|
||||
|
||||
onPhoneNumberChanged(ev) {
|
||||
this.setState({phoneNumber: ev.target.value});
|
||||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
}
|
||||
|
||||
onPhoneNumberFocus() {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
|
||||
}
|
||||
|
||||
onPhoneNumberBlur(ev) {
|
||||
this.props.onPhoneNumberBlur(ev.target.value);
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
|
||||
}
|
||||
|
||||
onPasswordChanged(ev) {
|
||||
this.setState({password: ev.target.value});
|
||||
this.props.onPasswordChanged(ev.target.value);
|
||||
}
|
||||
|
||||
renderLoginField(loginType, autoFocus) {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
|
||||
const classes = {};
|
||||
|
||||
switch (loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
classes.error = this.props.loginIncorrect && !this.state.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="email_input"
|
||||
type="text"
|
||||
label={_t("Email")}
|
||||
placeholder="joe@example.com"
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
/>;
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
classes.error = this.props.loginIncorrect && !this.state.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={_t("Username")}
|
||||
value={this.state.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
/>;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE: {
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
|
||||
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.state.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChanged}
|
||||
/>;
|
||||
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="phoneNumber"
|
||||
key="phone_input"
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
value={this.state.phoneNumber}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onFocus={this.onPhoneNumberFocus}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isLoginEmpty() {
|
||||
switch (this.state.loginType) {
|
||||
case PasswordLogin.LOGIN_FIELD_EMAIL:
|
||||
case PasswordLogin.LOGIN_FIELD_MXID:
|
||||
return !this.state.username;
|
||||
case PasswordLogin.LOGIN_FIELD_PHONE:
|
||||
return !this.state.phoneCountry || !this.state.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const SignInToText = sdk.getComponent('views.auth.SignInToText');
|
||||
|
||||
let forgotPasswordJsx;
|
||||
|
||||
if (this.props.onForgotPasswordClick) {
|
||||
forgotPasswordJsx = <span>
|
||||
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton
|
||||
className="mx_Login_forgot"
|
||||
disabled={this.props.busy}
|
||||
kind="link"
|
||||
onClick={this.onForgotPasswordClick}
|
||||
>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
</span>;
|
||||
}
|
||||
|
||||
const pwFieldClass = classNames({
|
||||
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
|
||||
});
|
||||
|
||||
// If login is empty, autoFocus login, otherwise autoFocus password.
|
||||
// this is for when auto server discovery remounts us when the user tries to tab from username to password
|
||||
const autoFocusPassword = !this.isLoginEmpty();
|
||||
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
|
||||
|
||||
let loginType;
|
||||
if (!SdkConfig.get().disable_3pid_login) {
|
||||
loginType = (
|
||||
<div className="mx_Login_type_container">
|
||||
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
|
||||
<Field
|
||||
element="select"
|
||||
value={this.state.loginType}
|
||||
onChange={this.onLoginTypeChange}
|
||||
disabled={this.props.disableSubmit}
|
||||
>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_MXID}
|
||||
value={PasswordLogin.LOGIN_FIELD_MXID}
|
||||
>
|
||||
{_t('Username')}
|
||||
</option>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_EMAIL}
|
||||
value={PasswordLogin.LOGIN_FIELD_EMAIL}
|
||||
>
|
||||
{_t('Email address')}
|
||||
</option>
|
||||
<option
|
||||
key={PasswordLogin.LOGIN_FIELD_PHONE}
|
||||
value={PasswordLogin.LOGIN_FIELD_PHONE}
|
||||
>
|
||||
{_t('Phone')}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SignInToText serverConfig={this.props.serverConfig}
|
||||
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
{loginType}
|
||||
{loginField}
|
||||
<Field
|
||||
className={pwFieldClass}
|
||||
type="password"
|
||||
name="password"
|
||||
label={_t('Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChanged}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocusPassword}
|
||||
/>
|
||||
{forgotPasswordJsx}
|
||||
{ !this.props.busy && <input className="mx_Login_submit"
|
||||
type="submit"
|
||||
value={_t('Sign in')}
|
||||
disabled={this.props.disableSubmit}
|
||||
/> }
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
495
src/components/views/auth/PasswordLogin.tsx
Normal file
495
src/components/views/auth/PasswordLogin.tsx
Normal file
|
@ -0,0 +1,495 @@
|
|||
/*
|
||||
Copyright 2015, 2016, 2017, 2019 New Vector Ltd.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import SdkConfig from '../../../SdkConfig';
|
||||
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
import withValidation from "../elements/Validation";
|
||||
import * as Email from "../../../email";
|
||||
import Field from "../elements/Field";
|
||||
import CountryDropdown from "./CountryDropdown";
|
||||
import SignInToText from "./SignInToText";
|
||||
|
||||
// For validating phone numbers without country codes
|
||||
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
|
||||
|
||||
interface IProps {
|
||||
username: string; // also used for email address
|
||||
phoneCountry: string;
|
||||
phoneNumber: string;
|
||||
|
||||
serverConfig: ValidatedServerConfig;
|
||||
loginIncorrect?: boolean;
|
||||
disableSubmit?: boolean;
|
||||
busy?: boolean;
|
||||
|
||||
onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void;
|
||||
onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void;
|
||||
onUsernameChanged?(username: string): void;
|
||||
onUsernameBlur?(username: string): void;
|
||||
onPhoneCountryChanged?(phoneCountry: string): void;
|
||||
onPhoneNumberChanged?(phoneNumber: string): void;
|
||||
onEditServerDetailsClick?(): void;
|
||||
onForgotPasswordClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
fieldValid: Partial<Record<LoginField, boolean>>;
|
||||
loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone,
|
||||
password: "",
|
||||
}
|
||||
|
||||
enum LoginField {
|
||||
Email = "login_field_email",
|
||||
MatrixId = "login_field_mxid",
|
||||
Phone = "login_field_phone",
|
||||
Password = "login_field_phone",
|
||||
}
|
||||
|
||||
/*
|
||||
* A pure UI component which displays a username/password form.
|
||||
* The email/username/phone fields are fully-controlled, the password field is not.
|
||||
*/
|
||||
export default class PasswordLogin extends React.PureComponent<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onEditServerDetailsClick: null,
|
||||
onUsernameChanged: function() {},
|
||||
onUsernameBlur: function() {},
|
||||
onPhoneCountryChanged: function() {},
|
||||
onPhoneNumberChanged: function() {},
|
||||
loginIncorrect: false,
|
||||
disableSubmit: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
// Field error codes by field ID
|
||||
fieldValid: {},
|
||||
loginType: LoginField.MatrixId,
|
||||
password: "",
|
||||
};
|
||||
}
|
||||
|
||||
private onForgotPasswordClick = ev => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
this.props.onForgotPasswordClick();
|
||||
};
|
||||
|
||||
private onSubmitForm = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
|
||||
if (!allFieldsValid) {
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_failed");
|
||||
return;
|
||||
}
|
||||
|
||||
let username = ''; // XXX: Synapse breaks if you send null here:
|
||||
let phoneCountry = null;
|
||||
let phoneNumber = null;
|
||||
|
||||
switch (this.state.loginType) {
|
||||
case LoginField.Email:
|
||||
case LoginField.MatrixId:
|
||||
username = this.props.username;
|
||||
break;
|
||||
case LoginField.Phone:
|
||||
phoneCountry = this.props.phoneCountry;
|
||||
phoneNumber = this.props.phoneNumber;
|
||||
break;
|
||||
}
|
||||
|
||||
this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password);
|
||||
};
|
||||
|
||||
private onUsernameChanged = ev => {
|
||||
this.props.onUsernameChanged(ev.target.value);
|
||||
};
|
||||
|
||||
private onUsernameFocus = () => {
|
||||
if (this.state.loginType === LoginField.MatrixId) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_focus");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_focus");
|
||||
}
|
||||
};
|
||||
|
||||
private onUsernameBlur = ev => {
|
||||
if (this.state.loginType === LoginField.MatrixId) {
|
||||
CountlyAnalytics.instance.track("onboarding_login_mxid_blur");
|
||||
} else {
|
||||
CountlyAnalytics.instance.track("onboarding_login_email_blur");
|
||||
}
|
||||
this.props.onUsernameBlur(ev.target.value);
|
||||
};
|
||||
|
||||
private onLoginTypeChange = ev => {
|
||||
const loginType = ev.target.value;
|
||||
this.setState({ loginType });
|
||||
this.props.onUsernameChanged(""); // Reset because email and username use the same state
|
||||
CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType });
|
||||
};
|
||||
|
||||
private onPhoneCountryChanged = country => {
|
||||
this.props.onPhoneCountryChanged(country.iso2);
|
||||
};
|
||||
|
||||
private onPhoneNumberChanged = ev => {
|
||||
this.props.onPhoneNumberChanged(ev.target.value);
|
||||
};
|
||||
|
||||
private onPhoneNumberFocus = () => {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_focus");
|
||||
};
|
||||
|
||||
private onPhoneNumberBlur = ev => {
|
||||
CountlyAnalytics.instance.track("onboarding_login_phone_number_blur");
|
||||
};
|
||||
|
||||
private onPasswordChanged = ev => {
|
||||
this.setState({password: ev.target.value});
|
||||
};
|
||||
|
||||
private async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
this.state.loginType,
|
||||
LoginField.Password,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
// values for required fields.
|
||||
for (const fieldID of fieldIDsInDisplayOrder) {
|
||||
const field = this[fieldID];
|
||||
if (!field) {
|
||||
continue;
|
||||
}
|
||||
// We must wait for these validations to finish before queueing
|
||||
// up the setState below so our setState goes in the queue after
|
||||
// all the setStates from these validate calls (that's how we
|
||||
// know they've finished).
|
||||
await field.validate({ allowEmpty: false });
|
||||
}
|
||||
|
||||
// Validation and state updates are async, so we need to wait for them to complete
|
||||
// first. Queue a `setState` callback and wait for it to resolve.
|
||||
await new Promise(resolve => this.setState({}, resolve));
|
||||
|
||||
if (this.allFieldsValid()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder);
|
||||
|
||||
if (!invalidField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Focus the first invalid field and show feedback in the stricter mode
|
||||
// that no longer allows empty values for required fields.
|
||||
invalidField.focus();
|
||||
invalidField.validate({ allowEmpty: false, focused: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
private allFieldsValid() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private findFirstInvalidField(fieldIDs: LoginField[]) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private markFieldValid(fieldID: LoginField, valid: boolean) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
fieldValid,
|
||||
});
|
||||
}
|
||||
|
||||
private validateUsernameRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter username"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onUsernameValidate = async (fieldState) => {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(LoginField.MatrixId, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validateEmailRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address"),
|
||||
}, {
|
||||
key: "email",
|
||||
test: ({ value }) => !value || Email.looksValid(value),
|
||||
invalid: () => _t("Doesn't look like a valid email address"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onEmailValidate = async (fieldState) => {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(LoginField.Email, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePhoneNumberRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number"),
|
||||
}, {
|
||||
key: "number",
|
||||
test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value),
|
||||
invalid: () => _t("Doesn't look like a valid phone number"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPhoneNumberValidate = async (fieldState) => {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(LoginField.Password, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
private validatePasswordRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter password"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
private onPasswordValidate = async (fieldState) => {
|
||||
const result = await this.validatePasswordRules(fieldState);
|
||||
this.markFieldValid(LoginField.Password, result.valid);
|
||||
return result;
|
||||
}
|
||||
|
||||
private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) {
|
||||
const classes = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
switch (loginType) {
|
||||
case LoginField.Email:
|
||||
classes.error = this.props.loginIncorrect && !this.props.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="email_input"
|
||||
type="text"
|
||||
label={_t("Email")}
|
||||
placeholder="joe@example.com"
|
||||
value={this.props.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onEmailValidate}
|
||||
ref={field => this[LoginField.Email] = field}
|
||||
/>;
|
||||
case LoginField.MatrixId:
|
||||
classes.error = this.props.loginIncorrect && !this.props.username;
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="username" // make it a little easier for browser's remember-password
|
||||
key="username_input"
|
||||
type="text"
|
||||
label={_t("Username")}
|
||||
value={this.props.username}
|
||||
onChange={this.onUsernameChanged}
|
||||
onFocus={this.onUsernameFocus}
|
||||
onBlur={this.onUsernameBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onUsernameValidate}
|
||||
ref={field => this[LoginField.MatrixId] = field}
|
||||
/>;
|
||||
case LoginField.Phone: {
|
||||
classes.error = this.props.loginIncorrect && !this.props.phoneNumber;
|
||||
|
||||
const phoneCountry = <CountryDropdown
|
||||
value={this.props.phoneCountry}
|
||||
isSmall={true}
|
||||
showPrefix={true}
|
||||
onOptionChange={this.onPhoneCountryChanged}
|
||||
/>;
|
||||
|
||||
return <Field
|
||||
className={classNames(classes)}
|
||||
name="phoneNumber"
|
||||
key="phone_input"
|
||||
type="text"
|
||||
label={_t("Phone")}
|
||||
value={this.props.phoneNumber}
|
||||
prefixComponent={phoneCountry}
|
||||
onChange={this.onPhoneNumberChanged}
|
||||
onFocus={this.onPhoneNumberFocus}
|
||||
onBlur={this.onPhoneNumberBlur}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocus}
|
||||
onValidate={this.onPhoneNumberValidate}
|
||||
ref={field => this[LoginField.Password] = field}
|
||||
/>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isLoginEmpty() {
|
||||
switch (this.state.loginType) {
|
||||
case LoginField.Email:
|
||||
case LoginField.MatrixId:
|
||||
return !this.props.username;
|
||||
case LoginField.Phone:
|
||||
return !this.props.phoneCountry || !this.props.phoneNumber;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let forgotPasswordJsx;
|
||||
|
||||
if (this.props.onForgotPasswordClick) {
|
||||
forgotPasswordJsx = <span>
|
||||
{_t('Not sure of your password? <a>Set a new one</a>', {}, {
|
||||
a: sub => (
|
||||
<AccessibleButton
|
||||
className="mx_Login_forgot"
|
||||
disabled={this.props.busy}
|
||||
kind="link"
|
||||
onClick={this.onForgotPasswordClick}
|
||||
>
|
||||
{sub}
|
||||
</AccessibleButton>
|
||||
),
|
||||
})}
|
||||
</span>;
|
||||
}
|
||||
|
||||
const pwFieldClass = classNames({
|
||||
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
|
||||
});
|
||||
|
||||
// If login is empty, autoFocus login, otherwise autoFocus password.
|
||||
// this is for when auto server discovery remounts us when the user tries to tab from username to password
|
||||
const autoFocusPassword = !this.isLoginEmpty();
|
||||
const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword);
|
||||
|
||||
let loginType;
|
||||
if (!SdkConfig.get().disable_3pid_login) {
|
||||
loginType = (
|
||||
<div className="mx_Login_type_container">
|
||||
<label className="mx_Login_type_label">{ _t('Sign in with') }</label>
|
||||
<Field
|
||||
element="select"
|
||||
value={this.state.loginType}
|
||||
onChange={this.onLoginTypeChange}
|
||||
disabled={this.props.disableSubmit}
|
||||
>
|
||||
<option key={LoginField.MatrixId} value={LoginField.MatrixId}>
|
||||
{_t('Username')}
|
||||
</option>
|
||||
<option
|
||||
key={LoginField.Email}
|
||||
value={LoginField.Email}
|
||||
>
|
||||
{_t('Email address')}
|
||||
</option>
|
||||
<option key={LoginField.Password} value={LoginField.Password}>
|
||||
{_t('Phone')}
|
||||
</option>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SignInToText serverConfig={this.props.serverConfig}
|
||||
onEditServerDetailsClick={this.props.onEditServerDetailsClick} />
|
||||
<form onSubmit={this.onSubmitForm}>
|
||||
{loginType}
|
||||
{loginField}
|
||||
<Field
|
||||
className={pwFieldClass}
|
||||
type="password"
|
||||
name="password"
|
||||
label={_t('Password')}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChanged}
|
||||
disabled={this.props.disableSubmit}
|
||||
autoFocus={autoFocusPassword}
|
||||
onValidate={this.onPasswordValidate}
|
||||
ref={field => this[LoginField.Password] = field}
|
||||
/>
|
||||
{forgotPasswordJsx}
|
||||
{ !this.props.busy && <input className="mx_Login_submit"
|
||||
type="submit"
|
||||
value={_t('Sign in')}
|
||||
disabled={this.props.disableSubmit}
|
||||
/> }
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
@ -18,7 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import * as Email from '../../../email';
|
||||
import { looksValid as phoneNumberLooksValid } from '../../../phonenumber';
|
||||
|
@ -31,32 +29,57 @@ import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
|
|||
import PassphraseField from "./PassphraseField";
|
||||
import CountlyAnalytics from "../../../CountlyAnalytics";
|
||||
|
||||
const FIELD_EMAIL = 'field_email';
|
||||
const FIELD_PHONE_NUMBER = 'field_phone_number';
|
||||
const FIELD_USERNAME = 'field_username';
|
||||
const FIELD_PASSWORD = 'field_password';
|
||||
const FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
|
||||
enum RegistrationField {
|
||||
Email = "field_email",
|
||||
PhoneNumber = "field_phone_number",
|
||||
Username = "field_username",
|
||||
Password = "field_password",
|
||||
PasswordConfirm = "field_password_confirm",
|
||||
}
|
||||
|
||||
const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario.
|
||||
|
||||
interface IProps {
|
||||
// Values pre-filled in the input boxes when the component loads
|
||||
defaultEmail?: string;
|
||||
defaultPhoneCountry?: string;
|
||||
defaultPhoneNumber?: string;
|
||||
defaultUsername?: string;
|
||||
defaultPassword?: string;
|
||||
flows: {
|
||||
stages: string[];
|
||||
}[];
|
||||
serverConfig: ValidatedServerConfig;
|
||||
canSubmit?: boolean;
|
||||
serverRequiresIdServer?: boolean;
|
||||
|
||||
onRegisterClick(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
phoneCountry?: string;
|
||||
phoneNumber?: string;
|
||||
}): Promise<void>;
|
||||
onEditServerDetailsClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
// Field error codes by field ID
|
||||
fieldValid: Partial<Record<RegistrationField, boolean>>;
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
passwordComplexity?: number;
|
||||
}
|
||||
|
||||
/*
|
||||
* A pure UI component which displays a registration form.
|
||||
*/
|
||||
export default class RegistrationForm extends React.Component {
|
||||
static propTypes = {
|
||||
// Values pre-filled in the input boxes when the component loads
|
||||
defaultEmail: PropTypes.string,
|
||||
defaultPhoneCountry: PropTypes.string,
|
||||
defaultPhoneNumber: PropTypes.string,
|
||||
defaultUsername: PropTypes.string,
|
||||
defaultPassword: PropTypes.string,
|
||||
onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise
|
||||
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
|
||||
canSubmit: PropTypes.bool,
|
||||
serverRequiresIdServer: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default class RegistrationForm extends React.PureComponent<IProps, IState> {
|
||||
static defaultProps = {
|
||||
onValidationChange: console.error,
|
||||
canSubmit: true,
|
||||
|
@ -66,9 +89,7 @@ export default class RegistrationForm extends React.Component {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
// Field error codes by field ID
|
||||
fieldValid: {},
|
||||
// The ISO2 country code selected in the phone number entry
|
||||
phoneCountry: this.props.defaultPhoneCountry,
|
||||
username: this.props.defaultUsername || "",
|
||||
email: this.props.defaultEmail || "",
|
||||
|
@ -81,7 +102,7 @@ export default class RegistrationForm extends React.Component {
|
|||
CountlyAnalytics.instance.track("onboarding_registration_begin");
|
||||
}
|
||||
|
||||
onSubmit = async ev => {
|
||||
private onSubmit = async ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (!this.props.canSubmit) return;
|
||||
|
@ -92,7 +113,6 @@ export default class RegistrationForm extends React.Component {
|
|||
return;
|
||||
}
|
||||
|
||||
const self = this;
|
||||
if (this.state.email === '') {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
|
||||
|
@ -102,14 +122,14 @@ export default class RegistrationForm extends React.Component {
|
|||
"No identity server is configured so you cannot add an email address in order to " +
|
||||
"reset your password in the future.",
|
||||
);
|
||||
} else if (this._showEmail()) {
|
||||
} else if (this.showEmail()) {
|
||||
desc = _t(
|
||||
"If you don't specify an email address, you won't be able to reset your password. " +
|
||||
"Are you sure?",
|
||||
);
|
||||
} else {
|
||||
// user can't set an e-mail so don't prompt them to
|
||||
self._doSubmit(ev);
|
||||
this.doSubmit(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -120,18 +140,18 @@ export default class RegistrationForm extends React.Component {
|
|||
title: _t("Warning!"),
|
||||
description: desc,
|
||||
button: _t("Continue"),
|
||||
onFinished(confirmed) {
|
||||
onFinished: (confirmed) => {
|
||||
if (confirmed) {
|
||||
self._doSubmit(ev);
|
||||
this.doSubmit(ev);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
self._doSubmit(ev);
|
||||
this.doSubmit(ev);
|
||||
}
|
||||
};
|
||||
|
||||
_doSubmit(ev) {
|
||||
private doSubmit(ev) {
|
||||
const email = this.state.email.trim();
|
||||
|
||||
CountlyAnalytics.instance.track("onboarding_registration_submit_ok", {
|
||||
|
@ -154,20 +174,20 @@ export default class RegistrationForm extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
async verifyFieldsBeforeSubmit() {
|
||||
private async verifyFieldsBeforeSubmit() {
|
||||
// Blur the active element if any, so we first run its blur validation,
|
||||
// which is less strict than the pass we're about to do below for all fields.
|
||||
const activeElement = document.activeElement;
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement) {
|
||||
activeElement.blur();
|
||||
}
|
||||
|
||||
const fieldIDsInDisplayOrder = [
|
||||
FIELD_USERNAME,
|
||||
FIELD_PASSWORD,
|
||||
FIELD_PASSWORD_CONFIRM,
|
||||
FIELD_EMAIL,
|
||||
FIELD_PHONE_NUMBER,
|
||||
RegistrationField.Username,
|
||||
RegistrationField.Password,
|
||||
RegistrationField.PasswordConfirm,
|
||||
RegistrationField.Email,
|
||||
RegistrationField.PhoneNumber,
|
||||
];
|
||||
|
||||
// Run all fields with stricter validation that no longer allows empty
|
||||
|
@ -208,7 +228,7 @@ export default class RegistrationForm extends React.Component {
|
|||
/**
|
||||
* @returns {boolean} true if all fields were valid last time they were validated.
|
||||
*/
|
||||
allFieldsValid() {
|
||||
private allFieldsValid() {
|
||||
const keys = Object.keys(this.state.fieldValid);
|
||||
for (let i = 0; i < keys.length; ++i) {
|
||||
if (!this.state.fieldValid[keys[i]]) {
|
||||
|
@ -218,7 +238,7 @@ export default class RegistrationForm extends React.Component {
|
|||
return true;
|
||||
}
|
||||
|
||||
findFirstInvalidField(fieldIDs) {
|
||||
private findFirstInvalidField(fieldIDs: RegistrationField[]) {
|
||||
for (const fieldID of fieldIDs) {
|
||||
if (!this.state.fieldValid[fieldID] && this[fieldID]) {
|
||||
return this[fieldID];
|
||||
|
@ -227,7 +247,7 @@ export default class RegistrationForm extends React.Component {
|
|||
return null;
|
||||
}
|
||||
|
||||
markFieldValid(fieldID, valid) {
|
||||
private markFieldValid(fieldID: RegistrationField, valid: boolean) {
|
||||
const { fieldValid } = this.state;
|
||||
fieldValid[fieldID] = valid;
|
||||
this.setState({
|
||||
|
@ -235,26 +255,26 @@ export default class RegistrationForm extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
onEmailChange = ev => {
|
||||
private onEmailChange = ev => {
|
||||
this.setState({
|
||||
email: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onEmailValidate = async fieldState => {
|
||||
private onEmailValidate = async fieldState => {
|
||||
const result = await this.validateEmailRules(fieldState);
|
||||
this.markFieldValid(FIELD_EMAIL, result.valid);
|
||||
this.markFieldValid(RegistrationField.Email, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
validateEmailRules = withValidation({
|
||||
private validateEmailRules = withValidation({
|
||||
description: () => _t("Use an email address to recover your account"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value;
|
||||
test(this: RegistrationForm, { value, allowEmpty }) {
|
||||
return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter email address (required on this homeserver)"),
|
||||
},
|
||||
|
@ -266,29 +286,29 @@ export default class RegistrationForm extends React.Component {
|
|||
],
|
||||
});
|
||||
|
||||
onPasswordChange = ev => {
|
||||
private onPasswordChange = ev => {
|
||||
this.setState({
|
||||
password: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onPasswordValidate = result => {
|
||||
this.markFieldValid(FIELD_PASSWORD, result.valid);
|
||||
private onPasswordValidate = result => {
|
||||
this.markFieldValid(RegistrationField.Password, result.valid);
|
||||
};
|
||||
|
||||
onPasswordConfirmChange = ev => {
|
||||
private onPasswordConfirmChange = ev => {
|
||||
this.setState({
|
||||
passwordConfirm: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onPasswordConfirmValidate = async fieldState => {
|
||||
private onPasswordConfirmValidate = async fieldState => {
|
||||
const result = await this.validatePasswordConfirmRules(fieldState);
|
||||
this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid);
|
||||
this.markFieldValid(RegistrationField.PasswordConfirm, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
validatePasswordConfirmRules = withValidation({
|
||||
private validatePasswordConfirmRules = withValidation({
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
|
@ -297,41 +317,40 @@ export default class RegistrationForm extends React.Component {
|
|||
},
|
||||
{
|
||||
key: "match",
|
||||
test({ value }) {
|
||||
test(this: RegistrationForm, { value }) {
|
||||
return !value || value === this.state.password;
|
||||
},
|
||||
invalid: () => _t("Passwords don't match"),
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
onPhoneCountryChange = newVal => {
|
||||
private onPhoneCountryChange = newVal => {
|
||||
this.setState({
|
||||
phoneCountry: newVal.iso2,
|
||||
phonePrefix: newVal.prefix,
|
||||
});
|
||||
};
|
||||
|
||||
onPhoneNumberChange = ev => {
|
||||
private onPhoneNumberChange = ev => {
|
||||
this.setState({
|
||||
phoneNumber: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onPhoneNumberValidate = async fieldState => {
|
||||
private onPhoneNumberValidate = async fieldState => {
|
||||
const result = await this.validatePhoneNumberRules(fieldState);
|
||||
this.markFieldValid(FIELD_PHONE_NUMBER, result.valid);
|
||||
this.markFieldValid(RegistrationField.PhoneNumber, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
validatePhoneNumberRules = withValidation({
|
||||
private validatePhoneNumberRules = withValidation({
|
||||
description: () => _t("Other users can invite you to rooms using your contact details"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
{
|
||||
key: "required",
|
||||
test({ value, allowEmpty }) {
|
||||
return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value;
|
||||
test(this: RegistrationForm, { value, allowEmpty }) {
|
||||
return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value;
|
||||
},
|
||||
invalid: () => _t("Enter phone number (required on this homeserver)"),
|
||||
},
|
||||
|
@ -343,19 +362,19 @@ export default class RegistrationForm extends React.Component {
|
|||
],
|
||||
});
|
||||
|
||||
onUsernameChange = ev => {
|
||||
private onUsernameChange = ev => {
|
||||
this.setState({
|
||||
username: ev.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onUsernameValidate = async fieldState => {
|
||||
private onUsernameValidate = async fieldState => {
|
||||
const result = await this.validateUsernameRules(fieldState);
|
||||
this.markFieldValid(FIELD_USERNAME, result.valid);
|
||||
this.markFieldValid(RegistrationField.Username, result.valid);
|
||||
return result;
|
||||
};
|
||||
|
||||
validateUsernameRules = withValidation({
|
||||
private validateUsernameRules = withValidation({
|
||||
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
|
||||
hideDescriptionIfValid: true,
|
||||
rules: [
|
||||
|
@ -378,7 +397,7 @@ export default class RegistrationForm extends React.Component {
|
|||
* @param {string} step A stage name to check
|
||||
* @returns {boolean} Whether it is required
|
||||
*/
|
||||
_authStepIsRequired(step) {
|
||||
private authStepIsRequired(step: string) {
|
||||
return this.props.flows.every((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
|
@ -390,46 +409,46 @@ export default class RegistrationForm extends React.Component {
|
|||
* @param {string} step A stage name to check
|
||||
* @returns {boolean} Whether it is used
|
||||
*/
|
||||
_authStepIsUsed(step) {
|
||||
private authStepIsUsed(step: string) {
|
||||
return this.props.flows.some((flow) => {
|
||||
return flow.stages.includes(step);
|
||||
});
|
||||
}
|
||||
|
||||
_showEmail() {
|
||||
private showEmail() {
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (
|
||||
(this.props.serverRequiresIdServer && !haveIs) ||
|
||||
!this._authStepIsUsed('m.login.email.identity')
|
||||
!this.authStepIsUsed('m.login.email.identity')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
_showPhoneNumber() {
|
||||
private showPhoneNumber() {
|
||||
const threePidLogin = !SdkConfig.get().disable_3pid_login;
|
||||
const haveIs = Boolean(this.props.serverConfig.isUrl);
|
||||
if (
|
||||
!threePidLogin ||
|
||||
(this.props.serverRequiresIdServer && !haveIs) ||
|
||||
!this._authStepIsUsed('m.login.msisdn')
|
||||
!this.authStepIsUsed('m.login.msisdn')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
renderEmail() {
|
||||
if (!this._showEmail()) {
|
||||
private renderEmail() {
|
||||
if (!this.showEmail()) {
|
||||
return null;
|
||||
}
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ?
|
||||
const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ?
|
||||
_t("Email") :
|
||||
_t("Email (optional)");
|
||||
return <Field
|
||||
ref={field => this[FIELD_EMAIL] = field}
|
||||
ref={field => this[RegistrationField.Email] = field}
|
||||
type="text"
|
||||
label={emailPlaceholder}
|
||||
value={this.state.email}
|
||||
|
@ -440,10 +459,10 @@ export default class RegistrationForm extends React.Component {
|
|||
/>;
|
||||
}
|
||||
|
||||
renderPassword() {
|
||||
private renderPassword() {
|
||||
return <PassphraseField
|
||||
id="mx_RegistrationForm_password"
|
||||
fieldRef={field => this[FIELD_PASSWORD] = field}
|
||||
fieldRef={field => this[RegistrationField.Password] = field}
|
||||
minScore={PASSWORD_MIN_SCORE}
|
||||
value={this.state.password}
|
||||
onChange={this.onPasswordChange}
|
||||
|
@ -457,7 +476,7 @@ export default class RegistrationForm extends React.Component {
|
|||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_passwordConfirm"
|
||||
ref={field => this[FIELD_PASSWORD_CONFIRM] = field}
|
||||
ref={field => this[RegistrationField.PasswordConfirm] = field}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
label={_t("Confirm password")}
|
||||
|
@ -470,12 +489,12 @@ export default class RegistrationForm extends React.Component {
|
|||
}
|
||||
|
||||
renderPhoneNumber() {
|
||||
if (!this._showPhoneNumber()) {
|
||||
if (!this.showPhoneNumber()) {
|
||||
return null;
|
||||
}
|
||||
const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown');
|
||||
const Field = sdk.getComponent('elements.Field');
|
||||
const phoneLabel = this._authStepIsRequired('m.login.msisdn') ?
|
||||
const phoneLabel = this.authStepIsRequired('m.login.msisdn') ?
|
||||
_t("Phone") :
|
||||
_t("Phone (optional)");
|
||||
const phoneCountry = <CountryDropdown
|
||||
|
@ -485,7 +504,7 @@ export default class RegistrationForm extends React.Component {
|
|||
onOptionChange={this.onPhoneCountryChange}
|
||||
/>;
|
||||
return <Field
|
||||
ref={field => this[FIELD_PHONE_NUMBER] = field}
|
||||
ref={field => this[RegistrationField.PhoneNumber] = field}
|
||||
type="text"
|
||||
label={phoneLabel}
|
||||
value={this.state.phoneNumber}
|
||||
|
@ -499,7 +518,7 @@ export default class RegistrationForm extends React.Component {
|
|||
const Field = sdk.getComponent('elements.Field');
|
||||
return <Field
|
||||
id="mx_RegistrationForm_username"
|
||||
ref={field => this[FIELD_USERNAME] = field}
|
||||
ref={field => this[RegistrationField.Username] = field}
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
label={_t("Username")}
|
||||
|
@ -517,8 +536,8 @@ export default class RegistrationForm extends React.Component {
|
|||
);
|
||||
|
||||
let emailHelperText = null;
|
||||
if (this._showEmail()) {
|
||||
if (this._showPhoneNumber()) {
|
||||
if (this.showEmail()) {
|
||||
if (this.showPhoneNumber()) {
|
||||
emailHelperText = <div>
|
||||
{_t(
|
||||
"Set an email for account recovery. " +
|
|
@ -1,28 +0,0 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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';
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
||||
const PulsedAvatar: React.FC<IProps> = (props) => {
|
||||
return <div className="mx_PulsedAvatar">
|
||||
{props.children}
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default PulsedAvatar;
|
|
@ -38,6 +38,7 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
|||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
import {OwnProfileStore} from "../../../stores/OwnProfileStore";
|
||||
import { arrayFastClone } from "../../../utils/arrays";
|
||||
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
|
||||
|
||||
interface IProps {
|
||||
widgetDefinition: IModalWidgetOpenRequestData;
|
||||
|
@ -64,7 +65,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
|||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.widget = new Widget({
|
||||
this.widget = new ElementWidget({
|
||||
...this.props.widgetDefinition,
|
||||
creatorUserId: MatrixClientPeg.get().getUserId(),
|
||||
id: `modal_${this.props.sourceWidgetId}`,
|
||||
|
@ -161,7 +162,9 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
|
|||
this.state.messaging.notifyModalWidgetButtonClicked(def.id);
|
||||
};
|
||||
|
||||
return <AccessibleButton key={def.id} kind={kind} onClick={onClick}>
|
||||
const isDisabled = this.state.disabledButtonIds.includes(def.id);
|
||||
|
||||
return <AccessibleButton key={def.id} kind={kind} onClick={onClick} disabled={isDisabled}>
|
||||
{ def.label }
|
||||
</AccessibleButton>;
|
||||
});
|
||||
|
|
|
@ -17,18 +17,17 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {_t} from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import * as sdk from "../../../index";
|
||||
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import {Widget} from "matrix-widget-api";
|
||||
import {OIDCState, WidgetPermissionStore} from "../../../stores/widgets/WidgetPermissionStore";
|
||||
|
||||
export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
widgetUrl: PropTypes.string.isRequired,
|
||||
widgetId: PropTypes.string.isRequired,
|
||||
isUserWidget: PropTypes.bool.isRequired,
|
||||
widget: PropTypes.objectOf(Widget).isRequired,
|
||||
widgetKind: PropTypes.string.isRequired, // WidgetKind from widget-api
|
||||
inRoomId: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -51,16 +50,10 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
|||
if (this.state.rememberSelection) {
|
||||
console.log(`Remembering ${this.props.widgetId} as allowed=${allowed} for OpenID`);
|
||||
|
||||
const currentValues = SettingsStore.getValue("widgetOpenIDPermissions");
|
||||
if (!currentValues.allow) currentValues.allow = [];
|
||||
if (!currentValues.deny) currentValues.deny = [];
|
||||
|
||||
const securityKey = WidgetUtils.getWidgetSecurityKey(
|
||||
this.props.widgetId,
|
||||
this.props.widgetUrl,
|
||||
this.props.isUserWidget);
|
||||
(allowed ? currentValues.allow : currentValues.deny).push(securityKey);
|
||||
SettingsStore.setValue("widgetOpenIDPermissions", null, SettingLevel.DEVICE, currentValues);
|
||||
WidgetPermissionStore.instance.setOIDCState(
|
||||
this.props.widget, this.props.widgetKind, this.props.inRoomId,
|
||||
allowed ? OIDCState.Allowed : OIDCState.Denied,
|
||||
);
|
||||
}
|
||||
|
||||
this.props.onFinished(allowed);
|
||||
|
@ -84,7 +77,7 @@ export default class WidgetOpenIDPermissionsDialog extends React.Component {
|
|||
"A widget located at %(widgetUrl)s would like to verify your identity. " +
|
||||
"By allowing this, the widget will be able to verify your user ID, but not " +
|
||||
"perform actions as you.", {
|
||||
widgetUrl: this.props.widgetUrl.split("?")[0],
|
||||
widgetUrl: this.props.widget.templateUrl.split("?")[0],
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
|
|
|
@ -23,7 +23,6 @@ import PropTypes from 'prop-types';
|
|||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import * as sdk from '../../../index';
|
||||
import AppPermission from './AppPermission';
|
||||
import AppWarning from './AppWarning';
|
||||
import Spinner from './Spinner';
|
||||
|
@ -375,19 +374,18 @@ export default class AppTile extends React.Component {
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
// if the widget would be allowed to remain on screen, we must put it in
|
||||
// a PersistedElement from the get-go, otherwise the iframe will be
|
||||
// re-mounted later when we do.
|
||||
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
// Also wrap the PersistedElement in a div to fix the height, otherwise
|
||||
// AppTile's border is in the wrong place
|
||||
appTileBody = <div className="mx_AppTile_persistedWrapper">
|
||||
<PersistedElement persistKey={this._persistKey}>
|
||||
{appTileBody}
|
||||
</PersistedElement>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// all widgets can theoretically be allowed to remain on screen, so we wrap
|
||||
// them all in a PersistedElement from the get-go. If we wait, the iframe will
|
||||
// be re-mounted later, which means the widget has to start over, which is bad.
|
||||
|
||||
// Also wrap the PersistedElement in a div to fix the height, otherwise
|
||||
// AppTile's border is in the wrong place
|
||||
appTileBody = <div className="mx_AppTile_persistedWrapper">
|
||||
<PersistedElement persistKey={this._persistKey}>
|
||||
{appTileBody}
|
||||
</PersistedElement>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -474,10 +472,6 @@ AppTile.propTypes = {
|
|||
handleMinimisePointerEvents: PropTypes.bool,
|
||||
// Optionally hide the popout widget icon
|
||||
showPopout: PropTypes.bool,
|
||||
// Widget capabilities to allow by default (without user confirmation)
|
||||
// NOTE -- Use with caution. This is intended to aid better integration / UX
|
||||
// basic widget capabilities, e.g. injecting sticker message events.
|
||||
whitelistCapabilities: PropTypes.array,
|
||||
// Is this an instance of a user widget
|
||||
userWidget: PropTypes.bool,
|
||||
};
|
||||
|
@ -488,7 +482,6 @@ AppTile.defaultProps = {
|
|||
showTitle: true,
|
||||
showPopout: true,
|
||||
handleMinimisePointerEvents: false,
|
||||
whitelistCapabilities: [],
|
||||
userWidget: false,
|
||||
miniMode: false,
|
||||
};
|
||||
|
|
|
@ -64,7 +64,7 @@ interface IProps {
|
|||
// All other props pass through to the <input>.
|
||||
}
|
||||
|
||||
interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
// The element to create. Defaults to "input".
|
||||
element?: "input";
|
||||
// The input's value. This is a controlled component, so the value is required.
|
||||
|
|
|
@ -71,7 +71,6 @@ export default class PersistentApp extends React.Component {
|
|||
appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
|
||||
persistentWidgetInRoomId, appEvent.getId(),
|
||||
);
|
||||
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
|
||||
const AppTile = sdk.getComponent('elements.AppTile');
|
||||
return <AppTile
|
||||
key={app.id}
|
||||
|
@ -82,7 +81,6 @@ export default class PersistentApp extends React.Component {
|
|||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
whitelistCapabilities={capWhitelist}
|
||||
miniMode={true}
|
||||
showMenubar={false}
|
||||
/>;
|
||||
|
|
|
@ -32,7 +32,7 @@ interface IRule<T, D = void> {
|
|||
|
||||
interface IArgs<T, D = void> {
|
||||
rules: IRule<T, D>[];
|
||||
description(this: T, derivedData: D): React.ReactChild;
|
||||
description?(this: T, derivedData: D): React.ReactChild;
|
||||
hideDescriptionIfValid?: boolean;
|
||||
deriveData?(data: Data): Promise<D>;
|
||||
}
|
||||
|
|
|
@ -103,7 +103,6 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
|
|||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
whitelistCapabilities={WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, room.roomId)}
|
||||
/>
|
||||
</BaseCard>;
|
||||
};
|
||||
|
|
|
@ -210,8 +210,6 @@ export default class AppsDrawer extends React.Component {
|
|||
if (!this.props.showApps) return <div />;
|
||||
|
||||
const apps = this.state.apps.map((app, index, arr) => {
|
||||
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
|
||||
|
||||
return (<AppTile
|
||||
key={app.id}
|
||||
app={app}
|
||||
|
@ -221,7 +219,6 @@ export default class AppsDrawer extends React.Component {
|
|||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
whitelistCapabilities={capWhitelist}
|
||||
/>);
|
||||
});
|
||||
|
||||
|
|
|
@ -280,7 +280,6 @@ export default class Stickerpicker extends React.Component {
|
|||
showPopout={false}
|
||||
onMinimiseClick={this._onHideStickersClick}
|
||||
handleMinimisePointerEvents={true}
|
||||
whitelistCapabilities={['m.sticker', 'visibility']}
|
||||
userWidget={true}
|
||||
/>
|
||||
</PersistedElement>
|
||||
|
|
|
@ -26,6 +26,15 @@ import PersistentApp from "../elements/PersistentApp";
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
|
||||
const SHOW_CALL_IN_STATES = [
|
||||
CallState.Connected,
|
||||
CallState.InviteSent,
|
||||
CallState.Connecting,
|
||||
CallState.CreateAnswer,
|
||||
CallState.CreateOffer,
|
||||
CallState.WaitLocalMedia,
|
||||
];
|
||||
|
||||
interface IProps {
|
||||
}
|
||||
|
||||
|
@ -94,14 +103,13 @@ export default class CallPreview extends React.Component<IProps, IState> {
|
|||
const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId);
|
||||
const showCall = (
|
||||
this.state.activeCall &&
|
||||
this.state.activeCall.state === CallState.Connected &&
|
||||
SHOW_CALL_IN_STATES.includes(this.state.activeCall.state) &&
|
||||
!callForRoom
|
||||
);
|
||||
|
||||
if (showCall) {
|
||||
return (
|
||||
<CallView
|
||||
className="mx_CallPreview"
|
||||
onClick={this.onCallViewClick}
|
||||
showHangup={true}
|
||||
/>
|
||||
|
|
|
@ -21,12 +21,13 @@ import dis from '../../../dispatcher/dispatcher';
|
|||
import CallHandler from '../../../CallHandler';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import VideoFeed, { VideoFeedType } from "./VideoFeed";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import PulsedAvatar from '../avatars/PulsedAvatar';
|
||||
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
|
||||
import classNames from 'classnames';
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
|
||||
|
||||
interface IProps {
|
||||
// js-sdk room object. If set, we will only show calls for the given
|
||||
|
@ -43,9 +44,6 @@ interface IProps {
|
|||
// in a way that is likely to cause a resize.
|
||||
onResize?: any;
|
||||
|
||||
// classname applied to view,
|
||||
className?: string;
|
||||
|
||||
// Whether to show the hang up icon:W
|
||||
showHangup?: boolean;
|
||||
}
|
||||
|
@ -53,6 +51,10 @@ interface IProps {
|
|||
interface IState {
|
||||
call: MatrixCall;
|
||||
isLocalOnHold: boolean,
|
||||
micMuted: boolean,
|
||||
vidMuted: boolean,
|
||||
callState: CallState,
|
||||
controlsVisible: boolean,
|
||||
}
|
||||
|
||||
function getFullScreenElement() {
|
||||
|
@ -83,10 +85,15 @@ function exitFullscreen() {
|
|||
if (exitMethod) exitMethod.call(document);
|
||||
}
|
||||
|
||||
const CONTROLS_HIDE_DELAY = 1000;
|
||||
// Height of the header duplicated from CSS because we need to subtract it from our max
|
||||
// height to get the max height of the video
|
||||
const HEADER_HEIGHT = 44;
|
||||
|
||||
export default class CallView extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private container = createRef<HTMLDivElement>();
|
||||
|
||||
private contentRef = createRef<HTMLDivElement>();
|
||||
private controlsHideTimer: number = null;
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
|
@ -94,6 +101,10 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
this.state = {
|
||||
call,
|
||||
isLocalOnHold: call ? call.isLocalOnHold() : null,
|
||||
micMuted: call ? call.isMicrophoneMuted() : null,
|
||||
vidMuted: call ? call.isLocalVideoMuted() : null,
|
||||
callState: call ? call.state : null,
|
||||
controlsVisible: true,
|
||||
}
|
||||
|
||||
this.updateCallListeners(null, call);
|
||||
|
@ -101,9 +112,11 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
|
||||
public componentDidMount() {
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
document.addEventListener('keydown', this.onNativeKeyDown);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
document.removeEventListener("keydown", this.onNativeKeyDown);
|
||||
this.updateCallListeners(this.state.call, null);
|
||||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
@ -111,11 +124,11 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
private onAction = (payload) => {
|
||||
switch (payload.action) {
|
||||
case 'video_fullscreen': {
|
||||
if (!this.container.current) {
|
||||
if (!this.contentRef.current) {
|
||||
return;
|
||||
}
|
||||
if (payload.fullscreen) {
|
||||
requestFullscreen(this.container.current);
|
||||
requestFullscreen(this.contentRef.current);
|
||||
} else if (getFullScreenElement()) {
|
||||
exitFullscreen();
|
||||
}
|
||||
|
@ -125,9 +138,21 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
const newCall = this.getCall();
|
||||
if (newCall !== this.state.call) {
|
||||
this.updateCallListeners(this.state.call, newCall);
|
||||
let newControlsVisible = this.state.controlsVisible;
|
||||
if (newCall && !this.state.call) {
|
||||
newControlsVisible = true;
|
||||
if (this.controlsHideTimer !== null) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
}
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
this.setState({
|
||||
call: newCall,
|
||||
isLocalOnHold: newCall ? newCall.isLocalOnHold() : null,
|
||||
micMuted: newCall ? newCall.isMicrophoneMuted() : null,
|
||||
vidMuted: newCall ? newCall.isLocalVideoMuted() : null,
|
||||
callState: newCall ? newCall.state : null,
|
||||
controlsVisible: newControlsVisible,
|
||||
});
|
||||
}
|
||||
if (!newCall && getFullScreenElement()) {
|
||||
|
@ -144,11 +169,6 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
if (this.props.room) {
|
||||
const roomId = this.props.room.roomId;
|
||||
call = CallHandler.sharedInstance().getCallForRoom(roomId);
|
||||
|
||||
// We don't currently show voice calls in this view when in the room:
|
||||
// they're represented in the room status bar at the bottom instead
|
||||
// (but this will all change with the new designs)
|
||||
if (call && call.type == CallType.Voice) call = null;
|
||||
} else {
|
||||
call = CallHandler.sharedInstance().getAnyActiveCall();
|
||||
// Ignore calls if we can't get the room associated with them.
|
||||
|
@ -160,7 +180,7 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
if (call && call.state == CallState.Ended) return null;
|
||||
if (call && [CallState.Ended, CallState.Ringing].includes(call.state)) return null;
|
||||
return call;
|
||||
}
|
||||
|
||||
|
@ -177,67 +197,240 @@ export default class CallView extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
public render() {
|
||||
let view: React.ReactNode;
|
||||
private onFullscreenClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'video_fullscreen',
|
||||
fullscreen: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (this.state.call) {
|
||||
if (this.state.call.type === "voice") {
|
||||
const client = MatrixClientPeg.get();
|
||||
const callRoom = client.getRoom(this.state.call.roomId);
|
||||
private onExpandClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: this.state.call.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
let caption = _t("Active call");
|
||||
if (this.state.isLocalOnHold) {
|
||||
// we currently have no UI for holding / unholding a call (apart from slash
|
||||
// commands) so we don't disintguish between when we've put the call on hold
|
||||
// (ie. we'd show an unhold button) and when the other side has put us on hold
|
||||
// (where obviously we would not show such a button).
|
||||
caption = _t("Call Paused");
|
||||
private onControlsHideTimer = () => {
|
||||
this.controlsHideTimer = null;
|
||||
this.setState({
|
||||
controlsVisible: false,
|
||||
});
|
||||
}
|
||||
|
||||
private onMouseMove = () => {
|
||||
this.showControls();
|
||||
}
|
||||
|
||||
private showControls() {
|
||||
if (!this.state.controlsVisible) {
|
||||
this.setState({
|
||||
controlsVisible: true,
|
||||
});
|
||||
}
|
||||
if (this.controlsHideTimer !== null) {
|
||||
clearTimeout(this.controlsHideTimer);
|
||||
}
|
||||
this.controlsHideTimer = window.setTimeout(this.onControlsHideTimer, CONTROLS_HIDE_DELAY);
|
||||
}
|
||||
|
||||
private onMicMuteClick = () => {
|
||||
if (!this.state.call) return;
|
||||
|
||||
const newVal = !this.state.micMuted;
|
||||
|
||||
this.state.call.setMicrophoneMuted(newVal);
|
||||
this.setState({micMuted: newVal});
|
||||
}
|
||||
|
||||
private onVidMuteClick = () => {
|
||||
if (!this.state.call) return;
|
||||
|
||||
const newVal = !this.state.vidMuted;
|
||||
|
||||
this.state.call.setLocalVideoMuted(newVal);
|
||||
this.setState({vidMuted: newVal});
|
||||
}
|
||||
|
||||
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
|
||||
// Note that this assumes we always have a callview on screen at any given time
|
||||
// CallHandler would probably be a better place for this
|
||||
private onNativeKeyDown = ev => {
|
||||
let handled = false;
|
||||
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.D:
|
||||
if (ctrlCmdOnly) {
|
||||
this.onMicMuteClick();
|
||||
// show the controls to give feedback
|
||||
this.showControls();
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
||||
view = <AccessibleButton className="mx_CallView_voice" onClick={this.props.onClick}>
|
||||
<PulsedAvatar>
|
||||
<RoomAvatar
|
||||
room={callRoom}
|
||||
height={35}
|
||||
width={35}
|
||||
/>
|
||||
</PulsedAvatar>
|
||||
<div>
|
||||
<h1>{callRoom.name}</h1>
|
||||
<p>{ caption }</p>
|
||||
</div>
|
||||
</AccessibleButton>;
|
||||
} else {
|
||||
// For video calls, we currently ignore the call hold state altogether
|
||||
// (the video will just go black)
|
||||
|
||||
// if we're fullscreen, we don't want to set a maxHeight on the video element.
|
||||
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight;
|
||||
view = <div className="mx_CallView_video" onClick={this.props.onClick}>
|
||||
<VideoFeed type={VideoFeedType.Remote} call={this.state.call} onResize={this.props.onResize}
|
||||
maxHeight={maxVideoHeight}
|
||||
/>
|
||||
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
|
||||
</div>;
|
||||
}
|
||||
case Key.E:
|
||||
if (ctrlCmdOnly) {
|
||||
this.onVidMuteClick();
|
||||
// show the controls to give feedback
|
||||
this.showControls();
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let hangup: React.ReactNode;
|
||||
if (this.props.showHangup) {
|
||||
hangup = <div
|
||||
className="mx_CallView_hangup"
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: 'hangup',
|
||||
room_id: this.state.call.roomId,
|
||||
});
|
||||
}}
|
||||
if (handled) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private onRoomAvatarClick = () => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
room_id: this.state.call.roomId,
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (!this.state.call) return null;
|
||||
|
||||
const client = MatrixClientPeg.get();
|
||||
const callRoom = client.getRoom(this.state.call.roomId);
|
||||
|
||||
let callControls;
|
||||
if (this.props.room) {
|
||||
const micClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_micOn: !this.state.micMuted,
|
||||
mx_CallView_callControls_button_micOff: this.state.micMuted,
|
||||
});
|
||||
|
||||
const vidClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_vidOn: !this.state.vidMuted,
|
||||
mx_CallView_callControls_button_vidOff: this.state.vidMuted,
|
||||
});
|
||||
|
||||
// Put the other states of the mic/video icons in the document to make sure they're cached
|
||||
// (otherwise the icon disappears briefly when toggled)
|
||||
const micCacheClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_micOn: this.state.micMuted,
|
||||
mx_CallView_callControls_button_micOff: !this.state.micMuted,
|
||||
mx_CallView_callControls_button_invisible: true,
|
||||
});
|
||||
|
||||
const vidCacheClasses = classNames({
|
||||
mx_CallView_callControls_button: true,
|
||||
mx_CallView_callControls_button_vidOn: this.state.micMuted,
|
||||
mx_CallView_callControls_button_vidOff: !this.state.micMuted,
|
||||
mx_CallView_callControls_button_invisible: true,
|
||||
});
|
||||
|
||||
const callControlsClasses = classNames({
|
||||
mx_CallView_callControls: true,
|
||||
mx_CallView_callControls_hidden: !this.state.controlsVisible,
|
||||
});
|
||||
|
||||
const vidMuteButton = this.state.call.type === CallType.Video ? <div
|
||||
className={vidClasses}
|
||||
onClick={this.onVidMuteClick}
|
||||
/> : null;
|
||||
|
||||
callControls = <div className={callControlsClasses}>
|
||||
<div
|
||||
className={micClasses}
|
||||
onClick={this.onMicMuteClick}
|
||||
/>
|
||||
<div
|
||||
className="mx_CallView_callControls_button mx_CallView_callControls_button_hangup"
|
||||
onClick={() => {
|
||||
dis.dispatch({
|
||||
action: 'hangup',
|
||||
room_id: this.state.call.roomId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{vidMuteButton}
|
||||
<div className={micCacheClasses} />
|
||||
<div className={vidCacheClasses} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
// The 'content' for the call, ie. the videos for a video call and profile picture
|
||||
// for voice calls (fills the bg)
|
||||
let contentView: React.ReactNode;
|
||||
|
||||
if (this.state.call.type === CallType.Video) {
|
||||
// if we're fullscreen, we don't want to set a maxHeight on the video element.
|
||||
const maxVideoHeight = getFullScreenElement() ? null : this.props.maxVideoHeight - HEADER_HEIGHT;
|
||||
contentView = <div className="mx_CallView_video" ref={this.contentRef} onMouseMove={this.onMouseMove}>
|
||||
<VideoFeed type={VideoFeedType.Remote} call={this.state.call} onResize={this.props.onResize}
|
||||
maxHeight={maxVideoHeight}
|
||||
/>
|
||||
<VideoFeed type={VideoFeedType.Local} call={this.state.call} />
|
||||
{callControls}
|
||||
</div>;
|
||||
} else {
|
||||
const avatarSize = this.props.room ? 200 : 75;
|
||||
contentView = <div className="mx_CallView_voice" onMouseMove={this.onMouseMove}>
|
||||
<RoomAvatar
|
||||
room={callRoom}
|
||||
height={avatarSize}
|
||||
width={avatarSize}
|
||||
/>
|
||||
{callControls}
|
||||
</div>;
|
||||
}
|
||||
|
||||
const callTypeText = this.state.call.type === CallType.Video ? _t("Video Call") : _t("Voice Call");
|
||||
let myClassName;
|
||||
|
||||
let fullScreenButton;
|
||||
if (this.state.call.type === CallType.Video && this.props.room) {
|
||||
fullScreenButton = <div className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
|
||||
onClick={this.onFullscreenClick} title={_t("Fill Screen")}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <div className={this.props.className} ref={this.container}>
|
||||
{view}
|
||||
{hangup}
|
||||
let expandButton;
|
||||
if (!this.props.room) {
|
||||
expandButton = <div className="mx_CallView_header_button mx_CallView_header_button_expand"
|
||||
onClick={this.onExpandClick} title={_t("Return to call")}
|
||||
/>;
|
||||
}
|
||||
|
||||
const headerControls = <div className="mx_CallView_header_controls">
|
||||
{fullScreenButton}
|
||||
{expandButton}
|
||||
</div>;
|
||||
|
||||
let header: React.ReactNode;
|
||||
if (this.props.room) {
|
||||
header = <div className="mx_CallView_header">
|
||||
<div className="mx_CallView_header_phoneIcon"></div>
|
||||
<span className="mx_CallView_header_callType">{callTypeText}</span>
|
||||
{headerControls}
|
||||
</div>;
|
||||
myClassName = 'mx_CallView_large';
|
||||
} else {
|
||||
header = <div className="mx_CallView_header">
|
||||
<AccessibleButton onClick={this.onRoomAvatarClick}>
|
||||
<RoomAvatar room={callRoom} height={32} width={32} />
|
||||
</AccessibleButton>
|
||||
<div>
|
||||
<div className="mx_CallView_header_roomName">{callRoom.name}</div>
|
||||
<div className="mx_CallView_header_callTypeSmall">{callTypeText}</div>
|
||||
</div>
|
||||
{headerControls}
|
||||
</div>;
|
||||
myClassName = 'mx_CallView_pip';
|
||||
}
|
||||
|
||||
return <div className={"mx_CallView " + myClassName}>
|
||||
{header}
|
||||
{contentView}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ import dis from '../../../dispatcher/dispatcher';
|
|||
import { _t } from '../../../languageHandler';
|
||||
import { ActionPayload } from '../../../dispatcher/payloads';
|
||||
import CallHandler from '../../../CallHandler';
|
||||
import PulsedAvatar from '../avatars/PulsedAvatar';
|
||||
import RoomAvatar from '../avatars/RoomAvatar';
|
||||
import FormButton from '../elements/FormButton';
|
||||
import { CallState } from 'matrix-js-sdk/lib/webrtc/call';
|
||||
|
@ -108,13 +107,11 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
|
|||
|
||||
return <div className="mx_IncomingCallBox">
|
||||
<div className="mx_IncomingCallBox_CallerInfo">
|
||||
<PulsedAvatar>
|
||||
<RoomAvatar
|
||||
room={room}
|
||||
height={32}
|
||||
width={32}
|
||||
/>
|
||||
</PulsedAvatar>
|
||||
<RoomAvatar
|
||||
room={room}
|
||||
height={32}
|
||||
width={32}
|
||||
/>
|
||||
<div>
|
||||
<h1>{caller}</h1>
|
||||
<p>{incomingCallText}</p>
|
||||
|
|
|
@ -73,8 +73,6 @@ export default class VideoFeed extends React.Component<IProps> {
|
|||
let videoStyle = {};
|
||||
if (this.props.maxHeight) videoStyle = { maxHeight: this.props.maxHeight };
|
||||
|
||||
return <div className={classnames(videoClasses)}>
|
||||
<video ref={this.vid} style={videoStyle}></video>
|
||||
</div>;
|
||||
return <video className={classnames(videoClasses)} ref={this.vid} style={videoStyle} />;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue