Merge pull request #1144 from matrix-org/rte-fixes2

RTE Fixes
This commit is contained in:
Luke Barnard 2017-06-26 09:38:14 +01:00 committed by GitHub
commit caa0250d30
18 changed files with 617 additions and 277 deletions

6
.flowconfig Normal file
View file

@ -0,0 +1,6 @@
[include]
src/**/*.js
test/**/*.js
[ignore]
node_modules/

View file

@ -51,7 +51,7 @@
"classnames": "^2.1.2",
"commonmark": "^0.27.0",
"counterpart": "^0.18.0",
"draft-js": "^0.8.1",
"draft-js": "^0.9.1",
"draft-js-export-html": "^0.5.0",
"draft-js-export-markdown": "^0.2.0",
"emojione": "2.2.3",
@ -64,7 +64,7 @@
"isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3",
"lodash": "^4.13.1",
"matrix-js-sdk": "0.7.13",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1",
"prop-types": "^15.5.8",
"q": "^1.4.1",

View file

@ -0,0 +1,84 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {ContentState} from 'draft-js';
import * as RichText from './RichText';
import Markdown from './Markdown';
import _flow from 'lodash/flow';
import _clamp from 'lodash/clamp';
type MessageFormat = 'html' | 'markdown';
class HistoryItem {
message: string = '';
format: MessageFormat = 'html';
constructor(message: string, format: MessageFormat) {
this.message = message;
this.format = format;
}
toContentState(format: MessageFormat): ContentState {
let {message} = this;
if (format === 'markdown') {
if (this.format === 'html') {
message = _flow([RichText.htmlToContentState, RichText.stateToMarkdown])(message);
}
return ContentState.createFromText(message);
} else {
if (this.format === 'markdown') {
message = new Markdown(message).toHTML();
}
return RichText.htmlToContentState(message);
}
}
}
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0;
currentIndex: number = -1;
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
for(; sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`); this.lastIndex++, this.currentIndex++) {
this.history.push(
Object.assign(
new HistoryItem(),
JSON.parse(sessionStorage.getItem(`${this.prefix}[${this.lastIndex}]`)),
),
);
}
this.currentIndex--;
}
addItem(message: string, format: MessageFormat) {
const item = new HistoryItem(message, format);
this.history.push(item);
this.currentIndex = this.lastIndex + 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item));
}
getItem(offset: number, format: MessageFormat): ?ContentState {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1);
const item = this.history[this.currentIndex];
return item ? item.toContentState(format) : null;
}
}

View file

@ -16,6 +16,7 @@ import * as sdk from './index';
import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html';
import {SelectionRange} from "./autocomplete/Autocompleter";
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
@ -30,9 +31,26 @@ const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export const contentStateToHTML = stateToHTML;
const ZWS_CODE = 8203;
const ZWS = String.fromCharCode(ZWS_CODE); // zero width space
export function stateToMarkdown(state) {
return __stateToMarkdown(state)
.replace(
ZWS, // draft-js-export-markdown adds these
''); // this is *not* a zero width space, trust me :)
}
export function HTMLtoContentState(html: string): ContentState {
export const contentStateToHTML = (contentState: ContentState) => {
return stateToHTML(contentState, {
inlineStyles: {
UNDERLINE: {
element: 'u'
}
}
});
};
export function htmlToContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html));
}
@ -146,9 +164,9 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
</a>
)
});
markdownDecorators.push(emojiDecorator);
return markdownDecorators;
// markdownDecorators.push(emojiDecorator);
// TODO Consider renabling "syntax highlighting" when we can do it properly
return [emojiDecorator];
}
/**

View file

@ -19,7 +19,7 @@ import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) {
constructor(commandRegex?: RegExp) {
if (commandRegex) {
if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set');

View file

@ -59,7 +59,7 @@ export async function getCompletions(query: string, selection: SelectionRange, f
PROVIDERS.map(provider => {
return Q(provider.getCompletions(query, selection, force))
.timeout(PROVIDER_COMPLETION_TIMEOUT);
})
}),
);
return completionsList

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import Fuse from 'fuse.js';
import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components';
// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file
@ -28,11 +28,21 @@ const COMMANDS = [
args: '<message>',
description: 'Displays action',
},
{
command: '/part',
args: '[#alias:domain]',
description: 'Leave room',
},
{
command: '/ban',
args: '<user-id> [reason]',
description: 'Bans user with given id',
},
{
command: '/unban',
args: '<user-id>',
description: 'Unbans user with given id',
},
{
command: '/deop',
args: '<user-id>',
@ -63,6 +73,11 @@ const COMMANDS = [
args: '<query>',
description: 'Searches DuckDuckGo for results',
},
{
command: '/op',
args: '<userId> [<power level>]',
description: 'Define the power level of a user',
},
];
const COMMAND_RE = /(^\/\w*)/g;
@ -72,7 +87,7 @@ let instance = null;
export default class CommandProvider extends AutocompleteProvider {
constructor() {
super(COMMAND_RE);
this.fuse = new Fuse(COMMANDS, {
this.matcher = new FuzzyMatcher(COMMANDS, {
keys: ['command', 'args', 'description'],
});
}
@ -81,7 +96,7 @@ export default class CommandProvider extends AutocompleteProvider {
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map((result) => {
completions = this.matcher.match(command[0]).map((result) => {
return {
completion: result.command + ' ',
component: (<TextualCompletion

View file

@ -19,20 +19,26 @@ import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js';
import FuzzyMatcher from './FuzzyMatcher';
import sdk from '../index';
import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter';
const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
const EMOJI_SHORTNAMES = Object.keys(emojioneList).map(shortname => {
return {
shortname,
};
});
let instance = null;
export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
this.fuse = new Fuse(EMOJI_SHORTNAMES, {});
this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
keys: 'shortname',
});
}
async getCompletions(query: string, selection: SelectionRange) {
@ -41,8 +47,8 @@ export default class EmojiProvider extends AutocompleteProvider {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map(result => {
const shortname = EMOJI_SHORTNAMES[result];
completions = this.matcher.match(command[0]).map(result => {
const {shortname} = result;
const unicode = shortnameToUnicode(shortname);
return {
completion: unicode,

View file

@ -0,0 +1,107 @@
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
//import Levenshtein from 'liblevenshtein';
//import _at from 'lodash/at';
//import _flatMap from 'lodash/flatMap';
//import _sortBy from 'lodash/sortBy';
//import _sortedUniq from 'lodash/sortedUniq';
//import _keys from 'lodash/keys';
//
//class KeyMap {
// keys: Array<String>;
// objectMap: {[String]: Array<Object>};
// priorityMap: {[String]: number}
//}
//
//const DEFAULT_RESULT_COUNT = 10;
//const DEFAULT_DISTANCE = 5;
// FIXME Until Fuzzy matching works better, we use prefix matching.
import PrefixMatcher from './QueryMatcher';
export default PrefixMatcher;
//class FuzzyMatcher { // eslint-disable-line no-unused-vars
// /**
// * @param {object[]} objects the objects to perform a match on
// * @param {string[]} keys an array of keys within each object to match on
// * Keys can refer to object properties by name and as in JavaScript (for nested properties)
// *
// * To use, simply presort objects by required criteria, run through this function and create a FuzzyMatcher with the
// * resulting KeyMap.
// *
// * TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
// * @return {KeyMap}
// */
// static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
// const keyMap = new KeyMap();
// const map = {};
// const priorities = {};
//
// objects.forEach((object, i) => {
// const keyValues = _at(object, keys);
// console.log(object, keyValues, keys);
// for (const keyValue of keyValues) {
// if (!map.hasOwnProperty(keyValue)) {
// map[keyValue] = [];
// }
// map[keyValue].push(object);
// }
// priorities[object] = i;
// });
//
// keyMap.objectMap = map;
// keyMap.priorityMap = priorities;
// keyMap.keys = _sortBy(_keys(map), [(value) => priorities[value]]);
// return keyMap;
// }
//
// constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
// this.options = options;
// this.keys = options.keys;
// this.setObjects(objects);
// }
//
// setObjects(objects: Array<Object>) {
// this.keyMap = FuzzyMatcher.valuesToKeyMap(objects, this.keys);
// console.log(this.keyMap.keys);
// this.matcher = new Levenshtein.Builder()
// .dictionary(this.keyMap.keys, true)
// .algorithm('transposition')
// .sort_candidates(false)
// .case_insensitive_sort(true)
// .include_distance(true)
// .maximum_candidates(this.options.resultCount || DEFAULT_RESULT_COUNT) // result count 0 doesn't make much sense
// .build();
// }
//
// match(query: String): Array<Object> {
// const candidates = this.matcher.transduce(query, this.options.distance || DEFAULT_DISTANCE);
// // TODO FIXME This is hideous. Clean up when possible.
// const val = _sortedUniq(_sortBy(_flatMap(candidates, (candidate) => {
// return this.keyMap.objectMap[candidate[0]].map((value) => {
// return {
// distance: candidate[1],
// ...value,
// };
// });
// }),
// [(candidate) => candidate.distance, (candidate) => this.keyMap.priorityMap[candidate]]));
// console.log(val);
// return val;
// }
//}

View file

@ -0,0 +1,79 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import _at from 'lodash/at';
import _flatMap from 'lodash/flatMap';
import _sortBy from 'lodash/sortBy';
import _sortedUniq from 'lodash/sortedUniq';
import _keys from 'lodash/keys';
class KeyMap {
keys: Array<String>;
objectMap: {[String]: Array<Object>};
priorityMap = new Map();
}
export default class QueryMatcher {
/**
* @param {object[]} objects the objects to perform a match on
* @param {string[]} keys an array of keys within each object to match on
* Keys can refer to object properties by name and as in JavaScript (for nested properties)
*
* To use, simply presort objects by required criteria, run through this function and create a QueryMatcher with the
* resulting KeyMap.
*
* TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
* @return {KeyMap}
*/
static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
const keyMap = new KeyMap();
const map = {};
objects.forEach((object, i) => {
const keyValues = _at(object, keys);
for (const keyValue of keyValues) {
if (!map.hasOwnProperty(keyValue)) {
map[keyValue] = [];
}
map[keyValue].push(object);
}
keyMap.priorityMap.set(object, i);
});
keyMap.objectMap = map;
keyMap.keys = _keys(map);
return keyMap;
}
constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
this.options = options;
this.keys = options.keys;
this.setObjects(objects);
}
setObjects(objects: Array<Object>) {
this.keyMap = QueryMatcher.valuesToKeyMap(objects, this.keys);
}
match(query: String): Array<Object> {
query = query.toLowerCase().replace(/[^\w]/g, '');
const results = _sortedUniq(_sortBy(_flatMap(this.keyMap.keys, (key) => {
return key.toLowerCase().replace(/[^\w]/g, '').indexOf(query) >= 0 ? this.keyMap.objectMap[key] : [];
}), (candidate) => this.keyMap.priorityMap.get(candidate)));
return results;
}
}

View file

@ -19,7 +19,7 @@ import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js';
import FuzzyMatcher from './FuzzyMatcher';
import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index';
@ -30,10 +30,8 @@ let instance = null;
export default class RoomProvider extends AutocompleteProvider {
constructor() {
super(ROOM_REGEX, {
keys: ['displayName', 'userId'],
});
this.fuse = new Fuse([], {
super(ROOM_REGEX);
this.matcher = new FuzzyMatcher([], {
keys: ['name', 'roomId', 'aliases'],
});
}
@ -46,17 +44,17 @@ export default class RoomProvider extends AutocompleteProvider {
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
this.fuse.set(client.getRooms().filter(room => !!room).map(room => {
this.matcher.setObjects(client.getRooms().filter(room => !!room && !!getDisplayAliasForRoom(room)).map(room => {
return {
room: room,
name: room.name,
aliases: room.getAliases(),
};
}));
completions = this.fuse.search(command[0]).map(room => {
completions = this.matcher.match(command[0]).map(room => {
let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return {
completion: displayAlias,
completion: displayAlias + ' ',
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
),
@ -84,8 +82,4 @@ export default class RoomProvider extends AutocompleteProvider {
{completions}
</div>;
}
shouldForceComplete(): boolean {
return true;
}
}

View file

@ -1,3 +1,4 @@
//@flow
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
@ -18,21 +19,27 @@ limitations under the License.
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import Fuse from 'fuse.js';
import {PillCompletion} from './Components';
import sdk from '../index';
import FuzzyMatcher from './FuzzyMatcher';
import _pull from 'lodash/pull';
import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg';
import type {Room, RoomMember} from 'matrix-js-sdk';
const USER_REGEX = /@\S*/g;
let instance = null;
export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = [];
constructor() {
super(USER_REGEX, {
keys: ['name', 'userId'],
});
this.users = [];
this.fuse = new Fuse([], {
this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'],
});
}
@ -43,8 +50,7 @@ export default class UserProvider extends AutocompleteProvider {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
this.fuse.set(this.users);
completions = this.fuse.search(command[0]).map(user => {
completions = this.matcher.match(command[0]).map(user => {
let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
let completion = displayName;
if (range.start === 0) {
@ -71,8 +77,31 @@ export default class UserProvider extends AutocompleteProvider {
return '👥 ' + _t('Users');
}
setUserList(users) {
this.users = users;
setUserListFromRoom(room: Room) {
const events = room.getLiveTimeline().getEvents();
const lastSpoken = {};
for(const event of events) {
lastSpoken[event.getSender()] = event.getTs();
}
const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = room.getJoinedMembers().filter((member) => {
if (member.userId !== currentUserId) return true;
});
this.users = _sortBy(this.users, (user) => 1E20 - lastSpoken[user.userId] || 1E20);
this.matcher.setObjects(this.users);
}
onUserSpoke(user: RoomMember) {
if(user.userId === MatrixClientPeg.get().credentials.userId) return;
// Probably unsafe to compare by reference here?
_pull(this.users, user);
this.users.splice(0, 0, user);
this.matcher.setObjects(this.users);
}
static getInstance(): UserProvider {

View file

@ -234,7 +234,7 @@ module.exports = React.createClass({
// making it impossible to indicate a newly joined room.
const room = this.state.room;
if (room) {
this._updateAutoComplete(room);
UserProvider.getInstance().setUserListFromRoom(room);
this.tabComplete.loadEntries(room);
this.setState({
unsentMessageError: this._getUnsentMessageError(room),
@ -500,8 +500,7 @@ module.exports = React.createClass({
// and that has probably just changed
if (ev.sender) {
this.tabComplete.onMemberSpoke(ev.sender);
// nb. we don't need to update the new autocomplete here since
// its results are currently ordered purely by search score.
UserProvider.getInstance().onUserSpoke(ev.sender);
}
},
@ -700,7 +699,7 @@ module.exports = React.createClass({
// refresh the tab complete list
this.tabComplete.loadEntries(this.state.room);
this._updateAutoComplete(this.state.room);
UserProvider.getInstance().setUserListFromRoom(this.state.room);
// if we are now a member of the room, where we were not before, that
// means we have finished joining a room we were previously peeking
@ -1425,14 +1424,6 @@ module.exports = React.createClass({
}
},
_updateAutoComplete: function(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
UserProvider.getInstance().setUserList(members);
},
render: function() {
const RoomHeader = sdk.getComponent('rooms.RoomHeader');
const MessageComposer = sdk.getComponent('rooms.MessageComposer');

View file

@ -642,6 +642,10 @@ module.exports = React.createClass({
},
_renderUserInterfaceSettings: function() {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = (e) =>
UserSettingsStore.setLocalSetting('autocompleteDelay', + e.target.value);
return (
<div>
<h3>{ _t("User Interface") }</h3>
@ -649,8 +653,21 @@ module.exports = React.createClass({
{ this._renderUrlPreviewSelector() }
{ SETTINGS_LABELS.map( this._renderSyncedSetting ) }
{ THEMES.map( this._renderThemeSelector ) }
<table>
<tbody>
<tr>
<td><strong>{_t('Autocomplete Delay (ms):')}</strong></td>
<td>
<input
type="number"
defaultValue={UserSettingsStore.getLocalSetting('autocompleteDelay', 200)}
onChange={onChange}
/>
</td>
</tr>
</tbody>
</table>
{ this._renderLanguageSetting() }
</div>
</div>
);

View file

@ -6,6 +6,7 @@ import isEqual from 'lodash/isEqual';
import sdk from '../../../index';
import type {Completion} from '../../../autocomplete/Autocompleter';
import Q from 'q';
import UserSettingsStore from '../../../UserSettingsStore';
import {getCompletions} from '../../../autocomplete/Autocompleter';
@ -58,7 +59,7 @@ export default class Autocomplete extends React.Component {
return;
}
const completionList = flatMap(completions, provider => provider.completions);
const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED;
@ -69,27 +70,35 @@ export default class Autocomplete extends React.Component {
const currentSelection = this.state.selectionOffset === 0 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex(
completion => completion.completion === currentSelection);
(completion) => completion.completion === currentSelection);
if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED;
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
} else {
// If no completions were returned, we should turn off force completion.
forceComplete = false;
}
let hide = this.state.hide;
// These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern
const oldMatches = this.state.completions.map(completion => !!completion.command.command),
newMatches = completions.map(completion => !!completion.command.command);
const oldMatches = this.state.completions.map((completion) => !!completion.command.command),
newMatches = completions.map((completion) => !!completion.command.command);
// So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one
if (!isEqual(oldMatches, newMatches)) {
hide = false;
}
const autocompleteDelay = UserSettingsStore.getSyncedSetting('autocompleteDelay', 200);
// We had no completions before, but do now, so we should apply our display delay here
if (this.state.completionList.length === 0 && completionList.length > 0 &&
!forceComplete && autocompleteDelay > 0) {
await Q.delay(autocompleteDelay);
}
// Force complete is turned off each time since we can't edit the query in that case
forceComplete = false;
this.setState({
completions,
completionList,
@ -149,6 +158,7 @@ export default class Autocomplete extends React.Component {
const done = Q.defer();
this.setState({
forceComplete: true,
hide: false,
}, () => {
this.complete(this.props.query, this.props.selection).then(() => {
done.resolve();
@ -169,7 +179,7 @@ export default class Autocomplete extends React.Component {
}
setSelection(selectionOffset: number) {
this.setState({selectionOffset});
this.setState({selectionOffset, hide: false});
}
componentDidUpdate() {
@ -185,21 +195,24 @@ export default class Autocomplete extends React.Component {
}
}
setState(state, func) {
super.setState(state, func);
}
render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 1;
let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => {
const renderedCompletions = this.state.completions.map((completionResult, i) => {
const completions = completionResult.completions.map((completion, i) => {
const className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset,
});
let componentPosition = position;
const componentPosition = position;
position++;
let onMouseOver = () => this.setSelection(componentPosition);
let onClick = () => {
const onMouseOver = () => this.setSelection(componentPosition);
const onClick = () => {
this.setSelection(componentPosition);
this.onCompletionClicked();
};
@ -220,7 +233,7 @@ export default class Autocomplete extends React.Component {
{completionResult.provider.renderCompletions(completions)}
</div>
) : null;
}).filter(completion => !!completion);
}).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}>

View file

@ -20,7 +20,6 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
convertFromRaw, convertToRaw, Modifier, EditorChangeType,
getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js';
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
import classNames from 'classnames';
import escape from 'lodash/escape';
import Q from 'q';
@ -41,6 +40,7 @@ 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;
@ -58,6 +58,29 @@ function stateToMarkdown(state) {
* 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)) {
@ -77,6 +100,7 @@ export default class MessageComposerInput extends React.Component {
client: MatrixClient;
autocomplete: Autocomplete;
historyManager: ComposerHistoryManager;
constructor(props, context) {
super(props, context);
@ -84,7 +108,6 @@ export default class MessageComposerInput extends React.Component {
this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.onEditorContentChanged = this.onEditorContentChanged.bind(this);
this.setEditorState = this.setEditorState.bind(this);
this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
@ -132,110 +155,13 @@ export default class MessageComposerInput extends React.Component {
return EditorState.moveFocusToEnd(editorState);
}
componentWillMount() {
const component = this;
this.sentHistory = {
// The list of typed messages. Index 0 is more recent
data: [],
// The position in data currently displayed
position: -1,
// The room the history is for.
roomId: null,
// The original text before they hit UP
originalText: null,
// The textarea element to set text to.
element: null,
init: function(element, roomId) {
this.roomId = roomId;
this.element = element;
this.position = -1;
var storedData = window.sessionStorage.getItem(
"mx_messagecomposer_history_" + roomId
);
if (storedData) {
this.data = JSON.parse(storedData);
}
if (this.roomId) {
this.setLastTextEntry();
}
},
push: function(text) {
// store a message in the sent history
this.data.unshift(text);
window.sessionStorage.setItem(
"mx_messagecomposer_history_" + this.roomId,
JSON.stringify(this.data)
);
// reset history position
this.position = -1;
this.originalText = null;
},
// move in the history. Returns true if we managed to move.
next: function(offset) {
if (this.position === -1) {
// user is going into the history, save the current line.
this.originalText = this.element.value;
}
else {
// user may have modified this line in the history; remember it.
this.data[this.position] = this.element.value;
}
if (offset > 0 && this.position === (this.data.length - 1)) {
// we've run out of history
return false;
}
// retrieve the next item (bounded).
var newPosition = this.position + offset;
newPosition = Math.max(-1, newPosition);
newPosition = Math.min(newPosition, this.data.length - 1);
this.position = newPosition;
if (this.position !== -1) {
// show the message
this.element.value = this.data[this.position];
}
else if (this.originalText !== undefined) {
// restore the original text the user was typing.
this.element.value = this.originalText;
}
return true;
},
saveLastTextEntry: function() {
// save the currently entered text in order to restore it later.
// NB: This isn't 'originalText' because we want to restore
// sent history items too!
let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent()));
window.sessionStorage.setItem("mx_messagecomposer_input_" + this.roomId, contentJSON);
},
setLastTextEntry: function() {
let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId);
if (contentJSON) {
let content = convertFromRaw(JSON.parse(contentJSON));
component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content));
}
},
};
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
this.sentHistory.init(
this.refs.editor,
this.props.room.roomId
);
this.historyManager = new ComposerHistoryManager(this.props.room.roomId);
}
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
this.sentHistory.saveLastTextEntry();
}
componentWillUpdate(nextProps, nextState) {
@ -247,8 +173,8 @@ export default class MessageComposerInput extends React.Component {
}
}
onAction(payload) {
let editor = this.refs.editor;
onAction = (payload) => {
const editor = this.refs.editor;
let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) {
@ -262,7 +188,7 @@ export default class MessageComposerInput extends React.Component {
contentState = Modifier.replaceText(
contentState,
this.state.editorState.getSelection(),
`${payload.displayname}: `
`${payload.displayname}: `,
);
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
@ -275,9 +201,9 @@ export default class MessageComposerInput extends React.Component {
let {body, formatted_body} = payload.event.getContent();
formatted_body = formatted_body || escape(body);
if (formatted_body) {
let content = RichText.HTMLtoContentState(`<blockquote>${formatted_body}</blockquote>`);
let content = RichText.htmlToContentState(`<blockquote>${formatted_body}</blockquote>`);
if (!this.state.isRichtextEnabled) {
content = ContentState.createFromText(stateToMarkdown(content));
content = ContentState.createFromText(RichText.stateToMarkdown(content));
}
const blockMap = content.getBlockMap();
@ -291,14 +217,14 @@ export default class MessageComposerInput extends React.Component {
if (this.state.isRichtextEnabled) {
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
}
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
const editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
this.onEditorContentChanged(editorState);
editor.focus();
}
}
break;
}
}
};
onTypingActivity() {
this.isTyping = true;
@ -318,7 +244,7 @@ export default class MessageComposerInput extends React.Component {
startUserTypingTimer() {
this.stopUserTypingTimer();
var self = this;
const self = this;
this.userTypingTimer = setTimeout(function() {
self.isTyping = false;
self.sendTyping(self.isTyping);
@ -335,7 +261,7 @@ export default class MessageComposerInput extends React.Component {
startServerTypingTimer() {
if (!this.serverTypingTimer) {
var self = this;
const self = this;
this.serverTypingTimer = setTimeout(function() {
if (self.isTyping) {
self.sendTyping(self.isTyping);
@ -356,7 +282,7 @@ export default class MessageComposerInput extends React.Component {
if (UserSettingsStore.getSyncedSetting('dontSendTypingNotifications', false)) return;
MatrixClientPeg.get().sendTyping(
this.props.room.roomId,
this.isTyping, TYPING_SERVER_TIMEOUT
this.isTyping, TYPING_SERVER_TIMEOUT,
).done();
}
@ -367,60 +293,80 @@ export default class MessageComposerInput extends React.Component {
}
}
// Called by Draft to change editor contents, and by setEditorState
onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) {
// Called by Draft to change editor contents
onEditorContentChanged = (editorState: EditorState) => {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
const contentChanged = Q.defer();
/* If a modification was made, set originalEditorState to null, since newState is now our original */
/* Since a modification was made, set originalEditorState to null, since newState is now our original */
this.setState({
editorState,
originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState,
}, () => contentChanged.resolve());
originalEditorState: null,
});
};
if (editorState.getCurrentContent().hasText()) {
/**
* 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 (this.props.onContentChanged) {
const textContent = editorState.getCurrentContent().getPlainText();
const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray());
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);
}
return contentChanged.promise;
}
setEditorState(editorState: EditorState) {
return this.onEditorContentChanged(editorState, false);
});
}
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());
contentState = RichText.htmlToContentState(md.toHTML());
} else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent());
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.setEditorState(this.createEditorState(enabled, contentState)).then(() => {
this.setState({
editorState: this.createEditorState(enabled, contentState),
isRichtextEnabled: enabled,
});
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
});
}
handleKeyCommand(command: string): boolean {
handleKeyCommand = (command: string): boolean => {
if (command === 'toggle-mode') {
this.enableRichtext(!this.state.isRichtextEnabled);
return true;
@ -434,31 +380,35 @@ export default class MessageComposerInput extends React.Component {
const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'];
if (blockCommands.includes(command)) {
this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, 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.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'));
this.setState({
editorState: RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'),
});
}
} else {
let contentState = this.state.editorState.getCurrentContent(),
selection = this.state.editorState.getSelection();
let modifyFn = {
'bold': text => `**${text}**`,
'italic': text => `*${text}*`,
'underline': text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
'strike': text => `~~${text}~~`,
'code': text => `\`${text}\``,
'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''),
'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''),
'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''),
const modifyFn = {
'bold': (text) => `**${text}**`,
'italic': (text) => `*${text}*`,
'underline': (text) => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
'strike': (text) => `~~${text}~~`,
'code-block': (text) => `\`\`\`\n${text}\n\`\`\``,
'blockquote': (text) => text.split('\n').map((line) => `> ${line}\n`).join(''),
'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(''),
}[command];
if (modifyFn) {
newState = EditorState.push(
this.state.editorState,
RichText.modifyText(contentState, selection, modifyFn),
'insert-characters'
'insert-characters',
);
}
}
@ -468,7 +418,7 @@ export default class MessageComposerInput extends React.Component {
}
if (newState != null) {
this.setEditorState(newState);
this.setState({editorState: newState});
return true;
}
@ -481,6 +431,13 @@ export default class MessageComposerInput extends React.Component {
return true;
}
const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState);
// If we're in any of these three types of blocks, shift enter should insert soft newlines
// And just enter should end the block
if(['blockquote', 'unordered-list-item', 'ordered-list-item'].includes(currentBlockType)) {
return false;
}
const contentState = this.state.editorState.getCurrentContent();
if (!contentState.hasText()) {
return true;
@ -489,11 +446,11 @@ export default class MessageComposerInput extends React.Component {
let contentText = contentState.getPlainText(), contentHTML;
var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
const cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
if (cmd) {
if (!cmd.error) {
this.setState({
editorState: this.createEditorState()
editorState: this.createEditorState(),
});
}
if (cmd.promise) {
@ -501,16 +458,15 @@ export default class MessageComposerInput extends React.Component {
console.log("Command success.");
}, function(err) {
console.error("Command failure: %s", err);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: _t("Server error"),
description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")),
});
});
}
else if (cmd.error) {
} else if (cmd.error) {
console.error(cmd.error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: _t("Command error"),
description: cmd.error,
@ -521,7 +477,7 @@ export default class MessageComposerInput extends React.Component {
if (this.state.isRichtextEnabled) {
contentHTML = HtmlUtils.stripParagraphs(
RichText.contentStateToHTML(contentState)
RichText.contentStateToHTML(contentState),
);
} else {
const md = new Markdown(contentText);
@ -543,12 +499,14 @@ export default class MessageComposerInput extends React.Component {
sendTextFn = this.client.sendEmoteMessage;
}
// XXX: We don't actually seem to use this history?
this.sentHistory.push(contentHTML || contentText);
this.historyManager.addItem(
this.state.isRichtextEnabled ? contentHTML : contentState.getPlainText(),
this.state.isRichtextEnabled ? 'html' : 'markdown');
let sendMessagePromise;
if (contentHTML) {
sendMessagePromise = sendHtmlFn.call(
this.client, this.props.room.roomId, contentText, contentHTML
this.client, this.props.room.roomId, contentText, contentHTML,
);
} else {
sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText);
@ -567,73 +525,92 @@ export default class MessageComposerInput extends React.Component {
this.autocomplete.hide();
return true;
}
};
async onUpArrow(e) {
onUpArrow = async (e) => {
const completion = this.autocomplete.onUpArrow();
if (completion != null) {
if (completion == null) {
const newContent = this.historyManager.getItem(-1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
}
e.preventDefault();
}
return await this.setDisplayedCompletion(completion);
}
};
async onDownArrow(e) {
onDownArrow = async (e) => {
const completion = this.autocomplete.onDownArrow();
if (completion == null) {
const newContent = this.historyManager.getItem(+1, this.state.isRichtextEnabled ? 'html' : 'markdown');
if (!newContent) return false;
const editorState = EditorState.push(this.state.editorState,
newContent,
'insert-characters');
this.setState({editorState});
return true;
}
e.preventDefault();
return await this.setDisplayedCompletion(completion);
}
};
// tab and shift-tab are mapped to down and up arrow respectively
async onTab(e) {
onTab = async (e) => {
e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes
const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e);
if (!didTab && this.autocomplete) {
this.autocomplete.forceComplete().then(() => {
if (this.autocomplete.state.completionList.length === 0) {
await this.autocomplete.forceComplete();
this.onDownArrow(e);
});
}
} else {
await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e);
}
};
onEscape(e) {
onEscape = async (e) => {
e.preventDefault();
if (this.autocomplete) {
this.autocomplete.onEscape(e);
}
this.setDisplayedCompletion(null); // restore originalEditorState
}
await this.setDisplayedCompletion(null); // restore originalEditorState
};
/* 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.
*/
async setDisplayedCompletion(displayedCompletion: ?Completion): boolean {
setDisplayedCompletion = async (displayedCompletion: ?Completion): boolean => {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
if (displayedCompletion == null) {
if (this.state.originalEditorState) {
this.setEditorState(this.state.originalEditorState);
let editorState = this.state.originalEditorState;
// This is a workaround from https://github.com/facebook/draft-js/issues/458
// Due to the way we swap editorStates, Draft does not rerender at times
editorState = EditorState.forceSelection(editorState,
editorState.getSelection());
this.setState({editorState});
}
return false;
}
const {range = {}, completion = ''} = displayedCompletion;
let contentState = Modifier.replaceText(
const contentState = Modifier.replaceText(
activeEditorState.getCurrentContent(),
RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()),
completion
completion,
);
let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
const originalEditorState = activeEditorState;
await this.setEditorState(editorState);
this.setState({originalEditorState});
this.setState({editorState, originalEditorState: activeEditorState});
// for some reason, doing this right away does not update the editor :(
setTimeout(() => this.refs.editor.focus(), 50);
// setTimeout(() => this.refs.editor.focus(), 50);
return true;
}
};
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
e.preventDefault(); // don't steal focus from the editor!
@ -658,8 +635,8 @@ export default class MessageComposerInput extends React.Component {
const originalStyle = editorState.getCurrentInlineStyle().toArray();
const style = originalStyle
.map(style => styleName[style] || null)
.filter(styleName => !!styleName);
.map((style) => styleName[style] || null)
.filter((styleName) => !!styleName);
const blockName = {
'code-block': 'code',
@ -678,10 +655,10 @@ export default class MessageComposerInput extends React.Component {
};
}
onMarkdownToggleClicked(e) {
onMarkdownToggleClicked = (e) => {
e.preventDefault(); // don't steal focus from the editor!
this.handleKeyCommand('toggle-mode');
}
};
render() {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
@ -713,7 +690,7 @@ export default class MessageComposerInput extends React.Component {
ref={(e) => this.autocomplete = e}
onConfirm={this.setDisplayedCompletion}
query={contentText}
selection={selection} />
selection={selection}/>
</div>
<div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"
@ -735,7 +712,7 @@ export default class MessageComposerInput extends React.Component {
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
onEscape={this.onEscape}
spellCheck={true} />
spellCheck={true}/>
</div>
</div>
);

View file

@ -240,6 +240,7 @@
"demote": "demote",
"Deops user with given id": "Deops user with given id",
"Default": "Default",
"Define the power level of a user": "Define the power level of a user",
"Device already verified!": "Device already verified!",
"Device ID": "Device ID",
"Device ID:": "Device ID:",
@ -581,6 +582,7 @@
"Unable to restore previous session": "Unable to restore previous session",
"Unable to verify email address.": "Unable to verify email address.",
"Unban": "Unban",
"Unbans user with given id": "Unbans user with given id",
"%(senderName)s unbanned %(targetName)s.": "%(senderName)s unbanned %(targetName)s.",
"Unable to ascertain that the address this invite was sent to matches one associated with your account.": "Unable to ascertain that the address this invite was sent to matches one associated with your account.",
"Unable to capture screen": "Unable to capture screen",
@ -920,5 +922,6 @@
"Ignore request": "Ignore request",
"You added a new device '%(displayName)s', which is requesting encryption keys.": "You added a new device '%(displayName)s', which is requesting encryption keys.",
"Your unverified device '%(displayName)s' is requesting encryption keys.": "Your unverified device '%(displayName)s' is requesting encryption keys.",
"Encryption key request": "Encryption key request"
"Encryption key request": "Encryption key request",
"Autocomplete Delay (ms):": "Autocomplete Delay (ms):"
}

View file

@ -99,17 +99,18 @@ describe('MessageComposerInput', () => {
});
it('should not change content unnecessarily on Markdown -> RTE conversion', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(false);
addTextToDraft('a');
mci.handleKeyCommand('toggle-mode');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('a');
});
it('should send emoji messages in rich text', () => {
const spy = sinon.spy(client, 'sendTextMessage');
const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(true);
addTextToDraft('☹');
mci.handleReturn(sinon.stub());