Merge pull request #4452 from matrix-org/t3chguy/autocomplete

Convert autocomplete stuff to TypeScript
This commit is contained in:
Michael Telatynski 2020-04-22 10:20:24 +01:00 committed by GitHub
commit dd1f1b3092
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 239 additions and 152 deletions

View file

@ -86,7 +86,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

@ -17,9 +17,20 @@ limitations under the License.
*/ */
import React from 'react'; import 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,25 +53,25 @@ 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()) {
commandRegex = this.forcedCommandRegex || /\S+/g; commandRegex = this.forcedCommandRegex || /\S+/g;
} }
if (commandRegex == null) { if (commandRegex === null) {
return null; return null;
} }
commandRegex.lastIndex = 0; commandRegex.lastIndex = 0;
let match; let match;
while ((match = commandRegex.exec(query)) != null) { while ((match = commandRegex.exec(query)) !== null) {
const start = match.index; const start = match.index;
const end = start + match[0].length; const end = start + match[0].length;
if (selection.start <= end && selection.end >= start) { if (selection.start <= end && selection.end >= start) {
@ -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

@ -22,12 +22,14 @@ 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

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import 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 = 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

@ -15,7 +15,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import 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 +23,14 @@ 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> {
render() { render() {
const { const {
title, title,
@ -42,14 +48,16 @@ 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> {
render() { render() {
const { const {
title, title,
@ -69,10 +77,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

@ -21,7 +21,7 @@ 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

@ -22,36 +22,37 @@ 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 { EMOJI, IEmoji } from '../emoji';
import EMOTICON_REGEX from 'emojibase-regex/emoticon'; import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import 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');
// XXX: it's very unclear why we bother with this generated emojidata file. interface IEmojiShort {
// all it means is that we end up bloating the bundle with precomputed stuff emoji: IEmoji;
// which would be trivial to calculate and cache on demand. shortname: string;
const EMOJI_SHORTNAMES = EMOJIBASE.sort((a, b) => { _orderBy: number;
}
const EMOJI_SHORTNAMES: IEmojiShort[] = EMOJI.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 +64,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 +84,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 +136,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

@ -15,22 +15,25 @@ limitations under the License.
*/ */
import React from 'react'; import 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

@ -18,6 +18,7 @@ limitations under the License.
*/ */
import React from 'react'; import 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,11 +27,11 @@ 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;
function score(query, space) { function score(query: string, space: string) {
const index = space.indexOf(query); const index = space.indexOf(query);
if (index === -1) { if (index === -1) {
return Infinity; return Infinity;
@ -39,7 +40,7 @@ function score(query, space) {
} }
} }
function matcherObject(room, displayedAlias, matchName = "") { function matcherObject(room: Room, displayedAlias: string, matchName = "") {
return { return {
room, room,
matchName, matchName,
@ -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
@ -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) { private 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) { private 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,16 +96,16 @@ 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(rawQuery: 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
if (this.users === null) this._makeUsers(); if (this.users === null) this._makeUsers();
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force); const {command, range} = this.getCurrentCommand(rawQuery, selection, force);
if (!command) return completions; if (!command) return completions;
@ -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

@ -17,28 +17,49 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import 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 {
// the query string for which to show autocomplete suggestions
query: string;
// method invoked with range and text content when completion is confirmed
onConfirm: (ICompletion) => void;
// method invoked when selected (if any) completion changes
onSelectionChange?: (ICompletion, number) => void;
selection: ISelectionRange;
// The room in which we're autocompleting
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> {
autocompleter: Autocompleter;
queryRequested: string;
debounceCompletionsRequest: NodeJS.Timeout;
containerRef: 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 +78,15 @@ export default class Autocomplete extends React.Component {
forceComplete: false, forceComplete: false,
}; };
this.containerRef = React.createRef();
} }
componentDidMount() { componentDidMount() {
this._applyNewProps(); this.applyNewProps();
} }
_applyNewProps(oldQuery, oldRoom) { private 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);
@ -81,7 +104,7 @@ export default class Autocomplete extends React.Component {
this.autocompleter.destroy(); this.autocompleter.destroy();
} }
complete(query, selection) { complete(query: string, selection: ISelectionRange) {
this.queryRequested = query; this.queryRequested = query;
if (this.debounceCompletionsRequest) { if (this.debounceCompletionsRequest) {
clearTimeout(this.debounceCompletionsRequest); clearTimeout(this.debounceCompletionsRequest);
@ -112,7 +135,7 @@ export default class Autocomplete extends React.Component {
}); });
} }
processQuery(query, selection) { processQuery(query: string, selection: ISelectionRange) {
return this.autocompleter.getCompletions( return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete, query, selection, this.state.forceComplete,
).then((completions) => { ).then((completions) => {
@ -124,7 +147,7 @@ export default class Autocomplete extends React.Component {
}); });
} }
processCompletions(completions) { processCompletions(completions: IProviderCompletions[]) {
const completionList = flatMap(completions, (provider) => provider.completions); const completionList = flatMap(completions, (provider) => provider.completions);
// Reset selection when completion list becomes empty. // Reset selection when completion list becomes empty.
@ -159,7 +182,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 +191,7 @@ export default class Autocomplete extends React.Component {
} }
// called from MessageComposerInput // called from MessageComposerInput
moveSelection(delta): ?Completion { moveSelection(delta: number) {
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
@ -177,7 +200,7 @@ export default class Autocomplete extends React.Component {
this.setSelection(index); this.setSelection(index);
} }
onEscape(e): boolean { onEscape(e: KeyboardEvent): boolean {
const completionCount = this.countCompletions(); const completionCount = this.countCompletions();
if (completionCount === 0) { if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault // autocomplete is already empty, so don't preventDefault
@ -190,9 +213,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 +235,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 +244,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});
@ -225,28 +253,24 @@ export default class Autocomplete extends React.Component {
} }
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps: IProps) {
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.containerRef.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.containerRef.current.scrollTop + this.containerRef.current.offsetHeight ||
offsetTop < this.container.scrollTop) { offsetTop < this.containerRef.current.scrollTop) {
this.container.scrollTop = offsetTop - this.container.offsetTop; this.containerRef.current.scrollTop = offsetTop - this.containerRef.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) => {
const completions = completionResult.completions.map((completion, i) => { const completions = completionResult.completions.map((completion, j) => {
const selected = position === this.state.selectionOffset; const selected = position === this.state.selectionOffset;
const className = classNames('mx_Autocomplete_Completion', {selected}); const className = classNames('mx_Autocomplete_Completion', {selected});
const componentPosition = position; const componentPosition = position;
@ -257,7 +281,7 @@ export default class Autocomplete extends React.Component {
}; };
return React.cloneElement(completion.component, { return React.cloneElement(completion.component, {
"key": i, "key": j,
"ref": `completion${componentPosition}`, "ref": `completion${componentPosition}`,
"id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs "id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs
className, className,
@ -276,23 +300,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.containerRef}>
{ 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

@ -14,12 +14,28 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// @ts-ignore - import * as EMOJIBASE actually breaks this
import EMOJIBASE from 'emojibase-data/en/compact.json'; import EMOJIBASE from 'emojibase-data/en/compact.json';
export interface IEmoji {
annotation: string;
group: number;
hexcode: string;
order: number;
shortcodes: string[];
tags: string[];
unicode: string;
emoticon?: string;
}
interface IEmojiWithFilterString extends IEmoji {
filterString?: string;
}
// The unicode is stored without the variant selector // The unicode is stored without the variant selector
const UNICODE_TO_EMOJI = new Map(); // not exported as gets for it are handled by getEmojiFromUnicode const UNICODE_TO_EMOJI = new Map<string, IEmojiWithFilterString>(); // not exported as gets for it are handled by getEmojiFromUnicode
export const EMOTICON_TO_EMOJI = new Map(); export const EMOTICON_TO_EMOJI = new Map<string, IEmojiWithFilterString>();
export const SHORTCODE_TO_EMOJI = new Map(); export const SHORTCODE_TO_EMOJI = new Map<string, IEmojiWithFilterString>();
export const getEmojiFromUnicode = unicode => UNICODE_TO_EMOJI.get(stripVariation(unicode)); export const getEmojiFromUnicode = unicode => UNICODE_TO_EMOJI.get(stripVariation(unicode));
@ -48,7 +64,7 @@ export const DATA_BY_CATEGORY = {
}; };
// Store various mappings from unicode/emoticon/shortcode to the Emoji objects // Store various mappings from unicode/emoticon/shortcode to the Emoji objects
EMOJIBASE.forEach(emoji => { EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group]; const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group];
if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) { if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) {
DATA_BY_CATEGORY[categoryId].push(emoji); DATA_BY_CATEGORY[categoryId].push(emoji);
@ -89,3 +105,5 @@ EMOJIBASE.forEach(emoji => {
function stripVariation(str) { function stripVariation(str) {
return str.replace(/[\uFE00-\uFE0F]$/, ""); return str.replace(/[\uFE00-\uFE0F]$/, "");
} }
export const EMOJI: IEmoji[] = EMOJIBASE;

View file

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