2016-06-01 14:24:21 +03:00
|
|
|
import React from 'react';
|
2016-08-17 14:57:19 +03:00
|
|
|
import ReactDOM from 'react-dom';
|
2016-06-21 16:03:39 +03:00
|
|
|
import classNames from 'classnames';
|
2016-07-04 19:26:09 +03:00
|
|
|
import flatMap from 'lodash/flatMap';
|
2016-09-13 13:11:52 +03:00
|
|
|
import isEqual from 'lodash/isEqual';
|
2016-08-17 14:57:19 +03:00
|
|
|
import sdk from '../../../index';
|
2017-06-02 23:35:55 +03:00
|
|
|
import type {Completion} from '../../../autocomplete/Autocompleter';
|
2017-07-12 15:58:14 +03:00
|
|
|
import Promise from 'bluebird';
|
2017-02-20 16:56:40 +03:00
|
|
|
import UserSettingsStore from '../../../UserSettingsStore';
|
2016-06-01 14:24:21 +03:00
|
|
|
|
|
|
|
import {getCompletions} from '../../../autocomplete/Autocompleter';
|
|
|
|
|
2016-09-13 13:11:52 +03:00
|
|
|
const COMPOSER_SELECTED = 0;
|
|
|
|
|
2016-06-01 14:24:21 +03:00
|
|
|
export default class Autocomplete extends React.Component {
|
2016-09-13 13:11:52 +03:00
|
|
|
|
2016-06-01 14:24:21 +03:00
|
|
|
constructor(props) {
|
|
|
|
super(props);
|
2016-07-03 19:45:13 +03:00
|
|
|
|
2016-09-13 14:16:20 +03:00
|
|
|
this.completionPromise = null;
|
2016-09-21 04:58:07 +03:00
|
|
|
this.hide = this.hide.bind(this);
|
2016-09-21 05:10:48 +03:00
|
|
|
this.onCompletionClicked = this.onCompletionClicked.bind(this);
|
2016-07-03 19:45:13 +03:00
|
|
|
|
2016-06-01 14:24:21 +03:00
|
|
|
this.state = {
|
2016-07-03 19:45:13 +03:00
|
|
|
// list of completionResults, each containing completions
|
2016-06-21 13:16:20 +03:00
|
|
|
completions: [],
|
|
|
|
|
2016-07-03 19:45:13 +03:00
|
|
|
// array of completions, so we can look up current selection by offset quickly
|
|
|
|
completionList: [],
|
|
|
|
|
2016-09-13 13:11:52 +03:00
|
|
|
// 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,
|
2016-06-01 14:24:21 +03:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2017-06-28 19:27:21 +03:00
|
|
|
componentWillReceiveProps(newProps, state) {
|
|
|
|
// Query hasn't changed so don't try to complete it
|
|
|
|
if (newProps.query === this.props.query) {
|
|
|
|
return;
|
2016-09-13 13:11:52 +03:00
|
|
|
}
|
|
|
|
|
2017-06-28 19:27:21 +03:00
|
|
|
this.complete(newProps.query, newProps.selection);
|
2016-09-13 13:11:52 +03:00
|
|
|
}
|
|
|
|
|
2017-06-28 19:27:21 +03:00
|
|
|
complete(query, selection) {
|
2017-07-07 17:30:31 +03:00
|
|
|
this.queryRequested = query;
|
2017-06-28 19:27:21 +03:00
|
|
|
if (this.debounceCompletionsRequest) {
|
|
|
|
clearTimeout(this.debounceCompletionsRequest);
|
|
|
|
}
|
|
|
|
if (query === "") {
|
|
|
|
this.setState({
|
|
|
|
// Clear displayed completions
|
|
|
|
completions: [],
|
|
|
|
completionList: [],
|
|
|
|
// Reset selected completion
|
|
|
|
selectionOffset: COMPOSER_SELECTED,
|
|
|
|
// Hide the autocomplete box
|
|
|
|
hide: true,
|
|
|
|
});
|
2017-07-12 16:02:00 +03:00
|
|
|
return Promise.resolve(null);
|
2017-06-28 19:27:21 +03:00
|
|
|
}
|
|
|
|
let autocompleteDelay = UserSettingsStore.getLocalSetting('autocompleteDelay', 200);
|
2016-09-13 13:11:52 +03:00
|
|
|
|
2017-06-28 19:27:21 +03:00
|
|
|
// Don't debounce if we are already showing completions
|
2017-07-04 19:49:50 +03:00
|
|
|
if (this.state.completions.length > 0 || this.state.forceComplete) {
|
2017-06-28 19:27:21 +03:00
|
|
|
autocompleteDelay = 0;
|
2016-07-02 22:41:34 +03:00
|
|
|
}
|
2016-06-17 02:28:09 +03:00
|
|
|
|
2017-07-12 16:04:20 +03:00
|
|
|
const deferred = Promise.defer();
|
2017-06-28 19:27:21 +03:00
|
|
|
this.debounceCompletionsRequest = setTimeout(() => {
|
2017-07-07 17:30:31 +03:00
|
|
|
this.processQuery(query, selection).then(() => {
|
2017-06-28 19:27:21 +03:00
|
|
|
deferred.resolve();
|
|
|
|
});
|
|
|
|
}, autocompleteDelay);
|
|
|
|
return deferred.promise;
|
|
|
|
}
|
|
|
|
|
2017-07-07 17:30:31 +03:00
|
|
|
processQuery(query, selection) {
|
|
|
|
return getCompletions(
|
|
|
|
query, selection, this.state.forceComplete,
|
|
|
|
).then((completions) => {
|
|
|
|
// Only ever process the completions for the most recent query being processed
|
|
|
|
if (query !== this.queryRequested) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
this.processCompletions(completions);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2017-06-28 19:27:21 +03:00
|
|
|
processCompletions(completions) {
|
2017-02-10 01:10:57 +03:00
|
|
|
const completionList = flatMap(completions, (provider) => provider.completions);
|
2016-09-13 13:11:52 +03:00
|
|
|
|
|
|
|
// 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(
|
2017-02-10 01:10:57 +03:00
|
|
|
(completion) => completion.completion === currentSelection);
|
2016-09-13 13:11:52 +03:00
|
|
|
if (selectionOffset === -1) {
|
|
|
|
selectionOffset = COMPOSER_SELECTED;
|
|
|
|
} else {
|
|
|
|
selectionOffset++; // selectionOffset is 1-indexed!
|
2016-06-12 14:32:46 +03:00
|
|
|
}
|
2016-09-13 13:11:52 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
let hide = this.state.hide;
|
2017-07-07 17:30:31 +03:00
|
|
|
// If `completion.command.command` is truthy, then a provider has matched with the query
|
|
|
|
const anyMatches = completions.some((completion) => !!completion.command.command);
|
|
|
|
hide = !anyMatches;
|
2016-09-13 13:11:52 +03:00
|
|
|
|
|
|
|
this.setState({
|
|
|
|
completions,
|
|
|
|
completionList,
|
|
|
|
selectionOffset,
|
|
|
|
hide,
|
2017-06-28 19:27:21 +03:00
|
|
|
// Force complete is turned off each time since we can't edit the query in that case
|
|
|
|
forceComplete: false,
|
2016-06-01 14:24:21 +03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2016-07-02 22:41:34 +03:00
|
|
|
countCompletions(): number {
|
2016-09-13 13:11:52 +03:00
|
|
|
return this.state.completionList.length;
|
2016-07-02 22:41:34 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// called from MessageComposerInput
|
2016-09-13 13:11:52 +03:00
|
|
|
onUpArrow(): ?Completion {
|
|
|
|
const completionCount = this.countCompletions();
|
|
|
|
// completionCount + 1, since 0 means composer is selected
|
|
|
|
const selectionOffset = (completionCount + 1 + this.state.selectionOffset - 1)
|
|
|
|
% (completionCount + 1);
|
2016-08-03 15:34:52 +03:00
|
|
|
if (!completionCount) {
|
2016-09-13 13:11:52 +03:00
|
|
|
return null;
|
2016-08-03 15:34:52 +03:00
|
|
|
}
|
2016-07-03 19:45:13 +03:00
|
|
|
this.setSelection(selectionOffset);
|
2016-09-13 13:11:52 +03:00
|
|
|
return selectionOffset === COMPOSER_SELECTED ? null : this.state.completionList[selectionOffset - 1];
|
2016-06-21 16:03:39 +03:00
|
|
|
}
|
|
|
|
|
2016-07-02 22:41:34 +03:00
|
|
|
// called from MessageComposerInput
|
2016-09-13 13:11:52 +03:00
|
|
|
onDownArrow(): ?Completion {
|
|
|
|
const completionCount = this.countCompletions();
|
|
|
|
// completionCount + 1, since 0 means composer is selected
|
|
|
|
const selectionOffset = (this.state.selectionOffset + 1) % (completionCount + 1);
|
2016-08-03 15:34:52 +03:00
|
|
|
if (!completionCount) {
|
2016-09-13 13:11:52 +03:00
|
|
|
return null;
|
2016-08-03 15:34:52 +03:00
|
|
|
}
|
2016-07-03 19:45:13 +03:00
|
|
|
this.setSelection(selectionOffset);
|
2016-09-13 13:11:52 +03:00
|
|
|
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
|
2016-09-21 04:58:07 +03:00
|
|
|
this.hide();
|
|
|
|
}
|
|
|
|
|
|
|
|
hide() {
|
2017-08-08 19:25:11 +03:00
|
|
|
this.setState({hide: true, selectionOffset: 0, completions: [], completionList: []});
|
2016-09-13 13:11:52 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
forceComplete() {
|
2017-07-12 16:04:20 +03:00
|
|
|
const done = Promise.defer();
|
2016-09-13 13:11:52 +03:00
|
|
|
this.setState({
|
|
|
|
forceComplete: true,
|
2017-02-09 23:36:06 +03:00
|
|
|
hide: false,
|
2016-09-13 13:11:52 +03:00
|
|
|
}, () => {
|
2016-09-13 15:32:33 +03:00
|
|
|
this.complete(this.props.query, this.props.selection).then(() => {
|
2017-07-05 20:14:22 +03:00
|
|
|
done.resolve(this.countCompletions());
|
2016-09-13 15:32:33 +03:00
|
|
|
});
|
2016-09-13 13:11:52 +03:00
|
|
|
});
|
2016-09-13 15:32:33 +03:00
|
|
|
return done.promise;
|
2016-06-21 16:03:39 +03:00
|
|
|
}
|
|
|
|
|
2016-09-21 05:10:48 +03:00
|
|
|
onCompletionClicked(): boolean {
|
2016-09-13 13:11:52 +03:00
|
|
|
if (this.countCompletions() === 0 || this.state.selectionOffset === COMPOSER_SELECTED) {
|
2016-07-03 19:45:13 +03:00
|
|
|
return false;
|
2016-07-04 19:14:35 +03:00
|
|
|
}
|
2016-07-03 19:45:13 +03:00
|
|
|
|
2016-09-21 05:10:48 +03:00
|
|
|
this.props.onConfirm(this.state.completionList[this.state.selectionOffset - 1]);
|
|
|
|
this.hide();
|
2016-07-03 19:45:13 +03:00
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
setSelection(selectionOffset: number) {
|
2017-02-10 01:10:57 +03:00
|
|
|
this.setState({selectionOffset, hide: false});
|
2016-07-03 19:45:13 +03:00
|
|
|
}
|
|
|
|
|
2016-08-17 14:57:19 +03:00
|
|
|
componentDidUpdate() {
|
|
|
|
// this is the selected completion, so scroll it into view if needed
|
|
|
|
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
|
|
|
|
if (selectedCompletion && this.container) {
|
2016-08-22 22:06:31 +03:00
|
|
|
const domNode = ReactDOM.findDOMNode(selectedCompletion);
|
|
|
|
const offsetTop = domNode && domNode.offsetTop;
|
2016-08-17 14:57:19 +03:00
|
|
|
if (offsetTop > this.container.scrollTop + this.container.offsetHeight ||
|
|
|
|
offsetTop < this.container.scrollTop) {
|
|
|
|
this.container.scrollTop = offsetTop - this.container.offsetTop;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-02-09 23:36:06 +03:00
|
|
|
setState(state, func) {
|
|
|
|
super.setState(state, func);
|
|
|
|
}
|
|
|
|
|
2016-06-01 14:24:21 +03:00
|
|
|
render() {
|
2016-08-17 14:57:19 +03:00
|
|
|
const EmojiText = sdk.getComponent('views.elements.EmojiText');
|
|
|
|
|
2016-09-13 13:11:52 +03:00
|
|
|
let position = 1;
|
2017-02-10 01:10:57 +03:00
|
|
|
const renderedCompletions = this.state.completions.map((completionResult, i) => {
|
|
|
|
const completions = completionResult.completions.map((completion, i) => {
|
2016-08-17 14:57:19 +03:00
|
|
|
const className = classNames('mx_Autocomplete_Completion', {
|
2016-07-02 22:41:34 +03:00
|
|
|
'selected': position === this.state.selectionOffset,
|
2016-06-21 16:03:39 +03:00
|
|
|
});
|
2017-02-10 01:10:57 +03:00
|
|
|
const componentPosition = position;
|
2016-06-21 16:03:39 +03:00
|
|
|
position++;
|
2016-07-02 22:41:34 +03:00
|
|
|
|
2017-02-10 01:10:57 +03:00
|
|
|
const onMouseOver = () => this.setSelection(componentPosition);
|
|
|
|
const onClick = () => {
|
2016-07-04 19:14:35 +03:00
|
|
|
this.setSelection(componentPosition);
|
2016-09-21 05:10:48 +03:00
|
|
|
this.onCompletionClicked();
|
2016-07-04 19:14:35 +03:00
|
|
|
};
|
2016-07-03 19:45:13 +03:00
|
|
|
|
2016-08-17 14:57:19 +03:00
|
|
|
return React.cloneElement(completion.component, {
|
|
|
|
key: i,
|
2016-09-13 13:11:52 +03:00
|
|
|
ref: `completion${position - 1}`,
|
2016-08-17 14:57:19 +03:00
|
|
|
className,
|
|
|
|
onMouseOver,
|
|
|
|
onClick,
|
|
|
|
});
|
2016-06-12 14:32:46 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return completions.length > 0 ? (
|
2016-06-17 02:28:09 +03:00
|
|
|
<div key={i} className="mx_Autocomplete_ProviderSection">
|
2016-08-17 14:57:19 +03:00
|
|
|
<EmojiText element="div" className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</EmojiText>
|
|
|
|
{completionResult.provider.renderCompletions(completions)}
|
2016-06-01 14:24:21 +03:00
|
|
|
</div>
|
2016-06-12 14:32:46 +03:00
|
|
|
) : null;
|
2017-02-10 01:10:57 +03:00
|
|
|
}).filter((completion) => !!completion);
|
2016-06-01 14:24:21 +03:00
|
|
|
|
2016-09-13 13:11:52 +03:00
|
|
|
return !this.state.hide && renderedCompletions.length > 0 ? (
|
2016-08-17 14:57:19 +03:00
|
|
|
<div className="mx_Autocomplete" ref={(e) => this.container = e}>
|
|
|
|
{renderedCompletions}
|
2016-06-01 14:24:21 +03:00
|
|
|
</div>
|
2016-09-07 22:06:23 +03:00
|
|
|
) : null;
|
2016-06-01 14:24:21 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Autocomplete.propTypes = {
|
|
|
|
// the query string for which to show autocomplete suggestions
|
2016-07-02 22:41:34 +03:00
|
|
|
query: React.PropTypes.string.isRequired,
|
2016-07-04 19:14:35 +03:00
|
|
|
|
|
|
|
// method invoked with range and text content when completion is confirmed
|
|
|
|
onConfirm: React.PropTypes.func.isRequired,
|
2016-06-01 14:24:21 +03:00
|
|
|
};
|