From b62622a814d7a3be9e67dfe1fc425e2b4b7c9368 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Tue, 13 Sep 2016 15:41:52 +0530 Subject: [PATCH] Improve autocomplete behaviour Fixes vector-im/vector-web#1761 --- .eslintrc | 16 +- code_style.md | 6 + package.json | 4 +- src/RichText.js | 3 +- src/SlashCommands.js | 12 ++ src/autocomplete/AutocompleteProvider.js | 31 ++- src/autocomplete/Autocompleter.js | 59 +++++- src/autocomplete/CommandProvider.js | 12 +- src/autocomplete/DuckDuckGoProvider.js | 97 +++++----- src/autocomplete/EmojiProvider.js | 6 +- src/autocomplete/RoomProvider.js | 11 +- src/autocomplete/UserProvider.js | 12 +- src/components/views/rooms/Autocomplete.js | 148 ++++++++++---- src/components/views/rooms/MessageComposer.js | 3 +- .../views/rooms/MessageComposerInput.js | 181 ++++++++++++------ 15 files changed, 407 insertions(+), 194 deletions(-) diff --git a/.eslintrc b/.eslintrc index 3f6c8e6953..e2baaed5a6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -78,18 +78,26 @@ /** react **/ // 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/prefer-stateless-function": ["warn"], - "react/sort-comp": ["warn"], /** flowtype **/ - "flowtype/require-parameter-type": 1, + "flowtype/require-parameter-type": [ + 1, + { + "excludeArrowFunctions": true + } + ], + "flowtype/define-flow-type": 1, "flowtype/require-return-type": [ 1, "always", { - "annotateUndefined": "never" + "annotateUndefined": "never", + "excludeArrowFunctions": true } ], "flowtype/space-after-type-colon": [ diff --git a/code_style.md b/code_style.md index 7b272e0656..cd4eabefb6 100644 --- a/code_style.md +++ b/code_style.md @@ -158,5 +158,11 @@ React // Better // Best, if onFooClick would do anything other than directly calling doStuff ``` + + Not doing so is acceptable in a single case; in function-refs: + + ```jsx + this.component = self}> + ``` - Think about whether your component really needs state: are you duplicating information in component state that could be derived from the model? diff --git a/package.json b/package.json index 192cefdf3a..4beb6bc6ce 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,8 @@ "babel-loader": "^5.4.0", "babel-polyfill": "^6.5.0", "eslint": "^2.13.1", - "eslint-plugin-flowtype": "^2.3.0", - "eslint-plugin-react": "^5.2.2", + "eslint-plugin-flowtype": "^2.17.0", + "eslint-plugin-react": "^6.2.1", "expect": "^1.16.0", "json-loader": "^0.5.3", "karma": "^0.13.22", diff --git a/src/RichText.js b/src/RichText.js index 31d82ee349..97a6f7a053 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -15,6 +15,7 @@ import { import * as sdk from './index'; import * as emojione from 'emojione'; import {stateToHTML} from 'draft-js-export-html'; +import {SelectionRange} from "./autocomplete/Autocompleter"; const MARKDOWN_REGEX = { 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): SelectionState { let selectionState = SelectionState.createEmpty(); diff --git a/src/SlashCommands.js b/src/SlashCommands.js index be007496dd..16df5ef2e1 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -17,6 +17,8 @@ limitations under the License. var MatrixClientPeg = require("./MatrixClientPeg"); var dis = require("./dispatcher"); var Tinter = require("./Tinter"); +import sdk from './index'; +import Modal from './Modal'; class Command { @@ -56,6 +58,16 @@ var success = function(promise) { }; var commands = { + ddg: new Command("ddg", "", 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 nick: new Command("nick", "", function(room_id, args) { if (args) { diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 87d7987856..137367194c 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,10 +1,10 @@ -import Q from 'q'; import React from 'react'; +import type {Completion, SelectionRange} from './Autocompleter'; export default class AutocompleteProvider { constructor(commandRegex?: RegExp, fuseOpts?: any) { - if(commandRegex) { - if(!commandRegex.global) { + if (commandRegex) { + if (!commandRegex.global) { throw new Error('commandRegex must have global flag set'); } 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. */ - getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array { - if (this.commandRegex == null) { + getCurrentCommand(query: string, selection: {start: number, end: number}, force: boolean = false): ?string { + let commandRegex = this.commandRegex; + + if (force && this.shouldForceComplete()) { + console.log('forcing complete'); + commandRegex = /[^\W]+/g; + } + + if (commandRegex == null) { return null; } - this.commandRegex.lastIndex = 0; + commandRegex.lastIndex = 0; let match; - while ((match = this.commandRegex.exec(query)) != null) { + while ((match = commandRegex.exec(query)) != null) { let matchStart = match.index, matchEnd = matchStart + match[0].length; - if (selection.start <= matchEnd && selection.end >= matchStart) { return { command: match, @@ -45,8 +51,8 @@ export default class AutocompleteProvider { }; } - getCompletions(query: string, selection: {start: number, end: number}) { - return Q.when([]); + async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { + return []; } getName(): string { @@ -57,4 +63,9 @@ export default class AutocompleteProvider { console.error('stub; should be implemented in subclasses'); return null; } + + // Whether we should provide completions even if triggered forcefully, without a sigil. + shouldForceComplete(): boolean { + return false; + } } diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 7f32e0ca40..1bf1b1dc14 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -1,22 +1,63 @@ +// @flow + +import type {Component} from 'react'; import CommandProvider from './CommandProvider'; import DuckDuckGoProvider from './DuckDuckGoProvider'; import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; 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 = [ UserProvider, - CommandProvider, - DuckDuckGoProvider, RoomProvider, EmojiProvider, + CommandProvider, + DuckDuckGoProvider, ].map(completer => completer.getInstance()); -export function getCompletions(query: string, selection: {start: number, end: number}) { - return PROVIDERS.map(provider => { - return { - completions: provider.getCompletions(query, selection), - provider, - }; - }); +// Providers will get rejected if they take longer than this. +const PROVIDER_COMPLETION_TIMEOUT = 3000; + +export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { + /* 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), + }; + }); } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 4652e69ddf..7d032006db 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -1,6 +1,5 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; -import Q from 'q'; import Fuse from 'fuse.js'; import {TextualCompletion} from './Components'; @@ -23,7 +22,7 @@ const COMMANDS = [ { command: '/invite', args: '', - description: 'Invites user with given id to current room' + description: 'Invites user with given id to current room', }, { command: '/join', @@ -40,6 +39,11 @@ const COMMANDS = [ args: '', description: 'Changes your display nickname', }, + { + command: '/ddg', + args: '', + description: 'Searches DuckDuckGo for results', + } ]; 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 {command, range} = this.getCurrentCommand(query, selection); if (command) { @@ -70,7 +74,7 @@ export default class CommandProvider extends AutocompleteProvider { }; }); } - return Q.when(completions); + return completions; } getName() { diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index c85eb8a10b..46aa4b0f03 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -1,6 +1,5 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; -import Q from 'q'; import 'whatwg-fetch'; 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)}`; } - getCompletions(query: string, selection: {start: number, end: number}) { + async getCompletions(query: string, selection: {start: number, end: number}) { let {command, range} = this.getCurrentCommand(query, selection); if (!query || !command) { - return Q.when([]); + return []; } - return fetch(DuckDuckGoProvider.getQueryUri(command[1]), { + const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), { method: 'GET', - }) - .then(response => response.json()) - .then(json => { - let results = json.Results.map(result => { - return { - completion: result.Text, - component: ( - - ), - range, - }; - }); - if (json.Answer) { - results.unshift({ - completion: json.Answer, - component: ( - - ), - range, - }); - } - if (json.RelatedTopics && json.RelatedTopics.length > 0) { - results.unshift({ - completion: json.RelatedTopics[0].Text, - component: ( - - ), - range, - }); - } - if (json.AbstractText) { - results.unshift({ - completion: json.AbstractText, - component: ( - - ), - range, - }); - } - return results; + }); + const json = await response.json(); + let results = json.Results.map(result => { + return { + completion: result.Text, + component: ( + + ), + range, + }; + }); + if (json.Answer) { + results.unshift({ + completion: json.Answer, + component: ( + + ), + range, }); + } + if (json.RelatedTopics && json.RelatedTopics.length > 0) { + results.unshift({ + completion: json.RelatedTopics[0].Text, + component: ( + + ), + range, + }); + } + if (json.AbstractText) { + results.unshift({ + completion: json.AbstractText, + component: ( + + ), + range, + }); + } + return results; } getName() { diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index e292808787..4c8bf60b83 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -1,10 +1,10 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; -import Q from 'q'; import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import Fuse from 'fuse.js'; 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); @@ -17,7 +17,7 @@ export default class EmojiProvider extends AutocompleteProvider { 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'); let completions = []; @@ -35,7 +35,7 @@ export default class EmojiProvider extends AutocompleteProvider { }; }).slice(0, 8); } - return Q.when(completions); + return completions; } getName() { diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index ac7f1b418a..b9593f197e 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,6 +1,5 @@ import React from 'react'; import AutocompleteProvider from './AutocompleteProvider'; -import Q from 'q'; import MatrixClientPeg from '../MatrixClientPeg'; import Fuse from 'fuse.js'; 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'); let client = MatrixClientPeg.get(); let completions = []; - const {command, range} = this.getCurrentCommand(query, selection); + 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 => { @@ -48,7 +47,7 @@ export default class RoomProvider extends AutocompleteProvider { }; }).slice(0, 4); } - return Q.when(completions); + return completions; } getName() { @@ -68,4 +67,8 @@ export default class RoomProvider extends AutocompleteProvider { {completions} ; } + + shouldForceComplete(): boolean { + return true; + } } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 7485d76484..00eb996c96 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -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'); let completions = []; - let {command, range} = this.getCurrentCommand(query, selection); + let {command, range} = this.getCurrentCommand(query, selection, force); if (command) { this.fuse.set(this.users); completions = this.fuse.search(command[0]).map(user => { @@ -37,11 +37,11 @@ export default class UserProvider extends AutocompleteProvider { title={displayName} description={user.userId} /> ), - range + range, }; }).slice(0, 4); } - return Q.when(completions); + return completions; } getName() { @@ -64,4 +64,8 @@ export default class UserProvider extends AutocompleteProvider { {completions} ; } + + shouldForceComplete(): boolean { + return true; + } } diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 9b8b55ab51..7ac138568c 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -2,11 +2,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; import flatMap from 'lodash/flatMap'; +import isEqual from 'lodash/isEqual'; import sdk from '../../../index'; +import type {Completion, SelectionRange} from '../../../autocomplete/Autocompleter'; import {getCompletions} from '../../../autocomplete/Autocompleter'; +const COMPOSER_SELECTED = 0; + export default class Autocomplete extends React.Component { + completionPromise: Promise = null; + constructor(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 completionList: [], - // how far down the completion list we are - selectionOffset: 0, + // how far down the completion list we are (THIS IS 1-INDEXED!) + 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) { + 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; } - getCompletions(props.query, props.selection).forEach(completionResult => { - try { - completionResult.completions.then(completions => { - let i = this.state.completions.findIndex( - completion => completion.provider === completionResult.provider - ); + const completionList = flatMap(completions, provider => provider.completions); - i = i === -1 ? this.state.completions.length : i; - let newCompletions = Object.assign([], this.state.completions); - completionResult.completions = completions; - newCompletions[i] = completionResult; - - this.setState({ - completions: newCompletions, - completionList: flatMap(newCompletions, provider => provider.completions), - }); - }, err => { - console.error(err); - }); - } catch (e) { - // An error in one provider shouldn't mess up the rest. - console.error(e); + // Reset selection when completion list becomes empty. + let selectionOffset = COMPOSER_SELECTED; + if (completionList.length > 0) { + /* 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({ + completions, + completionList, + selectionOffset, + hide, + forceComplete, }); } countCompletions(): number { - return this.state.completions.map(completionResult => { - return completionResult.completions.length; - }).reduce((l, r) => l + r); + return this.state.completionList.length; } // called from MessageComposerInput - onUpArrow(): boolean { - let completionCount = this.countCompletions(), - selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount; + onUpArrow(): ?Completion { + const completionCount = this.countCompletions(); + // completionCount + 1, since 0 means composer is selected + const selectionOffset = (completionCount + 1 + this.state.selectionOffset - 1) + % (completionCount + 1); if (!completionCount) { - return false; + return null; } this.setSelection(selectionOffset); - return true; + return selectionOffset === COMPOSER_SELECTED ? null : this.state.completionList[selectionOffset - 1]; } // called from MessageComposerInput - onDownArrow(): boolean { - let completionCount = this.countCompletions(), - selectionOffset = (this.state.selectionOffset + 1) % completionCount; + onDownArrow(): ?Completion { + const completionCount = this.countCompletions(); + // completionCount + 1, since 0 means composer is selected + const selectionOffset = (this.state.selectionOffset + 1) % (completionCount + 1); if (!completionCount) { - return false; + return null; } 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 * @returns {boolean} whether confirmation was handled */ onConfirm(): boolean { - if (this.countCompletions() === 0) { + if (this.countCompletions() === 0 || this.state.selectionOffset === COMPOSER_SELECTED) { 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); return true; @@ -117,7 +181,7 @@ export default class Autocomplete extends React.Component { render() { const EmojiText = sdk.getComponent('views.elements.EmojiText'); - let position = 0; + let position = 1; let renderedCompletions = this.state.completions.map((completionResult, i) => { let completions = completionResult.completions.map((completion, i) => { @@ -135,7 +199,7 @@ export default class Autocomplete extends React.Component { return React.cloneElement(completion.component, { key: i, - ref: `completion${i}`, + ref: `completion${position - 1}`, className, onMouseOver, onClick, @@ -151,7 +215,7 @@ export default class Autocomplete extends React.Component { ) : null; }).filter(completion => !!completion); - return renderedCompletions.length > 0 ? ( + return !this.state.hide && renderedCompletions.length > 0 ? (
this.container = e}> {renderedCompletions}
diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index ff5c7f5259..404db6a780 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -166,7 +166,7 @@ export default class MessageComposer extends React.Component { _onAutocompleteConfirm(range, completion) { 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 (
- {autoComplete}
{controls} diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index c326a7c4f5..945771a12c 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -34,6 +34,7 @@ import {Editor, EditorState, RichUtils, CompositeDecorator, import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; import classNames from 'classnames'; import escape from 'lodash/escape'; +import Q from 'q'; import MatrixClientPeg from '../../../MatrixClientPeg'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; @@ -46,6 +47,8 @@ import KeyCode from '../../../KeyCode'; import UserSettingsStore from '../../../UserSettingsStore'; import * as RichText from '../../../RichText'; +import Autocomplete from './Autocomplete'; +import {Completion} from "../../../autocomplete/Autocompleter"; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; @@ -88,34 +91,52 @@ export default class MessageComposerInput extends React.Component { return getDefaultKeyBinding(e); } + static getBlockStyle(block: ContentBlock): ?string { + if (block.getType() === 'strikethrough') { + return 'mx_Markdown_STRIKETHROUGH'; + } + + return null; + } + client: MatrixClient; + autocomplete: Autocomplete; constructor(props, context) { super(props, context); this.onAction = this.onAction.bind(this); 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); - this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); + this.onEscape = this.onEscape.bind(this); + this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this); this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this); const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true); this.state = { + // whether we're in rich text or markdown mode isRichtextEnabled, + + // the currently displayed editor state (note: this is always what is modified on input) editorState: 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 + /* eslint react/no-direct-mutation-state:0 */ this.state.editorState = this.createEditorState(); this.client = MatrixClientPeg.get(); } - /** + /* * "Does the right thing" to create an EditorState, based on: * - whether we've got rich text mode enabled * - contentState was passed in @@ -234,10 +255,6 @@ export default class MessageComposerInput extends React.Component { this.refs.editor, 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() { @@ -273,7 +290,7 @@ export default class MessageComposerInput extends React.Component { ); let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter()); - this.setEditorState(editorState); + this.onEditorContentChanged(editorState); editor.focus(); } break; @@ -295,10 +312,11 @@ export default class MessageComposerInput extends React.Component { startSelection, blockMap); startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey()); - if (this.state.isRichtextEnabled) + if (this.state.isRichtextEnabled) { contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote'); + } let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters'); - this.setEditorState(editorState); + this.onEditorContentChanged(editorState); editor.focus(); } } @@ -372,10 +390,16 @@ export default class MessageComposerInput extends React.Component { } } - - setEditorState(editorState: EditorState, cb = () => null) { + // Called by Draft to change editor contents, and by setEditorState + onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) { 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()) { this.onTypingActivity(); @@ -390,6 +414,11 @@ export default class MessageComposerInput extends React.Component { this.props.onContentChanged(textContent, selection); } + return setPromise; + } + + setEditorState(editorState: EditorState) { + this.onEditorContentChanged(editorState, false); } enableRichtext(enabled: boolean) { @@ -470,7 +499,7 @@ export default class MessageComposerInput extends React.Component { handleReturn(ev) { if (ev.shiftKey) { - this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState)); + this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState)); return true; } @@ -547,41 +576,68 @@ export default class MessageComposerInput extends React.Component { return true; } - onUpArrow(e) { - if (this.props.onUpArrow && this.props.onUpArrow()) { + async onUpArrow(e) { + const completion = this.autocomplete.onUpArrow(); + if (completion != null) { 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) { - if (this.props.onDownArrow && this.props.onDownArrow()) { - e.preventDefault(); + onEscape(e) { + e.preventDefault(); + if (this.autocomplete) { + this.autocomplete.onEscape(e); } + this.setDisplayedCompletion(null); // restore originalEditorState } - onTab(e) { - if (this.props.tryComplete) { - if (this.props.tryComplete()) { - e.preventDefault(); + /* 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 { + 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( - this.state.editorState.getCurrentContent(), - RichText.textOffsetsToSelectionState(range, this.state.editorState.getCurrentContent().getBlocksAsArray()), - content + activeEditorState.getCurrentContent(), + RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()), + 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()); + 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 :( setTimeout(() => this.refs.editor.focus(), 50); + return true; } 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'); } - getBlockStyle(block: ContentBlock): ?string { - if (block.getType() === 'strikethrough') { - return 'mx_Markdown_STRIKETHROUGH'; - } - - return null; - } - 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 // If the user changes block type before entering any text, we can // either style the placeholder or hide it. let hidePlaceholder = false; - const contentState = editorState.getCurrentContent(); + const contentState = activeEditorState.getCurrentContent(); if (!contentState.hasText()) { if (contentState.getBlockMap().first().getType() !== 'unstyled') { hidePlaceholder = true; @@ -655,28 +703,43 @@ export default class MessageComposerInput extends React.Component { } 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 ( -
- - +
+
+ this.autocomplete = e} + onConfirm={this.setDisplayedCompletion} + query={contentText} + selection={selection} /> +
+
+ + +
); }