/* Copyright 2015, 2016 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. */ import React from 'react'; import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent'; import {Editor, EditorState, RichUtils, CompositeDecorator, convertFromRaw, convertToRaw, Modifier, EditorChangeType, getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js'; import classNames from 'classnames'; import escape from 'lodash/escape'; import Q from 'q'; import MatrixClientPeg from '../../../MatrixClientPeg'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; import SlashCommands from '../../../SlashCommands'; import KeyCode from '../../../KeyCode'; import Modal from '../../../Modal'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher'; import UserSettingsStore from '../../../UserSettingsStore'; import * as RichText from '../../../RichText'; import * as HtmlUtils from '../../../HtmlUtils'; import Autocomplete from './Autocomplete'; import {Completion} from "../../../autocomplete/Autocompleter"; import Markdown from '../../../Markdown'; import ComposerHistoryManager from '../../../ComposerHistoryManager'; import {onSendMessageFailed} from './MessageComposerInputOld'; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const ZWS_CODE = 8203; const ZWS = String.fromCharCode(ZWS_CODE); // zero width space function stateToMarkdown(state) { return __stateToMarkdown(state) .replace( ZWS, // draft-js-export-markdown adds these ''); // this is *not* a zero width space, trust me :) } /* * The textInput part of the MessageComposer */ export default class MessageComposerInput extends React.Component { static propTypes = { tabComplete: React.PropTypes.any, // a callback which is called when the height of the composer is // changed due to a change in content. onResize: React.PropTypes.func, // js-sdk Room object room: React.PropTypes.object.isRequired, // called with current plaintext content (as a string) whenever it changes onContentChanged: React.PropTypes.func, onUpArrow: React.PropTypes.func, onDownArrow: React.PropTypes.func, // attempts to confirm currently selected completion, returns whether actually confirmed tryComplete: React.PropTypes.func, onInputStateChanged: React.PropTypes.func, }; static getKeyBinding(e: SyntheticKeyboardEvent): string { // C-m => Toggles between rich text and markdown modes if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { return 'toggle-mode'; } // Allow opening of dev tools. getDefaultKeyBinding would be 'italic' for KEY_I if (e.keyCode === KeyCode.KEY_I && e.shiftKey && e.ctrlKey) { // When null is returned, draft-js will NOT preventDefault, allowing dev tools // to be toggled when the editor is focussed return null; } return getDefaultKeyBinding(e); } static getBlockStyle(block: ContentBlock): ?string { if (block.getType() === 'strikethrough') { return 'mx_Markdown_STRIKETHROUGH'; } return null; } client: MatrixClient; autocomplete: Autocomplete; historyManager: ComposerHistoryManager; constructor(props, context) { super(props, context); this.onAction = this.onAction.bind(this); this.handleReturn = this.handleReturn.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); this.onEditorContentChanged = this.onEditorContentChanged.bind(this); this.onUpArrow = this.onUpArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this); this.onTab = this.onTab.bind(this); this.onEscape = this.onEscape.bind(this); this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this); this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); this.onTextPasted = this.onTextPasted.bind(this); const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false); this.state = { // whether we're in rich text or markdown mode isRichtextEnabled, // the currently displayed editor state (note: this is always what is modified on input) editorState: null, // the original editor state, before we started tabbing through completions originalEditorState: null, }; // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled /* eslint react/no-direct-mutation-state:0 */ this.state.editorState = this.createEditorState(); this.client = MatrixClientPeg.get(); } /* * "Does the right thing" to create an EditorState, based on: * - whether we've got rich text mode enabled * - contentState was passed in */ createEditorState(richText: boolean, contentState: ?ContentState): EditorState { let decorators = richText ? RichText.getScopedRTDecorators(this.props) : RichText.getScopedMDDecorators(this.props), compositeDecorator = new CompositeDecorator(decorators); let editorState = null; if (contentState) { editorState = EditorState.createWithContent(contentState, compositeDecorator); } else { editorState = EditorState.createEmpty(compositeDecorator); } return EditorState.moveFocusToEnd(editorState); } componentDidMount() { this.dispatcherRef = dis.register(this.onAction); this.historyManager = new ComposerHistoryManager(this.props.room.roomId); } componentWillUnmount() { dis.unregister(this.dispatcherRef); } componentWillUpdate(nextProps, nextState) { // this is dirty, but moving all this state to MessageComposer is dirtier if (this.props.onInputStateChanged && nextState !== this.state) { const state = this.getSelectionInfo(nextState.editorState); state.isRichtextEnabled = nextState.isRichtextEnabled; this.props.onInputStateChanged(state); } } onAction = (payload) => { const editor = this.refs.editor; let contentState = this.state.editorState.getCurrentContent(); switch (payload.action) { case 'focus_composer': editor.focus(); break; // TODO change this so we insert a complete user alias case 'insert_displayname': { contentState = Modifier.replaceText( contentState, this.state.editorState.getSelection(), `${payload.displayname}: `, ); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); this.onEditorContentChanged(editorState); editor.focus(); } break; case 'quote': { let {body, formatted_body} = payload.event.getContent(); formatted_body = formatted_body || escape(body); if (formatted_body) { let content = RichText.htmlToContentState(`
${formatted_body}`); if (!this.state.isRichtextEnabled) { content = ContentState.createFromText(RichText.stateToMarkdown(content)); } const blockMap = content.getBlockMap(); let startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); contentState = Modifier.splitBlock(contentState, startSelection); startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); contentState = Modifier.replaceWithFragment(contentState, startSelection, blockMap); startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); if (this.state.isRichtextEnabled) { contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); } const editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); this.onEditorContentChanged(editorState); editor.focus(); } } break; } }; onTypingActivity() { this.isTyping = true; if (!this.userTypingTimer) { this.sendTyping(true); } this.startUserTypingTimer(); this.startServerTypingTimer(); } onFinishedTyping() { this.isTyping = false; this.sendTyping(false); this.stopUserTypingTimer(); this.stopServerTypingTimer(); } startUserTypingTimer() { this.stopUserTypingTimer(); const self = this; this.userTypingTimer = setTimeout(function() { self.isTyping = false; self.sendTyping(self.isTyping); self.userTypingTimer = null; }, TYPING_USER_TIMEOUT); } stopUserTypingTimer() { if (this.userTypingTimer) { clearTimeout(this.userTypingTimer); this.userTypingTimer = null; } } startServerTypingTimer() { if (!this.serverTypingTimer) { const self = this; this.serverTypingTimer = setTimeout(function() { if (self.isTyping) { self.sendTyping(self.isTyping); self.startServerTypingTimer(); } }, TYPING_SERVER_TIMEOUT / 2); } } stopServerTypingTimer() { if (this.serverTypingTimer) { clearTimeout(this.servrTypingTimer); this.serverTypingTimer = null; } } sendTyping(isTyping) { if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return; MatrixClientPeg.get().sendTyping( this.props.room.roomId, this.isTyping, TYPING_SERVER_TIMEOUT, ).done(); } refreshTyping() { if (this.typingTimeout) { clearTimeout(this.typingTimeout); this.typingTimeout = null; } } // Called by Draft to change editor contents onEditorContentChanged = (editorState: EditorState) => { editorState = RichText.attachImmutableEntitiesToEmoji(editorState); /* Since a modification was made, set originalEditorState to null, since newState is now our original */ this.setState({ editorState, originalEditorState: null, }); }; /** * We're overriding setState here because it's the most convenient way to monitor changes to the editorState. * Doing it using a separate function that calls setState is a possibility (and was the old approach), but that * approach requires a callback and an extra setState whenever trying to set multiple state properties. * * @param state * @param callback */ setState(state, callback) { if (state.editorState != null) { state.editorState = RichText.attachImmutableEntitiesToEmoji( state.editorState); if (state.editorState.getCurrentContent().hasText()) { this.onTypingActivity(); } else { this.onFinishedTyping(); } if (!state.hasOwnProperty('originalEditorState')) { state.originalEditorState = null; } } super.setState(state, () => { if (callback != null) { callback(); } if (this.props.onContentChanged) { const textContent = this.state.editorState .getCurrentContent().getPlainText(); const selection = RichText.selectionStateToTextOffsets( this.state.editorState.getSelection(), this.state.editorState.getCurrentContent().getBlocksAsArray()); this.props.onContentChanged(textContent, selection); } }); } enableRichtext(enabled: boolean) { if (enabled === this.state.isRichtextEnabled) return; let contentState = null; if (enabled) { const md = new Markdown(this.state.editorState.getCurrentContent().getPlainText()); contentState = RichText.htmlToContentState(md.toHTML()); } else { let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent()); if (markdown[markdown.length - 1] === '\n') { markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) } contentState = ContentState.createFromText(markdown); } this.setState({ editorState: this.createEditorState(enabled, contentState), isRichtextEnabled: enabled, }); UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); } handleKeyCommand = (command: string): boolean => { if (command === 'toggle-mode') { this.enableRichtext(!this.state.isRichtextEnabled); return true; } let newState: ?EditorState = null; // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. if (this.state.isRichtextEnabled) { // These are block types, not handled by RichUtils by default. const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item']; if (blockCommands.includes(command)) { this.setState({ editorState: RichUtils.toggleBlockType(this.state.editorState, command), }); } else if (command === 'strike') { // this is the only inline style not handled by Draft by default this.setState({ editorState: RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'), }); } } else { let contentState = this.state.editorState.getCurrentContent(), selection = this.state.editorState.getSelection(); const modifyFn = { 'bold': (text) => `**${text}**`, 'italic': (text) => `*${text}*`, 'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* 'strike': (text) => `