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,10 +1,10 @@
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) {
if(commandRegex) { if (commandRegex) {
if(!commandRegex.global) { if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set'); throw new Error('commandRegex must have global flag set');
} }
this.commandRegex = commandRegex; this.commandRegex = commandRegex;
@ -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;
return {
completions: provider.getCompletions(query, selection), export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
provider, /* 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 {
completions: completionsState.value,
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,61 +19,59 @@ 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, component: (
component: ( <TextualCompletion
<TextualCompletion title={result.Text}
title={result.Text} description={result.Result} />
description={result.Result} /> ),
), range,
range, };
}; });
}); if (json.Answer) {
if (json.Answer) { results.unshift({
results.unshift({ completion: json.Answer,
completion: json.Answer, component: (
component: ( <TextualCompletion
<TextualCompletion title={json.Answer}
title={json.Answer} description={json.AnswerType} />
description={json.AnswerType} /> ),
), range,
range,
});
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
return results;
}); });
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
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.
this.setState({ */
completions: newCompletions, const currentSelection = this.state.selectionOffset === 0 ? null :
completionList: flatMap(newCompletions, provider => provider.completions), this.state.completionList[this.state.selectionOffset - 1].completion;
}); selectionOffset = completionList.findIndex(
}, err => { completion => completion.completion === currentSelection);
console.error(err); if (selectionOffset === -1) {
}); selectionOffset = COMPOSER_SELECTED;
} catch (e) { } else {
// An error in one provider shouldn't mess up the rest. selectionOffset++; // selectionOffset is 1-indexed!
console.error(e);
} }
} 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({
completions,
completionList,
selectionOffset,
hide,
forceComplete,
}); });
} }
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;
@ -655,28 +703,43 @@ export default class MessageComposerInput extends React.Component {
} }
const className = classNames('mx_MessageComposer_input', { const className = classNames('mx_MessageComposer_input', {
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={className}> <div className="mx_MessageComposer_input_wrapper">
<img className="mx_MessageComposer_input_markdownIndicator" <div className="mx_MessageComposer_autocomplete_wrapper">
onMouseDown={this.onMarkdownToggleClicked} <Autocomplete
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`} ref={(e) => this.autocomplete = e}
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} /> onConfirm={this.setDisplayedCompletion}
<Editor ref="editor" query={contentText}
placeholder="Type a message…" selection={selection} />
editorState={this.state.editorState} </div>
onChange={this.setEditorState} <div className={className}>
blockStyleFn={this.getBlockStyle} <img className="mx_MessageComposer_input_markdownIndicator"
keyBindingFn={MessageComposerInput.getKeyBinding} onMouseDown={this.onMarkdownToggleClicked}
handleKeyCommand={this.handleKeyCommand} title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
handleReturn={this.handleReturn} src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
stripPastedStyles={!this.state.isRichtextEnabled} <Editor ref="editor"
onTab={this.onTab} placeholder="Type a message…"
onUpArrow={this.onUpArrow} editorState={this.state.editorState}
onDownArrow={this.onDownArrow} onChange={this.onEditorContentChanged}
spellCheck={true} /> blockStyleFn={MessageComposerInput.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
onEscape={this.onEscape}
spellCheck={true} />
</div>
</div> </div>
); );
} }