Improve autocomplete behaviour

Fixes vector-im/vector-web#1761
This commit is contained in:
Aviral Dasgupta 2016-09-13 15:41:52 +05:30
parent ce40fa1a8f
commit b62622a814
15 changed files with 407 additions and 194 deletions

View file

@ -78,18 +78,26 @@
/** react **/ /** react **/
// bind or arrow function in props causes performance issues // bind or arrow function in props causes performance issues
"react/jsx-no-bind": ["error"], "react/jsx-no-bind": ["error", {
"ignoreRefs": true
}],
"react/jsx-key": ["error"], "react/jsx-key": ["error"],
"react/prefer-stateless-function": ["warn"], "react/prefer-stateless-function": ["warn"],
"react/sort-comp": ["warn"],
/** flowtype **/ /** flowtype **/
"flowtype/require-parameter-type": 1, "flowtype/require-parameter-type": [
1,
{
"excludeArrowFunctions": true
}
],
"flowtype/define-flow-type": 1,
"flowtype/require-return-type": [ "flowtype/require-return-type": [
1, 1,
"always", "always",
{ {
"annotateUndefined": "never" "annotateUndefined": "never",
"excludeArrowFunctions": true
} }
], ],
"flowtype/space-after-type-colon": [ "flowtype/space-after-type-colon": [

View file

@ -158,5 +158,11 @@ React
<Foo onClick={this.doStuff}> // Better <Foo onClick={this.doStuff}> // Better
<Foo onClick={this.onFooClick}> // Best, if onFooClick would do anything other than directly calling doStuff <Foo onClick={this.onFooClick}> // Best, if onFooClick would do anything other than directly calling doStuff
``` ```
Not doing so is acceptable in a single case; in function-refs:
```jsx
<Foo ref={(self) => this.component = self}>
```
- Think about whether your component really needs state: are you duplicating - Think about whether your component really needs state: are you duplicating
information in component state that could be derived from the model? information in component state that could be derived from the model?

View file

@ -62,8 +62,8 @@
"babel-loader": "^5.4.0", "babel-loader": "^5.4.0",
"babel-polyfill": "^6.5.0", "babel-polyfill": "^6.5.0",
"eslint": "^2.13.1", "eslint": "^2.13.1",
"eslint-plugin-flowtype": "^2.3.0", "eslint-plugin-flowtype": "^2.17.0",
"eslint-plugin-react": "^5.2.2", "eslint-plugin-react": "^6.2.1",
"expect": "^1.16.0", "expect": "^1.16.0",
"json-loader": "^0.5.3", "json-loader": "^0.5.3",
"karma": "^0.13.22", "karma": "^0.13.22",

View file

@ -15,6 +15,7 @@ import {
import * as sdk from './index'; import * as sdk from './index';
import * as emojione from 'emojione'; import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html'; import {stateToHTML} from 'draft-js-export-html';
import {SelectionRange} from "./autocomplete/Autocompleter";
const MARKDOWN_REGEX = { const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
@ -203,7 +204,7 @@ export function selectionStateToTextOffsets(selectionState: SelectionState,
}; };
} }
export function textOffsetsToSelectionState({start, end}: {start: number, end: number}, export function textOffsetsToSelectionState({start, end}: SelectionRange,
contentBlocks: Array<ContentBlock>): SelectionState { contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty(); let selectionState = SelectionState.createEmpty();

View file

@ -17,6 +17,8 @@ limitations under the License.
var MatrixClientPeg = require("./MatrixClientPeg"); var MatrixClientPeg = require("./MatrixClientPeg");
var dis = require("./dispatcher"); var dis = require("./dispatcher");
var Tinter = require("./Tinter"); var Tinter = require("./Tinter");
import sdk from './index';
import Modal from './Modal';
class Command { class Command {
@ -56,6 +58,16 @@ var success = function(promise) {
}; };
var commands = { var commands = {
ddg: new Command("ddg", "<query>", function(roomId, args) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createDialog(ErrorDialog, {
title: "/ddg is not a command",
description: "To use it, just wait for autocomplete results to load and tab through them.",
});
return success();
}),
// Change your nickname // Change your nickname
nick: new Command("nick", "<display_name>", function(room_id, args) { nick: new Command("nick", "<display_name>", function(room_id, args) {
if (args) { if (args) {

View file

@ -1,5 +1,5 @@
import Q from 'q';
import React from 'react'; import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider { export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) { constructor(commandRegex?: RegExp, fuseOpts?: any) {
@ -14,18 +14,24 @@ export default class AutocompleteProvider {
/** /**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/ */
getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array<string> { getCurrentCommand(query: string, selection: {start: number, end: number}, force: boolean = false): ?string {
if (this.commandRegex == null) { let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) {
console.log('forcing complete');
commandRegex = /[^\W]+/g;
}
if (commandRegex == null) {
return null; return null;
} }
this.commandRegex.lastIndex = 0; commandRegex.lastIndex = 0;
let match; let match;
while ((match = this.commandRegex.exec(query)) != null) { while ((match = commandRegex.exec(query)) != null) {
let matchStart = match.index, let matchStart = match.index,
matchEnd = matchStart + match[0].length; matchEnd = matchStart + match[0].length;
if (selection.start <= matchEnd && selection.end >= matchStart) { if (selection.start <= matchEnd && selection.end >= matchStart) {
return { return {
command: match, command: match,
@ -45,8 +51,8 @@ export default class AutocompleteProvider {
}; };
} }
getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
return Q.when([]); return [];
} }
getName(): string { getName(): string {
@ -57,4 +63,9 @@ export default class AutocompleteProvider {
console.error('stub; should be implemented in subclasses'); console.error('stub; should be implemented in subclasses');
return null; return null;
} }
// Whether we should provide completions even if triggered forcefully, without a sigil.
shouldForceComplete(): boolean {
return false;
}
} }

View file

@ -1,22 +1,63 @@
// @flow
import type {Component} from 'react';
import CommandProvider from './CommandProvider'; import CommandProvider from './CommandProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider'; import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider'; import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider'; import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider'; import EmojiProvider from './EmojiProvider';
import Q from 'q';
export type SelectionRange = {
start: number,
end: number
};
export type Completion = {
completion: string,
component: ?Component,
range: SelectionRange,
command: ?string,
};
const PROVIDERS = [ const PROVIDERS = [
UserProvider, UserProvider,
CommandProvider,
DuckDuckGoProvider,
RoomProvider, RoomProvider,
EmojiProvider, EmojiProvider,
CommandProvider,
DuckDuckGoProvider,
].map(completer => completer.getInstance()); ].map(completer => completer.getInstance());
export function getCompletions(query: string, selection: {start: number, end: number}) { // Providers will get rejected if they take longer than this.
return PROVIDERS.map(provider => { const PROVIDER_COMPLETION_TIMEOUT = 3000;
export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
/* Note: That this waits for all providers to return is *intentional*
otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended
It ends up containing a list of Q promise states, which are objects with
state (== "fulfilled" || "rejected") and value. */
const completionsList = await Q.allSettled(
PROVIDERS.map(provider => {
return Q(provider.getCompletions(query, selection, force))
.timeout(PROVIDER_COMPLETION_TIMEOUT);
})
);
return completionsList
.filter(completion => completion.state === "fulfilled")
.map((completionsState, i) => {
return { return {
completions: provider.getCompletions(query, selection), completions: completionsState.value,
provider, provider: PROVIDERS[i],
/* the currently matched "command" the completer tried to complete
* we pass this through so that Autocomplete can figure out when to
* re-show itself once hidden.
*/
command: PROVIDERS[i].getCurrentCommand(query, selection, force),
}; };
}); });
} }

View file

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
@ -23,7 +22,7 @@ const COMMANDS = [
{ {
command: '/invite', command: '/invite',
args: '<user-id>', args: '<user-id>',
description: 'Invites user with given id to current room' description: 'Invites user with given id to current room',
}, },
{ {
command: '/join', command: '/join',
@ -40,6 +39,11 @@ const COMMANDS = [
args: '<display-name>', args: '<display-name>',
description: 'Changes your display nickname', description: 'Changes your display nickname',
}, },
{
command: '/ddg',
args: '<query>',
description: 'Searches DuckDuckGo for results',
}
]; ];
let COMMAND_RE = /(^\/\w*)/g; let COMMAND_RE = /(^\/\w*)/g;
@ -54,7 +58,7 @@ export default class CommandProvider extends AutocompleteProvider {
}); });
} }
getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: {start: number, end: number}) {
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection); let {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
@ -70,7 +74,7 @@ export default class CommandProvider extends AutocompleteProvider {
}; };
}); });
} }
return Q.when(completions); return completions;
} }
getName() { getName() {

View file

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import 'whatwg-fetch'; import 'whatwg-fetch';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
@ -20,17 +19,16 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
} }
getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: {start: number, end: number}) {
let {command, range} = this.getCurrentCommand(query, selection); let {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) { if (!query || !command) {
return Q.when([]); return [];
} }
return fetch(DuckDuckGoProvider.getQueryUri(command[1]), { const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
method: 'GET', method: 'GET',
}) });
.then(response => response.json()) const json = await response.json();
.then(json => {
let results = json.Results.map(result => { let results = json.Results.map(result => {
return { return {
completion: result.Text, completion: result.Text,
@ -74,7 +72,6 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
}); });
} }
return results; return results;
});
} }
getName() { getName() {

View file

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import sdk from '../index'; import sdk from '../index';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter';
const EMOJI_REGEX = /:\w*:?/g; const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_SHORTNAMES = Object.keys(emojioneList);
@ -17,7 +17,7 @@ export default class EmojiProvider extends AutocompleteProvider {
this.fuse = new Fuse(EMOJI_SHORTNAMES); this.fuse = new Fuse(EMOJI_SHORTNAMES);
} }
getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: SelectionRange) {
const EmojiText = sdk.getComponent('views.elements.EmojiText'); const EmojiText = sdk.getComponent('views.elements.EmojiText');
let completions = []; let completions = [];
@ -35,7 +35,7 @@ export default class EmojiProvider extends AutocompleteProvider {
}; };
}).slice(0, 8); }).slice(0, 8);
} }
return Q.when(completions); return completions;
} }
getName() { getName() {

View file

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
@ -21,12 +20,12 @@ export default class RoomProvider extends AutocompleteProvider {
}); });
} }
getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get(); let client = MatrixClientPeg.get();
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) { if (command) {
// the only reason we need to do this is because Fuse only matches on properties // 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.fuse.set(client.getRooms().filter(room => !!room).map(room => {
@ -48,7 +47,7 @@ export default class RoomProvider extends AutocompleteProvider {
}; };
}).slice(0, 4); }).slice(0, 4);
} }
return Q.when(completions); return completions;
} }
getName() { getName() {
@ -68,4 +67,8 @@ export default class RoomProvider extends AutocompleteProvider {
{completions} {completions}
</div>; </div>;
} }
shouldForceComplete(): boolean {
return true;
}
} }

