From f78aeae83aafc75fe235e816e8b067eac32761a7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 12 Jun 2019 11:19:17 +0200 Subject: [PATCH 1/8] also consider pending events when looking for next/prev event to edit --- src/utils/EventUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js index ff20a68e3c..8b219d2a03 100644 --- a/src/utils/EventUtils.js +++ b/src/utils/EventUtils.js @@ -64,7 +64,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; From d13b3aa16c34db05811edb6591cd2e5d877eaf40 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 12 Jun 2019 11:20:21 +0200 Subject: [PATCH 2/8] don't block unsent events from being edited --- src/utils/EventUtils.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js index 8b219d2a03..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(); } From 678fd37549c81f17d09669171a17b37cf4d34ae9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 12 Jun 2019 18:29:21 +0200 Subject: [PATCH 3/8] helper class to preserve editor state between remounting the editor --- src/utils/EditorStateTransfer.js | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/utils/EditorStateTransfer.js diff --git a/src/utils/EditorStateTransfer.js b/src/utils/EditorStateTransfer.js new file mode 100644 index 0000000000..23c68a4ce5 --- /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 the next 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; + } +} From e674f39e3b3bd8b33321151f46ab4eb879da56c3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 12 Jun 2019 18:32:32 +0200 Subject: [PATCH 4/8] support (de)serializing parts with other dependencies than text --- src/editor/autocomplete.js | 5 +++-- src/editor/deserialize.js | 16 ++++++++-------- src/editor/model.js | 2 +- src/editor/parts.js | 39 +++++++++++++++++++++++++++++++++----- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index ceaf18c444..92c4db415e 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) { @@ -106,7 +107,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..a5d2f25f95 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) { diff --git a/src/editor/parts.js b/src/editor/parts.js index be3080db12..193b35a5ea 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -102,6 +102,10 @@ class BasePart { toString() { return `${this.type}(${this.text})`; } + + serialize() { + return {type: this.type, text: this.text}; + } } export class PlainPart extends BasePart { @@ -233,13 +237,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 +303,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 +344,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 +374,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); + } + } + } } From 41e41269dc0bc42432d4aa6a5a58e7518c6abacf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 12 Jun 2019 18:52:34 +0200 Subject: [PATCH 5/8] use EditorStateTransfer to pass on state to newly mounted editor --- src/components/structures/MessagePanel.js | 5 +- src/components/structures/TimelinePanel.js | 6 +- .../views/elements/MessageEditor.js | 79 ++++++++++++++----- src/components/views/messages/MessageEvent.js | 2 +- src/components/views/messages/TextualBody.js | 12 +-- src/components/views/rooms/EventTile.js | 9 ++- src/editor/model.js | 6 +- 7 files changed, 83 insertions(+), 36 deletions(-) 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/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/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 ? { const partLen = part.text.length; From d40f49e2c2233e6df80d16988405772c1d356028 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 12 Jun 2019 19:09:27 +0200 Subject: [PATCH 6/8] fix lint --- src/editor/parts.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/editor/parts.js b/src/editor/parts.js index 193b35a5ea..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 = "") { From 89cc45892c7795c2fa8d5cd8c619199c36c0612e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 13 Jun 2019 13:28:21 +0200 Subject: [PATCH 7/8] fix grammar fail --- src/utils/EditorStateTransfer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/EditorStateTransfer.js b/src/utils/EditorStateTransfer.js index 23c68a4ce5..c7782a9ea8 100644 --- a/src/utils/EditorStateTransfer.js +++ b/src/utils/EditorStateTransfer.js @@ -16,7 +16,7 @@ limitations under the License. /** * Used while editing, to pass the event, and to preserve editor state - * from one editor instance to another the next when remounting the editor + * from one editor instance to another when remounting the editor * upon receiving the remote echo for an unsent event. */ export default class EditorStateTransfer { From 8b16f91b3df14ae91ab08aa5a38c618d20bf55df Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 13 Jun 2019 14:22:50 +0200 Subject: [PATCH 8/8] fix karma tests? --- test/components/views/dialogs/InteractiveAuthDialog-test.js | 6 ------ 1 file changed, 6 deletions(-) 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(() => {