From dbca51837700ccca7f11a7ba5e99f9c9e43f7bda Mon Sep 17 00:00:00 2001 From: yny4ik Date: Wed, 29 Apr 2026 22:28:39 +0300 Subject: [PATCH] feat: add emoji tooltips --- src/components/common/Composer.tsx | 18 +- src/components/main/Main.tsx | 28 +- .../middle/composer/AttachmentModal.tsx | 18 +- .../middle/composer/EmojiButton.scss | 6 +- .../middle/composer/EmojiTooltip.scss | 3 +- .../middle/composer/EmojiTooltip.tsx | 38 ++- .../middle/composer/hooks/useEmojiTooltip.ts | 268 ++++++++++++++---- .../composer/hooks/useKeyboardNavigation.ts | 80 +++++- src/serviceWorker/index.ts | 6 + 9 files changed, 376 insertions(+), 89 deletions(-) diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 74d40c0eb7..48d5dfbd3d 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -347,6 +347,10 @@ const MOBILE_KEYBOARD_HIDE_DELAY_MS = 100; const SELECT_MODE_TRANSITION_MS = 200; const SENDING_ANIMATION_DURATION = 350; const MOUNT_ANIMATION_DURATION = 430; +const BROWSER_EMOJI_KEYWORD_LANG_FULL = typeof navigator !== 'undefined' + ? navigator.language.toLowerCase() + : undefined; +const BROWSER_EMOJI_KEYWORD_LANG_SHORT = BROWSER_EMOJI_KEYWORD_LANG_FULL?.split('-')[0]; const Composer = ({ type, @@ -2719,8 +2723,18 @@ export default memo(withGlobal( forwardMessages: { messageIds: forwardMessageIds }, shouldOpenMessageMediaEditor, } = selectTabState(global); + const normalizedLanguage = language.toLowerCase(); + const normalizedLanguageShort = normalizedLanguage.split('-')[0]; const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG]; - const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined; + const languageCandidates = [ + normalizedLanguage, + normalizedLanguageShort, + BROWSER_EMOJI_KEYWORD_LANG_FULL, + BROWSER_EMOJI_KEYWORD_LANG_SHORT, + ].filter(Boolean); + const emojiKeywords = languageCandidates.find((langCode) => ( + langCode !== BASE_EMOJI_KEYWORD_LANG && global.emojiKeywords[langCode] + )); const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined; const keyboardMessage = botKeyboardMessageId ? selectChatMessage(global, chatId, botKeyboardMessageId) : undefined; const { currentUserId } = global; @@ -2829,7 +2843,7 @@ export default memo(withGlobal( shouldUpdateStickerSetOrder, recentEmojis: global.recentEmojis, baseEmojiKeywords: baseEmojiKeywords?.keywords, - emojiKeywords: emojiKeywords?.keywords, + emojiKeywords: emojiKeywords ? global.emojiKeywords[emojiKeywords]?.keywords : undefined, inlineBots: tabState.inlineBots.byUsername, isInlineBotLoading: tabState.inlineBots.isLoading, botCommands: userFullInfo ? (userFullInfo.botInfo?.commands || false) : undefined, diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 57ed79b251..a6a1bbf494 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -284,6 +284,7 @@ const Main = ({ const containerRef = useRef(); const leftColumnRef = useRef(); + const requestedEmojiKeywordLangsRef = useRef>(new Set()); const { isDesktop } = useAppLayout(); useEffect(() => { @@ -363,13 +364,32 @@ const Main = ({ // Language-based API calls useEffect(() => { if (isMasterTab) { - if (lang.code !== BASE_EMOJI_KEYWORD_LANG) { - loadEmojiKeywords({ language: lang.code }); - } + const browserFullLanguageCode = typeof navigator !== 'undefined' ? navigator.language : undefined; + const languageCandidates = [ + lang.code, + lang.code.split('-')[0], + lang.rawCode, + lang.rawCode.split('-')[0], + browserFullLanguageCode, + browserFullLanguageCode?.split('-')[0], + ] + .filter(Boolean) + .map((value) => value.toLowerCase()); + + const uniqueLanguageCandidates = Array.from(new Set(languageCandidates)); + uniqueLanguageCandidates.forEach((language) => { + if ( + language !== BASE_EMOJI_KEYWORD_LANG + && !requestedEmojiKeywordLangsRef.current.has(language) + ) { + requestedEmojiKeywordLangsRef.current.add(language); + loadEmojiKeywords({ language }); + } + }); loadCountryList({ langCode: lang.code }); } - }, [lang.code, isMasterTab]); + }, [lang.code, lang.rawCode, isMasterTab]); // Re-fetch cached saved emoji for `localDb` useEffect(() => { diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 9a07029ba3..c3ca95b78a 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -114,6 +114,10 @@ const ATTACHMENT_MODAL_INPUT_ID = 'caption-input-text'; const DROP_LEAVE_TIMEOUT_MS = 150; const MAX_LEFT_CHARS_TO_SHOW = 100; const CLOSE_MENU_ANIMATION_DURATION = 200; +const BROWSER_EMOJI_KEYWORD_LANG_FULL = typeof navigator !== 'undefined' + ? navigator.language.toLowerCase() + : undefined; +const BROWSER_EMOJI_KEYWORD_LANG_SHORT = BROWSER_EMOJI_KEYWORD_LANG_FULL?.split('-')[0]; const AttachmentModal = ({ chatId, @@ -937,8 +941,18 @@ export default memo(withGlobal( const isChatWithSelf = selectIsChatWithSelf(global, chatId); const { shouldSuggestCustomEmoji } = global.settings.byKey; const { language } = selectSharedSettings(global); + const normalizedLanguage = language.toLowerCase(); + const normalizedLanguageShort = normalizedLanguage.split('-')[0]; const baseEmojiKeywords = global.emojiKeywords[BASE_EMOJI_KEYWORD_LANG]; - const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined; + const languageCandidates = [ + normalizedLanguage, + normalizedLanguageShort, + BROWSER_EMOJI_KEYWORD_LANG_FULL, + BROWSER_EMOJI_KEYWORD_LANG_SHORT, + ].filter(Boolean) as string[]; + const emojiKeywords = languageCandidates.find((langCode) => ( + langCode !== BASE_EMOJI_KEYWORD_LANG && global.emojiKeywords[langCode] + )); return { isChatWithSelf, @@ -946,7 +960,7 @@ export default memo(withGlobal( groupChatMembers: chatFullInfo?.members, recentEmojis, baseEmojiKeywords: baseEmojiKeywords?.keywords, - emojiKeywords: emojiKeywords?.keywords, + emojiKeywords: emojiKeywords ? global.emojiKeywords[emojiKeywords]?.keywords : undefined, shouldSuggestCustomEmoji, customEmojiForEmoji: customEmojis.forEmoji.stickers, captionLimit: selectCurrentLimit(global, 'captionLength'), diff --git a/src/components/middle/composer/EmojiButton.scss b/src/components/middle/composer/EmojiButton.scss index d04b791cc3..a1ecc83d38 100644 --- a/src/components/middle/composer/EmojiButton.scss +++ b/src/components/middle/composer/EmojiButton.scss @@ -21,11 +21,15 @@ line-height: inherit; } - &.focus, &:hover { background-color: var(--color-background-selected); } + &.focus { + background-color: var(--color-background-selected); + box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.08); + } + & > img { width: 2rem; height: 2rem; diff --git a/src/components/middle/composer/EmojiTooltip.scss b/src/components/middle/composer/EmojiTooltip.scss index b57049c9e2..53eeb34b84 100644 --- a/src/components/middle/composer/EmojiTooltip.scss +++ b/src/components/middle/composer/EmojiTooltip.scss @@ -3,9 +3,10 @@ overflow-y: hidden; display: flex; padding: 0; + width: fit-content; + max-width: 100%; .EmojiButton { flex: 0 0 2.25rem; - margin-right: 0; } } diff --git a/src/components/middle/composer/EmojiTooltip.tsx b/src/components/middle/composer/EmojiTooltip.tsx index 90bf4666a7..a25ba80ae6 100644 --- a/src/components/middle/composer/EmojiTooltip.tsx +++ b/src/components/middle/composer/EmojiTooltip.tsx @@ -26,7 +26,7 @@ const VIEWPORT_MARGIN = 8; const EMOJI_BUTTON_WIDTH = 44; const CLOSE_DURATION = 350; -function setItemVisible(index: number, containerRef: Record) { +function setItemVisible(index: number, prevIndex: number | undefined, containerRef: Record) { const container = containerRef.current!; if (!container) { return; @@ -41,15 +41,35 @@ function setItemVisible(index: number, containerRef: Record) { true, ); - if (!allElements.length || !allElements[index]) { + if (!allElements.length || !allElements[index] || !visibleIndexes.length) { return; } - const first = visibleIndexes[0]; - if (!visibleIndexes.includes(index) - || (index === first && !isFullyVisible(container, allElements[first], true))) { - const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end'; - const newLeft = position === 'start' ? index * EMOJI_BUTTON_WIDTH : 0; + if (prevIndex === undefined || prevIndex === -1 || Math.abs(index - prevIndex) !== 1) { + const first = visibleIndexes[0]; + if (!visibleIndexes.includes(index) + || (index === first && !isFullyVisible(container, allElements[first], true))) { + const position = index > visibleIndexes[visibleIndexes.length - 1] ? 'start' : 'end'; + const newLeft = position === 'start' ? index * EMOJI_BUTTON_WIDTH : 0; + animateHorizontalScroll(container, newLeft); + } + return; + } + + const middlePosition = Math.floor(visibleIndexes.length / 2); + const middleIndex = visibleIndexes[middlePosition]; + const maxScrollLeft = Math.max(0, container.scrollWidth - container.clientWidth); + + if (index > prevIndex) { + if (index >= middleIndex + 1) { + const newLeft = Math.min(maxScrollLeft, container.scrollLeft + EMOJI_BUTTON_WIDTH); + animateHorizontalScroll(container, newLeft); + } + return; + } + + if (index <= middleIndex - 1) { + const newLeft = Math.max(0, container.scrollLeft - EMOJI_BUTTON_WIDTH); animateHorizontalScroll(container, newLeft); } } @@ -127,11 +147,11 @@ const EmojiTooltip: FC = ({ }); useEffectWithPrevDeps(([prevSelectedIndex]) => { - if (prevSelectedIndex === undefined || prevSelectedIndex === -1) { + if (prevSelectedIndex === undefined) { return; } - setItemVisible(selectedIndex, containerRef); + setItemVisible(selectedIndex, prevSelectedIndex, containerRef); }, [selectedIndex]); const className = buildClassName( diff --git a/src/components/middle/composer/hooks/useEmojiTooltip.ts b/src/components/middle/composer/hooks/useEmojiTooltip.ts index 31ee122344..ca768a6b53 100644 --- a/src/components/middle/composer/hooks/useEmojiTooltip.ts +++ b/src/components/middle/composer/hooks/useEmojiTooltip.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from '../../../../lib/teact/teact'; +import { useEffect, useRef, useState } from '../../../../lib/teact/teact'; import { getGlobal } from '../../../../global'; import type { ApiSticker } from '../../../../api/types'; @@ -8,7 +8,7 @@ import type { Signal } from '../../../../util/signals'; import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_ID } from '../../../../config'; import { requestNextMutation } from '../../../../lib/fasterdom/fasterdom'; import { selectCustomEmojiForEmojis } from '../../../../global/selectors'; -import { uncompressEmoji } from '../../../../util/emoji/emoji'; +import { nativeToUnified, uncompressEmoji } from '../../../../util/emoji/emoji'; import focusEditableElement from '../../../../util/focusEditableElement'; import { buildCollectionByKey, mapValues, pickTruthy, unique, uniqueByField, @@ -27,9 +27,7 @@ import useLastCallback from '../../../../hooks/useLastCallback'; interface Library { keywords: string[]; byKeyword: Record; - names: string[]; - byName: Record; - maxKeyLength: number; + keysByPrefix: Record; } let emojiDataPromise: Promise | undefined; @@ -38,21 +36,26 @@ let emojiData: EmojiData; let RE_EMOJI_SEARCH: RegExp; let RE_LOWERCASE_TEST: RegExp; +let RE_EMOJI_WORD_SEARCH: RegExp; const EMOJIS_LIMIT = 36; const FILTER_MIN_LENGTH = 2; -const THROTTLE = 300; +const THROTTLE = 250; +const TOOLTIP_OPEN_DEBOUNCE = 250; const prepareRecentEmojisMemo = memoized(prepareRecentEmojis); const prepareLibraryMemo = memoized(prepareLibrary); const searchInLibraryMemo = memoized(searchInLibrary); +const normalizeEmojiNativeMemo = memoized(normalizeEmojiNative); try { RE_EMOJI_SEARCH = /(^|\s):(?!\s)[-+_:'\s\p{L}\p{N}]*$/gui; + RE_EMOJI_WORD_SEARCH = /^(?![:@/])(?!.*[:@/]).+$/u; RE_LOWERCASE_TEST = /\p{Ll}/u; } catch (e) { // Support for older versions of firefox RE_EMOJI_SEARCH = /(^|\s):(?!\s)[-+_:'\s\d\wа-яёґєії]*$/gi; + RE_EMOJI_WORD_SEARCH = /^(?![:@/])(?!.*[:@/]).+$/; RE_LOWERCASE_TEST = /[a-zяёґєії]/; } @@ -70,6 +73,10 @@ export default function useEmojiTooltip( const [byId, setById] = useState | undefined>(); const [filteredEmojis, setFilteredEmojis] = useState(MEMO_EMPTY_ARRAY); const [filteredCustomEmojis, setFilteredCustomEmojis] = useState(MEMO_EMPTY_ARRAY); + const [isTooltipVisible, setIsTooltipVisible] = useState(false); + const filteredEmojiIdsRef = useRef(''); + const filteredCustomEmojiIdsRef = useRef(''); + const openTooltipTimeoutRef = useRef(); // Initialize data on first render useEffect(() => { @@ -88,7 +95,28 @@ export default function useEmojiTooltip( const detectEmojiCodeThrottled = useThrottledResolver(() => { const html = getHtml(); - return isEnabled && html.includes(':') ? prepareForRegExp(html).match(RE_EMOJI_SEARCH)?.[0].trim() : undefined; + if (!isEnabled || !html) return undefined; + + const preparedHtml = prepareForRegExp(html); + const emojiCode = preparedHtml.includes(':') ? preparedHtml.match(RE_EMOJI_SEARCH)?.[0].trim() : undefined; + + if (emojiCode) { + return { + code: emojiCode, + isColonQuery: true, + }; + } + + const plainTextQuery = preparedHtml.replace(/^\s*/, ''); + const wordCode = plainTextQuery.match(RE_EMOJI_WORD_SEARCH)?.[0]; + if (wordCode) { + return { + code: wordCode, + isColonQuery: false, + }; + } + + return undefined; }, [getHtml, isEnabled], THROTTLE); const getEmojiCode = useDerivedSignal( @@ -96,10 +124,17 @@ export default function useEmojiTooltip( ); const updateFiltered = useLastCallback((emojis: Emoji[]) => { - setFilteredEmojis(emojis); + const emojiIds = emojis.length ? emojis.map(({ id }) => id).join('\x01') : ''; + if (filteredEmojiIdsRef.current !== emojiIds) { + filteredEmojiIdsRef.current = emojiIds; + setFilteredEmojis(emojis); + } if (emojis === MEMO_EMPTY_ARRAY) { - setFilteredCustomEmojis(MEMO_EMPTY_ARRAY); + if (filteredCustomEmojiIdsRef.current) { + filteredCustomEmojiIdsRef.current = ''; + setFilteredCustomEmojis(MEMO_EMPTY_ARRAY); + } return; } @@ -108,43 +143,75 @@ export default function useEmojiTooltip( selectCustomEmojiForEmojis(getGlobal(), nativeEmojis), 'id', ); - setFilteredCustomEmojis(customEmojis); + const customEmojiIds = customEmojis.length ? customEmojis.map(({ id }) => id).join('\x01') : ''; + if (filteredCustomEmojiIdsRef.current !== customEmojiIds) { + filteredCustomEmojiIdsRef.current = customEmojiIds; + 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); + const emojiCodeData = getEmojiCode(); + const emojiCode = emojiCodeData?.code; + const isColonQuery = emojiCodeData?.isColonQuery; + + if (!emojiCode) { + return; + } + + const emojiHtml = typeof emoji === 'string' + ? renderText(emoji, ['emoji_html'])[0] as string + : buildCustomEmojiHtml(emoji); + + if (isColonQuery) { + const atIndex = html.lastIndexOf(':', isForce ? html.lastIndexOf(':') - 1 : undefined); + if (atIndex === -1) { + return; + } - if (atIndex !== -1) { - const emojiHtml = typeof emoji === 'string' - ? renderText(emoji, ['emoji_html'])[0] as string - : buildCustomEmojiHtml(emoji); setHtml(`${html.substring(0, atIndex)}${emojiHtml}`); + } else { + const lowerCaseHtml = html.toLowerCase(); + const lowerCaseQuery = emojiCode.toLowerCase(); + const searchIndex = lowerCaseHtml.lastIndexOf(lowerCaseQuery); - const messageInput = inputId === EDITABLE_INPUT_ID - ? document.querySelector(EDITABLE_INPUT_CSS_SELECTOR)! - : document.getElementById(inputId) as HTMLDivElement; + if (searchIndex === -1) { + return; + } - requestNextMutation(() => { - focusEditableElement(messageInput, true, true); - }); + setHtml(`${html.substring(0, searchIndex)}${emojiHtml}${html.substring(searchIndex + emojiCode.length)}`); } + const messageInput = inputId === EDITABLE_INPUT_ID + ? document.querySelector(EDITABLE_INPUT_CSS_SELECTOR)! + : document.getElementById(inputId) as HTMLDivElement; + + requestNextMutation(() => { + focusEditableElement(messageInput, true, true); + }); + updateFiltered(MEMO_EMPTY_ARRAY); }); useEffect(() => { - const emojiCode = getEmojiCode(); + const emojiCodeData = getEmojiCode(); + const emojiCode = emojiCodeData?.code; + const isColonQuery = Boolean(emojiCodeData?.isColonQuery); if (!emojiCode || !byId) { updateFiltered(MEMO_EMPTY_ARRAY); return; } - const newShouldAutoInsert = emojiCode.length > 2 && emojiCode.endsWith(':'); + const newShouldAutoInsert = Boolean( + emojiCodeData?.isColonQuery && emojiCode.length > 2 && emojiCode.endsWith(':'), + ); - const filter = emojiCode.substring(1, newShouldAutoInsert ? 1 + emojiCode.length - 2 : undefined); + const filter = isColonQuery + ? emojiCode.substring(1, newShouldAutoInsert ? 1 + emojiCode.length - 2 : undefined) + : emojiCode; let matched: Emoji[] = MEMO_EMPTY_ARRAY; if (!filter) { @@ -170,8 +237,39 @@ export default function useEmojiTooltip( useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]); + const html = getHtml(); + const shouldBeOpen = Boolean(filteredEmojis.length || filteredCustomEmojis.length) && !isManuallyClosed; + + useEffect(() => { + if (openTooltipTimeoutRef.current) { + clearTimeout(openTooltipTimeoutRef.current); + openTooltipTimeoutRef.current = undefined; + } + + if (!shouldBeOpen) { + setIsTooltipVisible(false); + return; + } + + if (isTooltipVisible) { + return; + } + + openTooltipTimeoutRef.current = window.setTimeout(() => { + setIsTooltipVisible(true); + openTooltipTimeoutRef.current = undefined; + }, TOOLTIP_OPEN_DEBOUNCE); + }, [html, isTooltipVisible, shouldBeOpen]); + + useEffect(() => () => { + if (openTooltipTimeoutRef.current) { + clearTimeout(openTooltipTimeoutRef.current); + openTooltipTimeoutRef.current = undefined; + } + }, []); + return { - isEmojiTooltipOpen: Boolean(filteredEmojis.length || filteredCustomEmojis.length) && !isManuallyClosed, + isEmojiTooltipOpen: isTooltipVisible, closeEmojiTooltip: markManuallyClosed, filteredEmojis, filteredCustomEmojis, @@ -206,67 +304,113 @@ function prepareLibrary( const emojis = Object.values(byId); const byNative = buildCollectionByKey(emojis, 'native'); + const byNormalizedNative = emojis.reduce((acc, emoji) => { + const normalizedNative = normalizeEmojiNativeMemo(emoji.native); + if (!normalizedNative || acc[normalizedNative]) { + return acc; + } + + acc[normalizedNative] = emoji; + return acc; + }, {} as Record); + + const resolveNatives = (natives: string[]) => { + const exactMatches = Object.values(pickTruthy(byNative, natives)); + if (exactMatches.length === natives.length) { + return exactMatches; + } + + const normalizedMatches = natives.map((native) => { + return byNative[native] || byNormalizedNative[normalizeEmojiNativeMemo(native)]; + }).filter(Boolean); + + return unique(normalizedMatches); + }; + const baseEmojisByKeyword = baseEmojiKeywords ? mapValues(baseEmojiKeywords, (natives) => { - return Object.values(pickTruthy(byNative, natives)); + return resolveNatives(natives); }) : {}; const emojisByKeyword = emojiKeywords ? mapValues(emojiKeywords, (natives) => { - return Object.values(pickTruthy(byNative, natives)); + return resolveNatives(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); - - const names = Object.keys(byName); - const maxKeyLength = keywords.reduce((max, keyword) => Math.max(max, keyword.length), 0); + const keywords = Object.keys(byKeyword); + const keysByPrefix = buildPrefixIndex(keywords); return { byKeyword, keywords, - byName, - names, - maxKeyLength, + keysByPrefix, }; } function searchInLibrary(library: Library, filter: string, limit: number) { - const { - byKeyword, keywords, byName, names, maxKeyLength, - } = library; + const { byKeyword, keysByPrefix } = library; + const variants = getNormalizedVariants(filter); + const scoredByKey: Record = {}; + variants.forEach((variant) => { + const prefixedKeys = keysByPrefix[variant]; + if (!prefixedKeys?.length) { + return; + } - let matched: Emoji[] = []; + prefixedKeys.forEach((key) => { + const score = key.length - variant.length; + const prevScore = scoredByKey[key]; + if (prevScore === undefined || score < prevScore) { + scoredByKey[key] = score; + } + }); + }); + const scoredKeys = Object.keys(scoredByKey).map((key) => ({ key, score: scoredByKey[key] })); - if (filter.length > maxKeyLength) { - return MEMO_EMPTY_ARRAY; - } + if (!scoredKeys.length) return MEMO_EMPTY_ARRAY; - const matchedKeywords = keywords.filter((keyword) => keyword.startsWith(filter)).sort(); - matched = matched.concat(Object.values(pickTruthy(byKeyword, matchedKeywords)).flat()); + scoredKeys.sort((a, b) => a.score - b.score || a.key.length - b.key.length); + const matched = unique( + scoredKeys.flatMap(({ key }) => byKeyword[key] || MEMO_EMPTY_ARRAY), + ); - // 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()); + return matched.length ? matched.slice(0, limit) : MEMO_EMPTY_ARRAY; +} - matched = unique(matched); +function getNormalizedVariants(query: string) { + const normalized = query.toLowerCase().replace(/_/g, ' ').trim(); + if (!normalized) { + return [] as string[]; + } - if (!matched.length) { - return MEMO_EMPTY_ARRAY; + return [normalized]; +} + +function normalizeEmojiNative(native: string) { + try { + // Compare by canonical codepoints while ignoring VS16 variation selectors, + // including inside ZWJ sequences (e.g. 🤦‍♀️/🤦‍♂️). + return nativeToUnified(native.normalize('NFC').replace(/\uFE0F/g, '')); + } catch { + return native; } +} + +function buildPrefixIndex(keywords: string[]) { + const keysByPrefix: Record = {}; + + keywords.forEach((keyword) => { + for (let i = 1; i <= keyword.length; i++) { + const prefix = keyword.substring(0, i); + if (!keysByPrefix[prefix]) { + keysByPrefix[prefix] = [keyword]; + } else { + keysByPrefix[prefix].push(keyword); + } + } + }); - return matched.slice(0, limit); + return keysByPrefix; } diff --git a/src/components/middle/composer/hooks/useKeyboardNavigation.ts b/src/components/middle/composer/hooks/useKeyboardNavigation.ts index 6f61186a0f..15816d9af2 100644 --- a/src/components/middle/composer/hooks/useKeyboardNavigation.ts +++ b/src/components/middle/composer/hooks/useKeyboardNavigation.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from '../../../../lib/teact/teact'; +import { useEffect, useRef, useState } from '../../../../lib/teact/teact'; import captureKeyboardListeners from '../../../../util/captureKeyboardListeners'; import cycleRestrict from '../../../../util/cycleRestrict'; @@ -27,6 +27,7 @@ export function useKeyboardNavigation({ onClose: NoneToVoidFunction; }) { const [selectedItemIndex, setSelectedItemIndex] = useState(-1); + const prevItemsSignatureRef = useRef(); const getSelectedIndex = useLastCallback((newIndex: number) => { if (!items) { @@ -41,6 +42,42 @@ export function useKeyboardNavigation({ setSelectedItemIndex((index) => (getSelectedIndex(index + value))); }); + const handleHorizontalArrowLeft = useLastCallback((e: KeyboardEvent) => { + if (!items?.length) { + return; + } + + e.preventDefault(); + + setSelectedItemIndex((index) => { + if (index === -1) { + onClose(); + return -1; + } + + if (index === 0) { + return -1; + } + + return index - 1; + }); + }); + + const handleHorizontalArrowRight = useLastCallback((e: KeyboardEvent) => { + if (!items?.length) { + return; + } + + e.preventDefault(); + setSelectedItemIndex((index) => { + if (index === -1) { + return 0; + } + + return cycleRestrict(items.length, index + 1); + }); + }); + const handleItemSelect = useLastCallback((e: KeyboardEvent) => { // Prevent action on key combinations if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return false; @@ -65,22 +102,49 @@ export function useKeyboardNavigation({ }, [isActive, shouldRemoveSelectionOnReset]); const isSelectionOutOfRange = !items || selectedItemIndex > items.length - 1; + const itemsSignature = items?.map((item, index) => { + if (item && typeof item === 'object' && 'id' in item) { + return String((item as { id: unknown }).id); + } + + return String(item ?? index); + }).join('\x01') || ''; + useEffect(() => { - if (!shouldSaveSelectionOnUpdateItems || isSelectionOutOfRange) { + const hasItemsChanged = prevItemsSignatureRef.current !== undefined + && prevItemsSignatureRef.current !== itemsSignature; + prevItemsSignatureRef.current = itemsSignature; + + if ( + (!shouldSaveSelectionOnUpdateItems && hasItemsChanged) + || isSelectionOutOfRange + ) { setSelectedItemIndex(shouldRemoveSelectionOnReset ? -1 : 0); } - }, [isSelectionOutOfRange, shouldRemoveSelectionOnReset, shouldSaveSelectionOnUpdateItems]); + }, [itemsSignature, isSelectionOutOfRange, shouldRemoveSelectionOnReset, shouldSaveSelectionOnUpdateItems]); useEffect(() => (isActive ? captureKeyboardListeners({ onEsc: onClose, - onUp: noArrowNavigation || isHorizontal ? undefined : (e: KeyboardEvent) => handleArrowKey(-1, e), - onDown: noArrowNavigation || isHorizontal ? undefined : (e: KeyboardEvent) => handleArrowKey(1, e), - onLeft: noArrowNavigation || !isHorizontal ? undefined : (e: KeyboardEvent) => handleArrowKey(-1, e), - onRight: noArrowNavigation || !isHorizontal ? undefined : (e: KeyboardEvent) => handleArrowKey(1, e), + onUp: noArrowNavigation + ? undefined + : (isHorizontal ? handleHorizontalArrowLeft : (e: KeyboardEvent) => handleArrowKey(-1, e)), + onDown: noArrowNavigation + ? undefined + : (isHorizontal ? handleHorizontalArrowRight : (e: KeyboardEvent) => handleArrowKey(1, e)), + onLeft: noArrowNavigation || !isHorizontal ? undefined : handleHorizontalArrowLeft, + onRight: noArrowNavigation || !isHorizontal ? undefined : handleHorizontalArrowRight, onTab: shouldSelectOnTab ? handleItemSelect : undefined, onEnter: handleItemSelect, }) : undefined), [ - noArrowNavigation, handleArrowKey, handleItemSelect, isActive, isHorizontal, onClose, shouldSelectOnTab, + noArrowNavigation, + handleArrowKey, + handleHorizontalArrowLeft, + handleHorizontalArrowRight, + handleItemSelect, + isActive, + isHorizontal, + onClose, + shouldSelectOnTab, ]); return selectedItemIndex; diff --git a/src/serviceWorker/index.ts b/src/serviceWorker/index.ts index 3af26e7aec..b79cf972ba 100644 --- a/src/serviceWorker/index.ts +++ b/src/serviceWorker/index.ts @@ -14,6 +14,7 @@ declare const self: ServiceWorkerGlobalScope; const RE_NETWORK_FIRST_ASSETS = /\.(wasm|html)$/; const RE_CACHE_FIRST_ASSETS = /[\da-f]{20}.*\.(js|css|woff2?|svg|png|jpg|jpeg|tgs|json|wasm)$/; +const RE_EMOJI_IMAGE_ASSETS = /^\/img-apple-(64|160)\/.+\.png$/; const ACTIVATE_TIMEOUT = 3000; self.addEventListener('install', (e) => { @@ -79,6 +80,11 @@ self.addEventListener('fetch', (e: FetchEvent) => { e.respondWith(respondWithCache(e)); return true; } + + if (pathname.match(RE_EMOJI_IMAGE_ASSETS)) { + e.respondWith(respondWithCache(e)); + return true; + } } return false;