View file

@ -20,11 +20,11 @@ export default class UserProvider extends AutocompleteProvider {
}); });
} }
getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection); let {command, range} = this.getCurrentCommand(query, selection, force);
if (command) { if (command) {
this.fuse.set(this.users); this.fuse.set(this.users);
completions = this.fuse.search(command[0]).map(user => { completions = this.fuse.search(command[0]).map(user => {
@ -37,11 +37,11 @@ export default class UserProvider extends AutocompleteProvider {
title={displayName} title={displayName}
description={user.userId} /> description={user.userId} />
), ),
range range,
}; };
}).slice(0, 4); }).slice(0, 4);
} }
return Q.when(completions); return completions;
} }
getName() { getName() {
@ -64,4 +64,8 @@ export default class UserProvider extends AutocompleteProvider {
{completions} {completions}
</div>; </div>;
} }
shouldForceComplete(): boolean {
return true;
}
} }

View file

@ -2,11 +2,17 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import flatMap from 'lodash/flatMap'; import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual';
import sdk from '../../../index'; import sdk from '../../../index';
import type {Completion, SelectionRange} from '../../../autocomplete/Autocompleter';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import {getCompletions} from '../../../autocomplete/Autocompleter';
const COMPOSER_SELECTED = 0;
export default class Autocomplete extends React.Component { export default class Autocomplete extends React.Component {
completionPromise: Promise = null;
constructor(props) { constructor(props) {
super(props); super(props);
@ -19,79 +25,137 @@ export default class Autocomplete extends React.Component {
// array of completions, so we can look up current selection by offset quickly // array of completions, so we can look up current selection by offset quickly
completionList: [], completionList: [],
// how far down the completion list we are // how far down the completion list we are (THIS IS 1-INDEXED!)
selectionOffset: 0, selectionOffset: COMPOSER_SELECTED,
// whether we should show completions if they're available
shouldShowCompletions: true,
hide: false,
forceComplete: false,
}; };
} }
componentWillReceiveProps(props, state) { async componentWillReceiveProps(props, state) {
if (props.query === this.props.query) { if (props.query === this.props.query) {
return null;
}
return await this.complete(props.query, props.selection);
}
async complete(query, selection) {
let forceComplete = this.state.forceComplete;
const completionPromise = getCompletions(query, selection, forceComplete);
this.completionPromise = completionPromise;
const completions = await this.completionPromise;
// There's a newer completion request, so ignore results.
if (completionPromise !== this.completionPromise) {
return; return;
} }
getCompletions(props.query, props.selection).forEach(completionResult => { const completionList = flatMap(completions, provider => provider.completions);
try {
completionResult.completions.then(completions => {
let i = this.state.completions.findIndex(
completion => completion.provider === completionResult.provider
);
i = i === -1 ? this.state.completions.length : i; // Reset selection when completion list becomes empty.
let newCompletions = Object.assign([], this.state.completions); let selectionOffset = COMPOSER_SELECTED;
completionResult.completions = completions; if (completionList.length > 0) {
newCompletions[i] = completionResult; /* If the currently selected completion is still in the completion list,
try to find it and jump to it. If not, select composer.
*/
const currentSelection = this.state.selectionOffset === 0 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex(
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);
// 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;
}
this.setState({ this.setState({
completions: newCompletions, completions,
completionList: flatMap(newCompletions, provider => provider.completions), completionList,
}); selectionOffset,
}, err => { hide,
console.error(err); forceComplete,
});
} catch (e) {
// An error in one provider shouldn't mess up the rest.
console.error(e);
}
}); });
} }
countCompletions(): number { countCompletions(): number {
return this.state.completions.map(completionResult => { return this.state.completionList.length;
return completionResult.completions.length;
}).reduce((l, r) => l + r);
} }
// called from MessageComposerInput // called from MessageComposerInput
onUpArrow(): boolean { onUpArrow(): ?Completion {
let completionCount = this.countCompletions(), const completionCount = this.countCompletions();
selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount; // completionCount + 1, since 0 means composer is selected
const selectionOffset = (completionCount + 1 + this.state.selectionOffset - 1)
% (completionCount + 1);
if (!completionCount) { if (!completionCount) {
return false; return null;
} }
this.setSelection(selectionOffset); this.setSelection(selectionOffset);
return true; return selectionOffset === COMPOSER_SELECTED ? null : this.state.completionList[selectionOffset - 1];
} }
// called from MessageComposerInput // called from MessageComposerInput
onDownArrow(): boolean { onDownArrow(): ?Completion {
let completionCount = this.countCompletions(), const completionCount = this.countCompletions();
selectionOffset = (this.state.selectionOffset + 1) % completionCount; // completionCount + 1, since 0 means composer is selected
const selectionOffset = (this.state.selectionOffset + 1) % (completionCount + 1);
if (!completionCount) { if (!completionCount) {
return false; return null;
} }
this.setSelection(selectionOffset); this.setSelection(selectionOffset);
return true; return selectionOffset === COMPOSER_SELECTED ? null : this.state.completionList[selectionOffset - 1];
}
onEscape(e): boolean {
const completionCount = this.countCompletions();
if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault
return;
}
e.preventDefault();
// selectionOffset = 0, so we don't end up completing when autocomplete is hidden
this.setState({hide: true, selectionOffset: 0});
}
forceComplete() {
this.setState({
forceComplete: true,
}, () => {
this.complete(this.props.query, this.props.selection);
});
} }
/** called from MessageComposerInput /** called from MessageComposerInput
* @returns {boolean} whether confirmation was handled * @returns {boolean} whether confirmation was handled
*/ */
onConfirm(): boolean { onConfirm(): boolean {
if (this.countCompletions() === 0) { if (this.countCompletions() === 0 || this.state.selectionOffset === COMPOSER_SELECTED) {
return false; return false;
} }
let selectedCompletion = this.state.completionList[this.state.selectionOffset]; let selectedCompletion = this.state.completionList[this.state.selectionOffset - 1];
this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion); this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion);
return true; return true;
@ -117,7 +181,7 @@ export default class Autocomplete extends React.Component {
render() { render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText'); const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 0; let position = 1;
let renderedCompletions = this.state.completions.map((completionResult, i) => { let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => { let completions = completionResult.completions.map((completion, i) => {
@ -135,7 +199,7 @@ export default class Autocomplete extends React.Component {
return React.cloneElement(completion.component, { return React.cloneElement(completion.component, {
key: i, key: i,
ref: `completion${i}`, ref: `completion${position - 1}`,
className, className,
onMouseOver, onMouseOver,
onClick, onClick,
@ -151,7 +215,7 @@ export default class Autocomplete extends React.Component {
) : null; ) : null;
}).filter(completion => !!completion); }).filter(completion => !!completion);
return renderedCompletions.length > 0 ? ( return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}> <div className="mx_Autocomplete" ref={(e) => this.container = e}>
{renderedCompletions} {renderedCompletions}
</div> </div>

View file

@ -166,7 +166,7 @@ export default class MessageComposer extends React.Component {
_onAutocompleteConfirm(range, completion) { _onAutocompleteConfirm(range, completion) {
if (this.messageComposerInput) { if (this.messageComposerInput) {
this.messageComposerInput.onConfirmAutocompletion(range, completion); this.messageComposerInput.setDisplayedCompletion(range, completion);
} }
} }
@ -313,7 +313,6 @@ export default class MessageComposer extends React.Component {
return ( return (
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}> <div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}>
{autoComplete}
<div className="mx_MessageComposer_wrapper"> <div className="mx_MessageComposer_wrapper">
<div className="mx_MessageComposer_row"> <div className="mx_MessageComposer_row">
{controls} {controls}

View file

@ -34,6 +34,7 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
import classNames from 'classnames'; import classNames from 'classnames';
import escape from 'lodash/escape'; import escape from 'lodash/escape';
import Q from 'q';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@ -46,6 +47,8 @@ import KeyCode from '../../../KeyCode';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
import * as RichText from '../../../RichText'; import * as RichText from '../../../RichText';
import Autocomplete from './Autocomplete';
import {Completion} from "../../../autocomplete/Autocompleter";
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
@ -88,34 +91,52 @@ export default class MessageComposerInput extends React.Component {
return getDefaultKeyBinding(e); return getDefaultKeyBinding(e);
} }
static getBlockStyle(block: ContentBlock): ?string {
if (block.getType() === 'strikethrough') {
return 'mx_Markdown_STRIKETHROUGH';
}
return null;
}
client: MatrixClient; client: MatrixClient;
autocomplete: Autocomplete;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.onAction = this.onAction.bind(this); this.onAction = this.onAction.bind(this);
this.handleReturn = this.handleReturn.bind(this); this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.onEditorContentChanged = this.onEditorContentChanged.bind(this);
this.setEditorState = this.setEditorState.bind(this); this.setEditorState = this.setEditorState.bind(this);
this.onUpArrow = this.onUpArrow.bind(this); this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this); this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this); this.onTab = this.onTab.bind(this);
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); this.onEscape = this.onEscape.bind(this);
this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true); const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true);
this.state = { this.state = {
// whether we're in rich text or markdown mode
isRichtextEnabled, isRichtextEnabled,
// the currently displayed editor state (note: this is always what is modified on input)
editorState: null, editorState: null,
// the original editor state, before we started tabbing through completions
originalEditorState: null,
}; };
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
/* eslint react/no-direct-mutation-state:0 */
this.state.editorState = this.createEditorState(); this.state.editorState = this.createEditorState();
this.client = MatrixClientPeg.get(); this.client = MatrixClientPeg.get();
} }
/** /*
* "Does the right thing" to create an EditorState, based on: * "Does the right thing" to create an EditorState, based on:
* - whether we've got rich text mode enabled * - whether we've got rich text mode enabled
* - contentState was passed in * - contentState was passed in
@ -234,10 +255,6 @@ export default class MessageComposerInput extends React.Component {
this.refs.editor, this.refs.editor,
this.props.room.roomId this.props.room.roomId
); );
// this is disabled for now, since https://github.com/matrix-org/matrix-react-sdk/pull/296 will land soon
// if (this.props.tabComplete) {
// this.props.tabComplete.setEditor(this.refs.editor);
// }
} }
componentWillUnmount() { componentWillUnmount() {
@ -273,7 +290,7 @@ export default class MessageComposerInput extends React.Component {
); );
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
this.setEditorState(editorState); this.onEditorContentChanged(editorState);
editor.focus(); editor.focus();
} }
break; break;
@ -295,10 +312,11 @@ export default class MessageComposerInput extends React.Component {
startSelection, startSelection,
blockMap); blockMap);
startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
if (this.state.isRichtextEnabled) if (this.state.isRichtextEnabled) {
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
}
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
this.setEditorState(editorState); this.onEditorContentChanged(editorState);
editor.focus(); editor.focus();
} }
} }
@ -372,10 +390,16 @@ export default class MessageComposerInput extends React.Component {
} }
} }
// Called by Draft to change editor contents, and by setEditorState
setEditorState(editorState: EditorState, cb = () => null) { onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState); editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
this.setState({editorState}, cb);
const setPromise = Q.defer();
/* If a modification was made, set originalEditorState to null, since newState is now our original */
this.setState({
editorState,
originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState,
}, () => setPromise.resolve());
if (editorState.getCurrentContent().hasText()) { if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity(); this.onTypingActivity();
@ -390,6 +414,11 @@ export default class MessageComposerInput extends React.Component {
this.props.onContentChanged(textContent, selection); this.props.onContentChanged(textContent, selection);
} }
return setPromise;
}
setEditorState(editorState: EditorState) {
this.onEditorContentChanged(editorState, false);
} }
enableRichtext(enabled: boolean) { enableRichtext(enabled: boolean) {
@ -470,7 +499,7 @@ export default class MessageComposerInput extends React.Component {
handleReturn(ev) { handleReturn(ev) {
if (ev.shiftKey) { if (ev.shiftKey) {
this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState)); this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
return true; return true;
} }
@ -547,41 +576,68 @@ export default class MessageComposerInput extends React.Component {
return true; return true;
} }
onUpArrow(e) { async onUpArrow(e) {
if (this.props.onUpArrow && this.props.onUpArrow()) { const completion = this.autocomplete.onUpArrow();
if (completion != null) {
e.preventDefault(); e.preventDefault();
} }
return await this.setDisplayedCompletion(completion);
}
async onDownArrow(e) {
const completion = this.autocomplete.onDownArrow();
e.preventDefault();
return await this.setDisplayedCompletion(completion);
}
// tab and shift-tab are mapped to down and up arrow respectively
async onTab(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();
}
} }
onDownArrow(e) { onEscape(e) {
if (this.props.onDownArrow && this.props.onDownArrow()) {
e.preventDefault(); e.preventDefault();
if (this.autocomplete) {
this.autocomplete.onEscape(e);
} }
this.setDisplayedCompletion(null); // restore originalEditorState
} }
onTab(e) { /* If passed null, restores the original editor content from state.originalEditorState.
if (this.props.tryComplete) { * If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
if (this.props.tryComplete()) { */
e.preventDefault(); async setDisplayedCompletion(displayedCompletion: ?Completion): boolean {
} const activeEditorState = this.state.originalEditorState || this.state.editorState;
if (displayedCompletion == null) {
if (this.state.originalEditorState) {
this.setEditorState(this.state.originalEditorState);
} }
return false;
} }
onConfirmAutocompletion(range, content: string) { const {range = {}, completion = ''} = displayedCompletion;
let contentState = Modifier.replaceText( let contentState = Modifier.replaceText(
this.state.editorState.getCurrentContent(), activeEditorState.getCurrentContent(),
RichText.textOffsetsToSelectionState(range, this.state.editorState.getCurrentContent().getBlocksAsArray()), RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()),
content completion
); );
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
const originalEditorState = activeEditorState;
this.setEditorState(editorState); await this.setEditorState(editorState);
this.setState({originalEditorState});
// for some reason, doing this right away does not update the editor :( // 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) { onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
@ -632,22 +688,14 @@ export default class MessageComposerInput extends React.Component {
this.handleKeyCommand('toggle-mode'); this.handleKeyCommand('toggle-mode');
} }
getBlockStyle(block: ContentBlock): ?string {
if (block.getType() === 'strikethrough') {
return 'mx_Markdown_STRIKETHROUGH';
}
return null;
}
render() { render() {
const {editorState} = this.state; const activeEditorState = this.state.originalEditorState || this.state.editorState;
// From https://github.com/facebook/draft-js/blob/master/examples/rich/rich.html#L92 // From https://github.com/facebook/draft-js/blob/master/examples/rich/rich.html#L92
// If the user changes block type before entering any text, we can // If the user changes block type before entering any text, we can
// either style the placeholder or hide it. // either style the placeholder or hide it.
let hidePlaceholder = false; let hidePlaceholder = false;
const contentState = editorState.getCurrentContent(); const contentState = activeEditorState.getCurrentContent();
if (!contentState.hasText()) { if (!contentState.hasText()) {
if (contentState.getBlockMap().first().getType() !== 'unstyled') { if (contentState.getBlockMap().first().getType() !== 'unstyled') {
hidePlaceholder = true; hidePlaceholder = true;
@ -658,7 +706,20 @@ export default class MessageComposerInput extends React.Component {
mx_MessageComposer_input_empty: hidePlaceholder, mx_MessageComposer_input_empty: hidePlaceholder,
}); });
const content = activeEditorState.getCurrentContent();
const contentText = content.getPlainText();
const selection = RichText.selectionStateToTextOffsets(activeEditorState.getSelection(),
activeEditorState.getCurrentContent().getBlocksAsArray());
return ( return (
<div className="mx_MessageComposer_input_wrapper">
<div className="mx_MessageComposer_autocomplete_wrapper">
<Autocomplete
ref={(e) => this.autocomplete = e}
onConfirm={this.setDisplayedCompletion}
query={contentText}
selection={selection} />
</div>
<div className={className}> <div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator" <img className="mx_MessageComposer_input_markdownIndicator"
onMouseDown={this.onMarkdownToggleClicked} onMouseDown={this.onMarkdownToggleClicked}
@ -667,8 +728,8 @@ export default class MessageComposerInput extends React.Component {
<Editor ref="editor" <Editor ref="editor"
placeholder="Type a message…" placeholder="Type a message…"
editorState={this.state.editorState} editorState={this.state.editorState}
onChange={this.setEditorState} onChange={this.onEditorContentChanged}
blockStyleFn={this.getBlockStyle} blockStyleFn={MessageComposerInput.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding} keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand} handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn} handleReturn={this.handleReturn}
@ -676,8 +737,10 @@ export default class MessageComposerInput extends React.Component {
onTab={this.onTab} onTab={this.onTab}
onUpArrow={this.onUpArrow} onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow} onDownArrow={this.onDownArrow}
onEscape={this.onEscape}
spellCheck={true} /> spellCheck={true} />
</div> </div>
</div>
); );
} }
}; };