diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js new file mode 100644 index 0000000000..1b3fb588eb --- /dev/null +++ b/src/ComposerHistoryManager.js @@ -0,0 +1,86 @@ +//@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/LoggedInView.js b/src/components/structures/LoggedInView.js index 6c05b45111..cd752fc2ce 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -292,16 +292,6 @@ const LoggedInView = React.createClass({ const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); switch (ev.keyCode) { - case KeyCode.UP: - case KeyCode.DOWN: - if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) { - const action = ev.keyCode == KeyCode.UP ? - 'view_prev_room' : 'view_next_room'; - dis.dispatch({action: action}); - handled = true; - } - break; - case KeyCode.PAGE_UP: case KeyCode.PAGE_DOWN: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index e99e87ae1d..7684e1dced 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -60,6 +60,7 @@ import ReplyThread from "../elements/ReplyThread"; import {ContentHelpers} from 'matrix-js-sdk'; import AccessibleButton from '../elements/AccessibleButton'; import {findEditableEvent} from '../../../utils/EventUtils'; +import ComposerHistoryManager from "../../../ComposerHistoryManager"; const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); @@ -140,6 +141,7 @@ export default class MessageComposerInput extends React.Component { client: MatrixClient; autocomplete: Autocomplete; + historyManager: ComposerHistoryManager; constructor(props, context) { super(props, context); @@ -329,6 +331,7 @@ 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() { @@ -1062,6 +1065,7 @@ 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(), }, ()=>{ @@ -1139,6 +1143,8 @@ 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"); @@ -1198,31 +1204,88 @@ export default class MessageComposerInput extends React.Component { }; onVerticalArrow = (e, up) => { - if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return; + if (e.ctrlKey || e.shiftKey || e.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; - - const editEvent = findEditableEvent(this.props.room, false); - if (editEvent) { + if (e.altKey) { + // Try select composer history + const selected = this.selectHistory(up); + if (selected) { // We're selecting history, so prevent the key event from doing anything else e.preventDefault(); - dis.dispatch({ - action: 'edit_event', - event: editEvent, - }); + } + } else if (!e.altKey && up) { + // Try edit the latest message + 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 (!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, + }); + } } } }; + 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,