2016-03-24 11:25:41 +00:00
|
|
|
/*
|
|
|
|
Copyright 2015, 2016 OpenMarket Ltd
|
2017-11-02 18:01:28 +00:00
|
|
|
Copyright 2017 New Vector Ltd
|
2016-03-24 11:25:41 +00:00
|
|
|
|
|
|
|
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.
|
|
|
|
*/
|
2016-06-11 10:22:08 +00:00
|
|
|
import React from 'react';
|
2018-05-08 00:54:06 +00:00
|
|
|
import ReactDOM from 'react-dom';
|
2017-12-26 01:03:18 +00:00
|
|
|
import PropTypes from 'prop-types';
|
2016-07-08 07:24:28 +00:00
|
|
|
import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2018-04-23 00:13:18 +00:00
|
|
|
import { Editor } from 'slate-react';
|
2018-05-19 21:05:31 +00:00
|
|
|
import { getEventTransfer } from 'slate-react';
|
2018-05-12 15:21:36 +00:00
|
|
|
import { Value, Document, Event, Inline, Text, Range, Node } from 'slate';
|
2018-04-23 00:13:18 +00:00
|
|
|
|
|
|
|
import Html from 'slate-html-serializer';
|
2018-05-17 01:13:17 +00:00
|
|
|
import Md from 'slate-md-serializer';
|
2018-04-23 00:13:18 +00:00
|
|
|
import Plain from 'slate-plain-serializer';
|
2018-05-12 19:04:58 +00:00
|
|
|
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer";
|
2018-04-23 00:13:18 +00:00
|
|
|
|
|
|
|
// import {Editor, EditorState, RichUtils, CompositeDecorator, Modifier,
|
|
|
|
// getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState,
|
|
|
|
// Entity} from 'draft-js';
|
2016-06-11 10:22:08 +00:00
|
|
|
|
2016-09-04 15:33:40 +00:00
|
|
|
import classNames from 'classnames';
|
2016-09-05 12:08:53 +00:00
|
|
|
import escape from 'lodash/escape';
|
2017-07-12 12:58:14 +00:00
|
|
|
import Promise from 'bluebird';
|
2016-05-27 04:45:55 +00:00
|
|
|
|
2016-07-08 07:24:28 +00:00
|
|
|
import MatrixClientPeg from '../../../MatrixClientPeg';
|
|
|
|
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
|
|
|
|
import SlashCommands from '../../../SlashCommands';
|
2017-12-01 10:44:00 +00:00
|
|
|
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard';
|
2016-07-08 07:24:28 +00:00
|
|
|
import Modal from '../../../Modal';
|
|
|
|
import sdk from '../../../index';
|
2017-09-22 19:43:27 +00:00
|
|
|
import { _t, _td } from '../../../languageHandler';
|
2017-08-09 17:39:06 +00:00
|
|
|
import Analytics from '../../../Analytics';
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-07-08 07:24:28 +00:00
|
|
|
import dis from '../../../dispatcher';
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-06-11 16:54:09 +00:00
|
|
|
import * as RichText from '../../../RichText';
|
2016-09-16 15:02:08 +00:00
|
|
|
import * as HtmlUtils from '../../../HtmlUtils';
|
2016-09-13 10:11:52 +00:00
|
|
|
import Autocomplete from './Autocomplete';
|
|
|
|
import {Completion} from "../../../autocomplete/Autocompleter";
|
2016-09-23 17:50:25 +00:00
|
|
|
import Markdown from '../../../Markdown';
|
2017-03-10 15:04:31 +00:00
|
|
|
import ComposerHistoryManager from '../../../ComposerHistoryManager';
|
2017-07-05 09:24:55 +00:00
|
|
|
import MessageComposerStore from '../../../stores/MessageComposerStore';
|
|
|
|
|
2017-07-21 15:38:31 +00:00
|
|
|
import {MATRIXTO_URL_PATTERN, MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
|
2017-07-14 16:04:28 +00:00
|
|
|
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
|
2017-07-21 15:38:31 +00:00
|
|
|
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
|
2017-07-14 16:04:28 +00:00
|
|
|
|
2018-05-14 02:02:12 +00:00
|
|
|
import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione';
|
2017-11-04 05:19:45 +00:00
|
|
|
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
2018-02-10 11:19:43 +00:00
|
|
|
import {makeUserPermalink} from "../../../matrix-to";
|
2018-02-10 12:38:25 +00:00
|
|
|
import ReplyPreview from "./ReplyPreview";
|
2017-12-10 12:50:41 +00:00
|
|
|
import RoomViewStore from '../../../stores/RoomViewStore';
|
2018-03-04 12:39:34 +00:00
|
|
|
import ReplyThread from "../elements/ReplyThread";
|
2018-02-10 11:19:43 +00:00
|
|
|
import {ContentHelpers} from 'matrix-js-sdk';
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2017-07-13 16:37:43 +00:00
|
|
|
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
|
|
|
|
const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort();
|
2017-08-01 13:26:30 +00:00
|
|
|
const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$');
|
2018-05-14 02:02:12 +00:00
|
|
|
const EMOJI_REGEX = new RegExp(unicodeRegexp, 'g');
|
2017-07-13 16:37:43 +00:00
|
|
|
|
2016-06-11 10:22:08 +00:00
|
|
|
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
|
|
|
|
|
2017-11-06 17:15:09 +00:00
|
|
|
const ENTITY_TYPES = {
|
2017-11-06 22:01:23 +00:00
|
|
|
AT_ROOM_PILL: 'ATROOMPILL',
|
2017-11-06 17:15:09 +00:00
|
|
|
};
|
2017-11-06 15:11:42 +00:00
|
|
|
|
2018-05-19 19:38:07 +00:00
|
|
|
// the Slate node type to default to for unstyled text when in RTE mode.
|
|
|
|
// (we use 'line' for oneliners however)
|
|
|
|
const DEFAULT_NODE = 'paragraph';
|
|
|
|
|
2018-05-19 22:33:07 +00:00
|
|
|
// map HTML elements through to our Slate schema node types
|
|
|
|
// used for the HTML deserializer.
|
|
|
|
// (We don't use the same names so that they are closer to the MD serializer's schema)
|
|
|
|
const BLOCK_TAGS = {
|
|
|
|
p: 'paragraph',
|
|
|
|
blockquote: 'block-quote',
|
|
|
|
ul: 'bulleted-list',
|
|
|
|
h1: 'heading-one',
|
|
|
|
h2: 'heading-two',
|
|
|
|
h3: 'heading-three',
|
2018-05-19 22:34:30 +00:00
|
|
|
h4: 'heading-four,
|
|
|
|
h5: 'heading-five',
|
|
|
|
h6: 'heading-six',
|
2018-05-19 22:33:07 +00:00
|
|
|
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: 'underline',
|
|
|
|
del: 'strikethrough',
|
|
|
|
strike: 'strikethrough', // deprecated
|
|
|
|
s: 'strikethrough', // deprecated
|
|
|
|
};
|
2018-05-14 02:02:12 +00:00
|
|
|
|
2017-07-12 17:03:13 +00:00
|
|
|
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',
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2016-03-24 11:25:41 +00:00
|
|
|
/*
|
|
|
|
* The textInput part of the MessageComposer
|
|
|
|
*/
|
2016-05-28 06:28:22 +00:00
|
|
|
export default class MessageComposerInput extends React.Component {
|
2016-11-30 17:16:33 +00:00
|
|
|
static propTypes = {
|
|
|
|
// a callback which is called when the height of the composer is
|
|
|
|
// changed due to a change in content.
|
2017-12-26 01:03:18 +00:00
|
|
|
onResize: PropTypes.func,
|
2016-11-30 17:16:33 +00:00
|
|
|
|
|
|
|
// js-sdk Room object
|
2017-12-26 01:03:18 +00:00
|
|
|
room: PropTypes.object.isRequired,
|
2016-11-30 17:16:33 +00:00
|
|
|
|
|
|
|
// called with current plaintext content (as a string) whenever it changes
|
2017-12-26 01:03:18 +00:00
|
|
|
onContentChanged: PropTypes.func,
|
2016-11-30 17:16:33 +00:00
|
|
|
|
2018-05-13 21:43:20 +00:00
|
|
|
onFilesPasted: PropTypes.func,
|
|
|
|
|
2017-12-26 01:03:18 +00:00
|
|
|
onInputStateChanged: PropTypes.func,
|
2016-11-30 17:16:33 +00:00
|
|
|
};
|
|
|
|
|
2016-07-08 07:24:28 +00:00
|
|
|
client: MatrixClient;
|
2016-09-13 10:11:52 +00:00
|
|
|
autocomplete: Autocomplete;
|
2017-03-10 15:04:31 +00:00
|
|
|
historyManager: ComposerHistoryManager;
|
2016-07-08 07:24:28 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
constructor(props, context) {
|
|
|
|
super(props, context);
|
|
|
|
|
2017-10-29 07:43:52 +00:00
|
|
|
const isRichtextEnabled = SettingsStore.getValue('MessageComposerInput.isRichTextEnabled');
|
2016-06-14 18:43:34 +00:00
|
|
|
|
2017-08-09 18:00:38 +00:00
|
|
|
Analytics.setRichtextMode(isRichtextEnabled);
|
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
this.state = {
|
2016-09-13 10:11:52 +00:00
|
|
|
// whether we're in rich text or markdown mode
|
2016-09-05 12:08:53 +00:00
|
|
|
isRichtextEnabled,
|
2016-09-13 10:11:52 +00:00
|
|
|
|
|
|
|
// the currently displayed editor state (note: this is always what is modified on input)
|
2017-07-05 10:49:34 +00:00
|
|
|
editorState: this.createEditorState(
|
|
|
|
isRichtextEnabled,
|
2018-05-12 19:04:58 +00:00
|
|
|
MessageComposerStore.getEditorState(this.props.room.roomId),
|
2017-07-05 10:49:34 +00:00
|
|
|
),
|
2016-09-13 10:11:52 +00:00
|
|
|
|
|
|
|
// the original editor state, before we started tabbing through completions
|
|
|
|
originalEditorState: null,
|
2017-06-29 16:02:19 +00:00
|
|
|
|
|
|
|
// the virtual state "above" the history stack, the message currently being composed that
|
|
|
|
// we want to persist whilst browsing history
|
|
|
|
currentlyComposedEditorState: null,
|
2017-07-05 17:14:22 +00:00
|
|
|
|
|
|
|
// whether there were any completions
|
|
|
|
someCompletions: null,
|
2016-05-27 04:45:55 +00:00
|
|
|
};
|
2016-06-11 10:22:08 +00:00
|
|
|
|
2016-06-11 18:41:27 +00:00
|
|
|
this.client = MatrixClientPeg.get();
|
2018-05-12 19:04:58 +00:00
|
|
|
|
|
|
|
this.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' });
|
|
|
|
this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' });
|
|
|
|
this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' });
|
2018-05-17 01:13:17 +00:00
|
|
|
this.md = new Md();
|
2018-05-19 19:28:38 +00:00
|
|
|
this.html = new Html({
|
|
|
|
rules: [
|
|
|
|
{
|
2018-05-19 22:33:07 +00:00
|
|
|
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),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
2018-05-19 19:28:38 +00:00
|
|
|
serialize: (obj, children) => {
|
|
|
|
if (obj.object === 'block' || obj.object === 'inline') {
|
|
|
|
return this.renderNode({
|
|
|
|
node: obj,
|
2018-05-19 19:38:07 +00:00
|
|
|
children: children,
|
2018-05-19 19:28:38 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
else if (obj.object === 'mark') {
|
|
|
|
return this.renderMark({
|
|
|
|
mark: obj,
|
2018-05-19 19:38:07 +00:00
|
|
|
children: children,
|
2018-05-19 19:28:38 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
});
|
2018-05-12 23:40:54 +00:00
|
|
|
|
2018-05-13 02:16:55 +00:00
|
|
|
this.suppressAutoComplete = false;
|
2018-05-12 23:40:54 +00:00
|
|
|
this.direction = '';
|
2016-06-11 10:22:08 +00:00
|
|
|
}
|
|
|
|
|
2016-09-13 10:11:52 +00:00
|
|
|
/*
|
2018-04-23 00:13:18 +00:00
|
|
|
* "Does the right thing" to create an Editor value, based on:
|
2016-06-11 10:22:08 +00:00
|
|
|
* - whether we've got rich text mode enabled
|
|
|
|
* - contentState was passed in
|
|
|
|
*/
|
2018-05-17 01:13:17 +00:00
|
|
|
createEditorState(richText: boolean, editorState: ?Value): Value {
|
|
|
|
if (editorState instanceof Value) {
|
|
|
|
return editorState;
|
2018-05-05 23:18:11 +00:00
|
|
|
}
|
|
|
|
else {
|
2018-05-06 00:18:26 +00:00
|
|
|
// ...or create a new one.
|
2018-05-06 14:27:27 +00:00
|
|
|
return Plain.deserialize('')
|
2018-05-05 23:18:11 +00:00
|
|
|
}
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
componentDidMount() {
|
2016-03-24 11:25:41 +00:00
|
|
|
this.dispatcherRef = dis.register(this.onAction);
|
2017-03-10 15:04:31 +00:00
|
|
|
this.historyManager = new ComposerHistoryManager(this.props.room.roomId);
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
componentWillUnmount() {
|
2016-03-24 11:25:41 +00:00
|
|
|
dis.unregister(this.dispatcherRef);
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-09-05 12:08:53 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-02-09 20:03:06 +00:00
|
|
|
onAction = (payload) => {
|
|
|
|
const editor = this.refs.editor;
|
2018-05-05 23:18:11 +00:00
|
|
|
let editorState = this.state.editorState;
|
2016-06-11 10:22:08 +00:00
|
|
|
|
2016-03-24 11:25:41 +00:00
|
|
|
switch (payload.action) {
|
2018-02-19 23:41:07 +00:00
|
|
|
case 'reply_to_event':
|
2016-03-24 11:25:41 +00:00
|
|
|
case 'focus_composer':
|
2016-05-27 04:45:55 +00:00
|
|
|
editor.focus();
|
2016-03-24 11:25:41 +00:00
|
|
|
break;
|
2018-05-13 21:41:39 +00:00
|
|
|
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) ? ': ' : ' ',
|
|
|
|
});
|
|
|
|
}
|
2016-11-30 17:16:33 +00:00
|
|
|
break;
|
2018-05-13 21:41:39 +00:00
|
|
|
/*
|
2017-12-12 23:29:43 +00:00
|
|
|
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(`<blockquote>${body}</blockquote>`);
|
|
|
|
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;
|
2018-05-17 01:13:17 +00:00
|
|
|
*/
|
2016-03-24 11:25:41 +00:00
|
|
|
}
|
2016-11-30 17:16:33 +00:00
|
|
|
};
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
onTypingActivity() {
|
2016-03-24 11:25:41 +00:00
|
|
|
this.isTyping = true;
|
|
|
|
if (!this.userTypingTimer) {
|
|
|
|
this.sendTyping(true);
|
|
|
|
}
|
|
|
|
this.startUserTypingTimer();
|
|
|
|
this.startServerTypingTimer();
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
onFinishedTyping() {
|
2016-03-24 11:25:41 +00:00
|
|
|
this.isTyping = false;
|
|
|
|
this.sendTyping(false);
|
|
|
|
this.stopUserTypingTimer();
|
|
|
|
this.stopServerTypingTimer();
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
startUserTypingTimer() {
|
2016-03-24 11:25:41 +00:00
|
|
|
this.stopUserTypingTimer();
|
2017-02-09 20:03:06 +00:00
|
|
|
const self = this;
|
|
|
|
this.userTypingTimer = setTimeout(function() {
|
2016-03-24 11:25:41 +00:00
|
|
|
self.isTyping = false;
|
|
|
|
self.sendTyping(self.isTyping);
|
|
|
|
self.userTypingTimer = null;
|
|
|
|
}, TYPING_USER_TIMEOUT);
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
stopUserTypingTimer() {
|
2016-03-24 11:25:41 +00:00
|
|
|
if (this.userTypingTimer) {
|
|
|
|
clearTimeout(this.userTypingTimer);
|
|
|
|
this.userTypingTimer = null;
|
|
|
|
}
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
startServerTypingTimer() {
|
2016-03-24 11:25:41 +00:00
|
|
|
if (!this.serverTypingTimer) {
|
2017-02-09 20:03:06 +00:00
|
|
|
const self = this;
|
|
|
|
this.serverTypingTimer = setTimeout(function() {
|
2016-03-24 11:25:41 +00:00
|
|
|
if (self.isTyping) {
|
|
|
|
self.sendTyping(self.isTyping);
|
|
|
|
self.startServerTypingTimer();
|
|
|
|
}
|
|
|
|
}, TYPING_SERVER_TIMEOUT / 2);
|
|
|
|
}
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
stopServerTypingTimer() {
|
2016-03-24 11:25:41 +00:00
|
|
|
if (this.serverTypingTimer) {
|
2018-04-23 00:13:18 +00:00
|
|
|
clearTimeout(this.serverTypingTimer);
|
2016-03-24 11:25:41 +00:00
|
|
|
this.serverTypingTimer = null;
|
|
|
|
}
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
sendTyping(isTyping) {
|
2017-10-29 07:43:52 +00:00
|
|
|
if (SettingsStore.getValue('dontSendTypingNotifications')) return;
|
2016-03-24 11:25:41 +00:00
|
|
|
MatrixClientPeg.get().sendTyping(
|
|
|
|
this.props.room.roomId,
|
2017-02-09 20:03:06 +00:00
|
|
|
this.isTyping, TYPING_SERVER_TIMEOUT,
|
2016-03-24 11:25:41 +00:00
|
|
|
).done();
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
2016-03-24 11:25:41 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
refreshTyping() {
|
2016-03-24 11:25:41 +00:00
|
|
|
if (this.typingTimeout) {
|
|
|
|
clearTimeout(this.typingTimeout);
|
|
|
|
this.typingTimeout = null;
|
|
|
|
}
|
2016-05-27 04:45:55 +00:00
|
|
|
}
|
|
|
|
|
2018-05-15 00:16:06 +00:00
|
|
|
onChange = (change: Change, originalEditorState: value) => {
|
2018-05-12 23:40:54 +00:00
|
|
|
|
|
|
|
let editorState = change.value;
|
|
|
|
|
|
|
|
if (this.direction !== '') {
|
|
|
|
const focusedNode = editorState.focusInline || editorState.focusText;
|
|
|
|
if (focusedNode.isVoid) {
|
2018-05-14 02:02:12 +00:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
}
|
2018-05-12 23:40:54 +00:00
|
|
|
editorState = change.value;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-05-14 02:02:12 +00:00
|
|
|
if (!editorState.document.isEmpty) {
|
2018-05-13 22:34:00 +00:00
|
|
|
this.onTypingActivity();
|
|
|
|
} else {
|
|
|
|
this.onFinishedTyping();
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
// XXX: what was this ever doing?
|
|
|
|
if (!state.hasOwnProperty('originalEditorState')) {
|
|
|
|
state.originalEditorState = null;
|
|
|
|
}
|
|
|
|
*/
|
|
|
|
|
2018-05-14 02:02:12 +00:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
2016-09-13 10:11:52 +00:00
|
|
|
|
2018-05-17 01:13:17 +00:00
|
|
|
/*
|
2017-07-13 12:28:51 +00:00
|
|
|
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);
|
|
|
|
}
|
2018-05-13 22:34:00 +00:00
|
|
|
*/
|
2018-05-19 19:28:38 +00:00
|
|
|
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;
|
|
|
|
}
|
2017-07-13 16:37:43 +00:00
|
|
|
}
|
|
|
|
}
|
2018-05-13 22:34:00 +00:00
|
|
|
|
|
|
|
// 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,
|
|
|
|
});
|
|
|
|
|
2016-12-30 14:12:36 +00:00
|
|
|
/* Since a modification was made, set originalEditorState to null, since newState is now our original */
|
2016-09-13 10:11:52 +00:00
|
|
|
this.setState({
|
2018-05-12 23:40:54 +00:00
|
|
|
editorState,
|
2018-05-15 00:16:06 +00:00
|
|
|
originalEditorState: originalEditorState || null
|
2016-12-30 14:12:36 +00:00
|
|
|
});
|
|
|
|
};
|
2016-06-09 18:23:09 +00:00
|
|
|
|
2016-06-11 16:54:09 +00:00
|
|
|
enableRichtext(enabled: boolean) {
|
2017-03-10 15:04:31 +00:00
|
|
|
if (enabled === this.state.isRichtextEnabled) return;
|
|
|
|
|
2018-04-23 00:13:18 +00:00
|
|
|
// FIXME: this conversion should be handled in the store, surely
|
|
|
|
// i.e. "convert my current composer value into Rich or MD, as ComposerHistoryManager already does"
|
|
|
|
|
2018-05-17 01:13:17 +00:00
|
|
|
let editorState = null;
|
2016-06-11 21:51:18 +00:00
|
|
|
if (enabled) {
|
2018-05-19 19:28:38 +00:00
|
|
|
// for simplicity when roundtripping, we use slate-md-serializer rather than commonmark
|
|
|
|
editorState = this.md.deserialize(this.plainWithMdPills.serialize(this.state.editorState));
|
|
|
|
|
|
|
|
// the alternative would be something like:
|
|
|
|
//
|
2018-05-17 01:13:17 +00:00
|
|
|
// const sourceWithPills = this.plainWithMdPills.serialize(this.state.editorState);
|
|
|
|
// const markdown = new Markdown(sourceWithPills);
|
|
|
|
// editorState = this.html.deserialize(markdown.toHTML());
|
2018-04-23 00:13:18 +00:00
|
|
|
|
2016-06-11 16:54:09 +00:00
|
|
|
} else {
|
2018-04-23 00:13:18 +00:00
|
|
|
// let markdown = RichText.stateToMarkdown(this.state.editorState.getCurrentContent());
|
|
|
|
// value = ContentState.createFromText(markdown);
|
|
|
|
|
2018-05-17 01:13:17 +00:00
|
|
|
editorState = Plain.deserialize(this.md.serialize(this.state.editorState));
|
2016-06-11 16:54:09 +00:00
|
|
|
}
|
2016-06-11 21:13:57 +00:00
|
|
|
|
2017-08-09 17:39:06 +00:00
|
|
|
Analytics.setRichtextMode(enabled);
|
|
|
|
|
2016-12-30 14:12:36 +00:00
|
|
|
this.setState({
|
2018-05-17 01:13:17 +00:00
|
|
|
editorState: this.createEditorState(enabled, editorState),
|
2016-12-30 14:12:36 +00:00
|
|
|
isRichtextEnabled: enabled,
|
2016-09-13 11:16:20 +00:00
|
|
|
});
|
2017-11-04 05:19:45 +00:00
|
|
|
SettingsStore.setValue("MessageComposerInput.isRichTextEnabled", null, SettingLevel.ACCOUNT, enabled);
|
2018-05-17 01:13:17 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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)
|
|
|
|
};
|
2016-06-11 10:22:08 +00:00
|
|
|
|
2018-05-06 21:08:36 +00:00
|
|
|
onKeyDown = (ev: Event, change: Change, editor: Editor) => {
|
2018-05-12 23:40:54 +00:00
|
|
|
|
2018-05-13 02:16:55 +00:00
|
|
|
this.suppressAutoComplete = false;
|
|
|
|
|
2018-05-12 23:40:54 +00:00
|
|
|
// 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 = '';
|
|
|
|
}
|
|
|
|
|
2018-05-17 01:13:17 +00:00
|
|
|
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]: 'underline',
|
|
|
|
[KeyCode.KEY_J]: 'code',
|
|
|
|
}[ev.keyCode];
|
|
|
|
|
|
|
|
if (ctrlCmdCommand) {
|
|
|
|
return this.handleKeyCommand(ctrlCmdCommand);
|
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2018-05-08 00:54:06 +00:00
|
|
|
switch (ev.keyCode) {
|
|
|
|
case KeyCode.ENTER:
|
2018-05-19 20:36:22 +00:00
|
|
|
return this.handleReturn(ev, change);
|
2018-05-19 19:38:07 +00:00
|
|
|
case KeyCode.BACKSPACE:
|
2018-05-19 20:36:22 +00:00
|
|
|
return this.onBackspace(ev, change);
|
2018-05-08 00:54:06 +00:00
|
|
|
case KeyCode.UP:
|
|
|
|
return this.onVerticalArrow(ev, true);
|
|
|
|
case KeyCode.DOWN:
|
|
|
|
return this.onVerticalArrow(ev, false);
|
2018-05-12 00:10:38 +00:00
|
|
|
case KeyCode.TAB:
|
|
|
|
return this.onTab(ev);
|
|
|
|
case KeyCode.ESCAPE:
|
|
|
|
return this.onEscape(ev);
|
2018-05-08 00:54:06 +00:00
|
|
|
default:
|
|
|
|
// don't intercept it
|
|
|
|
return;
|
2018-05-06 21:08:36 +00:00
|
|
|
}
|
2018-05-17 01:13:17 +00:00
|
|
|
};
|
2018-05-06 21:08:36 +00:00
|
|
|
|
2018-05-19 20:36:22 +00:00
|
|
|
onBackspace = (ev: Event, change: Change): Change => {
|
2018-05-19 19:38:07 +00:00
|
|
|
if (this.state.isRichtextEnabled) {
|
|
|
|
// let backspace exit lists
|
|
|
|
const isList = this.hasBlock('list-item');
|
2018-05-19 20:14:39 +00:00
|
|
|
const { editorState } = this.state;
|
2018-05-19 20:36:22 +00:00
|
|
|
|
2018-05-19 20:14:39 +00:00
|
|
|
if (isList && editorState.anchorOffset == 0) {
|
2018-05-19 19:38:07 +00:00
|
|
|
change
|
|
|
|
.setBlocks(DEFAULT_NODE)
|
|
|
|
.unwrapBlock('bulleted-list')
|
|
|
|
.unwrapBlock('numbered-list');
|
2018-05-19 20:36:22 +00:00
|
|
|
return change;
|
|
|
|
}
|
|
|
|
else if (editorState.anchorOffset == 0 &&
|
|
|
|
(this.hasBlock('block-quote') ||
|
|
|
|
this.hasBlock('heading-one') ||
|
|
|
|
this.hasBlock('heading-two') ||
|
|
|
|
this.hasBlock('heading-three') ||
|
2018-05-19 22:34:30 +00:00
|
|
|
this.hasBlock('heading-four') ||
|
|
|
|
this.hasBlock('heading-five') ||
|
|
|
|
this.hasBlock('heading-six') ||
|
2018-05-19 20:36:22 +00:00
|
|
|
this.hasBlock('code-block')))
|
|
|
|
{
|
|
|
|
return change.setBlocks(DEFAULT_NODE);
|
2018-05-19 19:38:07 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
};
|
|
|
|
|
2016-11-30 17:16:33 +00:00
|
|
|
handleKeyCommand = (command: string): boolean => {
|
2016-07-08 07:24:28 +00:00
|
|
|
if (command === 'toggle-mode') {
|
2016-06-11 16:54:09 +00:00
|
|
|
this.enableRichtext(!this.state.isRichtextEnabled);
|
2016-06-11 10:22:08 +00:00
|
|
|
return true;
|
|
|
|
}
|
2018-05-16 23:01:23 +00:00
|
|
|
|
|
|
|
let newState: ?Value = null;
|
2016-06-11 16:54:09 +00:00
|
|
|
|
|
|
|
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
|
2016-09-07 17:22:14 +00:00
|
|
|
if (this.state.isRichtextEnabled) {
|
2018-05-17 01:13:17 +00:00
|
|
|
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);
|
|
|
|
}
|
2017-07-18 16:52:04 +00:00
|
|
|
}
|
2018-05-17 01:13:17 +00:00
|
|
|
break;
|
|
|
|
|
|
|
|
// simple blocks
|
|
|
|
case 'paragraph':
|
|
|
|
case 'block-quote':
|
|
|
|
case 'heading-one':
|
|
|
|
case 'heading-two':
|
|
|
|
case 'heading-three':
|
2018-05-19 22:34:30 +00:00
|
|
|
case 'heading-four':
|
|
|
|
case 'heading-five':
|
|
|
|
case 'heading-six':
|
2018-05-17 01:13:17 +00:00
|
|
|
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 'underline':
|
|
|
|
case 'strikethrough': {
|
|
|
|
change.toggleMark(type);
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
|
|
|
|
default:
|
|
|
|
console.warn(`ignoring unrecognised RTE command ${type}`);
|
|
|
|
return false;
|
2016-09-07 17:22:14 +00:00
|
|
|
}
|
2018-05-17 01:13:17 +00:00
|
|
|
|
|
|
|
this.onChange(change);
|
|
|
|
|
|
|
|
return true;
|
2016-09-07 17:22:14 +00:00
|
|
|
} else {
|
2018-05-17 01:13:17 +00:00
|
|
|
/*
|
2017-07-19 14:00:25 +00:00
|
|
|
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`;
|
2017-02-09 20:03:06 +00:00
|
|
|
const modifyFn = {
|
|
|
|
'bold': (text) => `**${text}**`,
|
|
|
|
'italic': (text) => `*${text}*`,
|
2017-07-06 12:49:13 +00:00
|
|
|
'underline': (text) => `<u>${text}</u>`,
|
2017-06-23 17:19:06 +00:00
|
|
|
'strike': (text) => `<del>${text}</del>`,
|
2017-07-19 14:00:25 +00:00
|
|
|
// ("code" is triggered by ctrl+j by draft-js by default)
|
|
|
|
'code': (text) => treatInlineCodeAsBlock ? textMdCodeBlock(text) : `\`${text}\``,
|
|
|
|
'code-block': textMdCodeBlock,
|
2017-07-03 14:23:24 +00:00
|
|
|
'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join('') + '\n',
|
2017-03-06 22:45:28 +00:00
|
|
|
'unordered-list-item': (text) => text.split('\n').map((line) => `\n- ${line}`).join(''),
|
|
|
|
'ordered-list-item': (text) => text.split('\n').map((line, i) => `\n${i + 1}. ${line}`).join(''),
|
2016-06-11 16:54:09 +00:00
|
|
|
}[command];
|
|
|
|
|
2017-07-03 14:23:24 +00:00
|
|
|
const selectionAfterOffset = {
|
|
|
|
'bold': -2,
|
|
|
|
'italic': -1,
|
2017-07-06 12:49:13 +00:00
|
|
|
'underline': -4,
|
2017-07-03 14:23:24 +00:00
|
|
|
'strike': -6,
|
2017-07-19 14:00:25 +00:00
|
|
|
'code': treatInlineCodeAsBlock ? -5 : -1,
|
2017-07-03 14:23:24 +00:00
|
|
|
'code-block': -5,
|
|
|
|
'blockquote': -2,
|
|
|
|
}[command];
|
|
|
|
|
2018-05-16 23:01:23 +00:00
|
|
|
// Returns a function that collapses a selection to its end and moves it by offset
|
|
|
|
const collapseAndOffsetSelection = (selection, offset) => {
|
|
|
|
const key = selection.endKey();
|
|
|
|
return new Range({
|
2017-07-03 14:23:24 +00:00
|
|
|
anchorKey: key, anchorOffset: offset,
|
|
|
|
focusKey: key, focusOffset: offset,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2016-07-08 07:24:28 +00:00
|
|
|
if (modifyFn) {
|
2018-05-16 23:01:23 +00:00
|
|
|
|
2017-07-03 14:23:24 +00:00
|
|
|
const previousSelection = this.state.editorState.getSelection();
|
|
|
|
const newContentState = RichText.modifyText(contentState, previousSelection, modifyFn);
|
2016-06-11 16:54:09 +00:00
|
|
|
newState = EditorState.push(
|
|
|
|
this.state.editorState,
|
2017-07-03 14:23:24 +00:00
|
|
|
newContentState,
|
2017-02-09 20:03:06 +00:00
|
|
|
'insert-characters',
|
2016-06-11 16:54:09 +00:00
|
|
|
);
|
2017-07-03 14:23:24 +00:00
|
|
|
|
|
|
|
let newSelection = newContentState.getSelectionAfter();
|
|
|
|
// If the selection range is 0, move the cursor inside the formatted body
|
|
|
|
if (previousSelection.getStartOffset() === previousSelection.getEndOffset() &&
|
|
|
|
previousSelection.getStartKey() === previousSelection.getEndKey() &&
|
|
|
|
selectionAfterOffset !== undefined
|
|
|
|
) {
|
|
|
|
const selectedBlock = newContentState.getBlockForKey(previousSelection.getAnchorKey());
|
|
|
|
const blockLength = selectedBlock.getText().length;
|
|
|
|
const newOffset = blockLength + selectionAfterOffset;
|
|
|
|
newSelection = collapseAndOffsetSelection(newSelection, newOffset);
|
|
|
|
}
|
|
|
|
|
|
|
|
newState = EditorState.forceSelection(newState, newSelection);
|
2016-06-11 16:54:09 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (newState != null) {
|
2016-12-30 14:12:36 +00:00
|
|
|
this.setState({editorState: newState});
|
2016-05-27 04:45:55 +00:00
|
|
|
return true;
|
|
|
|
}
|
2018-05-17 01:13:17 +00:00
|
|
|
*/
|
|
|
|
}
|
2016-05-27 04:45:55 +00:00
|
|
|
return false;
|
2017-12-10 12:50:41 +00:00
|
|
|
};
|
2018-05-19 21:05:31 +00:00
|
|
|
|
|
|
|
onPaste = (event: Event, change: Change, editor: Editor): Change => {
|
|
|
|
const transfer = getEventTransfer(event);
|
|
|
|
|
|
|
|
if (transfer.type === "files") {
|
|
|
|
return this.props.onFilesPasted(transfer.files);
|
|
|
|
}
|
2018-05-19 22:33:07 +00:00
|
|
|
else if (transfer.type === "html") {
|
|
|
|
const fragment = this.html.deserialize(transfer.html);
|
|
|
|
if (this.state.isRichtextEnabled) {
|
|
|
|
return change.insertFragment(fragment.document);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
return change.insertText(this.md.serialize(fragment));
|
|
|
|
}
|
2018-05-19 21:05:31 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2018-05-19 20:36:22 +00:00
|
|
|
handleReturn = (ev, change) => {
|
2016-07-03 16:45:13 +00:00
|
|
|
if (ev.shiftKey) {
|
2018-05-19 20:36:22 +00:00
|
|
|
|
2018-05-19 19:28:38 +00:00
|
|
|
// FIXME: we should insert a <br/> equivalent rather than letting Slate
|
|
|
|
// split the current block, otherwise <p> will be split into two paragraphs
|
|
|
|
// and it'll look like a double line-break.
|
2018-05-19 20:36:22 +00:00
|
|
|
return change.insertText('\n');
|
2017-03-10 15:04:31 +00:00
|
|
|
}
|
2018-05-17 01:13:17 +00:00
|
|
|
|
|
|
|
if (this.state.editorState.blocks.some(
|
2018-05-19 19:28:38 +00:00
|
|
|
block => ['code-block', 'block-quote', 'list-item'].includes(block.type)
|
2018-05-17 01:13:17 +00:00
|
|
|
)) {
|
|
|
|
// allow the user to terminate blocks by hitting return rather than sending a msg
|
|
|
|
return;
|
2016-07-03 16:45:13 +00:00
|
|
|
}
|
2018-05-17 01:13:17 +00:00
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
const editorState = this.state.editorState;
|
2016-09-23 17:50:25 +00:00
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
let contentText;
|
2018-04-23 00:13:18 +00:00
|
|
|
let contentHTML;
|
2016-06-11 10:22:08 +00:00
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
// only look for commands if the first block contains simple unformatted text
|
|
|
|
// i.e. no pills or rich-text formatting.
|
|
|
|
let cmd, commandText;
|
|
|
|
const firstChild = editorState.document.nodes.get(0);
|
|
|
|
const firstGrandChild = firstChild && firstChild.nodes.get(0);
|
|
|
|
if (firstChild && firstGrandChild &&
|
|
|
|
firstChild.object === 'block' && firstGrandChild.object === 'text' &&
|
2018-05-13 02:29:56 +00:00
|
|
|
firstGrandChild.text[0] === '/')
|
2018-05-12 19:04:58 +00:00
|
|
|
{
|
|
|
|
commandText = this.plainWithIdPills.serialize(editorState);
|
|
|
|
cmd = SlashCommands.processInput(this.props.room.roomId, commandText);
|
|
|
|
}
|
2018-05-08 00:54:06 +00:00
|
|
|
|
2016-06-11 18:41:27 +00:00
|
|
|
if (cmd) {
|
|
|
|
if (!cmd.error) {
|
2018-05-12 19:04:58 +00:00
|
|
|
this.historyManager.save(editorState, this.state.isRichtextEnabled ? 'rich' : 'markdown');
|
2016-06-11 18:41:27 +00:00
|
|
|
this.setState({
|
2017-02-09 20:03:06 +00:00
|
|
|
editorState: this.createEditorState(),
|
2016-06-11 18:41:27 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
if (cmd.promise) {
|
2018-05-13 02:26:22 +00:00
|
|
|
cmd.promise.then(()=>{
|
2016-06-11 18:41:27 +00:00
|
|
|
console.log("Command success.");
|
2018-05-13 02:26:22 +00:00
|
|
|
this.refs.editor.focus();
|
|
|
|
}, (err)=>{
|
2016-06-11 18:41:27 +00:00
|
|
|
console.error("Command failure: %s", err);
|
2017-02-09 20:03:06 +00:00
|
|
|
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
2017-08-10 12:49:11 +00:00
|
|
|
Modal.createTrackedDialog('Server error', '', ErrorDialog, {
|
2017-05-23 14:16:31 +00:00
|
|
|
title: _t("Server error"),
|
2017-06-01 14:44:56 +00:00
|
|
|
description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")),
|
2016-06-11 18:41:27 +00:00
|
|
|
});
|
|
|
|
});
|
2017-02-09 20:03:06 +00:00
|
|
|
} else if (cmd.error) {
|
2016-06-11 18:41:27 +00:00
|
|
|
console.error(cmd.error);
|
2017-02-09 20:03:06 +00:00
|
|
|
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
2017-08-10 14:21:01 +00:00
|
|
|
// TODO possibly track which command they ran (not its Arguments) here
|
|
|
|
Modal.createTrackedDialog('Command error', '', ErrorDialog, {
|
2017-05-23 14:16:31 +00:00
|
|
|
title: _t("Command error"),
|
2017-02-09 20:03:06 +00:00
|
|
|
description: cmd.error,
|
2016-06-11 18:41:27 +00:00
|
|
|
});
|
|
|
|
}
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2018-03-09 23:37:42 +00:00
|
|
|
const replyingToEv = RoomViewStore.getQuotingEvent();
|
|
|
|
const mustSendHTML = Boolean(replyingToEv);
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2016-09-05 12:08:53 +00:00
|
|
|
if (this.state.isRichtextEnabled) {
|
2017-06-29 17:08:57 +00:00
|
|
|
// We should only send HTML if any block is styled or contains inline style
|
|
|
|
let shouldSendHTML = false;
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2018-03-09 23:37:42 +00:00
|
|
|
if (mustSendHTML) shouldSendHTML = true;
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2017-07-13 12:27:49 +00:00
|
|
|
if (!shouldSendHTML) {
|
2018-05-19 19:28:38 +00:00
|
|
|
shouldSendHTML = !!editorState.document.findDescendant(node => {
|
|
|
|
// N.B. node.getMarks() might be private?
|
|
|
|
return ((node.object === 'block' && node.type !== 'line') ||
|
|
|
|
(node.object === 'inline') ||
|
|
|
|
(node.object === 'text' && node.getMarks().size > 0));
|
2017-07-13 12:27:49 +00:00
|
|
|
});
|
|
|
|
}
|
2018-05-19 19:28:38 +00:00
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
contentText = this.plainWithPlainPills.serialize(editorState);
|
|
|
|
if (contentText === '') return true;
|
|
|
|
|
2017-06-29 17:08:57 +00:00
|
|
|
if (shouldSendHTML) {
|
2018-05-19 19:28:38 +00:00
|
|
|
contentHTML = this.html.serialize(editorState); // HtmlUtils.processHtmlForSending();
|
2017-06-29 17:08:57 +00:00
|
|
|
}
|
2016-06-11 10:22:08 +00:00
|
|
|
} else {
|
2018-05-12 19:04:58 +00:00
|
|
|
const sourceWithPills = this.plainWithMdPills.serialize(editorState);
|
|
|
|
if (sourceWithPills === '') return true;
|
2018-05-12 15:21:36 +00:00
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
const mdWithPills = new Markdown(sourceWithPills);
|
2017-07-27 17:07:41 +00:00
|
|
|
|
2017-12-10 12:50:41 +00:00
|
|
|
// if contains no HTML and we're not quoting (needing HTML)
|
2018-05-12 19:04:58 +00:00
|
|
|
if (mdWithPills.isPlainText() && !mustSendHTML) {
|
|
|
|
// N.B. toPlainText is only usable here because we know that the MD
|
|
|
|
// didn't contain any formatting in the first place...
|
|
|
|
contentText = mdWithPills.toPlaintext();
|
2016-12-02 18:58:35 +00:00
|
|
|
} else {
|
2018-05-12 19:04:58 +00:00
|
|
|
// to avoid ugliness clients which can't parse HTML we don't send pills
|
|
|
|
// in the plaintext body.
|
|
|
|
contentText = this.plainWithPlainPills.serialize(editorState);
|
|
|
|
contentHTML = mdWithPills.toHTML();
|
2016-09-23 17:50:25 +00:00
|
|
|
}
|
2016-06-11 10:22:08 +00:00
|
|
|
}
|
2016-05-27 04:45:55 +00:00
|
|
|
|
2018-02-10 11:19:43 +00:00
|
|
|
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
|
|
|
|
let sendTextFn = ContentHelpers.makeTextMessage;
|
2016-06-11 18:41:27 +00:00
|
|
|
|
2017-07-20 17:01:39 +00:00
|
|
|
this.historyManager.save(
|
2018-05-12 19:04:58 +00:00
|
|
|
editorState,
|
2018-04-23 00:13:18 +00:00
|
|
|
this.state.isRichtextEnabled ? 'rich' : 'markdown',
|
2017-07-20 17:01:39 +00:00
|
|
|
);
|
2017-03-10 15:04:31 +00:00
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
if (commandText && commandText.startsWith('/me')) {
|
2018-03-04 12:39:34 +00:00
|
|
|
if (replyingToEv) {
|
|
|
|
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
|
|
|
Modal.createTrackedDialog('Emote Reply Fail', '', ErrorDialog, {
|
|
|
|
title: _t("Unable to reply"),
|
|
|
|
description: _t("At this time it is not possible to reply with an emote."),
|
|
|
|
});
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2017-07-03 14:47:03 +00:00
|
|
|
contentText = contentText.substring(4);
|
|
|
|
// bit of a hack, but the alternative would be quite complicated
|
|
|
|
if (contentHTML) contentHTML = contentHTML.replace(/\/me ?/, '');
|
2018-02-10 11:19:43 +00:00
|
|
|
sendHtmlFn = ContentHelpers.makeHtmlEmote;
|
|
|
|
sendTextFn = ContentHelpers.makeEmoteMessage;
|
2017-07-03 14:47:03 +00:00
|
|
|
}
|
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
let content = contentHTML ?
|
|
|
|
sendHtmlFn(contentText, contentHTML) :
|
|
|
|
sendTextFn(contentText);
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2018-03-04 12:39:34 +00:00
|
|
|
if (replyingToEv) {
|
2018-04-27 10:47:18 +00:00
|
|
|
const replyContent = ReplyThread.makeReplyMixIn(replyingToEv);
|
2018-03-04 12:39:34 +00:00
|
|
|
content = Object.assign(replyContent, content);
|
2017-12-10 12:50:41 +00:00
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
// Part of Replies fallback support - prepend the text we're sending
|
|
|
|
// with the text we're replying to
|
2018-03-04 12:39:34 +00:00
|
|
|
const nestedReply = ReplyThread.getNestedReplyText(replyingToEv);
|
|
|
|
if (nestedReply) {
|
|
|
|
if (content.formatted_body) {
|
|
|
|
content.formatted_body = nestedReply.html + content.formatted_body;
|
|
|
|
}
|
|
|
|
content.body = nestedReply.body + content.body;
|
|
|
|
}
|
2018-04-07 11:18:53 +00:00
|
|
|
|
|
|
|
// Clear reply_to_event as we put the message into the queue
|
|
|
|
// if the send fails, retry will handle resending.
|
2017-12-10 12:50:41 +00:00
|
|
|
dis.dispatch({
|
2018-04-07 11:18:53 +00:00
|
|
|
action: 'reply_to_event',
|
2017-12-10 12:50:41 +00:00
|
|
|
event: null,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-05-01 17:14:35 +00:00
|
|
|
this.client.sendMessage(this.props.room.roomId, content).then((res) => {
|
2016-06-11 10:22:08 +00:00
|
|
|
dis.dispatch({
|
2016-09-16 15:02:08 +00:00
|
|
|
action: 'message_sent',
|
2016-06-11 10:22:08 +00:00
|
|
|
});
|
2018-05-01 17:14:35 +00:00
|
|
|
}).catch((e) => {
|
|
|
|
onSendMessageFailed(e, this.props.room);
|
|
|
|
});
|
2016-05-27 04:45:55 +00:00
|
|
|
|
|
|
|
this.setState({
|
2016-09-16 15:02:08 +00:00
|
|
|
editorState: this.createEditorState(),
|
2018-05-06 21:08:36 +00:00
|
|
|
}, ()=>{ this.refs.editor.focus() });
|
2016-05-27 04:45:55 +00:00
|
|
|
|
|
|
|
return true;
|
2018-04-23 00:13:18 +00:00
|
|
|
};
|
2016-05-27 04:45:55 +00:00
|
|
|
|
2017-06-29 14:07:06 +00:00
|
|
|
onVerticalArrow = (e, up) => {
|
2017-07-05 09:24:55 +00:00
|
|
|
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2017-06-29 14:07:06 +00:00
|
|
|
// Select history only if we are not currently auto-completing
|
|
|
|
if (this.autocomplete.state.completionList.length === 0) {
|
2017-06-30 13:27:26 +00:00
|
|
|
|
2018-05-08 00:54:06 +00:00
|
|
|
// determine whether our cursor is at the top or bottom of the multiline
|
|
|
|
// input box by just looking at the position of the plain old DOM selection.
|
|
|
|
const selection = window.getSelection();
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
const cursorRect = range.getBoundingClientRect();
|
|
|
|
|
|
|
|
const editorNode = ReactDOM.findDOMNode(this.refs.editor);
|
|
|
|
const editorRect = editorNode.getBoundingClientRect();
|
|
|
|
|
2018-05-12 23:54:01 +00:00
|
|
|
// heuristic to handle tall emoji, pills, etc pushing the cursor away from the top
|
|
|
|
// or bottom of the page.
|
|
|
|
// XXX: is this going to break on large inline images or top-to-bottom scripts?
|
|
|
|
const EDGE_THRESHOLD = 8;
|
|
|
|
|
2018-05-08 00:54:06 +00:00
|
|
|
let navigateHistory = false;
|
|
|
|
if (up) {
|
2018-05-12 00:10:38 +00:00
|
|
|
const scrollCorrection = editorNode.scrollTop;
|
|
|
|
const distanceFromTop = cursorRect.top - editorRect.top + scrollCorrection;
|
2018-05-12 23:54:01 +00:00
|
|
|
console.log(`Cursor distance from editor top is ${distanceFromTop}`);
|
|
|
|
if (distanceFromTop < EDGE_THRESHOLD) {
|
2018-05-08 00:54:06 +00:00
|
|
|
navigateHistory = true;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else {
|
2018-05-12 00:10:38 +00:00
|
|
|
const scrollCorrection =
|
2018-05-08 00:54:06 +00:00
|
|
|
editorNode.scrollHeight - editorNode.clientHeight - editorNode.scrollTop;
|
2018-05-13 20:17:43 +00:00
|
|
|
const distanceFromBottom = editorRect.bottom - cursorRect.bottom + scrollCorrection;
|
2018-05-12 23:54:01 +00:00
|
|
|
console.log(`Cursor distance from editor bottom is ${distanceFromBottom}`);
|
|
|
|
if (distanceFromBottom < EDGE_THRESHOLD) {
|
2018-05-08 00:54:06 +00:00
|
|
|
navigateHistory = true;
|
|
|
|
}
|
2017-06-30 13:27:26 +00:00
|
|
|
}
|
|
|
|
|
2018-05-08 00:54:06 +00:00
|
|
|
if (!navigateHistory) return;
|
2017-06-30 13:27:26 +00:00
|
|
|
|
|
|
|
const selected = this.selectHistory(up);
|
|
|
|
if (selected) {
|
|
|
|
// We're selecting history, so prevent the key event from doing anything else
|
|
|
|
e.preventDefault();
|
|
|
|
}
|
2017-06-29 14:07:06 +00:00
|
|
|
} else {
|
2017-06-30 13:27:26 +00:00
|
|
|
this.moveAutocompleteSelection(up);
|
2017-08-14 14:16:13 +00:00
|
|
|
e.preventDefault();
|
2017-06-29 14:07:06 +00:00
|
|
|
}
|
2017-06-28 14:20:16 +00:00
|
|
|
};
|
|
|
|
|
2017-06-29 14:07:06 +00:00
|
|
|
selectHistory = async (up) => {
|
|
|
|
const delta = up ? -1 : 1;
|
|
|
|
|
2017-06-29 16:02:19 +00:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2018-05-08 00:54:06 +00:00
|
|
|
let editorState = this.historyManager.getItem(delta, this.state.isRichtextEnabled ? 'rich' : 'markdown');
|
2017-06-29 14:07:06 +00:00
|
|
|
|
|
|
|
// Move selection to the end of the selected history
|
2018-05-08 00:54:06 +00:00
|
|
|
const change = editorState.change().collapseToEndOf(editorState.document);
|
2018-05-14 02:02:12 +00:00
|
|
|
|
2018-05-08 00:54:06 +00:00
|
|
|
// XXX: should we be calling this.onChange(change) now?
|
2018-05-14 02:02:12 +00:00
|
|
|
// Answer: yes, if we want it to do any of the fixups on stuff like emoji.
|
|
|
|
// however, this should already have been done and persisted in the history,
|
|
|
|
// so shouldn't be necessary.
|
|
|
|
|
2018-05-08 00:54:06 +00:00
|
|
|
editorState = change.value;
|
2017-06-29 14:07:06 +00:00
|
|
|
|
2018-05-13 02:16:55 +00:00
|
|
|
this.suppressAutoComplete = true;
|
|
|
|
|
2018-05-08 00:54:06 +00:00
|
|
|
this.setState({ editorState }, ()=>{
|
|
|
|
this.refs.editor.focus();
|
|
|
|
});
|
2017-06-29 14:07:06 +00:00
|
|
|
return true;
|
2016-11-30 17:16:33 +00:00
|
|
|
};
|
2016-09-13 10:11:52 +00:00
|
|
|
|
2017-02-09 20:03:06 +00:00
|
|
|
onTab = async (e) => {
|
2017-07-05 17:14:22 +00:00
|
|
|
this.setState({
|
|
|
|
someCompletions: null,
|
|
|
|
});
|
2017-06-29 14:07:06 +00:00
|
|
|
e.preventDefault();
|
2017-02-09 20:36:06 +00:00
|
|
|
if (this.autocomplete.state.completionList.length === 0) {
|
2017-06-29 14:07:06 +00:00
|
|
|
// Force completions to show for the text currently entered
|
2017-07-05 17:14:22 +00:00
|
|
|
const completionCount = await this.autocomplete.forceComplete();
|
|
|
|
this.setState({
|
|
|
|
someCompletions: completionCount > 0,
|
|
|
|
});
|
2017-06-29 14:07:06 +00:00
|
|
|
// Select the first item by moving "down"
|
|
|
|
await this.moveAutocompleteSelection(false);
|
2017-02-09 20:36:06 +00:00
|
|
|
} else {
|
2017-06-29 14:07:06 +00:00
|
|
|
await this.moveAutocompleteSelection(e.shiftKey);
|
2016-06-21 13:03:39 +00:00
|
|
|
}
|
2016-11-30 17:16:33 +00:00
|
|
|
};
|
2016-06-21 13:03:39 +00:00
|
|
|
|
2017-06-29 14:07:06 +00:00
|
|
|
moveAutocompleteSelection = (up) => {
|
2017-08-23 15:22:14 +00:00
|
|
|
up ? this.autocomplete.onUpArrow() : this.autocomplete.onDownArrow();
|
2017-06-29 14:07:06 +00:00
|
|
|
};
|
|
|
|
|
2017-02-09 22:10:57 +00:00
|
|
|
onEscape = async (e) => {
|
2016-09-13 10:11:52 +00:00
|
|
|
e.preventDefault();
|
|
|
|
if (this.autocomplete) {
|
|
|
|
this.autocomplete.onEscape(e);
|
2016-06-21 13:03:39 +00:00
|
|
|
}
|
2017-02-09 22:10:57 +00:00
|
|
|
await this.setDisplayedCompletion(null); // restore originalEditorState
|
2016-11-30 17:16:33 +00:00
|
|
|
};
|
2016-06-21 13:03:39 +00:00
|
|
|
|
2018-05-17 01:13:17 +00:00
|
|
|
/* returns inline style and block type of current SelectionState so MessageComposer can render formatting
|
|
|
|
buttons. */
|
|
|
|
getSelectionInfo(editorState: Value) {
|
|
|
|
return {
|
|
|
|
marks: editorState.activeMarks,
|
|
|
|
// XXX: shouldn't we return all the types of blocks in the current selection,
|
|
|
|
// not just the anchor?
|
2018-05-19 19:28:38 +00:00
|
|
|
blockType: editorState.anchorBlock ? editorState.anchorBlock.type : null,
|
2018-05-17 01:13:17 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2016-09-13 10:11:52 +00:00
|
|
|
/* If passed null, restores the original editor content from state.originalEditorState.
|
|
|
|
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
|
|
|
|
*/
|
2016-11-30 17:16:33 +00:00
|
|
|
setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => {
|
2016-09-13 10:11:52 +00:00
|
|
|
const activeEditorState = this.state.originalEditorState || this.state.editorState;
|
|
|
|
|
|
|
|
if (displayedCompletion == null) {
|
|
|
|
if (this.state.originalEditorState) {
|
2017-02-09 22:10:57 +00:00
|
|
|
let editorState = this.state.originalEditorState;
|
|
|
|
this.setState({editorState});
|
2016-09-13 10:11:52 +00:00
|
|
|
}
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2018-05-12 19:04:58 +00:00
|
|
|
const {
|
|
|
|
range = null,
|
|
|
|
completion = '',
|
|
|
|
completionId = '',
|
|
|
|
href = null,
|
|
|
|
suffix = ''
|
|
|
|
} = displayedCompletion;
|
2017-07-20 15:46:53 +00:00
|
|
|
|
2018-05-12 00:10:38 +00:00
|
|
|
let inline;
|
2017-07-20 14:09:59 +00:00
|
|
|
if (href) {
|
2018-05-12 00:10:38 +00:00
|
|
|
inline = Inline.create({
|
|
|
|
type: 'pill',
|
2018-05-12 23:40:54 +00:00
|
|
|
data: { completion, completionId, url: href },
|
|
|
|
// we can't put text in here otherwise the editor tries to select it
|
|
|
|
isVoid: true,
|
2017-07-24 13:41:13 +00:00
|
|
|
});
|
2017-11-06 15:11:42 +00:00
|
|
|
} else if (completion === '@room') {
|
2018-05-12 00:10:38 +00:00
|
|
|
inline = Inline.create({
|
|
|
|
type: 'pill',
|
2018-05-12 23:40:54 +00:00
|
|
|
data: { completion, completionId },
|
|
|
|
// we can't put text in here otherwise the editor tries to select it
|
|
|
|
isVoid: true,
|
|
|
|
});
|
2017-07-17 14:53:29 +00:00
|
|
|
}
|
2016-09-13 10:11:52 +00:00
|
|
|
|
2018-05-12 00:10:38 +00:00
|
|
|
let editorState = activeEditorState;
|
|
|
|
|
|
|
|
if (range) {
|
2018-05-13 02:16:55 +00:00
|
|
|
const change = editorState.change()
|
|
|
|
.collapseToAnchor()
|
|
|
|
.moveOffsetsTo(range.start, range.end);
|
2018-05-12 00:10:38 +00:00
|
|
|
editorState = change.value;
|
|
|
|
}
|
|
|
|
|
2018-05-14 02:02:12 +00:00
|
|
|
let change;
|
|
|
|
if (inline) {
|
|
|
|
change = editorState.change()
|
|
|
|
.insertInlineAtRange(editorState.selection, inline)
|
|
|
|
.insertText(suffix);
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
change = editorState.change()
|
|
|
|
.insertTextAtRange(editorState.selection, completion)
|
|
|
|
.insertText(suffix);
|
|
|
|
}
|
2018-05-12 00:10:38 +00:00
|
|
|
editorState = change.value;
|
|
|
|
|
2018-05-15 00:16:06 +00:00
|
|
|
this.onChange(change, activeEditorState);
|
2018-05-12 00:10:38 +00:00
|
|
|
|
2016-09-13 10:11:52 +00:00
|
|
|
return true;
|
2016-11-30 17:16:33 +00:00
|
|
|
};
|
2016-07-03 16:45:13 +00:00
|
|
|
|
2018-05-12 00:10:38 +00:00
|
|
|
renderNode = props => {
|
|
|
|
const { attributes, children, node, isSelected } = props;
|
|
|
|
|
|
|
|
switch (node.type) {
|
2018-05-19 19:28:38 +00:00
|
|
|
case 'line':
|
|
|
|
// ideally we'd return { children }<br/>, but as this isn't
|
|
|
|
// a valid react component, we don't have much choice.
|
|
|
|
return <div {...attributes}>{children}</div>;
|
2018-05-17 01:13:17 +00:00
|
|
|
case 'paragraph':
|
|
|
|
return <p {...attributes}>{children}</p>;
|
|
|
|
case 'block-quote':
|
|
|
|
return <blockquote {...attributes}>{children}</blockquote>;
|
|
|
|
case 'bulleted-list':
|
|
|
|
return <ul {...attributes}>{children}</ul>;
|
|
|
|
case 'heading-one':
|
|
|
|
return <h1 {...attributes}>{children}</h1>;
|
|
|
|
case 'heading-two':
|
|
|
|
return <h2 {...attributes}>{children}</h2>;
|
|
|
|
case 'heading-three':
|
|
|
|
return <h3 {...attributes}>{children}</h3>;
|
2018-05-19 22:34:30 +00:00
|
|
|
case 'heading-four':
|
|
|
|
return <h4 {...attributes}>{children}</h4>;
|
|
|
|
case 'heading-five':
|
|
|
|
return <h5 {...attributes}>{children}</h5>;
|
|
|
|
case 'heading-six':
|
|
|
|
return <h6 {...attributes}>{children}</h6>;
|
2018-05-17 01:13:17 +00:00
|
|
|
case 'list-item':
|
|
|
|
return <li {...attributes}>{children}</li>;
|
|
|
|
case 'numbered-list':
|
|
|
|
return <ol {...attributes}>{children}</ol>;
|
|
|
|
case 'code-block':
|
2018-05-19 19:28:38 +00:00
|
|
|
return <pre {...attributes}><code {...attributes}>{children}</code></pre>;
|
2018-05-12 00:10:38 +00:00
|
|
|
case 'pill': {
|
2018-05-14 02:02:12 +00:00
|
|
|
const { data } = node;
|
2018-05-12 00:10:38 +00:00
|
|
|
const url = data.get('url');
|
2018-05-12 23:40:54 +00:00
|
|
|
const completion = data.get('completion');
|
2018-05-12 00:10:38 +00:00
|
|
|
|
|
|
|
const shouldShowPillAvatar = !SettingsStore.getValue("Pill.shouldHidePillAvatar");
|
|
|
|
const Pill = sdk.getComponent('elements.Pill');
|
|
|
|
|
2018-05-12 23:40:54 +00:00
|
|
|
if (completion === '@room') {
|
2018-05-12 00:10:38 +00:00
|
|
|
return <Pill
|
|
|
|
type={Pill.TYPE_AT_ROOM_MENTION}
|
|
|
|
room={this.props.room}
|
|
|
|
shouldShowPillAvatar={shouldShowPillAvatar}
|
2018-05-14 02:02:12 +00:00
|
|
|
isSelected={isSelected}
|
2018-05-12 00:10:38 +00:00
|
|
|
/>;
|
|
|
|
}
|
|
|
|
else if (Pill.isPillUrl(url)) {
|
|
|
|
return <Pill
|
|
|
|
url={url}
|
|
|
|
room={this.props.room}
|
|
|
|
shouldShowPillAvatar={shouldShowPillAvatar}
|
2018-05-14 02:02:12 +00:00
|
|
|
isSelected={isSelected}
|
2018-05-12 00:10:38 +00:00
|
|
|
/>;
|
|
|
|
}
|
|
|
|
else {
|
2018-05-14 02:02:12 +00:00
|
|
|
const { text } = node;
|
2018-05-12 00:10:38 +00:00
|
|
|
return <a href={url} {...props.attributes}>
|
|
|
|
{ text }
|
|
|
|
</a>;
|
|
|
|
}
|
|
|
|
}
|
2018-05-14 02:02:12 +00:00
|
|
|
case 'emoji': {
|
|
|
|
const { data } = node;
|
|
|
|
const emojiUnicode = data.get('emojiUnicode');
|
|
|
|
const uri = RichText.unicodeToEmojiUri(emojiUnicode);
|
|
|
|
const shortname = toShort(emojiUnicode);
|
|
|
|
const className = classNames('mx_emojione', {
|
|
|
|
mx_emojione_selected: isSelected
|
|
|
|
});
|
|
|
|
return <img className={ className } src={ uri } title={ shortname } alt={ emojiUnicode }/>;
|
|
|
|
}
|
2018-05-12 00:10:38 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2018-05-17 01:13:17 +00:00
|
|
|
renderMark = props => {
|
|
|
|
const { children, mark, attributes } = props;
|
|
|
|
switch (mark.type) {
|
|
|
|
case 'bold':
|
2018-05-19 19:28:38 +00:00
|
|
|
return <strong {...attributes}>{children}</strong>;
|
2018-05-17 01:13:17 +00:00
|
|
|
case 'italic':
|
2018-05-19 19:28:38 +00:00
|
|
|
return <em {...attributes}>{children}</em>;
|
2018-05-17 01:13:17 +00:00
|
|
|
case 'code':
|
2018-05-19 19:28:38 +00:00
|
|
|
return <code {...attributes}>{children}</code>;
|
2018-05-17 01:13:17 +00:00
|
|
|
case 'underline':
|
2018-05-19 19:28:38 +00:00
|
|
|
return <u {...attributes}>{children}</u>;
|
2018-05-17 01:13:17 +00:00
|
|
|
case 'strikethrough':
|
2018-05-19 19:28:38 +00:00
|
|
|
return <del {...attributes}>{children}</del>;
|
2018-05-17 01:13:17 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2018-04-23 00:13:18 +00:00
|
|
|
onFormatButtonClicked = (name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) => {
|
2016-09-07 21:16:56 +00:00
|
|
|
e.preventDefault(); // don't steal focus from the editor!
|
2018-05-12 00:10:38 +00:00
|
|
|
|
2016-09-07 17:22:14 +00:00
|
|
|
const command = {
|
2018-05-19 20:36:22 +00:00
|
|
|
// code: 'code-block', // let's have the button do inline code for now
|
2018-05-17 01:13:17 +00:00
|
|
|
quote: 'block-quote',
|
|
|
|
bullet: 'bulleted-list',
|
|
|
|
numbullet: 'numbered-list',
|
2018-05-19 20:36:22 +00:00
|
|
|
strike: 'strikethrough',
|
2016-11-30 17:16:33 +00:00
|
|
|
}[name] || name;
|
2016-09-07 17:22:14 +00:00
|
|
|
this.handleKeyCommand(command);
|
2018-04-23 00:13:18 +00:00
|
|
|
};
|
2016-09-04 15:33:40 +00:00
|
|
|
|
2018-05-12 00:10:38 +00:00
|
|
|
getAutocompleteQuery(editorState: Value) {
|
|
|
|
// We can just return the current block where the selection begins, which
|
|
|
|
// should be enough to capture any autocompletion input, given autocompletion
|
|
|
|
// providers only search for the first match which intersects with the current selection.
|
|
|
|
// This avoids us having to serialize the whole thing to plaintext and convert
|
|
|
|
// selection offsets in & out of the plaintext domain.
|
2018-05-13 02:16:55 +00:00
|
|
|
|
2018-05-19 19:28:38 +00:00
|
|
|
if (editorState.selection.anchorKey) {
|
|
|
|
return editorState.document.getDescendant(editorState.selection.anchorKey).text;
|
|
|
|
}
|
|
|
|
else {
|
|
|
|
return '';
|
|
|
|
}
|
2017-07-27 14:18:06 +00:00
|
|
|
}
|
|
|
|
|
2018-05-12 00:10:38 +00:00
|
|
|
getSelectionRange(editorState: Value) {
|
2018-05-13 02:16:55 +00:00
|
|
|
let beginning = false;
|
|
|
|
const query = this.getAutocompleteQuery(editorState);
|
|
|
|
const firstChild = editorState.document.nodes.get(0);
|
|
|
|
const firstGrandChild = firstChild && firstChild.nodes.get(0);
|
|
|
|
beginning = (firstChild && firstGrandChild &&
|
|
|
|
firstChild.object === 'block' && firstGrandChild.object === 'text' &&
|
|
|
|
editorState.selection.anchorKey === firstGrandChild.key);
|
|
|
|
|
2018-05-12 00:10:38 +00:00
|
|
|
// return a character range suitable for handing to an autocomplete provider.
|
|
|
|
// the range is relative to the anchor of the current editor selection.
|
|
|
|
// if the selection spans multiple blocks, then we collapse it for the calculation.
|
|
|
|
const range = {
|
2018-05-13 02:16:55 +00:00
|
|
|
beginning, // whether the selection is in the first block of the editor or not
|
2018-05-12 00:10:38 +00:00
|
|
|
start: editorState.selection.anchorOffset,
|
2018-05-17 01:13:17 +00:00
|
|
|
end: (editorState.selection.anchorKey == editorState.selection.focusKey) ?
|
2018-05-12 00:10:38 +00:00
|
|
|
editorState.selection.focusOffset : editorState.selection.anchorOffset,
|
|
|
|
}
|
|
|
|
if (range.start > range.end) {
|
|
|
|
const tmp = range.start;
|
|
|
|
range.start = range.end;
|
|
|
|
range.end = tmp;
|
|
|
|
}
|
|
|
|
return range;
|
|
|
|
}
|
|
|
|
|
2017-02-09 20:03:06 +00:00
|
|
|
onMarkdownToggleClicked = (e) => {
|
2016-09-07 21:16:56 +00:00
|
|
|
e.preventDefault(); // don't steal focus from the editor!
|
|
|
|
this.handleKeyCommand('toggle-mode');
|
2016-11-30 17:16:33 +00:00
|
|
|
};
|
2016-09-07 17:22:14 +00:00
|
|
|
|
2016-05-27 04:45:55 +00:00
|
|
|
render() {
|
2016-09-13 10:11:52 +00:00
|
|
|
const activeEditorState = this.state.originalEditorState || this.state.editorState;
|
2016-06-11 10:22:08 +00:00
|
|
|
|
2016-09-04 15:33:40 +00:00
|
|
|
const className = classNames('mx_MessageComposer_input', {
|
2017-07-05 17:14:22 +00:00
|
|
|
mx_MessageComposer_input_error: this.state.someCompletions === false,
|
2016-09-04 15:33:40 +00:00
|
|
|
});
|
|
|
|
|
2016-03-24 11:25:41 +00:00
|
|
|
return (
|
2016-09-13 10:11:52 +00:00
|
|
|
<div className="mx_MessageComposer_input_wrapper">
|
|
|
|
<div className="mx_MessageComposer_autocomplete_wrapper">
|
2018-02-10 12:38:25 +00:00
|
|
|
{ SettingsStore.isFeatureEnabled("feature_rich_quoting") && <ReplyPreview /> }
|
2016-09-13 10:11:52 +00:00
|
|
|
<Autocomplete
|
|
|
|
ref={(e) => this.autocomplete = e}
|
2017-11-02 17:51:08 +00:00
|
|
|
room={this.props.room}
|
2016-09-13 10:11:52 +00:00
|
|
|
onConfirm={this.setDisplayedCompletion}
|
2017-08-23 15:22:14 +00:00
|
|
|
onSelectionChange={this.setDisplayedCompletion}
|
2018-05-13 02:16:55 +00:00
|
|
|
query={ this.suppressAutoComplete ? '' : this.getAutocompleteQuery(activeEditorState) }
|
2018-05-12 00:10:38 +00:00
|
|
|
selection={this.getSelectionRange(activeEditorState)}
|
2017-11-02 17:51:08 +00:00
|
|
|
/>
|
2016-09-13 10:11:52 +00:00
|
|
|
</div>
|
|
|
|
<div className={className}>
|
2017-01-20 21:00:22 +00:00
|
|
|
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"
|
2016-09-13 10:11:52 +00:00
|
|
|
onMouseDown={this.onMarkdownToggleClicked}
|
2017-10-11 16:56:17 +00:00
|
|
|
title={this.state.isRichtextEnabled ? _t("Markdown is disabled") : _t("Markdown is enabled")}
|
2016-09-13 10:11:52 +00:00
|
|
|
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
|
|
|
|
<Editor ref="editor"
|
2017-06-03 15:52:45 +00:00
|
|
|
dir="auto"
|
2018-05-06 00:18:26 +00:00
|
|
|
className="mx_MessageComposer_editor"
|
2017-02-21 15:33:44 +00:00
|
|
|
placeholder={this.props.placeholder}
|
2018-05-05 23:18:11 +00:00
|
|
|
value={this.state.editorState}
|
2018-05-08 00:54:06 +00:00
|
|
|
onChange={this.onChange}
|
2018-05-06 21:08:36 +00:00
|
|
|
onKeyDown={this.onKeyDown}
|
2018-05-19 21:05:31 +00:00
|
|
|
onPaste={this.onPaste}
|
2018-05-12 00:10:38 +00:00
|
|
|
renderNode={this.renderNode}
|
2018-05-17 01:13:17 +00:00
|
|
|
renderMark={this.renderMark}
|
2018-05-12 00:10:38 +00:00
|
|
|
spellCheck={true}
|
2018-05-05 22:25:04 +00:00
|
|
|
/>
|
2016-09-13 10:11:52 +00:00
|
|
|
</div>
|
2016-03-24 11:25:41 +00:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
2017-01-20 14:22:27 +00:00
|
|
|
}
|