Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Improve autocomplete behaviour #466

Merged
merged 4 commits into from
Sep 13, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
6 changes: 6 additions & 0 deletions code_style.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,5 +158,11 @@ React
<Foo onClick={this.doStuff}> // Better
<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
information in component state that could be derived from the model?
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/RichText.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
let selectionState = SelectionState.createEmpty();

Expand Down
12 changes: 12 additions & 0 deletions src/SlashCommands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -56,6 +58,16 @@ var success = function(promise) {
};

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
nick: new Command("nick", "<display_name>", function(room_id, args) {
if (args) {
Expand Down
30 changes: 20 additions & 10 deletions src/autocomplete/AutocompleteProvider.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,18 +14,23 @@ 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()) {
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,
Expand All @@ -45,8 +50,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 {
Expand All @@ -57,4 +62,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;
}
}
59 changes: 50 additions & 9 deletions src/autocomplete/Autocompleter.js
Original file line number Diff line number Diff line change
@@ -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),
};
});
}
12 changes: 8 additions & 4 deletions src/autocomplete/CommandProvider.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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',
Expand All @@ -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;
Expand All @@ -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) {
Expand All @@ -70,7 +74,7 @@ export default class CommandProvider extends AutocompleteProvider {
};
});
}
return Q.when(completions);
return completions;
}

getName() {
Expand Down
97 changes: 47 additions & 50 deletions src/autocomplete/DuckDuckGoProvider.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import 'whatwg-fetch';

import {TextualCompletion} from './Components';
Expand All @@ -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() {
Expand Down
Loading