diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index e61f8b2053f..00af7581971 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -60,7 +60,6 @@ import { getClassNamesFor } from '../util/getClassNamesFor'; import { isNotNil } from '../util/isNotNil'; import * as log from '../logging/log'; import * as Errors from '../types/errors'; -import { useEmojiSearch } from '../hooks/useEmojiSearch'; import type { LinkPreviewForUIType } from '../types/message/LinkPreviews'; import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import type { DraftEditMessageType } from '../model-types.d'; @@ -79,6 +78,9 @@ import { dropNull } from '../util/dropNull'; import { SimpleQuillWrapper } from './SimpleQuillWrapper'; import type { EmojiSkinTone } from './fun/data/emojis'; import { FUN_STATIC_EMOJI_CLASS } from './fun/FunEmoji'; +import { useFunEmojiSearch } from './fun/useFunEmojiSearch'; +import type { EmojiCompletionOptions } from '../quill/emoji/completion'; +import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer'; Quill.register( { @@ -192,7 +194,7 @@ export function CompositionInput(props: Props): React.ReactElement { } = props; const [emojiCompletionElement, setEmojiCompletionElement] = - React.useState(); + React.useState(); const [formattingChooserElement, setFormattingChooserElement] = React.useState(); const [lastSelectionRange, setLastSelectionRange] = @@ -770,7 +772,8 @@ export function CompositionInput(props: Props): React.ReactElement { const callbacksRef = React.useRef(unstaleCallbacks); callbacksRef.current = unstaleCallbacks; - const search = useEmojiSearch(i18n.getLocale()); + const emojiSearch = useFunEmojiSearch(); + const emojiLocalizer = useFunEmojiLocalizer(); const reactQuill = React.useMemo( () => { @@ -839,8 +842,9 @@ export function CompositionInput(props: Props): React.ReactElement { onPickEmoji: (emoji: EmojiPickDataType) => callbacksRef.current.onPickEmoji(emoji), emojiSkinToneDefault, - search, - }, + emojiSearch, + emojiLocalizer, + } satisfies EmojiCompletionOptions, autoSubstituteAsciiEmojis: { emojiSkinToneDefault, } satisfies AutoSubstituteAsciiEmojisOptions, diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx index 5132f952017..35c26454e06 100644 --- a/ts/components/emoji/EmojiPicker.tsx +++ b/ts/components/emoji/EmojiPicker.tsx @@ -23,7 +23,6 @@ import { dataByCategory } from './lib'; import type { LocalizerType } from '../../types/Util'; import { isSingleGrapheme } from '../../util/grapheme'; import { missingCaseError } from '../../util/missingCaseError'; -import { useEmojiSearch } from '../../hooks/useEmojiSearch'; import { FunStaticEmoji } from '../fun/FunEmoji'; import { strictAssert } from '../../util/assert'; import { @@ -35,7 +34,9 @@ import { getEmojiVariantByParentKeyAndSkinTone, isEmojiEnglishShortName, EMOJI_SKIN_TONE_TO_NUMBER, + getEmojiParentByKey, } from '../fun/data/emojis'; +import { useFunEmojiSearch } from '../fun/useFunEmojiSearch'; export type EmojiPickDataType = { skinTone: EmojiSkinTone; @@ -122,7 +123,7 @@ export const EmojiPicker = React.memo( const [selectedTone, setSelectedTone] = React.useState(emojiSkinToneDefault); - const search = useEmojiSearch(i18n.getLocale()); + const emojiSearch = useFunEmojiSearch(); const handleToggleSearch = React.useCallback( ( @@ -261,7 +262,13 @@ export const EmojiPicker = React.memo( const emojiGrid = React.useMemo(() => { if (searchText) { - return chunk(search(searchText), COL_COUNT); + return chunk( + emojiSearch(searchText).map(result => { + const parent = getEmojiParentByKey(result.parentKey); + return parent.englishShortNameDefault; + }), + COL_COUNT + ); } const chunks = flatMap(renderableCategories, cat => @@ -272,7 +279,7 @@ export const EmojiPicker = React.memo( ); return [...chunk(firstRecent, COL_COUNT), ...chunks]; - }, [firstRecent, renderableCategories, searchText, search]); + }, [firstRecent, renderableCategories, searchText, emojiSearch]); const rowCount = emojiGrid.length; diff --git a/ts/components/emoji/lib.ts b/ts/components/emoji/lib.ts index 789742f6ea2..4417e311339 100644 --- a/ts/components/emoji/lib.ts +++ b/ts/components/emoji/lib.ts @@ -3,7 +3,6 @@ // Camelcase disabled due to emoji-datasource using snake_case /* eslint-disable camelcase */ -import Fuse from 'fuse.js'; import { compact, flatMap, @@ -12,9 +11,7 @@ import { map, mapValues, sortBy, - take, } from 'lodash'; -import type { LocaleEmojiType } from '../../types/emoji'; import { getOwn } from '../../util/getOwn'; import { EMOJI_SKIN_TONE_TO_KEY, @@ -155,103 +152,6 @@ export function getEmojiData( return base; } -export type SearchFnType = (query: string, count?: number) => Array; - -export type SearchEmojiListType = ReadonlyArray< - Pick ->; - -type CachedSearchFnType = Readonly<{ - localeEmoji: SearchEmojiListType; - fn: SearchFnType; -}>; - -let cachedSearchFn: CachedSearchFnType | undefined; - -export function createSearch(localeEmoji: SearchEmojiListType): SearchFnType { - if (cachedSearchFn && cachedSearchFn.localeEmoji === localeEmoji) { - return cachedSearchFn.fn; - } - - const knownSet = new Set(); - - const knownEmoji = localeEmoji.filter(({ shortName }) => { - knownSet.add(shortName); - return dataByShortName[shortName] != null; - }); - - for (const entry of data) { - if (!knownSet.has(entry.short_name)) { - knownEmoji.push({ - shortName: entry.short_name, - rank: 0, - tags: entry.short_names, - }); - } - } - - let maxShortNameLength = 0; - for (const { shortName } of knownEmoji) { - maxShortNameLength = Math.max(maxShortNameLength, shortName.length); - } - - const fuse = new Fuse(knownEmoji, { - shouldSort: false, - threshold: 0.2, - minMatchCharLength: 1, - keys: ['shortName', 'tags'], - includeScore: true, - }); - - const fuseExactPrefix = new Fuse(knownEmoji, { - // We re-rank and sort manually below - shouldSort: false, - threshold: 0, // effectively a prefix search - minMatchCharLength: 2, - keys: ['shortName', 'tags'], - includeScore: true, - }); - - const fn = (query: string, count = 0): Array => { - // when we only have 2 characters, do an exact prefix match - // to avoid matching on emoticon, like :-P - const fuseIndex = query.length === 2 ? fuseExactPrefix : fuse; - - const rawResults = fuseIndex.search(query.substr(0, 32)); - - const rankedResults = rawResults.map(entry => { - const rank = entry.item.rank || 1e9; - - // Rank exact prefix matches in [0,1] range - if (entry.item.shortName.startsWith(query)) { - return { - score: entry.item.shortName.length / maxShortNameLength, - item: entry.item, - }; - } - - // Other matches in [1,], ordered by score and rank - return { - score: 1 + (entry.score ?? 0) + rank / knownEmoji.length, - item: entry.item, - }; - }); - - const results = rankedResults - .sort((a, b) => a.score - b.score) - .map(result => result.item.shortName); - - if (count) { - return take(results, count); - } - - return results; - }; - - cachedSearchFn = { localeEmoji, fn }; - return fn; -} - const shortNames = new Set([ ...map(data, 'short_name'), ...compact(flatMap(data, 'short_names')), diff --git a/ts/components/fun/data/emojis.ts b/ts/components/fun/data/emojis.ts index 87da09a803c..3fc1c10f41c 100644 --- a/ts/components/fun/data/emojis.ts +++ b/ts/components/fun/data/emojis.ts @@ -9,6 +9,7 @@ import type { FunEmojiSearchIndexEntry, } from '../useFunEmojiSearch'; import type { FunEmojiLocalizerIndex } from '../useFunEmojiLocalizer'; +import { removeDiacritics } from '../../../util/removeDiacritics'; // Import emoji-datasource dynamically to avoid costly typechecking. // eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires @@ -551,6 +552,26 @@ export function emojiVariantConstant(input: string): EmojiVariantData { return getEmojiVariantByKey(key); } +/** + * Completions + */ + +/** For displaying in the ui */ +export function normalizeShortNameCompletionDisplay(shortName: string): string { + return removeDiacritics(shortName) + .normalize('NFD') + .replaceAll(' ', '_') + .toLowerCase(); +} + +/** For matching in search utils */ +export function normalizeShortNameCompletionQuery(query: string): string { + return removeDiacritics(query) + .normalize('NFD') + .replaceAll(/[\s_-]+/gi, ' ') + .toLowerCase(); +} + /** * Emojify */ diff --git a/ts/components/fun/panels/FunPanelEmojis.tsx b/ts/components/fun/panels/FunPanelEmojis.tsx index 2c3b595b86e..440c1dda7c8 100644 --- a/ts/components/fun/panels/FunPanelEmojis.tsx +++ b/ts/components/fun/panels/FunPanelEmojis.tsx @@ -186,7 +186,9 @@ export function FunPanelEmojis({ return [ toGridSectionNode( FunSectionCommon.SearchResults, - searchEmojis(searchQuery) + searchEmojis(searchQuery).map(result => { + return result.parentKey; + }) ), ]; } diff --git a/ts/components/fun/panels/FunPanelStickers.tsx b/ts/components/fun/panels/FunPanelStickers.tsx index 1847bb2a30b..bd6b728a78b 100644 --- a/ts/components/fun/panels/FunPanelStickers.tsx +++ b/ts/components/fun/panels/FunPanelStickers.tsx @@ -56,6 +56,7 @@ import { FunSubNavScroller, } from '../base/FunSubNav'; import { + type EmojiParentKey, emojiVariantConstant, getEmojiParentKeyByValue, isEmojiParentValue, @@ -228,7 +229,11 @@ export function FunPanelStickers({ const sections = useMemo(() => { if (searchQuery !== '') { - const emojiKeys = new Set(searchEmojis(searchQuery)); + const emojiKeys = new Set(); + + for (const result of searchEmojis(searchQuery)) { + emojiKeys.add(result.parentKey); + } const allStickers = installedStickerPacks.flatMap(pack => pack.stickers); const matchingStickers = allStickers.filter(sticker => { diff --git a/ts/components/fun/useFunEmojiLocalizer.tsx b/ts/components/fun/useFunEmojiLocalizer.tsx index 589b69cd879..e847496e0fb 100644 --- a/ts/components/fun/useFunEmojiLocalizer.tsx +++ b/ts/components/fun/useFunEmojiLocalizer.tsx @@ -1,6 +1,6 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { useCallback } from 'react'; +import { useMemo } from 'react'; import type { EmojiParentKey, EmojiVariantKey } from './data/emojis'; import { getEmojiParentByKey, @@ -28,26 +28,33 @@ export function createFunEmojiLocalizerIndex( const variantKey = getEmojiVariantKeyByValue(entry.emoji); const parentKey = getEmojiParentKeyByVariantKey(variantKey); - index.set(parentKey, entry.tags.at(0) ?? entry.shortName); + const localizedShortName = entry.tags.at(0) ?? entry.shortName; + index.set(parentKey, localizedShortName); } return index; } +/** @internal exported for tests */ +export function _createFunEmojiLocalizer( + emojiLocalizerIndex: FunEmojiLocalizerIndex +): FunEmojiLocalizer { + return variantKey => { + const parentKey = getEmojiParentKeyByVariantKey(variantKey); + const localeShortName = emojiLocalizerIndex.get(parentKey); + if (localeShortName != null) { + return localeShortName; + } + // Fallback to english short name + const parent = getEmojiParentByKey(parentKey); + return parent.englishShortNameDefault; + }; +} + export function useFunEmojiLocalizer(): FunEmojiLocalizer { const { emojiLocalizerIndex } = useFunEmojiLocalization(); - const emojiLocalizer: FunEmojiLocalizer = useCallback( - variantKey => { - const parentKey = getEmojiParentKeyByVariantKey(variantKey); - const localeShortName = emojiLocalizerIndex.get(parentKey); - if (localeShortName != null) { - return localeShortName; - } - // Fallback to english short name - const parent = getEmojiParentByKey(parentKey); - return parent.englishShortNameDefault; - }, - [emojiLocalizerIndex] - ); + const emojiLocalizer = useMemo(() => { + return _createFunEmojiLocalizer(emojiLocalizerIndex); + }, [emojiLocalizerIndex]); return emojiLocalizer; } diff --git a/ts/components/fun/useFunEmojiSearch.tsx b/ts/components/fun/useFunEmojiSearch.tsx index 45777cb8021..1726851128c 100644 --- a/ts/components/fun/useFunEmojiSearch.tsx +++ b/ts/components/fun/useFunEmojiSearch.tsx @@ -8,6 +8,7 @@ import { getEmojiParentByKey, getEmojiParentKeyByValue, isEmojiParentValue, + normalizeShortNameCompletionQuery, } from './data/emojis'; import type { LocaleEmojiListType } from '../../types/emoji'; import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider'; @@ -23,10 +24,14 @@ export type FunEmojiSearchIndexEntry = Readonly<{ export type FunEmojiSearchIndex = ReadonlyArray; +export type FunEmojiSearchResult = Readonly<{ + parentKey: EmojiParentKey; +}>; + export type FunEmojiSearch = ( query: string, limit?: number -) => ReadonlyArray; +) => ReadonlyArray; export function createFunEmojiSearchIndex( localeEmojiList: LocaleEmojiListType @@ -44,8 +49,10 @@ export function createFunEmojiSearchIndex( results.push({ key: parentKey, rank: localeEmoji.rank, - shortName: localeEmoji.shortName, - shortNames: localeEmoji.tags, + shortName: normalizeShortNameCompletionQuery(localeEmoji.shortName), + shortNames: localeEmoji.tags.map(tag => { + return normalizeShortNameCompletionQuery(tag); + }), emoticon: emoji.emoticonDefault, emoticons: emoji.emoticons, }); @@ -67,6 +74,7 @@ const FuseFuzzyOptions: Fuse.IFuseOptions = { minMatchCharLength: 1, keys: FuseKeys, includeScore: true, + includeMatches: true, }; const FuseExactOptions: Fuse.IFuseOptions = { @@ -75,49 +83,48 @@ const FuseExactOptions: Fuse.IFuseOptions = { minMatchCharLength: 1, keys: FuseKeys, includeScore: true, + includeMatches: true, }; -function createFunEmojiSearch( +/** @internal exported for tests */ +export function _createFunEmojiSearch( emojiSearchIndex: FunEmojiSearchIndex ): FunEmojiSearch { const fuseIndex = Fuse.createIndex(FuseKeys, emojiSearchIndex); const fuseFuzzy = new Fuse(emojiSearchIndex, FuseFuzzyOptions, fuseIndex); const fuseExact = new Fuse(emojiSearchIndex, FuseExactOptions, fuseIndex); - return function emojiSearch(query, limit = 200) { + return function emojiSearch(rawQuery, limit = 200) { + const query = normalizeShortNameCompletionQuery(rawQuery); + // Prefer exact matches at 2 characters const fuse = query.length < 2 ? fuseExact : fuseFuzzy; - const rawResults = fuse.search(query.substring(0, 32), { - limit: limit * 2, - }); + const rawResults = fuse.search(query.substring(0, 32)); - const rankedResults = rawResults.map(result => { + // Note: lodash's sortBy() only calls each iteratee once + const sortedResults = sortBy(rawResults, result => { const rank = result.item.rank ?? 1e9; + const localizedQueryMatch = + result.item.shortNames.at(0) ?? result.item.shortName; + // Exact prefix matches in [0,1] range - if (result.item.shortName.startsWith(query)) { - return { - score: result.item.shortName.length / query.length, - item: result.item, - }; + if (localizedQueryMatch.startsWith(query)) { + // Note: localizedQueryMatch will always be <= in length to the query + const matchRatio = query.length / localizedQueryMatch.length; // 1-0 + return 1 - matchRatio; } + const queryScore = result.score ?? 0; // 0-1 + const rankScore = rank / emojiSearchIndex.length; // 0-1 + // Other matches in [1,], ordered by score and rank - return { - score: 1 + (result.score ?? 0) + rank / emojiSearchIndex.length, - item: result.item, - }; + return 1 + queryScore + rankScore; }); - const sortedResults = sortBy(rankedResults, result => { - return result.score; - }); - - const truncatedResults = sortedResults.slice(0, limit); - - return truncatedResults.map(result => { - return result.item.key; + return sortedResults.slice(0, limit).map(result => { + return { parentKey: result.item.key }; }); }; } @@ -125,7 +132,7 @@ function createFunEmojiSearch( export function useFunEmojiSearch(): FunEmojiSearch { const { emojiSearchIndex } = useFunEmojiLocalization(); const emojiSearch = useMemo(() => { - return createFunEmojiSearch(emojiSearchIndex); + return _createFunEmojiSearch(emojiSearchIndex); }, [emojiSearchIndex]); return emojiSearch; } diff --git a/ts/hooks/useEmojiSearch.ts b/ts/hooks/useEmojiSearch.ts deleted file mode 100644 index 7379e87bc4e..00000000000 --- a/ts/hooks/useEmojiSearch.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2024 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { useEffect, useCallback, useRef } from 'react'; - -import { data, createSearch } from '../components/emoji/lib'; -import type { SearchEmojiListType } from '../components/emoji/lib'; -import { drop } from '../util/drop'; -import * as log from '../logging/log'; - -const uninitialized: SearchEmojiListType = data.map( - ({ short_name: shortName, short_names: shortNames }) => { - return { - shortName, - rank: 0, - tags: shortNames, - }; - } -); - -const defaultSearch = createSearch(uninitialized); - -export function useEmojiSearch( - locale: string -): ReturnType { - const searchRef = useRef(defaultSearch); - - useEffect(() => { - let canceled = false; - - async function run() { - let result: SearchEmojiListType | undefined; - try { - result = await window.SignalContext.getLocalizedEmojiList(locale); - } catch (error) { - log.error(`Failed to get localized emoji list for ${locale}`, error); - } - - // Fallback - if (result === undefined) { - try { - result = await window.SignalContext.getLocalizedEmojiList('en'); - } catch (error) { - log.error('Failed to get fallback localized emoji list'); - } - } - - if (!canceled && result !== undefined) { - searchRef.current = createSearch(result); - } - } - drop(run()); - - return () => { - canceled = true; - }; - }, [locale]); - - return useCallback((...args) => { - return searchRef.current?.(...args); - }, []); -} diff --git a/ts/quill/emoji/completion.tsx b/ts/quill/emoji/completion.tsx index b36965325b0..d41a2ab7afa 100644 --- a/ts/quill/emoji/completion.tsx +++ b/ts/quill/emoji/completion.tsx @@ -12,25 +12,27 @@ import classNames from 'classnames'; import { createPortal } from 'react-dom'; import type { VirtualElement } from '@popperjs/core'; import { convertShortName, isShortName } from '../../components/emoji/lib'; -import type { SearchFnType } from '../../components/emoji/lib'; import type { EmojiPickDataType } from '../../components/emoji/EmojiPicker'; import { getBlotTextPartitions, matchBlotTextPartitions } from '../util'; import { handleOutsideClick } from '../../util/handleOutsideClick'; import * as log from '../../logging/log'; import { FunStaticEmoji } from '../../components/fun/FunEmoji'; -import { strictAssert } from '../../util/assert'; import { EmojiSkinTone, - getEmojiParentKeyByEnglishShortName, + getEmojiParentByKey, getEmojiVariantByParentKeyAndSkinTone, - isEmojiEnglishShortName, + normalizeShortNameCompletionDisplay, } from '../../components/fun/data/emojis'; +import type { FunEmojiSearchResult } from '../../components/fun/useFunEmojiSearch'; +import { type FunEmojiSearch } from '../../components/fun/useFunEmojiSearch'; +import { type FunEmojiLocalizer } from '../../components/fun/useFunEmojiLocalizer'; export type EmojiCompletionOptions = { onPickEmoji: (emoji: EmojiPickDataType) => void; setEmojiPickerElement: (element: JSX.Element | null) => void; emojiSkinToneDefault: EmojiSkinTone | null; - search: SearchFnType; + emojiSearch: FunEmojiSearch; + emojiLocalizer: FunEmojiLocalizer; }; export type InsertEmojiOptionsType = Readonly<{ @@ -42,7 +44,7 @@ export type InsertEmojiOptionsType = Readonly<{ }>; export class EmojiCompletion { - results: Array; + results: ReadonlyArray; index: number; @@ -197,7 +199,7 @@ export class EmojiCompletion { return PASS_THROUGH; } - const showEmojiResults = this.options.search(leftTokenText, 10); + const showEmojiResults = this.options.emojiSearch(leftTokenText, 10); if (showEmojiResults.length > 0) { this.results = showEmojiResults; @@ -229,7 +231,7 @@ export class EmojiCompletion { return; } - const emoji = this.results[this.index]; + const result = this.results[this.index]; const [leafText] = this.getCurrentLeafTextPartitions(); const tokenTextMatch = /:([-+0-9\p{Alpha}_]*)(:?)$/iu.exec(leafText); @@ -239,9 +241,10 @@ export class EmojiCompletion { } const [, tokenText] = tokenTextMatch; + const parent = getEmojiParentByKey(result.parentKey); this.insertEmoji({ - shortName: emoji, + shortName: parent.englishShortNameDefault, index: range.index - tokenText.length - 1, range: tokenText.length + 1, withTrailingSpace: true, @@ -362,26 +365,30 @@ export class EmojiCompletion { role="listbox" aria-expanded aria-activedescendant={`emoji-result--${ - emojiResults.length ? emojiResults[emojiResultsIndex] : '' + emojiResults.length + ? emojiResults[emojiResultsIndex].parentKey + : '' }`} tabIndex={0} > - {emojiResults.map((emoji, index) => { - strictAssert( - isEmojiEnglishShortName(emoji), - 'Must be valid english short name' - ); - const emojiParentKey = getEmojiParentKeyByEnglishShortName(emoji); + {emojiResults.map((result, index) => { const emojiVariant = getEmojiVariantByParentKeyAndSkinTone( - emojiParentKey, + result.parentKey, this.options.emojiSkinToneDefault ?? EmojiSkinTone.None ); + const localeShortName = this.options.emojiLocalizer( + emojiVariant.key + ); + + const normalized = + normalizeShortNameCompletionDisplay(localeShortName); + return ( ); diff --git a/ts/test-electron/quill/emoji/completion_test.tsx b/ts/test-electron/quill/emoji/completion_test.tsx index b3256d7774c..30b09016465 100644 --- a/ts/test-electron/quill/emoji/completion_test.tsx +++ b/ts/test-electron/quill/emoji/completion_test.tsx @@ -9,8 +9,53 @@ import type { EmojiCompletionOptions, InsertEmojiOptionsType, } from '../../../quill/emoji/completion'; -import { createSearch } from '../../../components/emoji/lib'; -import { EmojiSkinTone } from '../../../components/fun/data/emojis'; +import { + EmojiSkinTone, + emojiVariantConstant, + getEmojiParentKeyByVariantKey, +} from '../../../components/fun/data/emojis'; +import { + _createFunEmojiSearch, + createFunEmojiSearchIndex, +} from '../../../components/fun/useFunEmojiSearch'; +import { + _createFunEmojiLocalizer, + createFunEmojiLocalizerIndex, +} from '../../../components/fun/useFunEmojiLocalizer'; +import type { LocaleEmojiListType } from '../../../types/emoji'; + +const EMOJI_VARIANTS = { + SMILE: emojiVariantConstant('\u{1F604}'), + SMILE_CAT: emojiVariantConstant('\u{1F638}'), + FRIEND_SHRIMP: emojiVariantConstant('\u{1F364}'), +} as const; + +const PARENT_KEYS = { + SMILE: getEmojiParentKeyByVariantKey(EMOJI_VARIANTS.SMILE.key), + SMILE_CAT: getEmojiParentKeyByVariantKey(EMOJI_VARIANTS.SMILE_CAT.key), + FRIED_SHRIMP: getEmojiParentKeyByVariantKey(EMOJI_VARIANTS.FRIEND_SHRIMP.key), +} as const; + +const EMOJI_LIST: LocaleEmojiListType = [ + { + emoji: EMOJI_VARIANTS.SMILE.value, + shortName: 'smile', + tags: [], + rank: 0, + }, + { + emoji: EMOJI_VARIANTS.SMILE_CAT.value, + shortName: 'smile_cat', + tags: [], + rank: 0, + }, + { + emoji: EMOJI_VARIANTS.FRIEND_SHRIMP.value, + shortName: 'fried_shrimp', + tags: [], + rank: 0, + }, +]; describe('emojiCompletion', () => { let emojiCompletion: EmojiCompletion; @@ -29,14 +74,19 @@ describe('emojiCompletion', () => { setSelection: sinon.stub(), updateContents: sinon.stub(), }; + + const searchIndex = createFunEmojiSearchIndex(EMOJI_LIST); + const localizerIndex = createFunEmojiLocalizerIndex(EMOJI_LIST); + + const emojiSearch = _createFunEmojiSearch(searchIndex); + const emojiLocalizer = _createFunEmojiLocalizer(localizerIndex); + const options: EmojiCompletionOptions = { onPickEmoji: sinon.stub(), setEmojiPickerElement: sinon.stub(), emojiSkinToneDefault: EmojiSkinTone.None, - search: createSearch([ - { shortName: 'smile', tags: [], rank: 0 }, - { shortName: 'smile_cat', tags: [], rank: 0 }, - ]), + emojiSearch, + emojiLocalizer, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -64,7 +114,7 @@ describe('emojiCompletion', () => { let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>; beforeEach(() => { - emojiCompletion.results = ['joy']; + emojiCompletion.results = [{ parentKey: PARENT_KEYS.SMILE }]; emojiCompletion.index = 5; insertEmojiStub = sinon .stub(emojiCompletion, 'insertEmoji') @@ -171,7 +221,7 @@ describe('emojiCompletion', () => { }); it('stores the results and renders', () => { - assert.equal(emojiCompletion.results.length, 10); + assert.equal(emojiCompletion.results.length, 2); assert.equal((emojiCompletion.render as sinon.SinonStub).called, true); }); }); @@ -353,7 +403,10 @@ describe('emojiCompletion', () => { let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>; beforeEach(() => { - emojiCompletion.results = ['smile', 'smile_cat']; + emojiCompletion.results = [ + { parentKey: PARENT_KEYS.SMILE }, + { parentKey: PARENT_KEYS.SMILE_CAT }, + ]; emojiCompletion.index = 1; insertEmojiStub = sinon.stub(emojiCompletion, 'insertEmoji'); }); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index c002021806f..252392eea70 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1994,6 +1994,27 @@ "reasonCategory": "usageTrusted", "updated": "2023-01-18T22:32:43.901Z" }, + { + "rule": "React-useRef", + "path": "ts/components/fun/FunGif.tsx", + "line": " const ref = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-03-21T23:22:07.920Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/fun/FunGif.tsx", + "line": " const timerRef = useRef>();", + "reasonCategory": "usageTrusted", + "updated": "2025-03-21T23:22:07.920Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/fun/FunGif.tsx", + "line": " const videoRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-03-24T18:57:50.198Z" + }, { "rule": "React-useRef", "path": "ts/components/fun/base/FunImage.tsx", @@ -2127,27 +2148,6 @@ "reasonCategory": "usageTrusted", "updated": "2025-02-19T20:14:46.879Z" }, - { - "rule": "React-useRef", - "path": "ts/components/fun/FunGif.tsx", - "line": " const ref = useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2025-03-21T23:22:07.920Z" - }, - { - "rule": "React-useRef", - "path": "ts/components/fun/FunGif.tsx", - "line": " const timerRef = useRef>();", - "reasonCategory": "usageTrusted", - "updated": "2025-03-21T23:22:07.920Z" - }, - { - "rule": "React-useRef", - "path": "ts/components/fun/FunGif.tsx", - "line": " const videoRef = useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2025-03-24T18:57:50.198Z" - }, { "rule": "React-useRef", "path": "ts/components/stickers/StickerButton.tsx", @@ -2156,14 +2156,6 @@ "updated": "2022-06-14T22:04:43.988Z", "reasonDetail": "Handling outside click" }, - { - "rule": "React-useRef", - "path": "ts/hooks/useEmojiSearch.ts", - "line": " const searchRef = useRef(defaultSearch);", - "reasonCategory": "usageTrusted", - "updated": "2024-03-16T18:34:38.165Z", - "reasonDetail": "Quill requires an immutable reference to the search function" - }, { "rule": "React-useRef", "path": "ts/hooks/useIntersectionObserver.ts",