Files
ThreadNet-Web/apps/web/src/autocomplete/EmojiProvider.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

185 lines
7.3 KiB
TypeScript
Raw Normal View History

2017-06-01 15:18:06 +01:00
/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
Copyright 2022 Ryan Browne <code@commonlawfeature.com>
2024-09-09 14:57:16 +01:00
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2016 Aviral Dasgupta
2017-06-01 15:18:06 +01:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2017-06-01 15:18:06 +01:00
*/
2020-04-21 10:01:05 +01:00
import React from "react";
2025-02-05 13:25:06 +00:00
import { uniq, sortBy, uniqBy, type ListIteratee } from "lodash";
2021-12-09 09:10:23 +00:00
import EMOTICON_REGEX from "emojibase-regex/emoticon";
2025-02-05 13:25:06 +00:00
import { type Room } from "matrix-js-sdk/src/matrix";
import { EMOJI, type Emoji, getEmojiFromUnicode } from "@matrix-org/emojibase-bindings";
2021-12-09 09:10:23 +00:00
2017-05-25 11:39:08 +01:00
import { _t } from "../languageHandler";
import AutocompleteProvider from "./AutocompleteProvider";
2018-08-13 19:15:42 +01:00
import QueryMatcher from "./QueryMatcher";
2021-06-29 13:11:58 +01:00
import { PillCompletion } from "./Components";
2025-02-05 13:25:06 +00:00
import { type ICompletion, type ISelectionRange } from "./Autocompleter";
import SettingsStore from "../settings/SettingsStore";
2025-02-05 13:25:06 +00:00
import { type TimelineRenderingType } from "../contexts/RoomContext";
import * as recent from "../emojipicker/recent";
import { filterBoolean } from "../utils/arrays";
const LIMIT = 20;
// Match for ascii-style ";-)" emoticons or ":wink:" shortcodes provided by emojibase
2020-07-21 17:53:16 +01:00
// anchored to only match from the start of parts otherwise it'll show emoji suggestions whilst typing matrix IDs
const EMOJI_REGEX = new RegExp("(" + EMOTICON_REGEX.source + "|(?:^|\\s):[+-\\w]*:?)$", "g");
interface ISortedEmoji {
emoji: Emoji;
2020-04-20 19:00:54 +01:00
_orderBy: number;
}
const SORTED_EMOJI: ISortedEmoji[] = EMOJI.sort((a, b) => {
if (a.group === b.group) {
return a.order! - b.order!;
}
return a.group! - b.group!;
2020-04-20 19:00:54 +01:00
}).map((emoji, index) => ({
emoji,
// Include the index so that we can preserve the original order
_orderBy: index,
}));
2023-02-13 11:39:16 +00:00
function score(query: string, space: string[] | string): number {
if (Array.isArray(space)) {
return Math.min(...space.map((s) => score(query, s)));
}
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
function colonsTrimmed(str: string): string {
// Trim off leading and potentially trailing `:` to correctly match the emoji data as they exist in emojibase.
// Notes: The regex is pinned to the start and end of the string so that we can use the lazy-capturing `*?` matcher.
// It needs to be lazy so that the trailing `:` is not captured in the replacement group, if it exists.
return str.replace(/^:(.*?):?$/, "$1");
}
export default class EmojiProvider extends AutocompleteProvider {
public matcher: QueryMatcher<ISortedEmoji>;
public nameMatcher: QueryMatcher<ISortedEmoji>;
private readonly recentlyUsed: Emoji[];
2020-04-20 19:00:54 +01:00
public constructor(room: Room, renderingType?: TimelineRenderingType) {
super({ commandRegex: EMOJI_REGEX, renderingType });
this.matcher = new QueryMatcher<ISortedEmoji>(SORTED_EMOJI, {
2021-08-20 15:16:22 +02:00
keys: [],
2021-07-19 15:09:15 -04:00
funcs: [(o) => o.emoji.shortcodes.map((s) => `:${s}:`)],
2017-06-29 11:29:55 +01:00
// For matching against ascii equivalents
shouldMatchWordsOnly: false,
2017-02-10 23:35:13 +05:30
});
this.nameMatcher = new QueryMatcher(SORTED_EMOJI, {
2022-03-23 18:08:34 +01:00
keys: ["emoji.label"],
2017-07-19 16:54:58 +01:00
// For removing punctuation
shouldMatchWordsOnly: true,
});
this.recentlyUsed = Array.from(new Set(filterBoolean(recent.get().map(getEmojiFromUnicode))));
}
public async getCompletions(
query: string,
selection: ISelectionRange,
force?: boolean,
limit = -1,
): Promise<ICompletion[]> {
2019-01-24 20:57:40 -07:00
if (!SettingsStore.getValue("MessageComposerInput.suggestEmoji")) {
2017-09-14 21:28:12 -06:00
return []; // don't give any suggestions if the user doesn't want them
}
2022-11-30 11:32:56 +00:00
let completions: ISortedEmoji[] = [];
2021-06-29 13:11:58 +01:00
const { command, range } = this.getCurrentCommand(query, selection);
if (command && command[0].length > 2) {
2019-05-19 21:00:14 +01:00
const matchedString = command[0];
completions = this.matcher.match(matchedString, limit);
2017-07-19 16:54:58 +01:00
// Do second match with shouldMatchWordsOnly in order to match against 'name'
completions = completions.concat(this.nameMatcher.match(matchedString));
const sorters: ListIteratee<ISortedEmoji>[] = [];
// make sure that emoticons come first
sorters.push((c) => score(matchedString, c.emoji.emoticon || ""));
// then sort by score (Infinity if matchedString not in shortcode)
2021-07-16 16:36:03 -04:00
sorters.push((c) => score(matchedString, c.emoji.shortcodes[0]));
// then sort by max score of all shortcodes, trim off the `:`
const trimmedMatch = colonsTrimmed(matchedString);
sorters.push((c) => Math.min(...c.emoji.shortcodes.map((s) => score(trimmedMatch, s))));
// If the matchedString is not empty, sort by length of shortcode. Example:
// matchedString = ":bookmark"
// completions = [":bookmark:", ":bookmark_tabs:", ...]
if (matchedString.length > 1) {
2021-07-16 16:36:03 -04:00
sorters.push((c) => c.emoji.shortcodes[0].length);
}
// Finally, sort by original ordering
sorters.push((c) => c._orderBy);
2022-11-30 11:32:56 +00:00
completions = sortBy<ISortedEmoji>(uniq(completions), sorters);
completions = completions.slice(0, LIMIT);
// Do a second sort to place emoji matching with frequently used one on top
const recentlyUsedAutocomplete: ISortedEmoji[] = [];
this.recentlyUsed.forEach((emoji) => {
if (emoji.shortcodes[0].indexOf(trimmedMatch) === 0) {
recentlyUsedAutocomplete.push({ emoji: emoji, _orderBy: 0 });
}
});
//if there is an exact shortcode match in the frequently used emojis, it goes before everything
for (let i = 0; i < recentlyUsedAutocomplete.length; i++) {
if (recentlyUsedAutocomplete[i].emoji.shortcodes[0] === trimmedMatch) {
const exactMatchEmoji = recentlyUsedAutocomplete[i];
for (let j = i; j > 0; j--) {
recentlyUsedAutocomplete[j] = recentlyUsedAutocomplete[j - 1];
}
recentlyUsedAutocomplete[0] = exactMatchEmoji;
break;
}
}
completions = recentlyUsedAutocomplete.concat(completions);
completions = uniqBy(completions, "emoji");
2022-11-30 11:32:56 +00:00
return completions.map((c) => ({
completion: c.emoji.unicode,
component: (
2021-07-16 16:36:03 -04:00
<PillCompletion title={`:${c.emoji.shortcodes[0]}:`} aria-label={c.emoji.unicode}>
<span>{c.emoji.unicode}</span>
</PillCompletion>
),
range: range!,
}));
}
2022-11-30 11:32:56 +00:00
return [];
}
public getName(): string {
return "😃 " + _t("common|emoji");
}
public renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_pill"
aria-label={_t("composer|autocomplete|emoji_a11y")}
>
{completions}
</div>
);
}
}