2023-06-12 11:47:13 +02:00

271 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from '../../../../lib/teact/teact';
import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom';
import { getGlobal } from '../../../../global';
import type { ApiSticker } from '../../../../api/types';
import type { EmojiData, EmojiModule, EmojiRawData } from '../../../../util/emoji';
import { uncompressEmoji } from '../../../../util/emoji';
import type { Signal } from '../../../../util/signals';
import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_ID } from '../../../../config';
import {
buildCollectionByKey, mapValues, pickTruthy, unique, uniqueByField,
} from '../../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { prepareForRegExp } from '../helpers/prepareForRegExp';
import focusEditableElement from '../../../../util/focusEditableElement';
import memoized from '../../../../util/memoized';
import renderText from '../../../common/helpers/renderText';
import { selectCustomEmojiForEmojis } from '../../../../global/selectors';
import { buildCustomEmojiHtml } from '../helpers/customEmoji';
import useLastCallback from '../../../../hooks/useLastCallback';
import useFlag from '../../../../hooks/useFlag';
import useDerivedSignal from '../../../../hooks/useDerivedSignal';
import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
interface Library {
keywords: string[];
byKeyword: Record<string, Emoji[]>;
names: string[];
byName: Record<string, Emoji[]>;
maxKeyLength: number;
}
let emojiDataPromise: Promise<EmojiModule>;
let emojiRawData: EmojiRawData;
let emojiData: EmojiData;
let RE_EMOJI_SEARCH: RegExp;
let RE_LOWERCASE_TEST: RegExp;
const EMOJIS_LIMIT = 36;
const FILTER_MIN_LENGTH = 2;
const THROTTLE = 300;
const prepareRecentEmojisMemo = memoized(prepareRecentEmojis);
const prepareLibraryMemo = memoized(prepareLibrary);
const searchInLibraryMemo = memoized(searchInLibrary);
try {
RE_EMOJI_SEARCH = /(^|\s):(?!\s)[-+_:'\s\p{L}\p{N}]*$/gui;
RE_LOWERCASE_TEST = /\p{Ll}/u;
} catch (e) {
// Support for older versions of firefox
RE_EMOJI_SEARCH = /(^|\s):(?!\s)[-+_:'\s\d\wа-яёґєії]*$/gi;
RE_LOWERCASE_TEST = /[a-zяёґєії]/;
}
export default function useEmojiTooltip(
isEnabled: boolean,
getHtml: Signal<string>,
setHtml: (html: string) => void,
inputId = EDITABLE_INPUT_ID,
recentEmojiIds: string[],
baseEmojiKeywords?: Record<string, string[]>,
emojiKeywords?: Record<string, string[]>,
) {
const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
const [byId, setById] = useState<Record<string, Emoji> | undefined>();
const [filteredEmojis, setFilteredEmojis] = useState<Emoji[]>(MEMO_EMPTY_ARRAY);
const [filteredCustomEmojis, setFilteredCustomEmojis] = useState<ApiSticker[]>(MEMO_EMPTY_ARRAY);
// Initialize data on first render
useEffect(() => {
if (!isEnabled) return;
function exec() {
setById(emojiData.emojis);
}
if (emojiData) {
exec();
} else {
ensureEmojiData().then(exec);
}
}, [isEnabled]);
const detectEmojiCodeThrottled = useThrottledResolver(() => {
const html = getHtml();
return isEnabled && html.includes(':') ? prepareForRegExp(html).match(RE_EMOJI_SEARCH)?.[0].trim() : undefined;
}, [getHtml, isEnabled], THROTTLE);
const getEmojiCode = useDerivedSignal(
detectEmojiCodeThrottled, [detectEmojiCodeThrottled, getHtml], true,
);
const updateFiltered = useLastCallback((emojis: Emoji[]) => {
setFilteredEmojis(emojis);
if (emojis === MEMO_EMPTY_ARRAY) {
setFilteredCustomEmojis(MEMO_EMPTY_ARRAY);
return;
}
const nativeEmojis = emojis.map((emoji) => emoji.native);
const customEmojis = uniqueByField(
selectCustomEmojiForEmojis(getGlobal(), nativeEmojis),
'id',
);
setFilteredCustomEmojis(customEmojis);
});
const insertEmoji = useLastCallback((emoji: string | ApiSticker, isForce = false) => {
const html = getHtml();
if (!html) return;
const atIndex = html.lastIndexOf(':', isForce ? html.lastIndexOf(':') - 1 : undefined);
if (atIndex !== -1) {
const emojiHtml = typeof emoji === 'string' ? renderText(emoji, ['emoji_html']) : buildCustomEmojiHtml(emoji);
setHtml(`${html.substring(0, atIndex)}${emojiHtml}`);
const messageInput = inputId === EDITABLE_INPUT_ID
? document.querySelector<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR)!
: document.getElementById(inputId) as HTMLDivElement;
requestNextMutation(() => {
focusEditableElement(messageInput, true, true);
});
}
updateFiltered(MEMO_EMPTY_ARRAY);
});
useEffect(() => {
const emojiCode = getEmojiCode();
if (!emojiCode || !byId) {
updateFiltered(MEMO_EMPTY_ARRAY);
return;
}
const newShouldAutoInsert = emojiCode.length > 2 && emojiCode.endsWith(':');
const filter = emojiCode.substring(1, newShouldAutoInsert ? 1 + emojiCode.length - 2 : undefined);
let matched: Emoji[] = MEMO_EMPTY_ARRAY;
if (!filter) {
matched = prepareRecentEmojisMemo(byId, recentEmojiIds, EMOJIS_LIMIT);
} else if ((filter.length === 1 && RE_LOWERCASE_TEST.test(filter)) || filter.length >= FILTER_MIN_LENGTH) {
const library = prepareLibraryMemo(byId, baseEmojiKeywords, emojiKeywords);
matched = searchInLibraryMemo(library, filter.toLowerCase(), EMOJIS_LIMIT);
}
if (!matched.length) {
updateFiltered(MEMO_EMPTY_ARRAY);
return;
}
if (newShouldAutoInsert) {
insertEmoji(matched[0].native, true);
} else {
updateFiltered(matched);
}
}, [
baseEmojiKeywords, byId, getEmojiCode, emojiKeywords, insertEmoji, recentEmojiIds, updateFiltered,
]);
useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
return {
isEmojiTooltipOpen: Boolean(filteredEmojis.length || filteredCustomEmojis.length) && !isManuallyClosed,
closeEmojiTooltip: markManuallyClosed,
filteredEmojis,
filteredCustomEmojis,
insertEmoji,
};
}
async function ensureEmojiData() {
if (!emojiDataPromise) {
emojiDataPromise = import('emoji-data-ios/emoji-data.json');
emojiRawData = (await emojiDataPromise).default;
emojiData = uncompressEmoji(emojiRawData);
}
return emojiDataPromise;
}
function prepareRecentEmojis(byId: Record<string, Emoji>, recentEmojiIds: string[], limit: number) {
if (!byId || !recentEmojiIds.length) {
return MEMO_EMPTY_ARRAY;
}
return Object.values(pickTruthy(byId, recentEmojiIds)).slice(0, limit);
}
function prepareLibrary(
byId: Record<string, Emoji>,
baseEmojiKeywords?: Record<string, string[]>,
emojiKeywords?: Record<string, string[]>,
): Library {
const emojis = Object.values(byId);
const byNative = buildCollectionByKey<Emoji>(emojis, 'native');
const baseEmojisByKeyword = baseEmojiKeywords
? mapValues(baseEmojiKeywords, (natives) => {
return Object.values(pickTruthy(byNative, natives));
})
: {};
const emojisByKeyword = emojiKeywords
? mapValues(emojiKeywords, (natives) => {
return Object.values(pickTruthy(byNative, natives));
})
: {};
const byKeyword = { ...baseEmojisByKeyword, ...emojisByKeyword };
const keywords = ([] as string[]).concat(Object.keys(baseEmojisByKeyword), Object.keys(emojisByKeyword));
const byName = emojis.reduce((result, emoji) => {
emoji.names.forEach((name) => {
if (!result[name]) {
result[name] = [];
}
result[name].push(emoji);
});
return result;
}, {} as Record<string, Emoji[]>);
const names = Object.keys(byName);
const maxKeyLength = keywords.reduce((max, keyword) => Math.max(max, keyword.length), 0);
return {
byKeyword,
keywords,
byName,
names,
maxKeyLength,
};
}
function searchInLibrary(library: Library, filter: string, limit: number) {
const {
byKeyword, keywords, byName, names, maxKeyLength,
} = library;
let matched: Emoji[] = [];
if (filter.length > maxKeyLength) {
return MEMO_EMPTY_ARRAY;
}
const matchedKeywords = keywords.filter((keyword) => keyword.startsWith(filter)).sort();
matched = matched.concat(Object.values(pickTruthy(byKeyword!, matchedKeywords)).flat());
// Also search by names, which is useful for non-English languages
const matchedNames = names.filter((name) => name.startsWith(filter));
matched = matched.concat(Object.values(pickTruthy(byName, matchedNames)).flat());
matched = unique(matched);
if (!matched.length) {
return MEMO_EMPTY_ARRAY;
}
return matched.slice(0, limit);
}