Merge pull request #4452 from matrix-org/t3chguy/autocomplete
Convert autocomplete stuff to TypeScriptpull/21833/head
commit
dd1f1b3092
|
@ -86,7 +86,7 @@ interface ICommandOpts {
|
|||
hideCompletionAfterSpace?: boolean;
|
||||
}
|
||||
|
||||
class Command {
|
||||
export class Command {
|
||||
command: string;
|
||||
aliases: string[];
|
||||
args: undefined | string;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}));
|
||||
|
|
@ -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 }
|
|
@ -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"
|
|
@ -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,
|
||||
};
|
|
@ -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"
|
|
@ -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 }
|
|
@ -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"
|
|
@ -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, '');
|
|
@ -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"
|
|
@ -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 }
|
|
@ -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),
|
||||
};
|
|
@ -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;
|
|
@ -2,6 +2,8 @@
|
|||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es2016",
|
||||
|
|
Loading…
Reference in New Issue