diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js deleted file mode 100644 index ecf773f2e7..0000000000 --- a/src/ComposerHistoryManager.js +++ /dev/null @@ -1,86 +0,0 @@ -//@flow -/* -Copyright 2017 Aviral Dasgupta - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Value } from 'slate'; - -import _clamp from 'lodash/clamp'; - -type MessageFormat = 'rich' | 'markdown'; - -class HistoryItem { - // We store history items in their native format to ensure history is accurate - // and then convert them if our RTE has subsequently changed format. - value: Value; - format: MessageFormat = 'rich'; - - constructor(value: ?Value, format: ?MessageFormat) { - this.value = value; - this.format = format; - } - - static fromJSON(obj: Object): HistoryItem { - return new HistoryItem( - Value.fromJSON(obj.value), - obj.format, - ); - } - - toJSON(): Object { - return { - value: this.value.toJSON(), - format: this.format, - }; - } -} - -export default class ComposerHistoryManager { - history: Array = []; - prefix: string; - lastIndex: number = 0; // used for indexing the storage - currentIndex: number = 0; // used for indexing the loaded validated history Array - - constructor(roomId: string, prefix: string = 'mx_composer_history_') { - this.prefix = prefix + roomId; - - // TODO: Performance issues? - let item; - for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { - try { - this.history.push( - HistoryItem.fromJSON(JSON.parse(item)), - ); - } catch (e) { - console.warn("Throwing away unserialisable history", e); - } - } - this.lastIndex = this.currentIndex; - // reset currentIndex to account for any unserialisable history - this.currentIndex = this.history.length; - } - - save(value: Value, format: MessageFormat) { - const item = new HistoryItem(value, format); - this.history.push(item); - this.currentIndex = this.history.length; - sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); - } - - getItem(offset: number): ?HistoryItem { - this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); - return this.history[this.currentIndex]; - } -} diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 17e44f2a0f..657b01c663 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -234,6 +234,13 @@ module.exports = React.createClass({ } }, + scrollToEventIfNeeded: function(eventId) { + const node = this.eventNodes[eventId]; + if (node) { + node.scrollIntoView({block: "nearest", behavior: "instant"}); + } + }, + /* check the scroll state and send out pagination requests if necessary. */ checkFillState: function() { diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 0b7b315915..44741ad521 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -408,7 +408,13 @@ const TimelinePanel = React.createClass({ this.forceUpdate(); } if (payload.action === "edit_event") { - this.setState({editEvent: payload.event}); + this.setState({editEvent: payload.event}, () => { + if (payload.event && this.refs.messagePanel) { + this.refs.messagePanel.scrollToEventIfNeeded( + payload.event.getId(), + ); + } + }); } }, diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 3478ce30a8..ed86bcb0a3 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -23,6 +23,7 @@ import EditorModel from '../../../editor/model'; import {setCaretPosition} from '../../../editor/caret'; import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize'; +import {findEditableEvent} from '../../../utils/EventUtils'; import {parseEvent} from '../../../editor/deserialize'; import Autocomplete from '../rooms/Autocomplete'; import {PartCreator} from '../../../editor/parts'; @@ -42,7 +43,7 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); - const room = this.context.matrixClient.getRoom(this.props.event.getRoomId()); + const room = this._getRoom(); const partCreator = new PartCreator( () => this._autocompleteRef, query => this.setState({query}), @@ -59,6 +60,11 @@ export default class MessageEditor extends React.Component { }; this._editorRef = null; this._autocompleteRef = null; + this._hasModifications = false; + } + + _getRoom() { + return this.context.matrixClient.getRoom(this.props.event.getRoomId()); } _updateEditorState = (caret) => { @@ -74,11 +80,22 @@ export default class MessageEditor extends React.Component { } _onInput = (event) => { + this._hasModifications = true; const sel = document.getSelection(); const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); this.model.update(text, event.inputType, caret); } + _isCaretAtStart() { + const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection()); + return caret.offset === 0; + } + + _isCaretAtEnd() { + const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); + return caret.offset === text.length; + } + _onKeyDown = (event) => { // insert newline on Shift+Enter if (event.shiftKey && event.key === "Enter") { @@ -112,11 +129,33 @@ export default class MessageEditor extends React.Component { event.preventDefault(); } else if (event.key === "Escape") { this._cancelEdit(); + } else if (event.key === "ArrowUp") { + if (this._hasModifications || !this._isCaretAtStart()) { + return; + } + const previousEvent = findEditableEvent(this._getRoom(), false, this.props.event.getId()); + if (previousEvent) { + dis.dispatch({action: 'edit_event', event: previousEvent}); + event.preventDefault(); + } + } else if (event.key === "ArrowDown") { + if (this._hasModifications || !this._isCaretAtEnd()) { + return; + } + const nextEvent = findEditableEvent(this._getRoom(), true, this.props.event.getId()); + if (nextEvent) { + dis.dispatch({action: 'edit_event', event: nextEvent}); + } else { + dis.dispatch({action: 'edit_event', event: null}); + dis.dispatch({action: 'focus_composer'}); + } + event.preventDefault(); } } _cancelEdit = () => { dis.dispatch({action: "edit_event", event: null}); + dis.dispatch({action: 'focus_composer'}); } _sendEdit = () => { @@ -147,6 +186,7 @@ export default class MessageEditor extends React.Component { this.context.matrixClient.sendMessage(roomId, content); dis.dispatch({action: "edit_event", event: null}); + dis.dispatch({action: 'focus_composer'}); } _onAutoCompleteConfirm = (completion) => { diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index df16310bda..30d7aa3237 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -44,7 +44,6 @@ import * as HtmlUtils from '../../../HtmlUtils'; import Autocomplete from './Autocomplete'; import {Completion} from "../../../autocomplete/Autocompleter"; import Markdown from '../../../Markdown'; -import ComposerHistoryManager from '../../../ComposerHistoryManager'; import MessageComposerStore from '../../../stores/MessageComposerStore'; import ContentMessages from '../../../ContentMessages'; @@ -60,6 +59,7 @@ import RoomViewStore from '../../../stores/RoomViewStore'; import ReplyThread from "../elements/ReplyThread"; import {ContentHelpers} from 'matrix-js-sdk'; import AccessibleButton from '../elements/AccessibleButton'; +import {findEditableEvent} from '../../../utils/EventUtils'; const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); @@ -140,7 +140,6 @@ export default class MessageComposerInput extends React.Component { client: MatrixClient; autocomplete: Autocomplete; - historyManager: ComposerHistoryManager; constructor(props, context) { super(props, context); @@ -330,7 +329,6 @@ export default class MessageComposerInput extends React.Component { componentWillMount() { this.dispatcherRef = dis.register(this.onAction); - this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); } componentWillUnmount() { @@ -1031,7 +1029,6 @@ export default class MessageComposerInput extends React.Component { if (cmd) { if (!cmd.error) { - this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown'); this.setState({ editorState: this.createEditorState(), }, ()=>{ @@ -1109,11 +1106,6 @@ export default class MessageComposerInput extends React.Component { let sendHtmlFn = ContentHelpers.makeHtmlMessage; let sendTextFn = ContentHelpers.makeTextMessage; - this.historyManager.save( - editorState, - this.state.isRichTextEnabled ? 'rich' : 'markdown', - ); - if (commandText && commandText.startsWith('/me')) { if (replyingToEv) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -1188,14 +1180,16 @@ export default class MessageComposerInput extends React.Component { // and we must be at the edge of the document (up=start, down=end) if (up) { if (!selection.anchor.isAtStartOfNode(document)) 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(); + const editEvent = findEditableEvent(this.props.room, false); + if (editEvent) { + // We're selecting history, so prevent the key event from doing anything else + e.preventDefault(); + dis.dispatch({ + action: 'edit_event', + event: editEvent, + }); + } } } else { this.moveAutocompleteSelection(up); @@ -1203,54 +1197,6 @@ export default class MessageComposerInput extends React.Component { } }; - selectHistory = async (up) => { - const delta = up ? -1 : 1; - - // True if we are not currently selecting history, but composing a message - if (this.historyManager.currentIndex === this.historyManager.history.length) { - // We can't go any further - there isn't any more history, so nop. - if (!up) { - return; - } - this.setState({ - currentlyComposedEditorState: this.state.editorState, - }); - } else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) { - // True when we return to the message being composed currently - this.setState({ - editorState: this.state.currentlyComposedEditorState, - }); - this.historyManager.currentIndex = this.historyManager.history.length; - return; - } - - let editorState; - const historyItem = this.historyManager.getItem(delta); - if (!historyItem) return; - - if (historyItem.format === 'rich' && !this.state.isRichTextEnabled) { - editorState = this.richToMdEditorState(historyItem.value); - } else if (historyItem.format === 'markdown' && this.state.isRichTextEnabled) { - editorState = this.mdToRichEditorState(historyItem.value); - } else { - editorState = historyItem.value; - } - - // Move selection to the end of the selected history - const change = editorState.change().moveToEndOfNode(editorState.document); - - // We don't call this.onChange(change) now, as fixups on stuff like pills - // should already have been done and persisted in the history. - editorState = change.value; - - this.suppressAutoComplete = true; - - this.setState({ editorState }, ()=>{ - this._editor.focus(); - }); - return true; - }; - onTab = async (e) => { this.setState({ someCompletions: null, diff --git a/src/utils/EventUtils.js b/src/utils/EventUtils.js index 68f6d4b14e..ff20a68e3c 100644 --- a/src/utils/EventUtils.js +++ b/src/utils/EventUtils.js @@ -16,7 +16,7 @@ limitations under the License. import { EventStatus } from 'matrix-js-sdk'; import MatrixClientPeg from '../MatrixClientPeg'; - +import shouldHideEvent from "../shouldHideEvent"; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. * which effectively checks whether it's a regular message that has been sent and that we @@ -50,3 +50,41 @@ export function canEditContent(mxEvent) { mxEvent.getOriginalContent().msgtype === "m.text" && mxEvent.getSender() === MatrixClientPeg.get().getUserId(); } + +export function canEditOwnEvent(mxEvent) { + // for now we only allow editing + // your own events. So this just call through + // In the future though, moderators will be able to + // edit other people's messages as well but we don't + // want findEditableEvent to return other people's events + // hence this method. + return canEditContent(mxEvent); +} + +const MAX_JUMP_DISTANCE = 100; +export function findEditableEvent(room, isForward, fromEventId = undefined) { + const liveTimeline = room.getLiveTimeline(); + const events = liveTimeline.getEvents(); + const maxIdx = events.length - 1; + const inc = isForward ? 1 : -1; + const beginIdx = isForward ? 0 : maxIdx; + let endIdx = isForward ? maxIdx : 0; + if (!fromEventId) { + endIdx = Math.min(Math.max(0, beginIdx + (inc * MAX_JUMP_DISTANCE)), maxIdx); + } + let foundFromEventId = !fromEventId; + for (let i = beginIdx; i !== (endIdx + inc); i += inc) { + const e = events[i]; + // find start event first + if (!foundFromEventId && e.getId() === fromEventId) { + foundFromEventId = true; + // don't look further than MAX_JUMP_DISTANCE events from `fromEventId` + // to not iterate potentially 1000nds of events on key up/down + endIdx = Math.min(Math.max(0, i + (inc * MAX_JUMP_DISTANCE)), maxIdx); + } else if (foundFromEventId && !shouldHideEvent(e) && canEditOwnEvent(e)) { + // otherwise look for editable event + return e; + } + } +} +