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

Fix autocomplete #2212

Merged
merged 4 commits into from
Oct 15, 2018
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
4 changes: 2 additions & 2 deletions src/autocomplete/CommandProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ limitations under the License.
import React from 'react';
import {_t} from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import FuzzyMatcher from './FuzzyMatcher';
import QueryMatcher from './QueryMatcher';
import {TextualCompletion} from './Components';
import type {Completion, SelectionRange} from "./Autocompleter";
import {CommandMap} from '../SlashCommands';
Expand All @@ -32,7 +32,7 @@ const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
export default class CommandProvider extends AutocompleteProvider {
constructor() {
super(COMMAND_RE);
this.matcher = new FuzzyMatcher(COMMANDS, {
this.matcher = new QueryMatcher(COMMANDS, {
keys: ['command', 'args', 'description'],
});
}
Expand Down
4 changes: 2 additions & 2 deletions src/autocomplete/CommunityProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg';
import FuzzyMatcher from './FuzzyMatcher';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import sdk from '../index';
import _sortBy from 'lodash/sortBy';
Expand All @@ -41,7 +41,7 @@ function score(query, space) {
export default class CommunityProvider extends AutocompleteProvider {
constructor() {
super(COMMUNITY_REGEX);
this.matcher = new FuzzyMatcher([], {
this.matcher = new QueryMatcher([], {
keys: ['groupId', 'name', 'shortDescription'],
});
}
Expand Down
6 changes: 3 additions & 3 deletions src/autocomplete/EmojiProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione';
import FuzzyMatcher from './FuzzyMatcher';
import QueryMatcher from './QueryMatcher';
import sdk from '../index';
import {PillCompletion} from './Components';
import type {Completion, SelectionRange} from './Autocompleter';
Expand Down Expand Up @@ -84,12 +84,12 @@ function score(query, space) {
export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, {
keys: ['aliases_ascii', 'shortname', 'aliases'],
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
});
this.nameMatcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
this.nameMatcher = new QueryMatcher(EMOJI_SHORTNAMES, {
keys: ['name'],
// For removing punctuation
shouldMatchWordsOnly: true,
Expand Down
107 changes: 0 additions & 107 deletions src/autocomplete/FuzzyMatcher.js

This file was deleted.

115 changes: 58 additions & 57 deletions src/autocomplete/QueryMatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/*
Copyright 2017 Aviral Dasgupta
Copyright 2018 Michael Telatynski <[email protected]>
Copyright 2018 New Vector Ltd

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -20,99 +21,99 @@ import _at from 'lodash/at';
import _flatMap from 'lodash/flatMap';
import _sortBy from 'lodash/sortBy';
import _uniq from 'lodash/uniq';
import _keys from 'lodash/keys';

class KeyMap {
keys: Array<String>;
objectMap: {[String]: Array<Object>};
priorityMap = new Map();
}

function stripDiacritics(str: string): string {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

/**
* Simple search matcher that matches any results with the query string anywhere
* in the search string. Returns matches in the order the query string appears
* in the search key, earliest first, then in the order the items appeared in
* the source array.
*
* @param {Object[]} objects Initial list of objects. Equivalent to calling
* setObjects() after construction
* @param {Object} options Options object
* @param {string[]} options.keys List of keys to use as indexes on the objects
* @param {function[]} options.funcs List of functions that when called with the
* object as an arg will return a string to use as an index
*/
export default class QueryMatcher {
/**
* @param {object[]} objects the objects to perform a match on
* @param {string[]} keys an array of keys within each object to match on
* Keys can refer to object properties by name and as in JavaScript (for nested properties)
*
* To use, simply presort objects by required criteria, run through this function and create a QueryMatcher with the
* resulting KeyMap.
*
* TODO: Handle arrays and objects (Fuse did this, RoomProvider uses it)
* @return {KeyMap}
*/
static valuesToKeyMap(objects: Array<Object>, keys: Array<String>): KeyMap {
const keyMap = new KeyMap();
const map = {};

objects.forEach((object, i) => {
const keyValues = _at(object, keys);
for (const keyValue of keyValues) {
const key = stripDiacritics(keyValue).toLowerCase();
if (!map.hasOwnProperty(key)) {
map[key] = [];
}
map[key].push(object);
}
keyMap.priorityMap.set(object, i);
});

keyMap.objectMap = map;
keyMap.keys = _keys(map);
return keyMap;
}

constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
this.options = options;
this.keys = options.keys;
this._options = options;
this._keys = options.keys;
this._funcs = options.funcs || [];

this.setObjects(objects);

// By default, we remove any non-alphanumeric characters ([^A-Za-z0-9_]) from the
// query and the value being queried before matching
if (this.options.shouldMatchWordsOnly === undefined) {
this.options.shouldMatchWordsOnly = true;
if (this._options.shouldMatchWordsOnly === undefined) {
this._options.shouldMatchWordsOnly = true;
}

// By default, match anywhere in the string being searched. If enabled, only return
// matches that are prefixed with the query.
if (this.options.shouldMatchPrefix === undefined) {
this.options.shouldMatchPrefix = false;
if (this._options.shouldMatchPrefix === undefined) {
this._options.shouldMatchPrefix = false;
}
}

setObjects(objects: Array<Object>) {
this.keyMap = QueryMatcher.valuesToKeyMap(objects, this.keys);
this._items = new Map();

for (const object of objects) {
const keyValues = _at(object, this._keys);

for (const f of this._funcs) {
keyValues.push(f(object));
}

for (const keyValue of keyValues) {
const key = stripDiacritics(keyValue).toLowerCase();
if (!this._items.has(key)) {
this._items.set(key, []);
}
this._items.get(key).push(object);
}
}
}

match(query: String): Array<Object> {
query = stripDiacritics(query).toLowerCase();
if (this.options.shouldMatchWordsOnly) {
if (this._options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
}
if (query.length === 0) {
return [];
}
const results = [];
this.keyMap.keys.forEach((key) => {
// Iterate through the map & check each key.
// ES6 Map iteration order is defined to be insertion order, so results
// here will come out in the order they were put in.
for (const key of this._items.keys()) {
let resultKey = key;
if (this.options.shouldMatchWordsOnly) {
if (this._options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this.options.shouldMatchPrefix || index === 0)) {
if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) {
results.push({key, index});
}
});
}

return _uniq(_flatMap(_sortBy(results, (candidate) => {
// Sort them by where the query appeared in the search key
// lodash sortBy is a stable sort, so results where the query
// appeared in the same place will retain their order with
// respect to each other.
const sortedResults = _sortBy(results, (candidate) => {
return candidate.index;
}).map((candidate) => {
// return an array of objects (those given to setObjects) that have the given
// key as a property.
return this.keyMap.objectMap[candidate.key];
})));
});

// Now map the keys to the result objects. Each result object is a list, so
// flatMap will flatten those lists out into a single list. Also remove any
// duplicates.
return _uniq(_flatMap(sortedResults, (candidate) => this._items.get(candidate.key)));
}
}
4 changes: 2 additions & 2 deletions src/autocomplete/RoomProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg';
import FuzzyMatcher from './FuzzyMatcher';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index';
Expand All @@ -43,7 +43,7 @@ function score(query, space) {
export default class RoomProvider extends AutocompleteProvider {
constructor() {
super(ROOM_REGEX);
this.matcher = new FuzzyMatcher([], {
this.matcher = new QueryMatcher([], {
keys: ['displayedAlias', 'name'],
});
}
Expand Down
11 changes: 7 additions & 4 deletions src/autocomplete/UserProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {PillCompletion} from './Components';
import sdk from '../index';
import FuzzyMatcher from './FuzzyMatcher';
import QueryMatcher from './QueryMatcher';
import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg';

Expand All @@ -44,8 +44,9 @@ export default class UserProvider extends AutocompleteProvider {
constructor(room) {
super(USER_REGEX, FORCED_USER_REGEX);
this.room = room;
this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'],
this.matcher = new QueryMatcher([], {
keys: ['name'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we not want to keep userId here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or something to match the localpart, at least

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's done below with a function to chop off the leading '@'

funcs: [obj => obj.userId.slice(1)], // index by user id minus the leading '@'
shouldMatchPrefix: true,
shouldMatchWordsOnly: false,
});
Expand Down Expand Up @@ -104,7 +105,9 @@ export default class UserProvider extends AutocompleteProvider {
const fullMatch = command[0];
// Don't search if the query is a single "@"
if (fullMatch && fullMatch !== '@') {
completions = this.matcher.match(fullMatch).map((user) => {
// Don't include the '@' in our search query - it's only used as a way to trigger completion
const query = fullMatch.startsWith('@') ? fullMatch.substring(1) : fullMatch;
completions = this.matcher.match(query).map((user) => {
const displayName = (user.name || user.userId || '');
return {
// Length of completion should equal length of text in decorator. draft-js
Expand Down
Loading