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

@ -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<string> {
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<Completion> {
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;
}
}

View file

@ -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<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 {
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 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: '<user-id>',
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: '<display-name>',
description: 'Changes your display nickname',
},
{
command: '/ddg',
args: '<query>',
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() {

View file

@ -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: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
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;
});
const json = await response.json();
let results = json.Results.map(result => {
return {
completion: result.Text,
component: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
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;
}
getName() {

View file

@ -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() {

View file

@ -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}
</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');
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}
</div>;
}
shouldForceComplete(): boolean {
return true;
}
}