diff --git a/res/css/_components.scss b/res/css/_components.scss
index 2a91f08ee4..843f314bd1 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -50,7 +50,6 @@
@import "./views/context_menus/_TopLeftMenu.scss";
@import "./views/dialogs/_AddressPickerDialog.scss";
@import "./views/dialogs/_Analytics.scss";
-@import "./views/dialogs/_BugReportDialog.scss";
@import "./views/dialogs/_ChangelogDialog.scss";
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
@import "./views/dialogs/_ConfirmUserActionDialog.scss";
diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss
index 16ac876869..cce3b5dbf5 100644
--- a/res/css/views/auth/_AuthBody.scss
+++ b/res/css/views/auth/_AuthBody.scss
@@ -72,7 +72,6 @@ limitations under the License.
}
.mx_Field input {
- width: 100%;
box-sizing: border-box;
}
@@ -110,7 +109,6 @@ limitations under the License.
.mx_AuthBody_fieldRow > .mx_Field {
margin: 0 5px;
- flex: 1;
}
.mx_AuthBody_fieldRow > .mx_Field:first-child {
diff --git a/res/css/views/auth/_ServerConfig.scss b/res/css/views/auth/_ServerConfig.scss
index fe96da2019..a31feb75d7 100644
--- a/res/css/views/auth/_ServerConfig.scss
+++ b/res/css/views/auth/_ServerConfig.scss
@@ -20,7 +20,6 @@ limitations under the License.
}
.mx_ServerConfig_fields .mx_Field {
- flex: 1;
margin: 0 5px;
}
diff --git a/res/css/views/dialogs/_BugReportDialog.scss b/res/css/views/dialogs/_BugReportDialog.scss
deleted file mode 100644
index 90ef55b945..0000000000
--- a/res/css/views/dialogs/_BugReportDialog.scss
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
-Copyright 2017 OpenMarket 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.
-*/
-
-.mx_BugReportDialog .mx_Field {
- flex: 1;
-}
-
-.mx_BugReportDialog_field_input {
- // TODO: We should really apply this to all .mx_Field inputs.
- // See https://github.com/vector-im/riot-web/issues/9344.
- flex: 1;
-}
diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss
index 1f5d36b57a..8e669acd10 100644
--- a/res/css/views/dialogs/_DevtoolsDialog.scss
+++ b/res/css/views/dialogs/_DevtoolsDialog.scss
@@ -23,7 +23,11 @@ limitations under the License.
cursor: default !important;
}
-.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button, .mx_DevTools_RoomStateExplorer_query {
+.mx_DevTools_RoomStateExplorer_query {
+ margin-bottom: 10px;
+}
+
+.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button {
margin-bottom: 10px;
width: 100%;
}
@@ -75,7 +79,6 @@ limitations under the License.
max-width: 684px;
min-height: 250px;
padding: 10px;
- width: 100%;
}
.mx_DevTools_content .mx_Field_input {
diff --git a/res/css/views/dialogs/_SetPasswordDialog.scss b/res/css/views/dialogs/_SetPasswordDialog.scss
index 28a8b7c9d7..325ff6c6ed 100644
--- a/res/css/views/dialogs/_SetPasswordDialog.scss
+++ b/res/css/views/dialogs/_SetPasswordDialog.scss
@@ -21,7 +21,6 @@ limitations under the License.
color: $primary-fg-color;
background-color: $primary-bg-color;
font-size: 15px;
- width: 100%;
max-width: 280px;
margin-bottom: 10px;
}
diff --git a/res/css/views/elements/_EditableItemList.scss b/res/css/views/elements/_EditableItemList.scss
index be96d811d3..51fa4c4423 100644
--- a/res/css/views/elements/_EditableItemList.scss
+++ b/res/css/views/elements/_EditableItemList.scss
@@ -42,12 +42,6 @@ limitations under the License.
margin-right: 5px;
}
-.mx_EditableItemList_newItem .mx_Field input {
- // Use 100% of the space available for the input, but don't let the 10px
- // padding on either side of the input to push it out of alignment.
- width: calc(100% - 20px);
-}
-
.mx_EditableItemList_label {
margin-bottom: 5px;
-}
\ No newline at end of file
+}
diff --git a/res/css/views/elements/_Field.scss b/res/css/views/elements/_Field.scss
index 147bb3b471..f9cbf8c541 100644
--- a/res/css/views/elements/_Field.scss
+++ b/res/css/views/elements/_Field.scss
@@ -42,6 +42,7 @@ limitations under the License.
padding: 8px 9px;
color: $primary-fg-color;
background-color: $primary-bg-color;
+ flex: 1;
}
.mx_Field select {
diff --git a/res/css/views/elements/_PowerSelector.scss b/res/css/views/elements/_PowerSelector.scss
index 69f3a8eebb..799f6f246e 100644
--- a/res/css/views/elements/_PowerSelector.scss
+++ b/res/css/views/elements/_PowerSelector.scss
@@ -20,6 +20,5 @@ limitations under the License.
.mx_PowerSelector .mx_Field select,
.mx_PowerSelector .mx_Field input {
- width: 100%;
box-sizing: border-box;
}
diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss
index c3b3ca2f7d..bb38c41581 100644
--- a/res/css/views/rooms/_MemberInfo.scss
+++ b/res/css/views/rooms/_MemberInfo.scss
@@ -43,6 +43,8 @@ limitations under the License.
.mx_MemberInfo_name h2 {
flex: 1;
+ overflow-x: auto;
+ max-height: 50px;
}
.mx_MemberInfo h2 {
diff --git a/res/css/views/settings/_EmailAddresses.scss b/res/css/views/settings/_EmailAddresses.scss
index eef804a33b..4f9541af2c 100644
--- a/res/css/views/settings/_EmailAddresses.scss
+++ b/res/css/views/settings/_EmailAddresses.scss
@@ -35,9 +35,3 @@ limitations under the License.
.mx_ExistingEmailAddress_confirmBtn {
margin-right: 5px;
}
-
-.mx_EmailAddresses_new .mx_Field input {
- // Use 100% of the space available for the input, but don't let the 10px
- // padding on either side of the input to push it out of alignment.
- width: calc(100% - 20px);
-}
diff --git a/res/css/views/settings/_PhoneNumbers.scss b/res/css/views/settings/_PhoneNumbers.scss
index 2f54babd6f..a3891882c2 100644
--- a/res/css/views/settings/_PhoneNumbers.scss
+++ b/res/css/views/settings/_PhoneNumbers.scss
@@ -36,12 +36,6 @@ limitations under the License.
margin-right: 5px;
}
-.mx_PhoneNumbers_new .mx_Field input {
- // Use 100% of the space available for the input, but don't let the 10px
- // padding on either side of the input to push it out of alignment.
- width: calc(100% - 20px);
-}
-
.mx_PhoneNumbers_input {
display: flex;
align-items: center;
diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss
index b2e449ac34..a972162618 100644
--- a/res/css/views/settings/_ProfileSettings.scss
+++ b/res/css/views/settings/_ProfileSettings.scss
@@ -22,11 +22,6 @@ limitations under the License.
flex-grow: 1;
}
-.mx_ProfileSettings_controls .mx_Field #profileDisplayName,
-.mx_ProfileSettings_controls .mx_Field #profileTopic {
- width: calc(100% - 20px); // subtract 10px padding on left and right
-}
-
.mx_ProfileSettings_controls .mx_Field #profileTopic {
height: 4em;
}
diff --git a/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss b/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss
index 91d7ed2c7d..af55820d66 100644
--- a/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss
+++ b/res/css/views/settings/tabs/room/_GeneralRoomSettingsTab.scss
@@ -17,7 +17,3 @@ limitations under the License.
.mx_GeneralRoomSettingsTab_profileSection {
margin-top: 10px;
}
-
-.mx_GeneralRoomSettingsTab .mx_AliasSettings .mx_Field select {
- width: 100%;
-}
diff --git a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss
index bec013674a..091c98ffb8 100644
--- a/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_GeneralUserSettingsTab.scss
@@ -14,33 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_GeneralUserSettingsTab_changePassword,
-.mx_GeneralUserSettingsTab_themeSection {
- display: block;
-}
-
.mx_GeneralUserSettingsTab_changePassword .mx_Field,
.mx_GeneralUserSettingsTab_themeSection .mx_Field {
- display: block;
margin-right: 100px; // Align with the other fields on the page
}
-.mx_GeneralUserSettingsTab_changePassword .mx_Field input {
- display: block;
- width: calc(100% - 20px); // subtract 10px padding on left and right
-}
-
.mx_GeneralUserSettingsTab_changePassword .mx_Field:first-child {
margin-top: 0;
}
-.mx_GeneralUserSettingsTab_themeSection .mx_Field select {
- display: block;
- width: 100%;
-}
-
.mx_GeneralUserSettingsTab_accountSection > .mx_EmailAddresses,
.mx_GeneralUserSettingsTab_accountSection > .mx_PhoneNumbers,
.mx_GeneralUserSettingsTab_languageInput {
margin-right: 100px; // Align with the other fields on the page
-}
\ No newline at end of file
+}
diff --git a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
index f447221b7a..b3430f47af 100644
--- a/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_PreferencesUserSettingsTab.scss
@@ -17,11 +17,3 @@ limitations under the License.
.mx_PreferencesUserSettingsTab .mx_Field {
margin-right: 100px; // Align with the rest of the controls
}
-
-.mx_PreferencesUserSettingsTab .mx_Field input {
- display: block;
-
- // Subtract 10px padding on left and right
- // This is to keep the input aligned with the rest of the tab's controls.
- width: calc(100% - 20px);
-}
diff --git a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss
index f5dba9831e..36c8cfd896 100644
--- a/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_VoiceUserSettingsTab.scss
@@ -14,11 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_VoiceUserSettingsTab .mx_Field select {
- width: 100%;
- max-width: 100%;
-}
-
.mx_VoiceUserSettingsTab .mx_Field {
margin-right: 100px; // align with the rest of the fields
}
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 698768067a..52fd6d9be4 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -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}
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js
index 220c56d754..9c48b8ede1 100644
--- a/src/components/structures/TimelinePanel.js
+++ b/src/components/structures/TimelinePanel.js
@@ -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}
/>
);
diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js
index bf4a86e410..3103ee41df 100644
--- a/src/components/structures/auth/Registration.js
+++ b/src/components/structures/auth/Registration.js
@@ -28,6 +28,7 @@ import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
+import * as Lifecycle from '../../../Lifecycle';
// Phases
// Show controls to configure server details
@@ -80,6 +81,9 @@ 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
@@ -209,6 +213,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."),
});
@@ -282,21 +287,27 @@ module.exports = React.createClass({
return;
}
- this.setState({
- // we're still busy until we get unmounted: don't show the registration form again
- busy: true,
+ 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.state.matrixClient.getHomeserverUrl(),
- identityServerUrl: this.state.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) {
@@ -353,6 +364,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).
@@ -360,6 +377,8 @@ module.exports = React.createClass({
email: true,
msisdn: true,
} : {};
+ // Likewise inhibitLogin
+ if (!this.state.formVals.password) inhibitLogin = null;
return this.state.matrixClient.register(
this.state.formVals.username,
@@ -368,6 +387,7 @@ module.exports = React.createClass({
auth,
bindThreepids,
null,
+ inhibitLogin,
);
},
@@ -379,6 +399,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");
@@ -528,17 +561,49 @@ module.exports = React.createClass({
;
}
+ let body;
+ if (this.state.completedNoSignin) {
+ let regDoneText;
+ if (this.state.formVals.password) {
+ // We're the client that started the registration
+ regDoneText = _t(
+ "Log in to your new account.", {},
+ {
+ a: (sub) => {sub},
+ },
+ );
+ } 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 log in to your new account.", {},
+ {
+ a: (sub) => {sub},
+ },
+ );
+ }
+ body =
+
{_t("Registration Successful")}
+ { regDoneText }
+ ;
+ } else {
+ body =
+
{ _t('Create your account') }
+ { errorText }
+ { serverDeadSection }
+ { this.renderServerComponent() }
+ { this.renderRegisterComponent() }
+ { goBack }
+ { signIn }
+ ;
+ }
+
return (
- { _t('Create your account') }
- { errorText }
- { serverDeadSection }
- { this.renderServerComponent() }
- { this.renderRegisterComponent() }
- { goBack }
- { signIn }
+ { body }
);
diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js
index 8d2e2e7bba..de4f16b684 100644
--- a/src/components/views/auth/ServerConfig.js
+++ b/src/components/views/auth/ServerConfig.js
@@ -101,16 +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;
- }
- this.setState({
- busy: false,
- errorText: message,
- });
- return null;
+ 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;
+ }
}
}
diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js
index ed86bcb0a3..0aff6781ee 100644
--- a/src/components/views/elements/MessageEditor.js
+++ b/src/components/views/elements/MessageEditor.js
@@ -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 {
@@ -178,11 +170,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 +189,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) {
diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js
index 8c90ec5a46..6d7aada542 100644
--- a/src/components/views/messages/MessageEvent.js
+++ b/src/components/views/messages/MessageEvent.js
@@ -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} />;
},
});
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js
index 1fc16d6a53..6f480b8d3c 100644
--- a/src/components/views/messages/TextualBody.js
+++ b/src/components/views/messages/TextualBody.js
@@ -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 ;
+ return ;
}
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js
index 9aef5433c3..243cfe2f75 100644
--- a/src/components/views/rooms/Autocomplete.js
+++ b/src/components/views/rooms/Autocomplete.js
@@ -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 {
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index 850f496e24..9837b4a029 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -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 ? {
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
@@ -683,8 +708,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) {
@@ -1181,45 +1204,36 @@ export default class MessageComposerInput extends React.Component {
};
onVerticalArrow = (e, up) => {
- if (e.ctrlKey || e.altKey || e.metaKey) {
- return;
+ if (e.ctrlKey || e.shiftKey || e.metaKey) return;
+
+ // Select history
+ const selection = this.state.editorState.selection;
+
+ // 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;
+
+ if (!e.altKey) {
+ 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,
+ });
+ }
+ return;
+ }
}
- // Select history only if we are not currently auto-completing
- if (this.autocomplete.state.completionList.length === 0) {
- const selection = this.state.editorState.selection;
-
- // 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;
-
- if (!e.shiftKey) {
- 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,
- });
- }
- return;
- }
- } else {
- if (!selection.anchor.isAtEndOfNode(document)) return;
- }
-
- const selected = this.selectHistory(up);
- if (selected) {
- // We're selecting history, so prevent the key event from doing anything else
- e.preventDefault();
- }
- } else {
- this.moveAutocompleteSelection(up);
+ const selected = this.selectHistory(up);
+ if (selected) {
+ // We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
}
};
@@ -1277,23 +1291,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) {
diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js
index ceaf18c444..6cb5974729 100644
--- a/src/editor/autocomplete.js
+++ b/src/editor/autocomplete.js
@@ -18,12 +18,13 @@ limitations under the License.
import {UserPillPart, RoomPillPart, PlainPart} from "./parts";
export default class AutocompleteWrapperModel {
- constructor(updateCallback, getAutocompleterComponent, updateQuery, room) {
+ constructor(updateCallback, getAutocompleterComponent, updateQuery, room, client) {
this._updateCallback = updateCallback;
this._getAutocompleterComponent = getAutocompleterComponent;
this._updateQuery = updateQuery;
this._query = null;
this._room = room;
+ this._client = client;
}
onEscape(e) {
@@ -42,17 +43,13 @@ export default class AutocompleteWrapperModel {
async onTab(e) {
const acComponent = this._getAutocompleterComponent();
- if (acComponent.state.completionList.length === 0) {
+ if (acComponent.countCompletions() === 0) {
// Force completions to show for the text currently entered
await acComponent.forceComplete();
// Select the first item by moving "down"
- await acComponent.onDownArrow();
+ await acComponent.moveSelection(+1);
} else {
- if (e.shiftKey) {
- await acComponent.onUpArrow();
- } else {
- await acComponent.onDownArrow();
- }
+ await acComponent.moveSelection(e.shiftKey ? -1 : +1);
}
this._updateCallback({
close: true,
@@ -60,11 +57,11 @@ export default class AutocompleteWrapperModel {
}
onUpArrow() {
- this._getAutocompleterComponent().onUpArrow();
+ this._getAutocompleterComponent().moveSelection(-1);
}
onDownArrow() {
- this._getAutocompleterComponent().onDownArrow();
+ this._getAutocompleterComponent().moveSelection(+1);
}
onPartUpdate(part, offset) {
@@ -106,7 +103,7 @@ export default class AutocompleteWrapperModel {
}
case "#": {
const displayAlias = completion.completionId;
- return new RoomPillPart(displayAlias);
+ return new RoomPillPart(displayAlias, this._client);
}
// also used for emoji completion
default:
diff --git a/src/editor/deserialize.js b/src/editor/deserialize.js
index 64b219c2a9..48625cba5f 100644
--- a/src/editor/deserialize.js
+++ b/src/editor/deserialize.js
@@ -21,7 +21,7 @@ import { walkDOMDepthFirst } from "./dom";
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
-function parseLink(a, room) {
+function parseLink(a, room, client) {
const {href} = a;
const pillMatch = REGEX_MATRIXTO.exec(href) || [];
const resourceId = pillMatch[1]; // The room/user ID
@@ -34,7 +34,7 @@ function parseLink(a, room) {
room.getMember(resourceId),
);
case "#":
- return new RoomPillPart(resourceId);
+ return new RoomPillPart(resourceId, client);
default: {
if (href === a.textContent) {
return new PlainPart(a.textContent);
@@ -57,10 +57,10 @@ function parseCodeBlock(n) {
return parts;
}
-function parseElement(n, room) {
+function parseElement(n, room, client) {
switch (n.nodeName) {
case "A":
- return parseLink(n, room);
+ return parseLink(n, room, client);
case "BR":
return new NewlinePart("\n");
case "EM":
@@ -140,7 +140,7 @@ function prefixQuoteLines(isFirstNode, parts) {
}
}
-function parseHtmlMessage(html, room) {
+function parseHtmlMessage(html, room, client) {
// no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine
@@ -165,7 +165,7 @@ function parseHtmlMessage(html, room) {
if (n.nodeType === Node.TEXT_NODE) {
newParts.push(new PlainPart(n.nodeValue));
} else if (n.nodeType === Node.ELEMENT_NODE) {
- const parseResult = parseElement(n, room);
+ const parseResult = parseElement(n, room, client);
if (parseResult) {
if (Array.isArray(parseResult)) {
newParts.push(...parseResult);
@@ -205,10 +205,10 @@ function parseHtmlMessage(html, room) {
return parts;
}
-export function parseEvent(event, room) {
+export function parseEvent(event, room, client) {
const content = event.getContent();
if (content.format === "org.matrix.custom.html") {
- return parseHtmlMessage(content.formatted_body || "", room);
+ return parseHtmlMessage(content.formatted_body || "", room, client);
} else {
const body = content.body || "";
const lines = body.split("\n");
diff --git a/src/editor/model.js b/src/editor/model.js
index fb6b417530..04a56ab65b 100644
--- a/src/editor/model.js
+++ b/src/editor/model.js
@@ -73,7 +73,7 @@ export default class EditorModel {
}
serializeParts() {
- return this._parts.map(({type, text}) => {return {type, text};});
+ return this._parts.map(p => p.serialize());
}
_diff(newValue, inputType, caret) {
@@ -88,7 +88,7 @@ export default class EditorModel {
update(newValue, inputType, caret) {
const diff = this._diff(newValue, inputType, caret);
- const position = this._positionForOffset(diff.at, caret.atNodeEnd);
+ const position = this.positionForOffset(diff.at, caret.atNodeEnd);
let removedOffsetDecrease = 0;
if (diff.removed) {
removedOffsetDecrease = this._removeText(position, diff.removed.length);
@@ -99,7 +99,7 @@ export default class EditorModel {
}
this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
- let newPosition = this._positionForOffset(caretOffset, true);
+ let newPosition = this.positionForOffset(caretOffset, true);
newPosition = newPosition.skipUneditableParts(this._parts);
this._setActivePart(newPosition);
this._updateCallback(newPosition);
@@ -248,7 +248,7 @@ export default class EditorModel {
return addLen;
}
- _positionForOffset(totalOffset, atPartEnd) {
+ positionForOffset(totalOffset, atPartEnd) {
let currentOffset = 0;
const index = this._parts.findIndex(part => {
const partLen = part.text.length;
diff --git a/src/editor/parts.js b/src/editor/parts.js
index be3080db12..a122c7ab7a 100644
--- a/src/editor/parts.js
+++ b/src/editor/parts.js
@@ -17,7 +17,6 @@ limitations under the License.
import AutocompleteWrapperModel from "./autocomplete";
import Avatar from "../Avatar";
-import MatrixClientPeg from "../MatrixClientPeg";
class BasePart {
constructor(text = "") {
@@ -102,6 +101,10 @@ class BasePart {
toString() {
return `${this.type}(${this.text})`;
}
+
+ serialize() {
+ return {type: this.type, text: this.text};
+ }
}
export class PlainPart extends BasePart {
@@ -233,13 +236,12 @@ export class NewlinePart extends BasePart {
}
export class RoomPillPart extends PillPart {
- constructor(displayAlias) {
+ constructor(displayAlias, client) {
super(displayAlias, displayAlias);
- this._room = this._findRoomByAlias(displayAlias);
+ this._room = this._findRoomByAlias(displayAlias, client);
}
- _findRoomByAlias(alias) {
- const client = MatrixClientPeg.get();
+ _findRoomByAlias(alias, client) {
if (alias[0] === '#') {
return client.getRooms().find((r) => {
return r.getAliases().includes(alias);
@@ -300,6 +302,12 @@ export class UserPillPart extends PillPart {
get className() {
return "mx_UserPill mx_Pill";
}
+
+ serialize() {
+ const obj = super.serialize();
+ obj.userId = this.resourceId;
+ return obj;
+ }
}
@@ -335,13 +343,16 @@ export class PillCandidatePart extends PlainPart {
}
export class PartCreator {
- constructor(getAutocompleterComponent, updateQuery, room) {
+ constructor(getAutocompleterComponent, updateQuery, room, client) {
+ this._room = room;
+ this._client = client;
this._autoCompleteCreator = (updateCallback) => {
return new AutocompleteWrapperModel(
updateCallback,
getAutocompleterComponent,
updateQuery,
room,
+ client,
);
};
}
@@ -362,5 +373,22 @@ export class PartCreator {
createDefaultPart(text) {
return new PlainPart(text);
}
+
+ deserializePart(part) {
+ switch (part.type) {
+ case "plain":
+ return new PlainPart(part.text);
+ case "newline":
+ return new NewlinePart(part.text);
+ case "pill-candidate":
+ return new PillCandidatePart(part.text, this._autoCompleteCreator);
+ case "room-pill":
+ return new RoomPillPart(part.text, this._client);
+ case "user-pill": {
+ const member = this._room.getMember(part.userId);
+ return new UserPillPart(part.userId, part.text, member);
+ }
+ }
+ }
}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 69ac59f984..53fd82f6f2 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1557,6 +1557,9 @@
"Registration has been disabled on this homeserver.": "Registration has been disabled on this homeserver.",
"Unable to query for supported registration methods.": "Unable to query for supported registration methods.",
"This server does not support authentication with a phone number.": "This server does not support authentication with a phone number.",
+ "Log in to your new account.": "Log in to your new account.",
+ "You can now close this window or log in to your new account.": "You can now close this window or log in to your new account.",
+ "Registration Successful": "Registration Successful",
"Create your account": "Create your account",
"Commands": "Commands",
"Results from DuckDuckGo": "Results from DuckDuckGo",
diff --git a/src/utils/EditorStateTransfer.js b/src/utils/EditorStateTransfer.js
new file mode 100644
index 0000000000..c7782a9ea8
--- /dev/null
+++ b/src/utils/EditorStateTransfer.js
@@ -0,0 +1,49 @@
+/*
+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.
+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.
+*/
+
+/**
+ * Used while editing, to pass the event, and to preserve editor state
+ * from one editor instance to another when remounting the editor
+ * upon receiving the remote echo for an unsent event.
+ */
+export default class EditorStateTransfer {
+ constructor(event) {
+ this._event = event;
+ this._serializedParts = null;
+ this.caret = null;
+ }
+
+ setEditorState(caret, serializedParts) {
+ this._caret = caret;
+ this._serializedParts = serializedParts;
+ }
+
+ hasEditorState() {
+ return !!this._serializedParts;
+ }
+
+ getSerializedParts() {
+ return this._serializedParts;
+ }
+
+ getCaret() {
+ return this._caret;
+ }
+
+ getEvent() {
+ return this._event;
+ }
+}
diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js
index ff20a68e3c..219b53bc5e 100644
--- a/src/utils/EventUtils.js
+++ b/src/utils/EventUtils.js
@@ -46,7 +46,8 @@ export function isContentActionable(mxEvent) {
}
export function canEditContent(mxEvent) {
- return isContentActionable(mxEvent) &&
+ return mxEvent.status !== EventStatus.CANCELLED &&
+ mxEvent.getType() === 'm.room.message' &&
mxEvent.getOriginalContent().msgtype === "m.text" &&
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
}
@@ -64,7 +65,7 @@ export function canEditOwnEvent(mxEvent) {
const MAX_JUMP_DISTANCE = 100;
export function findEditableEvent(room, isForward, fromEventId = undefined) {
const liveTimeline = room.getLiveTimeline();
- const events = liveTimeline.getEvents();
+ const events = liveTimeline.getEvents().concat(room.getPendingEvents());
const maxIdx = events.length - 1;
const inc = isForward ? 1 : -1;
const beginIdx = isForward ? 0 : maxIdx;
diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js
index 88d1c804ca..2d1fb29bd9 100644
--- a/test/components/views/dialogs/InteractiveAuthDialog-test.js
+++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js
@@ -103,12 +103,6 @@ describe('InteractiveAuthDialog', function() {
password: "s3kr3t",
user: "@user:id",
})).toBe(true);
-
- // there should now be a spinner
- ReactTestUtils.findRenderedComponentWithType(
- dlg, sdk.getComponent('elements.Spinner'),
- );
-
// let the request complete
return Promise.delay(1);
}).then(() => {