Convert autocomplete stuff to TypeScript

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2020-04-20 19:00:54 +01:00
parent 37bd0f3508
commit fced4ea51e
14 changed files with 251 additions and 142 deletions

View file

@ -84,7 +84,7 @@ interface ICommandOpts {
hideCompletionAfterSpace?: boolean; hideCompletionAfterSpace?: boolean;
} }
class Command { export class Command {
command: string; command: string;
aliases: string[]; aliases: string[];
args: undefined | string; args: undefined | string;

View file

@ -16,10 +16,21 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import type {Completion, SelectionRange} from './Autocompleter'; import type {ICompletion, ISelectionRange} from './Autocompleter';
export interface ICommand {
command: string | null;
range: {
start: number;
end: number;
};
}
export default class AutocompleteProvider { export default class AutocompleteProvider {
commandRegex: RegExp;
forcedCommandRegex: RegExp;
constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) { constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
if (commandRegex) { if (commandRegex) {
if (!commandRegex.global) { if (!commandRegex.global) {
@ -42,11 +53,11 @@ export default class AutocompleteProvider {
/** /**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
* @param {string} query The query string * @param {string} query The query string
* @param {SelectionRange} selection Selection to search * @param {ISelectionRange} selection Selection to search
* @param {boolean} force True if the user is forcing completion * @param {boolean} force True if the user is forcing completion
* @return {object} { command, range } where both objects fields are null if no match * @return {object} { command, range } where both objects fields are null if no match
*/ */
getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false) { getCurrentCommand(query: string, selection: ISelectionRange, force = false) {
let commandRegex = this.commandRegex; let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) { if (force && this.shouldForceComplete()) {
@ -82,7 +93,7 @@ export default class AutocompleteProvider {
}; };
} }
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> { async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
return []; return [];
} }
@ -90,7 +101,7 @@ export default class AutocompleteProvider {
return 'Default Provider'; return 'Default Provider';
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: React.ReactNode[]): React.ReactNode | null {
console.error('stub; should be implemented in subclasses'); console.error('stub; should be implemented in subclasses');
return null; return null;
} }

View file

@ -15,10 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// @flow import {ReactElement} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import type {Component} from 'react';
import {Room} from 'matrix-js-sdk';
import CommandProvider from './CommandProvider'; import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider'; import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider'; import DuckDuckGoProvider from './DuckDuckGoProvider';
@ -27,22 +25,26 @@ import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider'; import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider'; import NotifProvider from './NotifProvider';
import {timeout} from "../utils/promise"; import {timeout} from "../utils/promise";
import AutocompleteProvider, {ICommand} from "./AutocompleteProvider";
export type SelectionRange = { export interface ISelectionRange {
beginning: boolean, // whether the selection is in the first block of the editor or not beginning?: boolean; // whether the selection is in the first block of the editor or not
start: number, // byte offset relative to the start anchor of the current editor selection. start: number; // byte offset relative to the start anchor of the current editor selection.
end: number, // byte offset relative to the end anchor of the current editor selection. end: number; // byte offset relative to the end anchor of the current editor selection.
}; }
export type Completion = { export interface ICompletion {
type: "at-room" | "command" | "community" | "room" | "user";
completion: string, completion: string,
component: ?Component, completionId?: string;
range: SelectionRange, component?: ReactElement,
command: ?string, range: ISelectionRange,
command?: string,
suffix?: string;
// If provided, apply a LINK entity to the completion with the // If provided, apply a LINK entity to the completion with the
// data = { url: href }. // data = { url: href }.
href: ?string, href?: string,
}; }
const PROVIDERS = [ const PROVIDERS = [
UserProvider, UserProvider,
@ -57,7 +59,16 @@ const PROVIDERS = [
// Providers will get rejected if they take longer than this. // Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000; const PROVIDER_COMPLETION_TIMEOUT = 3000;
export interface IProviderCompletions {
completions: ICompletion[];
provider: AutocompleteProvider;
command: ICommand;
}
export default class Autocompleter { export default class Autocompleter {
room: Room;
providers: AutocompleteProvider[];
constructor(room: Room) { constructor(room: Room) {
this.room = room; this.room = room;
this.providers = PROVIDERS.map((Prov) => { this.providers = PROVIDERS.map((Prov) => {
@ -71,13 +82,14 @@ export default class Autocompleter {
}); });
} }
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> { async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<IProviderCompletions[]> {
/* Note: This intentionally waits for all providers to return, /* Note: This intentionally waits for all providers to return,
otherwise, we run into a condition where new completions are displayed otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended to predict whether an action will actually do what is intended
*/ */
const completionsList = await Promise.all(this.providers.map(provider => { // list of results from each provider, each being a list of completions or null if it times out
const completionsList: ICompletion[][] = await Promise.all(this.providers.map(provider => {
return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT); return timeout(provider.getCompletions(query, selection, force), null, PROVIDER_COMPLETION_TIMEOUT);
})); }));

View file

@ -17,17 +17,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import {_t} from '../languageHandler'; import {_t} from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher'; import QueryMatcher from './QueryMatcher';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
import type {Completion, SelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
import {Commands, CommandMap} from '../SlashCommands'; import {Command, Commands, CommandMap} from '../SlashCommands';
const COMMAND_RE = /(^\/\w*)(?: .*)?/g; const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
export default class CommandProvider extends AutocompleteProvider { export default class CommandProvider extends AutocompleteProvider {
matcher: QueryMatcher<Command>;
constructor() { constructor() {
super(COMMAND_RE); super(COMMAND_RE);
this.matcher = new QueryMatcher(Commands, { this.matcher = new QueryMatcher(Commands, {
@ -36,7 +38,7 @@ export default class CommandProvider extends AutocompleteProvider {
}); });
} }
async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array<Completion> { async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
if (!command) return []; if (!command) return [];
@ -85,7 +87,7 @@ export default class CommandProvider extends AutocompleteProvider {
return '*️⃣ ' + _t('Commands'); return '*️⃣ ' + _t('Commands');
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return ( return (
<div className="mx_Autocomplete_Completion_container_block" role="listbox" aria-label={_t("Command Autocomplete")}> <div className="mx_Autocomplete_Completion_container_block" role="listbox" aria-label={_t("Command Autocomplete")}>
{ completions } { completions }

View file

@ -15,7 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import Group from "matrix-js-sdk/src/models/group";
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';
@ -24,7 +25,7 @@ import {PillCompletion} from './Components';
import * as sdk from '../index'; import * as sdk from '../index';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore"; import FlairStore from "../stores/FlairStore";
const COMMUNITY_REGEX = /\B\+\S*/g; const COMMUNITY_REGEX = /\B\+\S*/g;
@ -39,6 +40,8 @@ function score(query, space) {
} }
export default class CommunityProvider extends AutocompleteProvider { export default class CommunityProvider extends AutocompleteProvider {
matcher: QueryMatcher<Group>;
constructor() { constructor() {
super(COMMUNITY_REGEX); super(COMMUNITY_REGEX);
this.matcher = new QueryMatcher([], { this.matcher = new QueryMatcher([], {
@ -46,7 +49,7 @@ export default class CommunityProvider extends AutocompleteProvider {
}); });
} }
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> { async getCompletions(query: string, selection: ISelectionRange, force: boolean = false): Promise<ICompletion[]> {
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
// Disable autocompletions when composing commands because of various issues // Disable autocompletions when composing commands because of various issues
@ -104,7 +107,7 @@ export default class CommunityProvider extends AutocompleteProvider {
return '💬 ' + _t('Communities'); return '💬 ' + _t('Communities');
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return ( return (
<div <div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate" className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
/* These were earlier stateless functional components but had to be converted /* These were earlier stateless functional components but had to be converted
@ -24,7 +24,21 @@ something that is not entirely possible with stateless functional components. On
presumably wrap them in a <div> before rendering but I think this is the better way to do it. presumably wrap them in a <div> before rendering but I think this is the better way to do it.
*/ */
export class TextualCompletion extends React.Component { interface ITextualCompletionProps {
title?: string;
subtitle?: string;
description?: string;
className?: string;
}
export class TextualCompletion extends React.PureComponent<ITextualCompletionProps> {
static propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
className: PropTypes.string,
};
render() { render() {
const { const {
title, title,
@ -42,14 +56,24 @@ export class TextualCompletion extends React.Component {
); );
} }
} }
TextualCompletion.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
className: PropTypes.string,
};
export class PillCompletion extends React.Component { interface IPillCompletionProps {
title?: string;
subtitle?: string;
description?: string;
initialComponent?: React.ReactNode,
className?: string;
}
export class PillCompletion extends React.PureComponent<IPillCompletionProps> {
static propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
initialComponent: PropTypes.element,
className: PropTypes.string,
};
render() { render() {
const { const {
title, title,
@ -69,10 +93,3 @@ export class PillCompletion extends React.Component {
); );
} }
} }
PillCompletion.propTypes = {
title: PropTypes.string,
subtitle: PropTypes.string,
description: PropTypes.string,
initialComponent: PropTypes.element,
className: PropTypes.string,
};

View file

@ -16,12 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
import type {SelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
const DDG_REGEX = /\/ddg\s+(.+)$/g; const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector'; const REFERRER = 'vector';
@ -31,12 +31,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
super(DDG_REGEX); super(DDG_REGEX);
} }
static getQueryUri(query: String) { static getQueryUri(query: string) {
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}` return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
} }
async getCompletions(query: string, selection: SelectionRange, force: boolean = false) { async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) { if (!query || !command) {
return []; return [];
@ -95,7 +95,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
return '🔍 ' + _t('Results from DuckDuckGo'); return '🔍 ' + _t('Results from DuckDuckGo');
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return ( return (
<div <div
className="mx_Autocomplete_Completion_container_block" className="mx_Autocomplete_Completion_container_block"

View file

@ -17,41 +17,56 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher'; import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import type {Completion, SelectionRange} from './Autocompleter'; import {ICompletion, ISelectionRange} from './Autocompleter';
import _uniq from 'lodash/uniq'; import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import { shortcodeToUnicode } from '../HtmlUtils'; import { shortcodeToUnicode } from '../HtmlUtils';
import EMOTICON_REGEX from 'emojibase-regex/emoticon'; import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import EMOJIBASE from 'emojibase-data/en/compact.json'; import * as EMOJIBASE from 'emojibase-data/en/compact.json';
const LIMIT = 20; const LIMIT = 20;
// Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase // Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|:[+-\\w]*:?)$', 'g'); const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|:[+-\\w]*:?)$', 'g');
interface IEmoji {
annotation: string;
group: number;
hexcode: string;
order: number;
shortcodes: string[];
tags: string[];
unicode: string;
emoticon: string;
}
interface IEmojiShort {
emoji: IEmoji;
shortname: string;
_orderBy: number;
}
// XXX: it's very unclear why we bother with this generated emojidata file. // XXX: it's very unclear why we bother with this generated emojidata file.
// all it means is that we end up bloating the bundle with precomputed stuff // all it means is that we end up bloating the bundle with precomputed stuff
// which would be trivial to calculate and cache on demand. // which would be trivial to calculate and cache on demand.
const EMOJI_SHORTNAMES = EMOJIBASE.sort((a, b) => { const EMOJI_SHORTNAMES: IEmojiShort[] = (EMOJIBASE as IEmoji[]).sort((a, b) => {
if (a.group === b.group) { if (a.group === b.group) {
return a.order - b.order; return a.order - b.order;
} }
return a.group - b.group; return a.group - b.group;
}).map((emoji, index) => { }).map((emoji, index) => ({
return { emoji,
emoji, shortname: `:${emoji.shortcodes[0]}:`,
shortname: `:${emoji.shortcodes[0]}:`, // Include the index so that we can preserve the original order
// Include the index so that we can preserve the original order _orderBy: index,
_orderBy: index, }));
};
});
function score(query, space) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);
@ -63,6 +78,9 @@ function score(query, space) {
} }
export default class EmojiProvider extends AutocompleteProvider { export default class EmojiProvider extends AutocompleteProvider {
matcher: QueryMatcher<IEmojiShort>;
nameMatcher: QueryMatcher<IEmojiShort>;
constructor() { constructor() {
super(EMOJI_REGEX); super(EMOJI_REGEX);
this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, { this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, {
@ -80,7 +98,7 @@ export default class EmojiProvider extends AutocompleteProvider {
}); });
} }
async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array<Completion> { async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise<ICompletion[]> {
if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) { if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them return []; // don't give any suggestions if the user doesn't want them
} }
@ -132,7 +150,7 @@ export default class EmojiProvider extends AutocompleteProvider {
return '😃 ' + _t('Emoji'); return '😃 ' + _t('Emoji');
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return ( return (
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("Emoji Autocomplete")}> <div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("Emoji Autocomplete")}>
{ completions } { completions }

View file

@ -14,23 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import * as sdk from '../index'; import * as sdk from '../index';
import type {Completion, SelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
const AT_ROOM_REGEX = /@\S*/g; const AT_ROOM_REGEX = /@\S*/g;
export default class NotifProvider extends AutocompleteProvider { export default class NotifProvider extends AutocompleteProvider {
room: Room;
constructor(room) { constructor(room) {
super(AT_ROOM_REGEX); super(AT_ROOM_REGEX);
this.room = room; this.room = room;
} }
async getCompletions(query: string, selection: SelectionRange, force:boolean = false): Array<Completion> { async getCompletions(query: string, selection: ISelectionRange, force= false): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -57,7 +60,7 @@ export default class NotifProvider extends AutocompleteProvider {
return '❗️ ' + _t('Room Notification'); return '❗️ ' + _t('Room Notification');
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return ( return (
<div <div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate" className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

@ -1,4 +1,3 @@
//@flow
/* /*
Copyright 2017 Aviral Dasgupta Copyright 2017 Aviral Dasgupta
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
@ -26,6 +25,13 @@ function stripDiacritics(str: string): string {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
} }
interface IOptions<T extends {}> {
keys: Array<string | keyof T>;
funcs?: Array<(T) => string>;
shouldMatchWordsOnly?: boolean;
shouldMatchPrefix?: boolean;
}
/** /**
* Simple search matcher that matches any results with the query string anywhere * 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 string. Returns matches in the order the query string appears
@ -39,8 +45,13 @@ function stripDiacritics(str: string): string {
* @param {function[]} options.funcs List of functions that when called with the * @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 * object as an arg will return a string to use as an index
*/ */
export default class QueryMatcher { export default class QueryMatcher<T> {
constructor(objects: Array<Object>, options: {[Object]: Object} = {}) { private _options: IOptions<T>;
private _keys: IOptions<T>["keys"];
private _funcs: Required<IOptions<T>["funcs"]>;
private _items: Map<string, T[]>;
constructor(objects: T[], options: IOptions<T> = { keys: [] }) {
this._options = options; this._options = options;
this._keys = options.keys; this._keys = options.keys;
this._funcs = options.funcs || []; this._funcs = options.funcs || [];
@ -60,7 +71,7 @@ export default class QueryMatcher {
} }
} }
setObjects(objects: Array<Object>) { setObjects(objects: T[]) {
this._items = new Map(); this._items = new Map();
for (const object of objects) { for (const object of objects) {
@ -81,7 +92,7 @@ export default class QueryMatcher {
} }
} }
match(query: String): Array<Object> { match(query: string): T[] {
query = stripDiacritics(query).toLowerCase(); query = stripDiacritics(query).toLowerCase();
if (this._options.shouldMatchWordsOnly) { if (this._options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, ''); query = query.replace(/[^\w]/g, '');

View file

@ -17,7 +17,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';
@ -26,7 +27,7 @@ import {PillCompletion} from './Components';
import * as sdk from '../index'; import * as sdk from '../index';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
const ROOM_REGEX = /\B#\S*/g; const ROOM_REGEX = /\B#\S*/g;
@ -48,6 +49,8 @@ function matcherObject(room, displayedAlias, matchName = "") {
} }
export default class RoomProvider extends AutocompleteProvider { export default class RoomProvider extends AutocompleteProvider {
matcher: QueryMatcher<Room>;
constructor() { constructor() {
super(ROOM_REGEX); super(ROOM_REGEX);
this.matcher = new QueryMatcher([], { this.matcher = new QueryMatcher([], {
@ -55,7 +58,7 @@ export default class RoomProvider extends AutocompleteProvider {
}); });
} }
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> { async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -115,7 +118,7 @@ export default class RoomProvider extends AutocompleteProvider {
return '💬 ' + _t('Rooms'); return '💬 ' + _t('Rooms');
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return ( return (
<div <div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate" className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

@ -1,4 +1,3 @@
//@flow
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
@ -18,7 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
@ -27,9 +26,13 @@ import QueryMatcher from './QueryMatcher';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';
import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk'; import MatrixEvent from "matrix-js-sdk/src/models/event";
import Room from "matrix-js-sdk/src/models/room";
import RoomMember from "matrix-js-sdk/src/models/room-member";
import RoomState from "matrix-js-sdk/src/models/room-state";
import EventTimeline from "matrix-js-sdk/src/models/event-timeline";
import {makeUserPermalink} from "../utils/permalinks/Permalinks"; import {makeUserPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter"; import {ICompletion, ISelectionRange} from "./Autocompleter";
const USER_REGEX = /\B@\S*/g; const USER_REGEX = /\B@\S*/g;
@ -37,9 +40,15 @@ const USER_REGEX = /\B@\S*/g;
// to allow you to tab-complete /mat into /(matthew) // to allow you to tab-complete /mat into /(matthew)
const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g; const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g;
interface IRoomTimelineData {
timeline: EventTimeline;
liveEvent?: boolean;
}
export default class UserProvider extends AutocompleteProvider { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null; matcher: QueryMatcher<RoomMember>;
room: Room = null; users: RoomMember[];
room: Room;
constructor(room: Room) { constructor(room: Room) {
super(USER_REGEX, FORCED_USER_REGEX); super(USER_REGEX, FORCED_USER_REGEX);
@ -51,21 +60,19 @@ export default class UserProvider extends AutocompleteProvider {
shouldMatchWordsOnly: false, shouldMatchWordsOnly: false,
}); });
this._onRoomTimelineBound = this._onRoomTimeline.bind(this); MatrixClientPeg.get().on("Room.timeline", this._onRoomTimeline);
this._onRoomStateMemberBound = this._onRoomStateMember.bind(this); MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMember);
MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound);
} }
destroy() { destroy() {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound); MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimeline);
MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound); MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMember);
} }
} }
_onRoomTimeline(ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean, data: Object) { _onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean,
data: IRoomTimelineData) => {
if (!room) return; if (!room) return;
if (removed) return; if (removed) return;
if (room.roomId !== this.room.roomId) return; if (room.roomId !== this.room.roomId) return;
@ -79,9 +86,9 @@ export default class UserProvider extends AutocompleteProvider {
// TODO: lazyload if we have no ev.sender room member? // TODO: lazyload if we have no ev.sender room member?
this.onUserSpoke(ev.sender); this.onUserSpoke(ev.sender);
} };
_onRoomStateMember(ev: MatrixEvent, state: RoomState, member: RoomMember) { _onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember) => {
// ignore members in other rooms // ignore members in other rooms
if (member.roomId !== this.room.roomId) { if (member.roomId !== this.room.roomId) {
return; return;
@ -89,9 +96,9 @@ export default class UserProvider extends AutocompleteProvider {
// blow away the users cache // blow away the users cache
this.users = null; this.users = null;
} };
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> { async getCompletions(query: string, selection: ISelectionRange, force = false): Promise<ICompletion[]> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// lazy-load user list into matcher // lazy-load user list into matcher
@ -163,7 +170,7 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher.setObjects(this.users); this.matcher.setObjects(this.users);
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return ( return (
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("User Autocomplete")}> <div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("User Autocomplete")}>
{ completions } { completions }

View file

@ -15,30 +15,62 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import * as React from 'react';
import ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import flatMap from 'lodash/flatMap'; import flatMap from 'lodash/flatMap';
import type {Completion} from '../../../autocomplete/Autocompleter'; import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';
import { Room } from 'matrix-js-sdk'; import {Room} from 'matrix-js-sdk/src/models/room';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter'; import Autocompleter from '../../../autocomplete/Autocompleter';
import {sleep} from "../../../utils/promise";
const COMPOSER_SELECTED = 0; const COMPOSER_SELECTED = 0;
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`; export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
export default class Autocomplete extends React.Component { interface IProps {
query: string;
onConfirm: (ICompletion) => void;
onSelectionChange?: (ICompletion, number) => void;
selection: ISelectionRange;
room: Room;
}
interface IState {
completions: IProviderCompletions[];
completionList: ICompletion[];
selectionOffset: number;
shouldShowCompletions: boolean;
hide: boolean;
forceComplete: boolean;
}
export default class Autocomplete extends React.PureComponent<IProps, IState> {
static propTypes = {
// the query string for which to show autocomplete suggestions
query: PropTypes.string.isRequired,
// method invoked with range and text content when completion is confirmed
onConfirm: PropTypes.func.isRequired,
// method invoked when selected (if any) completion changes
onSelectionChange: PropTypes.func,
// The room in which we're autocompleting
room: PropTypes.instanceOf(Room),
};
autocompleter: Autocompleter;
queryRequested: string;
debounceCompletionsRequest: NodeJS.Timeout;
container: React.RefObject<HTMLDivElement>;
constructor(props) { constructor(props) {
super(props); super(props);
this.autocompleter = new Autocompleter(props.room); this.autocompleter = new Autocompleter(props.room);
this.completionPromise = null;
this.hide = this.hide.bind(this);
this.onCompletionClicked = this.onCompletionClicked.bind(this);
this.state = { this.state = {
// list of completionResults, each containing completions // list of completionResults, each containing completions
@ -57,13 +89,15 @@ export default class Autocomplete extends React.Component {
forceComplete: false, forceComplete: false,
}; };
this.container = React.createRef();
} }
componentDidMount() { componentDidMount() {
this._applyNewProps(); this._applyNewProps();
} }
_applyNewProps(oldQuery, oldRoom) { _applyNewProps(oldQuery?: string, oldRoom?: Room) {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) { if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy(); this.autocompleter.destroy();
this.autocompleter = new Autocompleter(this.props.room); this.autocompleter = new Autocompleter(this.props.room);
@ -159,7 +193,7 @@ export default class Autocomplete extends React.Component {
}); });
} }
hasSelection(): bool { hasSelection(): boolean {
return this.countCompletions() > 0 && this.state.selectionOffset !== 0; return this.countCompletions() > 0 && this.state.selectionOffset !== 0;
} }
@ -168,7 +202,7 @@ export default class Autocomplete extends React.Component {
} }
// called from MessageComposerInput // called from MessageComposerInput
moveSelection(delta): ?Completion { moveSelection(delta): ICompletion | undefined {
const completionCount = this.countCompletions(); const completionCount = this.countCompletions();
if (completionCount === 0) return; // there are no items to move the selection through if (completionCount === 0) return; // there are no items to move the selection through
@ -190,9 +224,14 @@ export default class Autocomplete extends React.Component {
this.hide(); this.hide();
} }
hide() { hide = () => {
this.setState({hide: true, selectionOffset: 0, completions: [], completionList: []}); this.setState({
} hide: true,
selectionOffset: 0,
completions: [],
completionList: [],
});
};
forceComplete() { forceComplete() {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -207,7 +246,7 @@ export default class Autocomplete extends React.Component {
}); });
} }
onCompletionClicked(selectionOffset: number): boolean { onCompletionClicked = (selectionOffset: number): boolean => {
if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) { if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) {
return false; return false;
} }
@ -216,7 +255,7 @@ export default class Autocomplete extends React.Component {
this.hide(); this.hide();
return true; return true;
} };
setSelection(selectionOffset: number) { setSelection(selectionOffset: number) {
this.setState({selectionOffset, hide: false}); this.setState({selectionOffset, hide: false});
@ -229,20 +268,16 @@ export default class Autocomplete extends React.Component {
this._applyNewProps(prevProps.query, prevProps.room); this._applyNewProps(prevProps.query, prevProps.room);
// this is the selected completion, so scroll it into view if needed // this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`]; const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
if (selectedCompletion && this.container) { if (selectedCompletion && this.container.current) {
const domNode = ReactDOM.findDOMNode(selectedCompletion); const domNode = ReactDOM.findDOMNode(selectedCompletion);
const offsetTop = domNode && domNode.offsetTop; const offsetTop = domNode && domNode.offsetTop;
if (offsetTop > this.container.scrollTop + this.container.offsetHeight || if (offsetTop > this.container.current.scrollTop + this.container.current.offsetHeight ||
offsetTop < this.container.scrollTop) { offsetTop < this.container.current.scrollTop) {
this.container.scrollTop = offsetTop - this.container.offsetTop; this.container.current.scrollTop = offsetTop - this.container.current.offsetTop;
} }
} }
} }
setState(state, func) {
super.setState(state, func);
}
render() { render() {
let position = 1; let position = 1;
const renderedCompletions = this.state.completions.map((completionResult, i) => { const renderedCompletions = this.state.completions.map((completionResult, i) => {
@ -276,23 +311,9 @@ export default class Autocomplete extends React.Component {
}).filter((completion) => !!completion); }).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? ( return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}> <div className="mx_Autocomplete" ref={this.container}>
{ renderedCompletions } { renderedCompletions }
</div> </div>
) : null; ) : null;
} }
} }
Autocomplete.propTypes = {
// the query string for which to show autocomplete suggestions
query: PropTypes.string.isRequired,
// method invoked with range and text content when completion is confirmed
onConfirm: PropTypes.func.isRequired,
// method invoked when selected (if any) completion changes
onSelectionChange: PropTypes.func,
// The room in which we're autocompleting
room: PropTypes.instanceOf(Room),
};

View file

@ -2,6 +2,7 @@
"compilerOptions": { "compilerOptions": {
"experimentalDecorators": true, "experimentalDecorators": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"resolveJsonModule": true,
"module": "commonjs", "module": "commonjs",
"moduleResolution": "node", "moduleResolution": "node",
"target": "es2016", "target": "es2016",