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;
}
class Command {
export class Command {
command: string;
aliases: string[];
args: undefined | string;

View file

@ -17,9 +17,20 @@ limitations under the License.
*/
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 {
commandRegex: RegExp;
forcedCommandRegex: RegExp;
constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
if (commandRegex) {
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.
* @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
* @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;
if (force && this.shouldForceComplete()) {
commandRegex = this.forcedCommandRegex || /\S+/g;
}
if (commandRegex == null) {
if (commandRegex === null) {
return null;
}
commandRegex.lastIndex = 0;
let match;
while ((match = commandRegex.exec(query)) != null) {
while ((match = commandRegex.exec(query)) !== null) {
const start = match.index;
const end = start + match[0].length;
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 [];
}
@ -90,7 +101,7 @@ export default class AutocompleteProvider {
return 'Default Provider';
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode | null {
console.error('stub; should be implemented in subclasses');
return null;
}

View file

@ -15,10 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// @flow
import type {Component} from 'react';
import {Room} from 'matrix-js-sdk';
import {ReactElement} from 'react';
import Room from 'matrix-js-sdk/src/models/room';
import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
@ -27,22 +25,26 @@ import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider';
import {timeout} from "../utils/promise";
import AutocompleteProvider, {ICommand} from "./AutocompleteProvider";
export type SelectionRange = {
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.
end: number, // byte offset relative to the end anchor of the current editor selection.
};
export interface ISelectionRange {
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.
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,
component: ?Component,
range: SelectionRange,
command: ?string,
completionId?: string;
component?: ReactElement,
range: ISelectionRange,
command?: string,
suffix?: string;
// If provided, apply a LINK entity to the completion with the
// data = { url: href }.
href: ?string,
};
href?: string,
}
const PROVIDERS = [
UserProvider,
@ -57,7 +59,16 @@ const PROVIDERS = [
// Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000;
export interface IProviderCompletions {
completions: ICompletion[];
provider: AutocompleteProvider;
command: ICommand;
}
export default class Autocompleter {
room: Room;
providers: AutocompleteProvider[];
constructor(room: Room) {
this.room = room;
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,
otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended
*/
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);
}));

View file

@ -22,12 +22,14 @@ import {_t} from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher';
import {TextualCompletion} from './Components';
import type {Completion, SelectionRange} from "./Autocompleter";
import {Commands, CommandMap} from '../SlashCommands';
import {ICompletion, ISelectionRange} from "./Autocompleter";
import {Command, Commands, CommandMap} from '../SlashCommands';
const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
export default class CommandProvider extends AutocompleteProvider {
matcher: QueryMatcher<Command>;
constructor() {
super(COMMAND_RE);
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);
if (!command) return [];
@ -85,7 +87,7 @@ export default class CommandProvider extends AutocompleteProvider {
return '*️⃣ ' + _t('Commands');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_block" role="listbox" aria-label={_t("Command Autocomplete")}>
{ completions }

View file

@ -16,6 +16,7 @@ limitations under the License.
*/
import React from 'react';
import Group from "matrix-js-sdk/src/models/group";
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {MatrixClientPeg} from '../MatrixClientPeg';
@ -24,7 +25,7 @@ import {PillCompletion} from './Components';
import * as sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeGroupPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore";
const COMMUNITY_REGEX = /\B\+\S*/g;
@ -39,6 +40,8 @@ function score(query, space) {
}
export default class CommunityProvider extends AutocompleteProvider {
matcher: QueryMatcher<Group>;
constructor() {
super(COMMUNITY_REGEX);
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');
// Disable autocompletions when composing commands because of various issues
@ -104,7 +107,7 @@ export default class CommunityProvider extends AutocompleteProvider {
return '💬 ' + _t('Communities');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
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 PropTypes from 'prop-types';
import classNames from 'classnames';
/* 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.
*/
export class TextualCompletion extends React.Component {
interface ITextualCompletionProps {
title?: string;
subtitle?: string;
description?: string;
className?: string;
}
export class TextualCompletion extends React.PureComponent<ITextualCompletionProps> {
render() {
const {
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() {
const {
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 {TextualCompletion} from './Components';
import type {SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector';
@ -31,12 +31,12 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
super(DDG_REGEX);
}
static getQueryUri(query: String) {
static getQueryUri(query: string) {
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
+ `&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);
if (!query || !command) {
return [];
@ -95,7 +95,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
return '🔍 ' + _t('Results from DuckDuckGo');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_block"

View file

@ -22,36 +22,37 @@ import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import QueryMatcher from './QueryMatcher';
import {PillCompletion} from './Components';
import type {Completion, SelectionRange} from './Autocompleter';
import {ICompletion, ISelectionRange} from './Autocompleter';
import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy';
import SettingsStore from "../settings/SettingsStore";
import { shortcodeToUnicode } from '../HtmlUtils';
import { EMOJI, IEmoji } from '../emoji';
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
import EMOJIBASE from 'emojibase-data/en/compact.json';
const LIMIT = 20;
// Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase
const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|:[+-\\w]*:?)$', 'g');
// 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
// which would be trivial to calculate and cache on demand.
const EMOJI_SHORTNAMES = EMOJIBASE.sort((a, b) => {
interface IEmojiShort {
emoji: IEmoji;
shortname: string;
_orderBy: number;
}
const EMOJI_SHORTNAMES: IEmojiShort[] = EMOJI.sort((a, b) => {
if (a.group === b.group) {
return a.order - b.order;
}
return a.group - b.group;
}).map((emoji, index) => {
return {
emoji,
shortname: `:${emoji.shortcodes[0]}:`,
// Include the index so that we can preserve the original order
_orderBy: index,
};
});
}).map((emoji, index) => ({
emoji,
shortname: `:${emoji.shortcodes[0]}:`,
// Include the index so that we can preserve the original order
_orderBy: index,
}));
function score(query, space) {
const index = space.indexOf(query);
@ -63,6 +64,9 @@ function score(query, space) {
}
export default class EmojiProvider extends AutocompleteProvider {
matcher: QueryMatcher<IEmojiShort>;
nameMatcher: QueryMatcher<IEmojiShort>;
constructor() {
super(EMOJI_REGEX);
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")) {
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');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("Emoji Autocomplete")}>
{ completions }

View file

@ -15,22 +15,25 @@ limitations under the License.
*/
import React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import AutocompleteProvider from './AutocompleteProvider';
import { _t } from '../languageHandler';
import {MatrixClientPeg} from '../MatrixClientPeg';
import {PillCompletion} from './Components';
import * as sdk from '../index';
import type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
const AT_ROOM_REGEX = /@\S*/g;
export default class NotifProvider extends AutocompleteProvider {
room: Room;
constructor(room) {
super(AT_ROOM_REGEX);
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 client = MatrixClientPeg.get();
@ -57,7 +60,7 @@ export default class NotifProvider extends AutocompleteProvider {
return '❗️ ' + _t('Room Notification');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

@ -1,4 +1,3 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
@ -26,6 +25,13 @@ function stripDiacritics(str: string): string {
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
* 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
* object as an arg will return a string to use as an index
*/
export default class QueryMatcher {
constructor(objects: Array<Object>, options: {[Object]: Object} = {}) {
export default class QueryMatcher<T> {
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._keys = options.keys;
this._funcs = options.funcs || [];
@ -60,7 +71,7 @@ export default class QueryMatcher {
}
}
setObjects(objects: Array<Object>) {
setObjects(objects: T[]) {
this._items = new Map();
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();
if (this._options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');

View file

@ -18,6 +18,7 @@ limitations under the License.
*/
import React from 'react';
import Room from "matrix-js-sdk/src/models/room";
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {MatrixClientPeg} from '../MatrixClientPeg';
@ -26,11 +27,11 @@ import {PillCompletion} from './Components';
import * as sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../utils/permalinks/Permalinks";
import type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
const ROOM_REGEX = /\B#\S*/g;
function score(query, space) {
function score(query: string, space: string) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
@ -39,7 +40,7 @@ function score(query, space) {
}
}
function matcherObject(room, displayedAlias, matchName = "") {
function matcherObject(room: Room, displayedAlias: string, matchName = "") {
return {
room,
matchName,
@ -48,6 +49,8 @@ function matcherObject(room, displayedAlias, matchName = "") {
}
export default class RoomProvider extends AutocompleteProvider {
matcher: QueryMatcher<Room>;
constructor() {
super(ROOM_REGEX);
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 client = MatrixClientPeg.get();
@ -115,7 +118,7 @@ export default class RoomProvider extends AutocompleteProvider {
return '💬 ' + _t('Rooms');
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"

View file

@ -1,4 +1,3 @@
//@flow
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
@ -27,9 +26,13 @@ import QueryMatcher from './QueryMatcher';
import _sortBy from 'lodash/sortBy';
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 type {Completion, SelectionRange} from "./Autocompleter";
import {ICompletion, ISelectionRange} from "./Autocompleter";
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)
const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g;
interface IRoomTimelineData {
timeline: EventTimeline;
liveEvent?: boolean;
}
export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null;
room: Room = null;
matcher: QueryMatcher<RoomMember>;
users: RoomMember[];
room: Room;
constructor(room: Room) {
super(USER_REGEX, FORCED_USER_REGEX);
@ -51,21 +60,19 @@ export default class UserProvider extends AutocompleteProvider {
shouldMatchWordsOnly: false,
});
this._onRoomTimelineBound = this._onRoomTimeline.bind(this);
this._onRoomStateMemberBound = this._onRoomStateMember.bind(this);
MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
}
destroy() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
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 (removed) 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?
this.onUserSpoke(ev.sender);
}
};
_onRoomStateMember(ev: MatrixEvent, state: RoomState, member: RoomMember) {
private onRoomStateMember = (ev: MatrixEvent, state: RoomState, member: RoomMember) => {
// ignore members in other rooms
if (member.roomId !== this.room.roomId) {
return;
@ -89,16 +96,16 @@ export default class UserProvider extends AutocompleteProvider {
// blow away the users cache
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');
// lazy-load user list into matcher
if (this.users === null) this._makeUsers();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force);
const {command, range} = this.getCurrentCommand(rawQuery, selection, force);
if (!command) return completions;
@ -163,7 +170,7 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher.setObjects(this.users);
}
renderCompletions(completions: [React.Component]): ?React.Component {
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("User Autocomplete")}>
{ completions }

View file

@ -17,28 +17,49 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import flatMap from 'lodash/flatMap';
import type {Completion} from '../../../autocomplete/Autocompleter';
import { Room } from 'matrix-js-sdk';
import {ICompletion, ISelectionRange, IProviderCompletions} from '../../../autocomplete/Autocompleter';
import {Room} from 'matrix-js-sdk/src/models/room';
import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter';
import {sleep} from "../../../utils/promise";
const COMPOSER_SELECTED = 0;
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) {
super(props);
this.autocompleter = new Autocompleter(props.room);
this.completionPromise = null;
this.hide = this.hide.bind(this);
this.onCompletionClicked = this.onCompletionClicked.bind(this);
this.state = {
// list of completionResults, each containing completions
@ -57,13 +78,15 @@ export default class Autocomplete extends React.Component {
forceComplete: false,
};
this.containerRef = React.createRef();
}
componentDidMount() {
this._applyNewProps();
this.applyNewProps();
}
_applyNewProps(oldQuery, oldRoom) {
private applyNewProps(oldQuery?: string, oldRoom?: Room) {
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
this.autocompleter.destroy();
this.autocompleter = new Autocompleter(this.props.room);
@ -81,7 +104,7 @@ export default class Autocomplete extends React.Component {
this.autocompleter.destroy();
}
complete(query, selection) {
complete(query: string, selection: ISelectionRange) {
this.queryRequested = query;
if (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(
query, selection, this.state.forceComplete,
).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);
// 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;
}
@ -168,7 +191,7 @@ export default class Autocomplete extends React.Component {
}
// called from MessageComposerInput
moveSelection(delta): ?Completion {
moveSelection(delta: number) {
const completionCount = this.countCompletions();
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);
}
onEscape(e): boolean {
onEscape(e: KeyboardEvent): boolean {
const completionCount = this.countCompletions();
if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault
@ -190,9 +213,14 @@ export default class Autocomplete extends React.Component {
this.hide();
}
hide() {
this.setState({hide: true, selectionOffset: 0, completions: [], completionList: []});
}
hide = () => {
this.setState({
hide: true,
selectionOffset: 0,
completions: [],
completionList: [],
});
};
forceComplete() {
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) {
return false;
}
@ -216,7 +244,7 @@ export default class Autocomplete extends React.Component {
this.hide();
return true;
}
};
setSelection(selectionOffset: number) {
this.setState({selectionOffset, hide: false});
@ -225,28 +253,24 @@ export default class Autocomplete extends React.Component {
}
}
componentDidUpdate(prevProps) {
this._applyNewProps(prevProps.query, prevProps.room);
componentDidUpdate(prevProps: IProps) {
this.applyNewProps(prevProps.query, prevProps.room);
// this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
if (selectedCompletion && this.container) {
if (selectedCompletion && this.containerRef.current) {
const domNode = ReactDOM.findDOMNode(selectedCompletion);
const offsetTop = domNode && domNode.offsetTop;
if (offsetTop > this.container.scrollTop + this.container.offsetHeight ||
offsetTop < this.container.scrollTop) {
this.container.scrollTop = offsetTop - this.container.offsetTop;
if (offsetTop > this.containerRef.current.scrollTop + this.containerRef.current.offsetHeight ||
offsetTop < this.containerRef.current.scrollTop) {
this.containerRef.current.scrollTop = offsetTop - this.containerRef.current.offsetTop;
}
}
}
setState(state, func) {
super.setState(state, func);
}
render() {
let position = 1;
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 className = classNames('mx_Autocomplete_Completion', {selected});
const componentPosition = position;
@ -257,7 +281,7 @@ export default class Autocomplete extends React.Component {
};
return React.cloneElement(completion.component, {
"key": i,
"key": j,
"ref": `completion${componentPosition}`,
"id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs
className,
@ -276,23 +300,9 @@ export default class Autocomplete extends React.Component {
}).filter((completion) => !!completion);
return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}>
<div className="mx_Autocomplete" ref={this.containerRef}>
{ renderedCompletions }
</div>
) : 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.
*/
// @ts-ignore - import * as EMOJIBASE actually breaks this
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
const UNICODE_TO_EMOJI = new Map(); // not exported as gets for it are handled by getEmojiFromUnicode
export const EMOTICON_TO_EMOJI = new Map();
export const SHORTCODE_TO_EMOJI = new Map();
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<string, IEmojiWithFilterString>();
export const SHORTCODE_TO_EMOJI = new Map<string, IEmojiWithFilterString>();
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
EMOJIBASE.forEach(emoji => {
EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group];
if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) {
DATA_BY_CATEGORY[categoryId].push(emoji);
@ -89,3 +105,5 @@ EMOJIBASE.forEach(emoji => {
function stripVariation(str) {
return str.replace(/[\uFE00-\uFE0F]$/, "");
}
export const EMOJI: IEmoji[] = EMOJIBASE;

View file

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