Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/authentication_password_field

This commit is contained in:
Michael Telatynski
2019-06-16 10:31:30 +01:00
79 changed files with 1697 additions and 732 deletions

View File

@@ -183,11 +183,14 @@ export default class ContextualMenu extends React.Component {
const menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_noChevron': chevronFace === 'none',
'mx_ContextualMenu_left': chevronFace === 'left',
'mx_ContextualMenu_right': chevronFace === 'right',
'mx_ContextualMenu_top': chevronFace === 'top',
'mx_ContextualMenu_bottom': chevronFace === 'bottom',
'mx_ContextualMenu_left': !hasChevron && position.left,
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
});
const menuStyle = {};

View File

@@ -46,6 +46,8 @@ export default class EmbeddedPage extends React.PureComponent {
constructor(props) {
super(props);
this._dispatcherRef = null;
this.state = {
page: '',
};
@@ -90,7 +92,7 @@ export default class EmbeddedPage extends React.PureComponent {
componentWillUnmount() {
this._unmounted = true;
dis.unregister(this._dispatcherRef);
if (this._dispatcherRef !== null) dis.unregister(this._dispatcherRef);
}
onAction = (payload) => {

View File

@@ -16,22 +16,18 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import {_t} from "../../languageHandler";
export default class GenericErrorPage extends React.PureComponent {
static propTypes = {
title: PropTypes.object.isRequired, // jsx for title
message: PropTypes.object.isRequired, // jsx to display
};
render() {
return <div className='mx_GenericErrorPage'>
<div className='mx_GenericErrorPage_box'>
<h1>{_t("Error loading Riot")}</h1>
<h1>{this.props.title}</h1>
<p>{this.props.message}</p>
<p>{_t(
"If this is unexpected, please contact your system administrator " +
"or technical support representative.",
)}</p>
</div>
</div>;
}

View File

@@ -23,6 +23,8 @@ import PropTypes from 'prop-types';
import {getEntryComponentForLoginType} from '../views/auth/InteractiveAuthEntryComponents';
import sdk from '../../index';
export default React.createClass({
displayName: 'InteractiveAuth',
@@ -91,13 +93,14 @@ export default React.createClass({
this._authLogic = new InteractiveAuth({
authData: this.props.authData,
doRequest: this._requestCallback,
busyChanged: this._onBusyChanged,
inputs: this.props.inputs,
stateUpdated: this._authStateUpdated,
matrixClient: this.props.matrixClient,
sessionId: this.props.sessionId,
clientSecret: this.props.clientSecret,
emailSid: this.props.emailSid,
requestEmailToken: this.props.requestEmailToken,
requestEmailToken: this._requestEmailToken,
});
this._authLogic.attemptAuth().then((result) => {
@@ -135,6 +138,19 @@ export default React.createClass({
}
},
_requestEmailToken: async function(...args) {
this.setState({
busy: true,
});
try {
return await this.props.requestEmailToken(...args);
} finally {
this.setState({
busy: false,
});
}
},
tryContinue: function() {
if (this.refs.stageComponent && this.refs.stageComponent.tryContinue) {
this.refs.stageComponent.tryContinue();
@@ -152,27 +168,26 @@ export default React.createClass({
});
},
_requestCallback: function(auth, background) {
const makeRequestPromise = this.props.makeRequest(auth);
_requestCallback: function(auth) {
// This wrapper just exists because the js-sdk passes a second
// 'busy' param for backwards compat. This throws the tests off
// so discard it here.
return this.props.makeRequest(auth);
},
// if it's a background request, just do it: we don't want
// it to affect the state of our UI.
if (background) return makeRequestPromise;
// otherwise, manage the state of the spinner and error messages
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
return makeRequestPromise.finally(() => {
if (this._unmounted) {
return;
}
_onBusyChanged: function(busy) {
// if we've started doing stuff, reset the error messages
if (busy) {
this.setState({
busy: true,
errorText: null,
stageErrorText: null,
});
} else {
this.setState({
busy: false,
});
});
}
},
_setFocus: function() {
@@ -187,7 +202,14 @@ export default React.createClass({
_renderCurrentStage: function() {
const stage = this.state.authStage;
if (!stage) return null;
if (!stage) {
if (this.state.busy) {
const Loader = sdk.getComponent("elements.Spinner");
return <Loader />;
} else {
return null;
}
}
const StageComponent = getEntryComponentForLoginType(stage);
return (

View File

@@ -325,10 +325,11 @@ const LoggedInView = React.createClass({
handled = true;
}
break;
case KeyCode.KEY_I:
case KeyCode.KEY_BACKTICK:
// Ideally this would be CTRL+P for "Profile", but that's
// taken by the print dialog. CTRL+I for "Information"
// will have to do.
// was previously chosen but conflicted with italics in
// composer, so CTRL+` it is
if (ctrlCmdOnly) {
dis.dispatch({

View File

@@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017-2019 New Vector Ltd
Copyright 2019 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.
@@ -50,7 +51,9 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import {ValidatedServerConfig} from "../../utils/AutoDiscoveryUtils";
import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
import DMRoomMap from '../../utils/DMRoomMap';
// Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings.
@@ -435,9 +438,15 @@ export default React.createClass({
break;
case 'start_registration':
// This starts the full registration flow
if (payload.screenAfterLogin) {
this._screenAfterLogin = payload.screenAfterLogin;
}
this._startRegistration(payload.params || {});
break;
case 'start_login':
if (payload.screenAfterLogin) {
this._screenAfterLogin = payload.screenAfterLogin;
}
this.setStateForNewView({
view: VIEWS.LOGIN,
});
@@ -669,7 +678,7 @@ export default React.createClass({
});
},
_startRegistration: function(params) {
_startRegistration: async function(params) {
const newState = {
view: VIEWS.REGISTER,
};
@@ -682,10 +691,12 @@ export default React.createClass({
params.is_url &&
params.sid
) {
newState.serverConfig = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
params.hs_url, params.is_url,
);
newState.register_client_secret = params.client_secret;
newState.register_session_id = params.session_id;
newState.register_hs_url = params.hs_url;
newState.register_is_url = params.is_url;
newState.register_id_sid = params.sid;
}
@@ -877,6 +888,7 @@ export default React.createClass({
}
return;
}
MatrixClientPeg.setJustRegisteredUserId(credentials.user_id);
this.onRegistered(credentials);
},
onDifferentServerClicked: (ev) => {
@@ -1121,29 +1133,80 @@ export default React.createClass({
}
},
/**
* Starts a chat with the welcome user, if the user doesn't already have one
* @returns {string} The room ID of the new room, or null if no room was created
*/
async _startWelcomeUserChat() {
// We can end up with multiple tabs post-registration where the user
// might then end up with a session and we don't want them all making
// a chat with the welcome user: try to de-dupe.
// We need to wait for the first sync to complete for this to
// work though.
let waitFor;
if (!this.firstSyncComplete) {
waitFor = this.firstSyncPromise.promise;
} else {
waitFor = Promise.resolve();
}
await waitFor;
const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId(
this.props.config.welcomeUserId,
);
if (welcomeUserRooms.length === 0) {
const roomId = await createRoom({
dmUserId: this.props.config.welcomeUserId,
// Only view the welcome user if we're NOT looking at a room
andView: !this.state.currentRoomId,
});
// This is a bit of a hack, but since the deduplication relies
// on m.direct being up to date, we need to force a sync
// of the database, otherwise if the user goes to the other
// tab before the next save happens (a few minutes), the
// saved sync will be restored from the db and this code will
// run without the update to m.direct, making another welcome
// user room (it doesn't wait for new data from the server, just
// the saved sync to be loaded).
const saveWelcomeUser = (ev) => {
if (
ev.getType() == 'm.direct' &&
ev.getContent() &&
ev.getContent()[this.props.config.welcomeUserId]
) {
MatrixClientPeg.get().store.save(true);
MatrixClientPeg.get().removeListener(
"accountData", saveWelcomeUser,
);
}
};
MatrixClientPeg.get().on("accountData", saveWelcomeUser);
return roomId;
}
return null;
},
/**
* Called when a new logged in session has started
*/
_onLoggedIn: async function() {
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (this._is_registered) {
this._is_registered = false;
if (MatrixClientPeg.currentUserIsJustRegistered()) {
MatrixClientPeg.setJustRegisteredUserId(null);
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
const roomId = await createRoom({
dmUserId: this.props.config.welcomeUserId,
// Only view the welcome user if we're NOT looking at a room
andView: !this.state.currentRoomId,
});
// if successful, return because we're already
// viewing the welcomeUserId room
// else, if failed, fall through to view_home_page
if (roomId) {
return;
const welcomeUserRoom = await this._startWelcomeUserChat();
if (welcomeUserRoom === null) {
// We didn't rediret to the welcome user room, so show
// the homepage.
dis.dispatch({action: 'view_home_page'});
}
} else {
// The user has just logged in after registering,
// so show the homepage.
dis.dispatch({action: 'view_home_page'});
}
// The user has just logged in after registering
dis.dispatch({action: 'view_home_page'});
} else {
this._showScreenAfterLogin();
}
@@ -1684,9 +1747,6 @@ export default React.createClass({
return MatrixClientPeg.get();
}
}
// XXX: This should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
this._is_registered = true;
return Lifecycle.setLoggedIn(credentials);
},

View File

@@ -517,7 +517,8 @@ module.exports = React.createClass({
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const ret = [];
const isEditing = this.props.editEvent && this.props.editEvent.getId() === mxEv.getId();
const isEditing = this.props.editState &&
this.props.editState.getEvent().getId() === mxEv.getId();
// is this a continuation of the previous message?
let continuation = false;
@@ -585,7 +586,7 @@ module.exports = React.createClass({
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
isEditing={isEditing}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}

View File

@@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018, 2019 New Vector Ltd
Copyright 2019 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.
@@ -1551,6 +1552,8 @@ module.exports = React.createClass({
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
oobData={this.props.oobData}
signUrl={this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : null}
room={this.state.room}
/>
</div>
@@ -1681,6 +1684,7 @@ module.exports = React.createClass({
joining={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
oobData={this.props.oobData}
canPreview={this.state.canPeek}
room={this.state.room}
/>

View File

@@ -35,6 +35,7 @@ const Modal = require("../../Modal");
const UserActivity = require("../../UserActivity");
import { KeyCode } from '../../Keyboard';
import Timer from '../../utils/Timer';
import EditorStateTransfer from '../../utils/EditorStateTransfer';
const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20;
@@ -411,7 +412,8 @@ const TimelinePanel = React.createClass({
this.forceUpdate();
}
if (payload.action === "edit_event") {
this.setState({editEvent: payload.event}, () => {
const editState = payload.event ? new EditorStateTransfer(payload.event) : null;
this.setState({editState}, () => {
if (payload.event && this.refs.messagePanel) {
this.refs.messagePanel.scrollToEventIfNeeded(
payload.event.getId(),
@@ -1306,7 +1308,7 @@ const TimelinePanel = React.createClass({
tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}
getRelationsForEvent={this.getRelationsForEvent}
editEvent={this.state.editEvent}
editState={this.state.editState}
showReactions={this.props.showReactions}
/>
);

View File

@@ -22,7 +22,8 @@ import sdk from '../../../index';
import Modal from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import PasswordReset from "../../../PasswordReset";
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
// Phases
// Show controls to configure server details
@@ -53,9 +54,41 @@ module.exports = React.createClass({
password: "",
password2: "",
errorText: null,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
};
},
componentWillMount: function() {
this._checkServerLiveliness(this.props.serverConfig);
},
componentWillReceiveProps: function(newProps) {
if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl &&
newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return;
// Do a liveliness check on the new URLs
this._checkServerLiveliness(newProps.serverConfig);
},
_checkServerLiveliness: async function(serverConfig) {
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
serverConfig.hsUrl,
serverConfig.isUrl,
);
this.setState({serverIsAlive: true});
} catch (e) {
this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password"));
}
},
submitPasswordReset: function(email, password) {
this.setState({
phase: PHASE_SENDING_EMAIL,
@@ -86,9 +119,12 @@ module.exports = React.createClass({
});
},
onSubmitForm: function(ev) {
onSubmitForm: async function(ev) {
ev.preventDefault();
// refresh the server errors, just in case the server came back online
await this._checkServerLiveliness(this.props.serverConfig);
if (!this.state.email) {
this.showErrorDialog(_t('The email address linked to your account must be entered.'));
} else if (!this.state.password || !this.state.password2) {
@@ -173,11 +209,25 @@ module.exports = React.createClass({
const Field = sdk.getComponent('elements.Field');
let errorText = null;
const err = this.state.errorText || this.props.defaultServerDiscoveryError;
const err = this.state.errorText;
if (err) {
errorText = <div className="mx_Login_error">{ err }</div>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
</div>
);
}
let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', {
serverName: this.props.serverConfig.hsName,
});
@@ -207,11 +257,12 @@ module.exports = React.createClass({
}
return <div>
{errorText}
{serverDeadSection}
<h3>
{yourMatrixAccountText}
{editLink}
</h3>
{errorText}
<form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow">
<Field
@@ -246,7 +297,11 @@ module.exports = React.createClass({
'A verification email will be sent to your inbox to confirm ' +
'setting your new password.',
)}</span>
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
<input
className="mx_Login_submit"
type="submit"
value={_t('Send Reset Email')}
/>
</form>
<a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{_t('Sign in instead')}

View File

@@ -26,6 +26,7 @@ import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@@ -94,6 +95,14 @@ module.exports = React.createClass({
phase: PHASE_LOGIN,
// The current login flow, such as password, SSO, etc.
currentFlow: "m.login.password",
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
};
},
@@ -138,7 +147,7 @@ module.exports = React.createClass({
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
// Prevent people from submitting their password when something isn't right.
if (this.isBusy() || !this.state.canTryLogin) return;
if (this.isBusy()) return;
this.setState({
busy: true,
@@ -149,6 +158,7 @@ module.exports = React.createClass({
this._loginLogic.loginViaPassword(
username, phoneCountry, phoneNumber, password,
).then((data) => {
this.setState({serverIsAlive: true}); // it must be, we logged in.
this.props.onLoggedIn(data);
}, (error) => {
if (this._unmounted) {
@@ -231,7 +241,7 @@ module.exports = React.createClass({
const doWellknownLookup = username[0] === "@";
this.setState({
username: username,
busy: doWellknownLookup, // unset later by the result of onServerConfigChange
busy: doWellknownLookup,
errorText: null,
canTryLogin: true,
});
@@ -240,6 +250,16 @@ module.exports = React.createClass({
try {
const result = await AutoDiscoveryUtils.validateServerName(serverName);
this.props.onServerConfigChange(result);
// We'd like to rely on new props coming in via `onServerConfigChange`
// so that we know the servers have definitely updated before clearing
// the busy state. In the case of a full MXID that resolves to the same
// HS as Riot's default HS though, there may not be any server change.
// To avoid this trap, we clear busy here. For cases where the server
// actually has changed, `_initLoginLogic` will be called and manages
// busy state for its own liveness check.
this.setState({
busy: false,
});
} catch (e) {
console.error("Problem parsing URL or unhandled error doing .well-known discovery:", e);
@@ -247,7 +267,19 @@ module.exports = React.createClass({
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({errorText: message, busy: false, canTryLogin: false});
let errorText = message;
let discoveryState = {};
if (AutoDiscoveryUtils.isLivelinessError(e)) {
errorText = this.state.errorText;
discoveryState = AutoDiscoveryUtils.authComponentStateForError(e);
}
this.setState({
busy: false,
errorText,
...discoveryState,
});
}
}
},
@@ -297,13 +329,18 @@ module.exports = React.createClass({
});
},
_initLoginLogic: function(hsUrl, isUrl) {
const self = this;
_initLoginLogic: async function(hsUrl, isUrl) {
hsUrl = hsUrl || this.props.serverConfig.hsUrl;
isUrl = isUrl || this.props.serverConfig.isUrl;
// TODO: TravisR - Only use this if the homeserver is the default homeserver
const fallbackHsUrl = this.props.fallbackHsUrl;
let isDefaultServer = false;
if (this.props.serverConfig.isDefault
&& hsUrl === this.props.serverConfig.hsUrl
&& isUrl === this.props.serverConfig.isUrl) {
isDefaultServer = true;
}
const fallbackHsUrl = isDefaultServer ? this.props.fallbackHsUrl : null;
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
@@ -315,6 +352,20 @@ module.exports = React.createClass({
loginIncorrect: false,
});
// Do a quick liveliness check on the URLs
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl);
this.setState({serverIsAlive: true, errorText: ""});
} catch (e) {
this.setState({
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e),
});
if (this.state.serverErrorIsFatal) {
return; // Server is dead - do not continue.
}
}
loginLogic.getFlows().then((flows) => {
// look for a flow where we understand all of the steps.
for (let i = 0; i < flows.length; i++ ) {
@@ -339,14 +390,14 @@ module.exports = React.createClass({
"supported by this client.",
),
});
}, function(err) {
self.setState({
errorText: self._errorTextFromError(err),
}, (err) => {
this.setState({
errorText: this._errorTextFromError(err),
loginIncorrect: false,
canTryLogin: false,
});
}).finally(function() {
self.setState({
}).finally(() => {
this.setState({
busy: false,
});
}).done();
@@ -522,6 +573,20 @@ module.exports = React.createClass({
);
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
</div>
);
}
return (
<AuthPage>
<AuthHeader />
@@ -531,6 +596,7 @@ module.exports = React.createClass({
{loader}
</h2>
{ errorTextSection }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderLoginComponentForStep() }
<a className="mx_AuthBody_changeFlow" onClick={this.onRegisterClick} href="#">

View File

@@ -26,7 +26,10 @@ import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector';
import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import MatrixClientPeg from "../../../MatrixClientPeg";
// Phases
// Show controls to configure server details
@@ -79,6 +82,21 @@ module.exports = React.createClass({
// Phase of the overall registration dialog.
phase: PHASE_REGISTRATION,
flows: null,
// If set, we've registered but are not going to log
// the user in to their new account automatically.
completedNoSignin: false,
// We perform liveliness checks later, but for now suppress the errors.
// We also track the server dead errors independently of the regular errors so
// that we can render it differently, and override any other error the user may
// be seeing.
serverIsAlive: true,
serverErrorIsFatal: false,
serverDeadError: "",
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient: null,
};
},
@@ -150,13 +168,37 @@ module.exports = React.createClass({
_replaceClient: async function(serverConfig) {
this.setState({
errorText: null,
// busy while we do liveness check (we need to avoid trying to render
// the UI auth component while we don't have a matrix client)
busy: true,
});
if (!serverConfig) serverConfig = this.props.serverConfig;
// Do a liveliness check on the URLs
try {
await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
serverConfig.hsUrl,
serverConfig.isUrl,
);
this.setState({serverIsAlive: true});
} catch (e) {
this.setState({
busy: false,
...AutoDiscoveryUtils.authComponentStateForError(e, "register"),
});
if (this.state.serverErrorIsFatal) {
return; // Server is dead - do not continue.
}
}
const {hsUrl, isUrl} = serverConfig;
this._matrixClient = Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
this.setState({
matrixClient: Matrix.createClient({
baseUrl: hsUrl,
idBaseUrl: isUrl,
}),
});
this.setState({busy: false});
try {
await this._makeRegisterRequest({});
// This should never succeed since we specified an empty
@@ -172,6 +214,7 @@ module.exports = React.createClass({
errorText: _t("Registration has been disabled on this homeserver."),
});
} else {
console.log("Unable to query for supported registration methods.", e);
this.setState({
errorText: _t("Unable to query for supported registration methods."),
});
@@ -189,14 +232,14 @@ module.exports = React.createClass({
},
_requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) {
return this._matrixClient.requestRegisterEmailToken(
return this.state.matrixClient.requestRegisterEmailToken(
emailAddress,
clientSecret,
sendAttempt,
this.props.makeRegistrationUrl({
client_secret: clientSecret,
hs_url: this._matrixClient.getHomeserverUrl(),
is_url: this._matrixClient.getIdentityServerUrl(),
hs_url: this.state.matrixClient.getHomeserverUrl(),
is_url: this.state.matrixClient.getIdentityServerUrl(),
session_id: sessionId,
}),
);
@@ -245,21 +288,29 @@ module.exports = React.createClass({
return;
}
this.setState({
// we're still busy until we get unmounted: don't show the registration form again
busy: true,
MatrixClientPeg.setJustRegisteredUserId(response.user_id);
const newState = {
doingUIAuth: false,
});
};
if (response.access_token) {
const cli = await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
});
const cli = await this.props.onLoggedIn({
userId: response.user_id,
deviceId: response.device_id,
homeserverUrl: this._matrixClient.getHomeserverUrl(),
identityServerUrl: this._matrixClient.getIdentityServerUrl(),
accessToken: response.access_token,
});
this._setupPushers(cli);
// we're still busy until we get unmounted: don't show the registration form again
newState.busy = true;
} else {
newState.busy = false;
newState.completedNoSignin = true;
}
this._setupPushers(cli);
this.setState(newState);
},
_setupPushers: function(matrixClient) {
@@ -316,6 +367,12 @@ module.exports = React.createClass({
},
_makeRegisterRequest: function(auth) {
// We inhibit login if we're trying to register with an email address: this
// avoids a lot of complex race conditions that can occur if we try to log
// the user in one one or both of the tabs they might end up with after
// clicking the email link.
let inhibitLogin = Boolean(this.state.formVals.email);
// Only send the bind params if we're sending username / pw params
// (Since we need to send no params at all to use the ones saved in the
// session).
@@ -323,14 +380,17 @@ module.exports = React.createClass({
email: true,
msisdn: true,
} : {};
// Likewise inhibitLogin
if (!this.state.formVals.password) inhibitLogin = null;
return this._matrixClient.register(
return this.state.matrixClient.register(
this.state.formVals.username,
this.state.formVals.password,
undefined, // session id: included in the auth dict already
auth,
bindThreepids,
null,
inhibitLogin,
);
},
@@ -342,6 +402,19 @@ module.exports = React.createClass({
};
},
// Links to the login page shown after registration is completed are routed through this
// which checks the user hasn't already logged in somewhere else (perhaps we should do
// this more generally?)
_onLoginClickWithCheck: async function(ev) {
ev.preventDefault();
const sessionLoaded = await Lifecycle.loadSession({});
if (!sessionLoaded) {
// ok fine, there's still no session: really go to the login page
this.props.onLoginClick();
}
},
renderServerComponent() {
const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector");
const ServerConfig = sdk.getComponent("auth.ServerConfig");
@@ -409,9 +482,9 @@ module.exports = React.createClass({
const Spinner = sdk.getComponent('elements.Spinner');
const RegistrationForm = sdk.getComponent('auth.RegistrationForm');
if (this.state.doingUIAuth) {
if (this.state.matrixClient && this.state.doingUIAuth) {
return <InteractiveAuth
matrixClient={this._matrixClient}
matrixClient={this.state.matrixClient}
makeRequest={this._makeRegisterRequest}
onAuthFinished={this._onUIAuthFinished}
inputs={this._getUIAuthInputs()}
@@ -421,6 +494,8 @@ module.exports = React.createClass({
emailSid={this.props.idSid}
poll={true}
/>;
} else if (!this.state.matrixClient && !this.state.busy) {
return null;
} else if (this.state.busy || !this.state.flows) {
return <div className="mx_AuthBody_spinner">
<Spinner />
@@ -447,6 +522,7 @@ module.exports = React.createClass({
onEditServerDetailsClick={onEditServerDetailsClick}
flows={this.state.flows}
serverConfig={this.props.serverConfig}
canSubmit={!this.state.serverErrorIsFatal}
/>;
}
},
@@ -462,6 +538,20 @@ module.exports = React.createClass({
errorText = <div className="mx_Login_error">{ err }</div>;
}
let serverDeadSection;
if (!this.state.serverIsAlive) {
const classes = classNames({
"mx_Login_error": true,
"mx_Login_serverError": true,
"mx_Login_serverErrorNonFatal": !this.state.serverErrorIsFatal,
});
serverDeadSection = (
<div className={classes}>
{this.state.serverDeadError}
</div>
);
}
const signIn = <a className="mx_AuthBody_changeFlow" onClick={this.onLoginClick} href="#">
{ _t('Sign in instead') }
</a>;
@@ -474,16 +564,49 @@ module.exports = React.createClass({
</a>;
}
let body;
if (this.state.completedNoSignin) {
let regDoneText;
if (this.state.formVals.password) {
// We're the client that started the registration
regDoneText = _t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
},
);
} else {
// We're not the original client: the user probably got to us by clicking the
// email validation link. We can't offer a 'go straight to your account' link
// as we don't have the original creds.
regDoneText = _t(
"You can now close this window or <a>log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
},
);
}
body = <div>
<h2>{_t("Registration Successful")}</h2>
<h3>{ regDoneText }</h3>
</div>;
} else {
body = <div>
<h2>{ _t('Create your account') }</h2>
{ errorText }
{ serverDeadSection }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
</div>;
}
return (
<AuthPage>
<AuthHeader />
<AuthBody>
<h2>{ _t('Create your account') }</h2>
{ errorText }
{ this.renderServerComponent() }
{ this.renderRegisterComponent() }
{ goBack }
{ signIn }
{ body }
</AuthBody>
</AuthPage>
);

View File

@@ -108,6 +108,8 @@ export default class ModularServerConfig extends React.PureComponent {
busy: false,
errorText: message,
});
return null;
}
}
@@ -132,7 +134,8 @@ export default class ModularServerConfig extends React.PureComponent {
onSubmit = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
await this.validateServer();
const result = await this.validateServer();
if (!result) return; // Do not continue.
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();

View File

@@ -53,11 +53,13 @@ module.exports = React.createClass({
onEditServerDetailsClick: PropTypes.func,
flows: PropTypes.arrayOf(PropTypes.object).isRequired,
serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired,
canSubmit: PropTypes.bool,
},
getDefaultProps: function() {
return {
onValidationChange: console.error,
canSubmit: true,
};
},
@@ -80,6 +82,8 @@ module.exports = React.createClass({
onSubmit: async function(ev) {
ev.preventDefault();
if (!this.props.canSubmit) return;
const allFieldsValid = await this.verifyFieldsBeforeSubmit();
if (!allFieldsValid) {
return;
@@ -380,7 +384,7 @@ module.exports = React.createClass({
},
validateUsernameRules: withValidation({
description: () => _t("Use letters, numbers, dashes and underscores only"),
description: () => _t("Use lowercase letters, numbers, dashes and underscores only"),
rules: [
{
key: "required",
@@ -540,7 +544,7 @@ module.exports = React.createClass({
}
const registerButton = (
<input className="mx_Login_submit" type="submit" value={_t("Register")} />
<input className="mx_Login_submit" type="submit" value={_t("Register")} disabled={!this.props.canSubmit} />
);
return (

View File

@@ -101,14 +101,25 @@ export default class ServerConfig extends React.PureComponent {
return result;
} catch (e) {
console.error(e);
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) {
// carry on anyway
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true);
this.props.onServerConfigChange(result);
return result;
} else {
let message = _t("Unable to validate homeserver/identity server");
if (e.translatedMessage) {
message = e.translatedMessage;
}
this.setState({
busy: false,
errorText: message,
});
return null;
}
this.setState({
busy: false,
errorText: message,
});
}
}
@@ -137,7 +148,8 @@ export default class ServerConfig extends React.PureComponent {
onSubmit = async (ev) => {
ev.preventDefault();
ev.stopPropagation();
await this.validateServer();
const result = await this.validateServer();
if (!result) return; // Do not continue.
if (this.props.onAfterSubmit) {
this.props.onAfterSubmit();

View File

@@ -205,7 +205,7 @@ module.exports = React.createClass({
onSelected: function(index) {
const selectedList = this.state.selectedList.slice();
selectedList.push(this.state.suggestedList[index]);
selectedList.push(this._getFilteredSuggestions()[index]);
this.setState({
selectedList,
suggestedList: [],
@@ -526,12 +526,7 @@ module.exports = React.createClass({
});
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
_getFilteredSuggestions: function() {
// map addressType => set of addresses to avoid O(n*m) operation
const selectedAddresses = {};
this.state.selectedList.forEach(({address, addressType}) => {
@@ -540,9 +535,16 @@ module.exports = React.createClass({
});
// Filter out any addresses in the above already selected addresses (matching both type and address)
const filteredSuggestedList = this.state.suggestedList.filter(({address, addressType}) => {
return this.state.suggestedList.filter(({address, addressType}) => {
return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address));
});
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
const query = [];
// create the invite list
@@ -574,6 +576,8 @@ module.exports = React.createClass({
</textarea>,
);
const filteredSuggestedList = this._getFilteredSuggestions();
let error;
let addressSelector;
if (this.state.error) {

View File

@@ -28,13 +28,14 @@ import {parseEvent} from '../../../editor/deserialize';
import Autocomplete from '../rooms/Autocomplete';
import {PartCreator} from '../../../editor/parts';
import {renderModel} from '../../../editor/render';
import {MatrixEvent, MatrixClient} from 'matrix-js-sdk';
import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import {MatrixClient} from 'matrix-js-sdk';
import classNames from 'classnames';
export default class MessageEditor extends React.Component {
static propTypes = {
// the message event being edited
event: PropTypes.instanceOf(MatrixEvent).isRequired,
editState: PropTypes.instanceOf(EditorStateTransfer).isRequired,
};
static contextTypes = {
@@ -44,16 +45,7 @@ export default class MessageEditor extends React.Component {
constructor(props, context) {
super(props, context);
const room = this._getRoom();
const partCreator = new PartCreator(
() => this._autocompleteRef,
query => this.setState({query}),
room,
);
this.model = new EditorModel(
parseEvent(this.props.event, room),
partCreator,
this._updateEditorState,
);
this.model = null;
this.state = {
autoComplete: null,
room,
@@ -64,7 +56,7 @@ export default class MessageEditor extends React.Component {
}
_getRoom() {
return this.context.matrixClient.getRoom(this.props.event.getRoomId());
return this.context.matrixClient.getRoom(this.props.editState.getEvent().getRoomId());
}
_updateEditorState = (caret) => {
@@ -133,7 +125,7 @@ export default class MessageEditor extends React.Component {
if (this._hasModifications || !this._isCaretAtStart()) {
return;
}
const previousEvent = findEditableEvent(this._getRoom(), false, this.props.event.getId());
const previousEvent = findEditableEvent(this._getRoom(), false, this.props.editState.getEvent().getId());
if (previousEvent) {
dis.dispatch({action: 'edit_event', event: previousEvent});
event.preventDefault();
@@ -142,7 +134,7 @@ export default class MessageEditor extends React.Component {
if (this._hasModifications || !this._isCaretAtEnd()) {
return;
}
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.event.getId());
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId());
if (nextEvent) {
dis.dispatch({action: 'edit_event', event: nextEvent});
} else {
@@ -158,16 +150,28 @@ export default class MessageEditor extends React.Component {
dis.dispatch({action: 'focus_composer'});
}
_isEmote() {
const firstPart = this.model.parts[0];
return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me ");
}
_sendEdit = () => {
const isEmote = this._isEmote();
let model = this.model;
if (isEmote) {
// trim "/me "
model = model.clone();
model.removeText({index: 0, offset: 0}, 4);
}
const newContent = {
"msgtype": "m.text",
"body": textSerialize(this.model),
"msgtype": isEmote ? "m.emote" : "m.text",
"body": textSerialize(model),
};
const contentBody = {
msgtype: newContent.msgtype,
body: ` * ${newContent.body}`,
};
const formattedBody = htmlSerializeIfNeeded(this.model);
const formattedBody = htmlSerializeIfNeeded(model);
if (formattedBody) {
newContent.format = "org.matrix.custom.html";
newContent.formatted_body = formattedBody;
@@ -178,11 +182,11 @@ export default class MessageEditor extends React.Component {
"m.new_content": newContent,
"m.relates_to": {
"rel_type": "m.replace",
"event_id": this.props.event.getId(),
"event_id": this.props.editState.getEvent().getId(),
},
}, contentBody);
const roomId = this.props.event.getRoomId();
const roomId = this.props.editState.getEvent().getRoomId();
this.context.matrixClient.sendMessage(roomId, content);
dis.dispatch({action: "edit_event", event: null});
@@ -197,12 +201,63 @@ export default class MessageEditor extends React.Component {
this.model.autoComplete.onComponentSelectionChange(completion);
}
componentWillUnmount() {
const sel = document.getSelection();
const {caret} = getCaretOffsetAndText(this._editorRef, sel);
const parts = this.model.serializeParts();
this.props.editState.setEditorState(caret, parts);
}
componentDidMount() {
this.model = this._createEditorModel();
// initial render of model
this._updateEditorState();
setCaretPosition(this._editorRef, this.model, this.model.getPositionAtEnd());
// initial caret position
this._initializeCaret();
this._editorRef.focus();
}
_createEditorModel() {
const {editState} = this.props;
const room = this._getRoom();
const partCreator = new PartCreator(
() => this._autocompleteRef,
query => this.setState({query}),
room,
this.context.matrixClient,
);
let parts;
if (editState.hasEditorState()) {
// if restoring state from a previous editor,
// restore serialized parts from the state
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
} else {
// otherwise, parse the body of the event
parts = parseEvent(editState.getEvent(), room, this.context.matrixClient);
}
return new EditorModel(
parts,
partCreator,
this._updateEditorState,
);
}
_initializeCaret() {
const {editState} = this.props;
let caretPosition;
if (editState.hasEditorState()) {
// if restoring state from a previous editor,
// restore caret position from the state
const caret = editState.getCaret();
caretPosition = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
} else {
// otherwise, set it at the end
caretPosition = this.model.getPositionAtEnd();
}
setCaretPosition(this._editorRef, this.model, caretPosition);
}
render() {
let autoComplete;
if (this.state.autoComplete) {

View File

@@ -36,6 +36,20 @@ export default class MessageActionBar extends React.PureComponent {
onFocusChange: PropTypes.func,
};
componentDidMount() {
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
}
componentWillUnmount() {
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
}
onDecrypted = () => {
// When an event decrypts, it is likely to change the set of available
// actions, so we force an update to check again.
this.forceUpdate();
}
onFocusChange = (focused) => {
if (!this.props.onFocusChange) {
return;
@@ -69,10 +83,6 @@ export default class MessageActionBar extends React.PureComponent {
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
const buttonRect = ev.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = buttonRect.right + window.pageXOffset;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
const { getTile, getReplyThread } = this.props;
const tile = getTile && getTile();
const replyThread = getReplyThread && getReplyThread();
@@ -82,11 +92,9 @@ export default class MessageActionBar extends React.PureComponent {
e2eInfoCallback = () => this.onCryptoClicked();
}
createMenu(MessageContextMenu, {
chevronOffset: 10,
const menuOptions = {
mxEvent: this.props.mxEvent,
left: x,
top: y,
chevronFace: "none",
permalinkCreator: this.props.permalinkCreator,
eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined,
collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined,
@@ -94,7 +102,23 @@ export default class MessageActionBar extends React.PureComponent {
onFinished: () => {
this.onFocusChange(false);
},
});
};
// The window X and Y offsets are to adjust position when zoomed in to page
const buttonRight = buttonRect.right + window.pageXOffset;
const buttonBottom = buttonRect.bottom + window.pageYOffset;
const buttonTop = buttonRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more
// space available.
if (buttonBottom < window.innerHeight / 2) {
menuOptions.top = buttonBottom;
} else {
menuOptions.bottom = window.innerHeight - buttonTop;
}
createMenu(MessageContextMenu, menuOptions);
this.onFocusChange(true);
}

View File

@@ -90,7 +90,7 @@ module.exports = React.createClass({
tileShape={this.props.tileShape}
maxImageHeight={this.props.maxImageHeight}
replacingEventId={this.props.replacingEventId}
isEditing={this.props.isEditing}
editState={this.props.editState}
onHeightChanged={this.props.onHeightChanged} />;
},
});

View File

@@ -19,6 +19,7 @@ import PropTypes from 'prop-types';
import sdk from '../../../index';
import { isContentActionable } from '../../../utils/EventUtils';
import { isSingleEmoji } from '../../../HtmlUtils';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default class ReactionsRow extends React.PureComponent {
@@ -103,6 +104,9 @@ export default class ReactionsRow extends React.PureComponent {
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
const items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
if (!isSingleEmoji(content)) {
return null;
}
const count = events.size;
if (!count) {
return null;

View File

@@ -90,7 +90,7 @@ module.exports = React.createClass({
componentDidMount: function() {
this._unmounted = false;
if (!this.props.isEditing) {
if (!this.props.editState) {
this._applyFormatting();
}
},
@@ -131,8 +131,8 @@ module.exports = React.createClass({
},
componentDidUpdate: function(prevProps) {
if (!this.props.isEditing) {
const stoppedEditing = prevProps.isEditing && !this.props.isEditing;
if (!this.props.editState) {
const stoppedEditing = prevProps.editState && !this.props.editState;
const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
if (messageWasEdited || stoppedEditing) {
this._applyFormatting();
@@ -153,7 +153,7 @@ module.exports = React.createClass({
nextProps.replacingEventId !== this.props.replacingEventId ||
nextProps.highlightLink !== this.props.highlightLink ||
nextProps.showUrlPreview !== this.props.showUrlPreview ||
nextProps.isEditing !== this.props.isEditing ||
nextProps.editState !== this.props.editState ||
nextState.links !== this.state.links ||
nextState.editedMarkerHovered !== this.state.editedMarkerHovered ||
nextState.widgetHidden !== this.state.widgetHidden);
@@ -469,9 +469,9 @@ module.exports = React.createClass({
},
render: function() {
if (this.props.isEditing) {
if (this.props.editState) {
const MessageEditor = sdk.getComponent('elements.MessageEditor');
return <MessageEditor event={this.props.mxEvent} className="mx_EventTile_content" />;
return <MessageEditor editState={this.props.editState} className="mx_EventTile_content" />;
}
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();

View File

@@ -171,26 +171,13 @@ export default class Autocomplete extends React.Component {
}
// called from MessageComposerInput
onUpArrow(): ?Completion {
moveSelection(delta): ?Completion {
const completionCount = this.countCompletions();
// completionCount + 1, since 0 means composer is selected
const selectionOffset = (completionCount + 1 + this.state.selectionOffset - 1)
% (completionCount + 1);
if (!completionCount) {
return null;
}
this.setSelection(selectionOffset);
}
if (completionCount === 0) return; // there are no items to move the selection through
// called from MessageComposerInput
onDownArrow(): ?Completion {
const completionCount = this.countCompletions();
// completionCount + 1, since 0 means composer is selected
const selectionOffset = (this.state.selectionOffset + 1) % (completionCount + 1);
if (!completionCount) {
return null;
}
this.setSelection(selectionOffset);
// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1);
this.setSelection(index);
}
onEscape(e): boolean {

View File

@@ -552,13 +552,14 @@ module.exports = withMatrixClient(React.createClass({
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const isEditing = !!this.props.editState;
const classes = classNames({
mx_EventTile: true,
mx_EventTile_isEditing: this.props.isEditing,
mx_EventTile_isEditing: isEditing,
mx_EventTile_info: isInfoMessage,
mx_EventTile_12hr: this.props.isTwelveHour,
mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting',
mx_EventTile_sending: isSending,
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
@@ -632,7 +633,7 @@ module.exports = withMatrixClient(React.createClass({
}
const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar = !this.props.isEditing ? <MessageActionBar
const actionBar = !isEditing ? <MessageActionBar
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator}
@@ -794,7 +795,7 @@ module.exports = withMatrixClient(React.createClass({
<EventTileType ref="tile"
mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId}
isEditing={this.props.isEditing}
editState={this.props.editState}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}

View File

@@ -533,21 +533,24 @@ export default class MessageComposerInput extends React.Component {
// The first matched group includes just the matched plaintext emoji
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(text.slice(0, currentStartOffset));
if (emoticonMatch) {
const data = EMOJIBASE.find(e => e.emoticon === emoticonMatch[1]);
const unicodeEmoji = data ? data.unicode : '';
const query = emoticonMatch[1].toLowerCase().replace("-", "");
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
const range = Range.create({
anchor: {
key: editorState.startText.key,
offset: currentStartOffset - emoticonMatch[1].length - 1,
},
focus: {
key: editorState.startText.key,
offset: currentStartOffset - 1,
},
});
change = change.insertTextAtRange(range, unicodeEmoji);
editorState = change.value;
// only perform replacement if we found a match, otherwise we would be not letting user type
if (data) {
const range = Range.create({
anchor: {
key: editorState.startText.key,
offset: currentStartOffset - emoticonMatch[1].length - 1,
},
focus: {
key: editorState.startText.key,
offset: currentStartOffset - 1,
},
});
change = change.insertTextAtRange(range, data.unicode);
editorState = change.value;
}
}
}
}
@@ -670,6 +673,31 @@ export default class MessageComposerInput extends React.Component {
onKeyDown = (ev: KeyboardEvent, change: Change, editor: Editor) => {
this.suppressAutoComplete = false;
this.direction = '';
// Navigate autocomplete list with arrow keys
if (this.autocomplete.countCompletions() > 0) {
if (!(ev.ctrlKey || ev.shiftKey || ev.altKey || ev.metaKey)) {
switch (ev.keyCode) {
case KeyCode.LEFT:
this.autocomplete.moveSelection(-1);
ev.preventDefault();
return true;
case KeyCode.RIGHT:
this.autocomplete.moveSelection(+1);
ev.preventDefault();
return true;
case KeyCode.UP:
this.autocomplete.moveSelection(-1);
ev.preventDefault();
return true;
case KeyCode.DOWN:
this.autocomplete.moveSelection(+1);
ev.preventDefault();
return true;
}
}
}
// skip void nodes - see
// https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095
@@ -677,8 +705,6 @@ export default class MessageComposerInput extends React.Component {
this.direction = 'Previous';
} else if (ev.keyCode === KeyCode.RIGHT) {
this.direction = 'Next';
} else {
this.direction = '';
}
switch (ev.keyCode) {
@@ -1172,35 +1198,28 @@ export default class MessageComposerInput extends React.Component {
};
onVerticalArrow = (e, up) => {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
return;
}
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
// Select history only if we are not currently auto-completing
if (this.autocomplete.state.completionList.length === 0) {
const selection = this.state.editorState.selection;
// Select history
const selection = this.state.editorState.selection;
// selection must be collapsed
if (!selection.isCollapsed) return;
const document = this.state.editorState.document;
// selection must be collapsed
if (!selection.isCollapsed) return;
const document = this.state.editorState.document;
// and we must be at the edge of the document (up=start, down=end)
if (up) {
if (!selection.anchor.isAtStartOfNode(document)) return;
// and we must be at the edge of the document (up=start, down=end)
if (up) {
if (!selection.anchor.isAtStartOfNode(document)) return;
const editEvent = findEditableEvent(this.props.room, false);
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
dis.dispatch({
action: 'edit_event',
event: editEvent,
});
}
const editEvent = findEditableEvent(this.props.room, false);
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
dis.dispatch({
action: 'edit_event',
event: editEvent,
});
}
} else {
this.moveAutocompleteSelection(up);
e.preventDefault();
}
};
@@ -1209,23 +1228,19 @@ export default class MessageComposerInput extends React.Component {
someCompletions: null,
});
e.preventDefault();
if (this.autocomplete.state.completionList.length === 0) {
if (this.autocomplete.countCompletions() === 0) {
// Force completions to show for the text currently entered
const completionCount = await this.autocomplete.forceComplete();
this.setState({
someCompletions: completionCount > 0,
});
// Select the first item by moving "down"
await this.moveAutocompleteSelection(false);
await this.autocomplete.moveSelection(+1);
} else {
await this.moveAutocompleteSelection(e.shiftKey);
await this.autocomplete.moveSelection(e.shiftKey ? -1 : +1);
}
};
moveAutocompleteSelection = (up) => {
up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
};
onEscape = async (e) => {
e.preventDefault();
if (this.autocomplete) {

View File

@@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019 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.
@@ -54,6 +55,12 @@ module.exports = React.createClass({
// If invited by 3rd party invite, the email address the invite was sent to
invitedEmail: PropTypes.string,
// For third party invites, information passed about the room out-of-band
oobData: PropTypes.object,
// For third party invites, a URL for a 3pid invite signing service
signUrl: PropTypes.string,
// A standard client/server API error object. If supplied, indicates that the
// caller was unable to fetch details about the room for the given reason.
error: PropTypes.object,
@@ -87,6 +94,16 @@ module.exports = React.createClass({
},
componentWillMount: function() {
this._checkInvitedEmail();
},
componentDidUpdate: function(prevProps, prevState) {
if (this.props.invitedEmail !== prevProps.invitedEmail || this.props.inviterName !== prevProps.inviterName) {
this._checkInvitedEmail();
}
},
_checkInvitedEmail: function() {
// If this is an invite and we've been told what email
// address was invited, fetch the user's list of Threepids
// so we can check them against the one that was invited
@@ -215,12 +232,25 @@ module.exports = React.createClass({
return memberContent.membership === "invite" && memberContent.is_direct;
},
_makeScreenAfterLogin() {
return {
screen: 'room',
params: {
email: this.props.invitedEmail,
signurl: this.props.signUrl,
room_name: this.props.oobData.room_name,
room_avatar_url: this.props.oobData.avatarUrl,
inviter_name: this.props.oobData.inviterName,
}
};
},
onLoginClick: function() {
dis.dispatch({ action: 'start_login' });
dis.dispatch({ action: 'start_login', screenAfterLogin: this._makeScreenAfterLogin() });
},
onRegisterClick: function() {
dis.dispatch({ action: 'start_registration' });
dis.dispatch({ action: 'start_registration', screenAfterLogin: this._makeScreenAfterLogin() });
},
render: function() {
@@ -335,7 +365,7 @@ module.exports = React.createClass({
}
case MessageCase.Invite: {
const RoomAvatar = sdk.getComponent("views.avatars.RoomAvatar");
const avatar = <RoomAvatar room={this.props.room} />;
const avatar = <RoomAvatar room={this.props.room} oobData={this.props.oobData} />;
const inviteMember = this._getInviteMember();
let inviterElement;

View File

@@ -135,11 +135,27 @@ export default class HelpUserSettingsTab extends React.Component {
<ul>
<li>
The <a href="themes/riot/img/backgrounds/valley.jpg" rel="noopener" target="_blank">
default cover photo</a> is (C)&nbsp;
default cover photo</a> is ©&nbsp;
<a href="https://www.flickr.com/golan" rel="noopener" target="_blank">Jesús Roncero</a>{' '}
used under the terms of&nbsp;
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="noopener" target="_blank">
CC-BY-SA 4.0</a>. No warranties are given.
CC-BY-SA 4.0</a>.
</li>
<li>
The <a href="https://github.com/matrix-org/twemoji-colr" rel="noopener" target="_blank">
twemoji-colr</a> font is ©&nbsp;
<a href="https://mozilla.org" rel="noopener" target="_blank">Mozilla Foundation</a>{' '}
used under the terms of&nbsp;
<a href="http://www.apache.org/licenses/LICENSE-2.0" rel="noopener" target="_blank">
Apache 2.0</a>.
</li>
<li>
The <a href="https://twemoji.twitter.com/" rel="noopener" target="_blank">
Twemoji</a> emoji art is ©&nbsp;
<a href="https://twemoji.twitter.com/" rel="noopener" target="_blank">Twitter, Inc and other
contributors</a> used under the terms of&nbsp;
<a href="https://creativecommons.org/licenses/by/4.0/" rel="noopener" target="_blank">
CC-BY 4.0</a>.
</li>
</ul>
</div>