/* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 New Vector 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 ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent'; import { Editor } from 'slate-react'; import { getEventTransfer } from 'slate-react'; import { Value, Document, Event, Inline, Text, Range, Node } from 'slate'; import Html from 'slate-html-serializer'; import Md from 'slate-md-serializer'; import Plain from 'slate-plain-serializer'; import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer"; // import {Editor, EditorState, RichUtils, CompositeDecorator, Modifier, // getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState, // Entity} from 'draft-js'; import classNames from 'classnames'; import escape from 'lodash/escape'; import Promise from 'bluebird'; import MatrixClientPeg from '../../../MatrixClientPeg'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; import SlashCommands from '../../../SlashCommands'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard'; import Modal from '../../../Modal'; import sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; import Analytics from '../../../Analytics'; import dis from '../../../dispatcher'; 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 MessageComposerStore from '../../../stores/MessageComposerStore'; import {MATRIXTO_URL_PATTERN, MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix'; const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g'); import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import {makeUserPermalink} from "../../../matrix-to"; import ReplyPreview from "./ReplyPreview"; import RoomViewStore from '../../../stores/RoomViewStore'; import ReplyThread from "../elements/ReplyThread"; import {ContentHelpers} from 'matrix-js-sdk'; const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort(); const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$'); const EMOJI_REGEX = new RegExp(unicodeRegexp, 'g'); const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const ENTITY_TYPES = { AT_ROOM_PILL: 'ATROOMPILL', }; // the Slate node type to default to for unstyled text const DEFAULT_NODE = 'paragraph'; // map HTML elements through to our Slate schema node types // used for the HTML deserializer. // (The names here are chosen to match the MD serializer's schema for convenience) const BLOCK_TAGS = { p: 'paragraph', blockquote: 'block-quote', ul: 'bulleted-list', h1: 'heading1', h2: 'heading2', h3: 'heading3', h4: 'heading4', h5: 'heading5', h6: 'heading6', li: 'list-item', ol: 'numbered-list', pre: 'code-block', }; const MARK_TAGS = { strong: 'bold', b: 'bold', // deprecated em: 'italic', i: 'italic', // deprecated code: 'code', u: 'underlined', del: 'deleted', strike: 'deleted', // deprecated s: 'deleted', // deprecated }; function onSendMessageFailed(err, room) { // XXX: temporary logging to try to diagnose // https://github.com/vector-im/riot-web/issues/3148 console.log('MessageComposer got send failure: ' + err.name + '('+err+')'); dis.dispatch({ action: 'message_send_failed', }); } /* * The textInput part of the MessageComposer */ export default class MessageComposerInput extends React.Component { static propTypes = { // a callback which is called when the height of the composer is // changed due to a change in content. onResize: PropTypes.func, // js-sdk Room object room: PropTypes.object.isRequired, onFilesPasted: PropTypes.func, onInputStateChanged: PropTypes.func, }; client: MatrixClient; autocomplete: Autocomplete; historyManager: ComposerHistoryManager; constructor(props, context) { super(props, context); const isRichTextEnabled = SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'); Analytics.setRichtextMode(isRichTextEnabled); 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: this.createEditorState( isRichTextEnabled, MessageComposerStore.getEditorState(this.props.room.roomId), ), // the original editor state, before we started tabbing through completions originalEditorState: null, // the virtual state "above" the history stack, the message currently being composed that // we want to persist whilst browsing history currentlyComposedEditorState: null, // whether there were any completions someCompletions: null, }; this.client = MatrixClientPeg.get(); this.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' }); this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' }); this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' }); this.md = new Md({ rules: [ { serialize: (obj, children) => { if (obj.object === 'string') { // escape any MD in it. i have no idea why the serializer doesn't // do this already. // TODO: this can probably be more robust - it doesn't consider // indenting or lists for instance. return children.replace(/([*_~`+])/g, '\\$1') .replace(/^([>#\|])/g, '\\$1'); } else if (obj.object === 'inline') { switch (obj.type) { case 'pill': return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`; case 'emoji': return obj.data.get('emojiUnicode'); } } } } ] }); this.html = new Html({ rules: [ { deserialize: (el, next) => { const tag = el.tagName.toLowerCase(); let type = BLOCK_TAGS[tag]; if (type) { return { object: 'block', type: type, nodes: next(el.childNodes), } } type = MARK_TAGS[tag]; if (type) { return { object: 'mark', type: type, nodes: next(el.childNodes), } } // special case links if (tag === 'a') { const href = el.getAttribute('href'); let m; if (href) { m = href.match(MATRIXTO_URL_PATTERN); } if (m) { return { object: 'inline', type: 'pill', data: { href, completion: el.innerText, completionId: m[1], }, isVoid: true, } } else { return { object: 'inline', type: 'link', data: { href }, nodes: next(el.childNodes), } } } }, serialize: (obj, children) => { if (obj.object === 'block') { return this.renderNode({ node: obj, children: children, }); } else if (obj.object === 'mark') { return this.renderMark({ mark: obj, children: children, }); } else if (obj.object === 'inline') { // special case links, pills and emoji otherwise we // end up with React components getting rendered out(!) switch (obj.type) { case 'pill': return { obj.data.get('completion') }; case 'link': return { children }; case 'emoji': // XXX: apparently you can't return plain strings from serializer rules // until https://github.com/ianstormtaylor/slate/pull/1854 is merged. // So instead we temporarily wrap emoji from RTE in an arbitrary tag // (). would be nicer, but in practice it causes CSS issues. return { obj.data.get('emojiUnicode') }; } return this.renderNode({ node: obj, children: children, }); } } } ] }); this.suppressAutoComplete = false; this.direction = ''; } /* * "Does the right thing" to create an Editor value, based on: * - whether we've got rich text mode enabled * - contentState was passed in */ createEditorState(richText: boolean, editorState: ?Value): Value { if (editorState instanceof Value) { return editorState; } else { // ...or create a new one. return Plain.deserialize('', { defaultBlock: DEFAULT_NODE }); } } componentDidMount() { this.dispatcherRef = dis.register(this.onAction); this.historyManager = new ComposerHistoryManager(this.props.room.roomId); } componentWillUnmount() { dis.unregister(this.dispatcherRef); } onAction = (payload) => { const editor = this.refs.editor; let editorState = this.state.editorState; switch (payload.action) { case 'reply_to_event': case 'focus_composer': editor.focus(); break; case 'insert_mention': { // Pretend that we've autocompleted this user because keeping two code // paths for inserting a user pill is not fun const selection = this.getSelectionRange(this.state.editorState); const member = this.props.room.getMember(payload.user_id); const completion = member ? member.rawDisplayName.replace(' (IRC)', '') : payload.user_id; this.setDisplayedCompletion({ completion, completionId: payload.user_id, selection, href: makeUserPermalink(payload.user_id), suffix: (selection.beginning && selection.start === 0) ? ': ' : ' ', }); } break; /* case 'quote': { // old quoting, whilst rich quoting is in labs /// XXX: Not doing rich-text quoting from formatted-body because draft-js /// has regressed such that when links are quoted, errors are thrown. See /// https://github.com/vector-im/riot-web/issues/4756. const body = escape(payload.text); if (body) { let content = RichText.htmlToContentState(`
${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'); } let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); editorState = EditorState.moveSelectionToEnd(editorState); 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.serverTypingTimer); this.serverTypingTimer = null; } } sendTyping(isTyping) { if (SettingsStore.getValue('dontSendTypingNotifications')) return; MatrixClientPeg.get().sendTyping( this.props.room.roomId, this.isTyping, TYPING_SERVER_TIMEOUT, ).done(); } refreshTyping() { if (this.typingTimeout) { clearTimeout(this.typingTimeout); this.typingTimeout = null; } } onChange = (change: Change, originalEditorState: value) => { let editorState = change.value; if (this.direction !== '') { const focusedNode = editorState.focusInline || editorState.focusText; if (focusedNode.isVoid) { if (editorState.isCollapsed) { change = change[`collapseToEndOf${ this.direction }Text`](); } else { const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText; if (block) { change = change.moveFocusToEndOf(block) } } editorState = change.value; } } if (!editorState.document.isEmpty) { this.onTypingActivity(); } else { this.onFinishedTyping(); } /* // XXX: what was this ever doing? if (!state.hasOwnProperty('originalEditorState')) { state.originalEditorState = null; } */ // emojioneify any emoji // XXX: is getTextsAsArray a private API? editorState.document.getTextsAsArray().forEach(node => { if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) { let match; while ((match = EMOJI_REGEX.exec(node.text)) !== null) { const range = Range.create({ anchorKey: node.key, anchorOffset: match.index, focusKey: node.key, focusOffset: match.index + match[0].length, }); const inline = Inline.create({ type: 'emoji', data: { emojiUnicode: match[0] }, isVoid: true, }); change = change.insertInlineAtRange(range, inline); editorState = change.value; } } }); /* const currentBlock = editorState.getSelection().getStartKey(); const currentSelection = editorState.getSelection(); const currentStartOffset = editorState.getSelection().getStartOffset(); const block = editorState.getCurrentContent().getBlockForKey(currentBlock); const text = block.getText(); const entityBeforeCurrentOffset = block.getEntityAt(currentStartOffset - 1); const entityAtCurrentOffset = block.getEntityAt(currentStartOffset); // If the cursor is on the boundary between an entity and a non-entity and the // text before the cursor has whitespace at the end, set the entity state of the // character before the cursor (the whitespace) to null. This allows the user to // stop editing the link. if (entityBeforeCurrentOffset && !entityAtCurrentOffset && /\s$/.test(text.slice(0, currentStartOffset))) { editorState = RichUtils.toggleLink( editorState, currentSelection.merge({ anchorOffset: currentStartOffset - 1, focusOffset: currentStartOffset, }), null, ); // Reset selection editorState = EditorState.forceSelection(editorState, currentSelection); } */ if (editorState.startText !== null) { const text = editorState.startText.text; const currentStartOffset = editorState.startOffset; // Automatic replacement of plaintext emoji to Unicode emoji if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) { // The first matched group includes just the matched plaintext emoji const emojiMatch = REGEX_EMOJI_WHITESPACE.exec(text.slice(0, currentStartOffset)); if (emojiMatch) { // plaintext -> hex unicode const emojiUc = asciiList[emojiMatch[1]]; // hex unicode -> shortname -> actual unicode const unicodeEmoji = shortnameToUnicode(EMOJI_UNICODE_TO_SHORTNAME[emojiUc]); const range = Range.create({ anchorKey: editorState.selection.startKey, anchorOffset: currentStartOffset - emojiMatch[1].length - 1, focusKey: editorState.selection.startKey, focusOffset: currentStartOffset, }); change = change.insertTextAtRange(range, unicodeEmoji); editorState = change.value; } } } if (this.props.onInputStateChanged) { let blockType = editorState.blocks.first().type; console.log("onInputStateChanged; current block type is " + blockType + " and marks are " + editorState.activeMarks); if (blockType === 'list-item') { const parent = editorState.document.getParent(editorState.blocks.first().key); if (parent.type === 'numbered-list') { blockType = 'numbered-list'; } else if (parent.type === 'bulleted-list') { blockType = 'bulleted-list'; } } const inputState = { marks: editorState.activeMarks, isRichTextEnabled: this.state.isRichTextEnabled, blockType }; this.props.onInputStateChanged(inputState); } // Record the editor state for this room so that it can be retrieved after // switching to another room and back dis.dispatch({ action: 'editor_state', room_id: this.props.room.roomId, editor_state: editorState, }); /* Since a modification was made, set originalEditorState to null, since newState is now our original */ this.setState({ editorState, originalEditorState: originalEditorState || null }); }; mdToRichEditorState(editorState: Value): Value { // for consistency when roundtripping, we could use slate-md-serializer rather than // commonmark, but then we would lose pills as the MD deserialiser doesn't know about // them and doesn't have any extensibility hooks. // // The code looks like this: // // const markdown = this.plainWithMdPills.serialize(editorState); // // // weirdly, the Md serializer can't deserialize '' to a valid Value... // if (markdown !== '') { // editorState = this.md.deserialize(markdown); // } // else { // editorState = Plain.deserialize('', { defaultBlock: DEFAULT_NODE }); // } // so, instead, we use commonmark proper (which is arguably more logical to the user // anyway, as they'll expect the RTE view to match what they'll see in the timeline, // but the HTML->MD conversion is anyone's guess). const textWithMdPills = this.plainWithMdPills.serialize(editorState); const markdown = new Markdown(textWithMdPills); // HTML deserialize has custom rules to turn matrix.to links into pill objects. return this.html.deserialize(markdown.toHTML()); } richToMdEditorState(editorState: Value): Value { // FIXME: this conversion loses pills (turning them into pure MD links). // We need to add a pill-aware deserialize method // to PlainWithPillsSerializer which recognises pills in raw MD and turns them into pills. return Plain.deserialize( // FIXME: we compile the MD out of the RTE state using slate-md-serializer // which doesn't roundtrip symmetrically with commonmark, which we use for // compiling MD out of the MD editor state above. this.md.serialize(editorState), { defaultBlock: DEFAULT_NODE } ); } enableRichtext(enabled: boolean) { if (enabled === this.state.isRichTextEnabled) return; let editorState = null; if (enabled) { editorState = this.mdToRichEditorState(this.state.editorState); } else { editorState = this.richToMdEditorState(this.state.editorState); } Analytics.setRichtextMode(enabled); this.setState({ editorState: this.createEditorState(enabled, editorState), isRichTextEnabled: enabled, }, ()=>{ this.refs.editor.focus(); }); SettingsStore.setValue("MessageComposerInput.isRichTextEnabled", null, SettingLevel.ACCOUNT, enabled); }; /** * Check if the current selection has a mark with `type` in it. * * @param {String} type * @return {Boolean} */ hasMark = type => { const { editorState } = this.state return editorState.activeMarks.some(mark => mark.type == type) }; /** * Check if the any of the currently selected blocks are of `type`. * * @param {String} type * @return {Boolean} */ hasBlock = type => { const { editorState } = this.state return editorState.blocks.some(node => node.type == type) }; onKeyDown = (ev: Event, change: Change, editor: Editor) => { this.suppressAutoComplete = false; // skip void nodes - see // https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095 if (ev.keyCode === KeyCode.LEFT) { this.direction = 'Previous'; } else if (ev.keyCode === KeyCode.RIGHT) { this.direction = 'Next'; } else { this.direction = ''; } if (isOnlyCtrlOrCmdKeyEvent(ev)) { const ctrlCmdCommand = { // C-m => Toggles between rich text and markdown modes [KeyCode.KEY_M]: 'toggle-mode', [KeyCode.KEY_B]: 'bold', [KeyCode.KEY_I]: 'italic', [KeyCode.KEY_U]: 'underlined', [KeyCode.KEY_J]: 'code', }[ev.keyCode]; if (ctrlCmdCommand) { return this.handleKeyCommand(ctrlCmdCommand); } return false; } switch (ev.keyCode) { case KeyCode.ENTER: return this.handleReturn(ev, change); case KeyCode.BACKSPACE: return this.onBackspace(ev, change); case KeyCode.UP: return this.onVerticalArrow(ev, true); case KeyCode.DOWN: return this.onVerticalArrow(ev, false); case KeyCode.TAB: return this.onTab(ev); case KeyCode.ESCAPE: return this.onEscape(ev); default: // don't intercept it return; } }; onBackspace = (ev: Event, change: Change): Change => { if (this.state.isRichTextEnabled) { // let backspace exit lists const isList = this.hasBlock('list-item'); const { editorState } = this.state; if (isList && editorState.anchorOffset == 0) { change .setBlocks(DEFAULT_NODE) .unwrapBlock('bulleted-list') .unwrapBlock('numbered-list'); return change; } else if (editorState.anchorOffset == 0 && (this.hasBlock('block-quote') || this.hasBlock('heading1') || this.hasBlock('heading2') || this.hasBlock('heading3') || this.hasBlock('heading4') || this.hasBlock('heading5') || this.hasBlock('heading6') || this.hasBlock('code-block'))) { return change.setBlocks(DEFAULT_NODE); } } return; }; handleKeyCommand = (command: string): boolean => { if (command === 'toggle-mode') { this.enableRichtext(!this.state.isRichTextEnabled); return true; } let newState: ?Value = null; // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. if (this.state.isRichTextEnabled) { const type = command; const { editorState } = this.state; const change = editorState.change(); const { document } = editorState; switch (type) { // list-blocks: case 'bulleted-list': case 'numbered-list': { // Handle the extra wrapping required for list buttons. const isList = this.hasBlock('list-item'); const isType = editorState.blocks.some(block => { return !!document.getClosest(block.key, parent => parent.type == type); }); if (isList && isType) { change .setBlocks(DEFAULT_NODE) .unwrapBlock('bulleted-list') .unwrapBlock('numbered-list'); } else if (isList) { change .unwrapBlock( type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list' ) .wrapBlock(type); } else { change.setBlocks('list-item').wrapBlock(type); } } break; // simple blocks case 'paragraph': case 'block-quote': case 'heading1': case 'heading2': case 'heading3': case 'heading4': case 'heading5': case 'heading6': case 'list-item': case 'code-block': { const isActive = this.hasBlock(type); const isList = this.hasBlock('list-item'); if (isList) { change .setBlocks(isActive ? DEFAULT_NODE : type) .unwrapBlock('bulleted-list') .unwrapBlock('numbered-list'); } else { change.setBlocks(isActive ? DEFAULT_NODE : type); } } break; // marks: case 'bold': case 'italic': case 'code': case 'underlined': case 'deleted': { change.toggleMark(type); } break; default: console.warn(`ignoring unrecognised RTE command ${type}`); return false; } this.onChange(change); return true; } else { /* const contentState = this.state.editorState.getCurrentContent(); const multipleLinesSelected = RichText.hasMultiLineSelection(this.state.editorState); const selectionState = this.state.editorState.getSelection(); const start = selectionState.getStartOffset(); const end = selectionState.getEndOffset(); // If multiple lines are selected or nothing is selected, insert a code block // instead of applying inline code formatting. This is an attempt to mimic what // happens in non-MD mode. const treatInlineCodeAsBlock = multipleLinesSelected || start === end; const textMdCodeBlock = (text) => `\`\`\`\n${text}\n\`\`\`\n`; const modifyFn = { 'bold': (text) => `**${text}**`, 'italic': (text) => `*${text}*`, 'underline': (text) => `${text}`, 'strike': (text) => `
{children}
; case 'block-quote': return{children}; case 'bulleted-list': return
{children}
;
case 'link':
return {children};
case 'pill': {
const { data } = node;
const url = data.get('href');
const completion = data.get('completion');
const shouldShowPillAvatar = !SettingsStore.getValue("Pill.shouldHidePillAvatar");
const Pill = sdk.getComponent('elements.Pill');
if (completion === '@room') {
return {children}
;
case 'underlined':
return {children};
case 'deleted':
return