diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index d061dbed60..7b9bbeface 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -86,7 +86,7 @@ interface ICommandOpts { hideCompletionAfterSpace?: boolean; } -class Command { +export class Command { command: string; aliases: string[]; args: undefined | string; diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.tsx similarity index 80% rename from src/autocomplete/AutocompleteProvider.js rename to src/autocomplete/AutocompleteProvider.tsx index 98ae83c526..2cda2593a4 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -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 { + async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { 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; } diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.ts similarity index 70% rename from src/autocomplete/Autocompleter.js rename to src/autocomplete/Autocompleter.ts index a26eb6033b..8384eb9d4f 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.ts @@ -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 { + async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { /* 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); })); diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.tsx similarity index 90% rename from src/autocomplete/CommandProvider.js rename to src/autocomplete/CommandProvider.tsx index 0b8af4d6f9..e7a6f44536 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.tsx @@ -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; + 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 { + async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { 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 (
{ completions } diff --git a/src/autocomplete/CommunityProvider.js b/src/autocomplete/CommunityProvider.tsx similarity index 92% rename from src/autocomplete/CommunityProvider.js rename to src/autocomplete/CommunityProvider.tsx index b863603aae..3edb1ff81d 100644 --- a/src/autocomplete/CommunityProvider.js +++ b/src/autocomplete/CommunityProvider.tsx @@ -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; + 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 { + async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { 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 (
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 { 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 { 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, -}; diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.tsx similarity index 91% rename from src/autocomplete/DuckDuckGoProvider.js rename to src/autocomplete/DuckDuckGoProvider.tsx index 8cff83554a..e63f7255dc 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.tsx @@ -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 { 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 (
{ +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; + nameMatcher: QueryMatcher; + 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 { + async getCompletions(query: string, selection: ISelectionRange, force?: boolean): Promise { 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 (
{ completions } diff --git a/src/autocomplete/NotifProvider.js b/src/autocomplete/NotifProvider.tsx similarity index 87% rename from src/autocomplete/NotifProvider.js rename to src/autocomplete/NotifProvider.tsx index e7c8f6f70d..b217612b0e 100644 --- a/src/autocomplete/NotifProvider.js +++ b/src/autocomplete/NotifProvider.tsx @@ -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 { + async getCompletions(query: string, selection: ISelectionRange, force= false): Promise { 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 (
@@ -26,6 +25,13 @@ function stripDiacritics(str: string): string { return str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } +interface IOptions { + keys: Array; + 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, options: {[Object]: Object} = {}) { +export default class QueryMatcher { + private _options: IOptions; + private _keys: IOptions["keys"]; + private _funcs: Required["funcs"]>; + private _items: Map; + + constructor(objects: T[], options: IOptions = { keys: [] }) { this._options = options; this._keys = options.keys; this._funcs = options.funcs || []; @@ -60,7 +71,7 @@ export default class QueryMatcher { } } - setObjects(objects: Array) { + setObjects(objects: T[]) { this._items = new Map(); for (const object of objects) { @@ -81,7 +92,7 @@ export default class QueryMatcher { } } - match(query: String): Array { + match(query: string): T[] { query = stripDiacritics(query).toLowerCase(); if (this._options.shouldMatchWordsOnly) { query = query.replace(/[^\w]/g, ''); diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.tsx similarity index 91% rename from src/autocomplete/RoomProvider.js rename to src/autocomplete/RoomProvider.tsx index a0f670e769..01e770407c 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.tsx @@ -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; + 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 { + async getCompletions(query: string, selection: ISelectionRange, force = false): Promise { 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 (
= null; - room: Room = null; + matcher: QueryMatcher; + 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 { + async getCompletions(rawQuery: string, selection: ISelectionRange, force = false): Promise { 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 (
{ completions } diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.tsx similarity index 81% rename from src/components/views/rooms/Autocomplete.js rename to src/components/views/rooms/Autocomplete.tsx index 76a3a19e00..6843df4121 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.tsx @@ -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 { + autocompleter: Autocompleter; + queryRequested: string; + debounceCompletionsRequest: NodeJS.Timeout; + containerRef: React.RefObject; + 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 ? ( -
this.container = e}> +
{ renderedCompletions }
) : 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), -}; diff --git a/src/emoji.js b/src/emoji.ts similarity index 79% rename from src/emoji.js rename to src/emoji.ts index 20b05531ca..c0a755145a 100644 --- a/src/emoji.js +++ b/src/emoji.ts @@ -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(); // not exported as gets for it are handled by getEmojiFromUnicode +export const EMOTICON_TO_EMOJI = new Map(); +export const SHORTCODE_TO_EMOJI = new Map(); 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; diff --git a/tsconfig.json b/tsconfig.json index d70e0a85f0..b87f640734 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,8 @@ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true, + "resolveJsonModule": true, + "esModuleInterop": true, "module": "commonjs", "moduleResolution": "node", "target": "es2016",