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(() => {