/* * Wire * Copyright (C) 2023 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. * */ import {MutableRefObject, useMemo, useState} from 'react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {MenuOption, MenuRenderFn, MenuTextMatch} from '@lexical/react/LexicalTypeaheadMenuPlugin'; // The emoji list comes from the emoji-picker-react package that we also use for reactions. It's a little hacky how we import it but since it's typechecked, we will be warned if this file doesn't exist in the repo with further updates import emojiList from 'emoji-picker-react/src/data/emojis.json'; import {$createTextNode, TextNode} from 'lexical'; import * as ReactDOM from 'react-dom'; import {TypeaheadMenuPlugin} from 'Components/RichTextEditor/plugins/TypeaheadMenuPlugin'; import {loadValue, storeValue} from 'Util/StorageUtil'; import {sortByPriority} from 'Util/StringUtil'; import {EmojiItem} from './EmojiItem'; import {StorageKey} from '../../../../storage'; import {getDOMRangeRect} from '../../utils/getDomRangeRect'; import {getSelectionInfo} from '../../utils/getSelectionInfo'; const TRIGGER = ':'; const triggerRegexp = new RegExp(`(\\W|^)(${TRIGGER}([\\w+\\-][\\w \\-]*))$`); /** * Will detect emoji triggers in a text * @param text the text in which to look for emoji triggers */ function checkForEmojis(text: string): MenuTextMatch | null { const match = triggerRegexp.exec(text); if (match === null) { return null; } const [, , search, term] = match; if (term.length === 0) { return null; } return { leadOffset: match.index, matchingString: term, replaceableString: search, }; } export class EmojiOption extends MenuOption { title: string; emoji: string; keywords: string[]; constructor( title: string, emoji: string, options: { keywords?: string[]; }, ) { super(title + options.keywords?.join('_')); this.title = title.replace(/_/g, ' '); this.emoji = emoji; this.keywords = options.keywords || []; } } const emojiUsageCount: Record = loadValue(StorageKey.CONVERSATION.EMOJI_USAGE_COUNT) || {}; const getUsageCount = (emojiName: string): number => emojiUsageCount[emojiName] || 0; const MAX_EMOJI_SUGGESTION_COUNT = 5; type Props = { openStateRef: MutableRefObject; }; const emojies: {n: string[]; u: string}[] = Object.values(emojiList).flat(); const emojiOptions = emojies.map(({n: aliases, u: codepoint}) => { const codepoints = codepoint.split('-').map(code => parseInt(code, 16)); return new EmojiOption(aliases[0], String.fromCodePoint(...codepoints), { keywords: aliases, }); }); export function EmojiPickerPlugin({openStateRef}: Props) { const [lexicalEditor] = useLexicalComposerContext(); const [queryString, setQueryString] = useState(null); const increaseUsageCount = (emojiName: string | null): void => { if (emojiName) { emojiUsageCount[emojiName] = getUsageCount(emojiName) + 1; storeValue(StorageKey.CONVERSATION.EMOJI_USAGE_COUNT, emojiUsageCount); } }; const checkForEmojiPickerMatch = (text: string) => { const info = getSelectionInfo([TRIGGER]); if (!info || (info.isTextNode && info.wordCharAfterCursor)) { // Don't show the menu if the next character is a word character return null; } return checkForEmojis(text); }; const options: Array = useMemo(() => { const filteredEmojis = emojiOptions.filter((emoji: EmojiOption) => { if (queryString === null) { return false; } return emoji.keywords.some(emojiNameWord => emojiNameWord.includes(queryString)); }); return filteredEmojis .sort((emojiA, emojiB) => { const usageCountA = getUsageCount(emojiA.title); const usageCountB = getUsageCount(emojiB.title); const sameUsageCount = usageCountA === usageCountB; return sameUsageCount ? sortByPriority(emojiA.title, emojiB.title, queryString || '') : usageCountB - usageCountA; }) .slice(0, MAX_EMOJI_SUGGESTION_COUNT); }, [queryString]); const insertEmoji = (selectedOption: EmojiOption, nodeToRemove: TextNode | null, closeMenu: () => void) => { lexicalEditor.update(() => { nodeToRemove?.replace($createTextNode(selectedOption.emoji)); }); increaseUsageCount(selectedOption.title); closeMenu(); }; const rootElement = lexicalEditor.getRootElement(); const getPosition = () => { const nativeSelection = window.getSelection(); if (!rootElement || !nativeSelection) { return {bottom: 0, left: 0}; } const rangeRect = getDOMRangeRect(nativeSelection, rootElement); return { bottom: window.innerHeight - rangeRect.top, left: rangeRect.x, }; }; const menuRender: MenuRenderFn = ( anchorElementRef, {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, ) => { if (!anchorElementRef.current || !options.length) { return null; } const {bottom, left} = getPosition(); return ReactDOM.createPortal(
{options.map((option: EmojiOption, index) => ( setHighlightedIndex(index)} onClick={() => { setHighlightedIndex(index); selectOptionAndCleanUp(option); }} /> ))}
, anchorElementRef.current, ); }; openStateRef.current = options.length > 0; return ( (openStateRef.current = false)} containerId="emoji-typeahead-menu" /> ); }