Translate emoji completions in composer

This commit is contained in:
Jamie Kyle 2025-04-23 16:03:35 -07:00 committed by GitHub
commit e802ea0dc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 216 additions and 273 deletions

View file

@ -60,7 +60,6 @@ import { getClassNamesFor } from '../util/getClassNamesFor';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { useEmojiSearch } from '../hooks/useEmojiSearch';
import type { LinkPreviewForUIType } from '../types/message/LinkPreviews'; import type { LinkPreviewForUIType } from '../types/message/LinkPreviews';
import { StagedLinkPreview } from './conversation/StagedLinkPreview'; import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import type { DraftEditMessageType } from '../model-types.d'; import type { DraftEditMessageType } from '../model-types.d';
@ -79,6 +78,9 @@ import { dropNull } from '../util/dropNull';
import { SimpleQuillWrapper } from './SimpleQuillWrapper'; import { SimpleQuillWrapper } from './SimpleQuillWrapper';
import type { EmojiSkinTone } from './fun/data/emojis'; import type { EmojiSkinTone } from './fun/data/emojis';
import { FUN_STATIC_EMOJI_CLASS } from './fun/FunEmoji'; 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( Quill.register(
{ {
@ -192,7 +194,7 @@ export function CompositionInput(props: Props): React.ReactElement {
} = props; } = props;
const [emojiCompletionElement, setEmojiCompletionElement] = const [emojiCompletionElement, setEmojiCompletionElement] =
React.useState<JSX.Element>(); React.useState<JSX.Element | null>();
const [formattingChooserElement, setFormattingChooserElement] = const [formattingChooserElement, setFormattingChooserElement] =
React.useState<JSX.Element>(); React.useState<JSX.Element>();
const [lastSelectionRange, setLastSelectionRange] = const [lastSelectionRange, setLastSelectionRange] =
@ -770,7 +772,8 @@ export function CompositionInput(props: Props): React.ReactElement {
const callbacksRef = React.useRef(unstaleCallbacks); const callbacksRef = React.useRef(unstaleCallbacks);
callbacksRef.current = unstaleCallbacks; callbacksRef.current = unstaleCallbacks;
const search = useEmojiSearch(i18n.getLocale()); const emojiSearch = useFunEmojiSearch();
const emojiLocalizer = useFunEmojiLocalizer();
const reactQuill = React.useMemo( const reactQuill = React.useMemo(
() => { () => {
@ -839,8 +842,9 @@ export function CompositionInput(props: Props): React.ReactElement {
onPickEmoji: (emoji: EmojiPickDataType) => onPickEmoji: (emoji: EmojiPickDataType) =>
callbacksRef.current.onPickEmoji(emoji), callbacksRef.current.onPickEmoji(emoji),
emojiSkinToneDefault, emojiSkinToneDefault,
search, emojiSearch,
}, emojiLocalizer,
} satisfies EmojiCompletionOptions,
autoSubstituteAsciiEmojis: { autoSubstituteAsciiEmojis: {
emojiSkinToneDefault, emojiSkinToneDefault,
} satisfies AutoSubstituteAsciiEmojisOptions, } satisfies AutoSubstituteAsciiEmojisOptions,

View file

@ -23,7 +23,6 @@ import { dataByCategory } from './lib';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { isSingleGrapheme } from '../../util/grapheme'; import { isSingleGrapheme } from '../../util/grapheme';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { useEmojiSearch } from '../../hooks/useEmojiSearch';
import { FunStaticEmoji } from '../fun/FunEmoji'; import { FunStaticEmoji } from '../fun/FunEmoji';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { import {
@ -35,7 +34,9 @@ import {
getEmojiVariantByParentKeyAndSkinTone, getEmojiVariantByParentKeyAndSkinTone,
isEmojiEnglishShortName, isEmojiEnglishShortName,
EMOJI_SKIN_TONE_TO_NUMBER, EMOJI_SKIN_TONE_TO_NUMBER,
getEmojiParentByKey,
} from '../fun/data/emojis'; } from '../fun/data/emojis';
import { useFunEmojiSearch } from '../fun/useFunEmojiSearch';
export type EmojiPickDataType = { export type EmojiPickDataType = {
skinTone: EmojiSkinTone; skinTone: EmojiSkinTone;
@ -122,7 +123,7 @@ export const EmojiPicker = React.memo(
const [selectedTone, setSelectedTone] = const [selectedTone, setSelectedTone] =
React.useState(emojiSkinToneDefault); React.useState(emojiSkinToneDefault);
const search = useEmojiSearch(i18n.getLocale()); const emojiSearch = useFunEmojiSearch();
const handleToggleSearch = React.useCallback( const handleToggleSearch = React.useCallback(
( (
@ -261,7 +262,13 @@ export const EmojiPicker = React.memo(
const emojiGrid = React.useMemo(() => { const emojiGrid = React.useMemo(() => {
if (searchText) { 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 => const chunks = flatMap(renderableCategories, cat =>
@ -272,7 +279,7 @@ export const EmojiPicker = React.memo(
); );
return [...chunk(firstRecent, COL_COUNT), ...chunks]; return [...chunk(firstRecent, COL_COUNT), ...chunks];
}, [firstRecent, renderableCategories, searchText, search]); }, [firstRecent, renderableCategories, searchText, emojiSearch]);
const rowCount = emojiGrid.length; const rowCount = emojiGrid.length;

View file

@ -3,7 +3,6 @@
// Camelcase disabled due to emoji-datasource using snake_case // Camelcase disabled due to emoji-datasource using snake_case
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import Fuse from 'fuse.js';
import { import {
compact, compact,
flatMap, flatMap,
@ -12,9 +11,7 @@ import {
map, map,
mapValues, mapValues,
sortBy, sortBy,
take,
} from 'lodash'; } from 'lodash';
import type { LocaleEmojiType } from '../../types/emoji';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
import { import {
EMOJI_SKIN_TONE_TO_KEY, EMOJI_SKIN_TONE_TO_KEY,
@ -155,103 +152,6 @@ export function getEmojiData(
return base; return base;
} }
export type SearchFnType = (query: string, count?: number) => Array<string>;
export type SearchEmojiListType = ReadonlyArray<
Pick<LocaleEmojiType, 'shortName' | 'rank' | 'tags'>
>;
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<string>();
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<string> => {
// 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([ const shortNames = new Set([
...map(data, 'short_name'), ...map(data, 'short_name'),
...compact<string>(flatMap(data, 'short_names')), ...compact<string>(flatMap(data, 'short_names')),

View file

@ -9,6 +9,7 @@ import type {
FunEmojiSearchIndexEntry, FunEmojiSearchIndexEntry,
} from '../useFunEmojiSearch'; } from '../useFunEmojiSearch';
import type { FunEmojiLocalizerIndex } from '../useFunEmojiLocalizer'; import type { FunEmojiLocalizerIndex } from '../useFunEmojiLocalizer';
import { removeDiacritics } from '../../../util/removeDiacritics';
// Import emoji-datasource dynamically to avoid costly typechecking. // Import emoji-datasource dynamically to avoid costly typechecking.
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires // 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); 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 * Emojify
*/ */

View file

@ -186,7 +186,9 @@ export function FunPanelEmojis({
return [ return [
toGridSectionNode( toGridSectionNode(
FunSectionCommon.SearchResults, FunSectionCommon.SearchResults,
searchEmojis(searchQuery) searchEmojis(searchQuery).map(result => {
return result.parentKey;
})
), ),
]; ];
} }

View file

@ -56,6 +56,7 @@ import {
FunSubNavScroller, FunSubNavScroller,
} from '../base/FunSubNav'; } from '../base/FunSubNav';
import { import {
type EmojiParentKey,
emojiVariantConstant, emojiVariantConstant,
getEmojiParentKeyByValue, getEmojiParentKeyByValue,
isEmojiParentValue, isEmojiParentValue,
@ -228,7 +229,11 @@ export function FunPanelStickers({
const sections = useMemo(() => { const sections = useMemo(() => {
if (searchQuery !== '') { if (searchQuery !== '') {
const emojiKeys = new Set(searchEmojis(searchQuery)); const emojiKeys = new Set<EmojiParentKey>();
for (const result of searchEmojis(searchQuery)) {
emojiKeys.add(result.parentKey);
}
const allStickers = installedStickerPacks.flatMap(pack => pack.stickers); const allStickers = installedStickerPacks.flatMap(pack => pack.stickers);
const matchingStickers = allStickers.filter(sticker => { const matchingStickers = allStickers.filter(sticker => {

View file

@ -1,6 +1,6 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { useCallback } from 'react'; import { useMemo } from 'react';
import type { EmojiParentKey, EmojiVariantKey } from './data/emojis'; import type { EmojiParentKey, EmojiVariantKey } from './data/emojis';
import { import {
getEmojiParentByKey, getEmojiParentByKey,
@ -28,26 +28,33 @@ export function createFunEmojiLocalizerIndex(
const variantKey = getEmojiVariantKeyByValue(entry.emoji); const variantKey = getEmojiVariantKeyByValue(entry.emoji);
const parentKey = getEmojiParentKeyByVariantKey(variantKey); 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; 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 { export function useFunEmojiLocalizer(): FunEmojiLocalizer {
const { emojiLocalizerIndex } = useFunEmojiLocalization(); const { emojiLocalizerIndex } = useFunEmojiLocalization();
const emojiLocalizer: FunEmojiLocalizer = useCallback( const emojiLocalizer = useMemo(() => {
variantKey => { return _createFunEmojiLocalizer(emojiLocalizerIndex);
const parentKey = getEmojiParentKeyByVariantKey(variantKey); }, [emojiLocalizerIndex]);
const localeShortName = emojiLocalizerIndex.get(parentKey);
if (localeShortName != null) {
return localeShortName;
}
// Fallback to english short name
const parent = getEmojiParentByKey(parentKey);
return parent.englishShortNameDefault;
},
[emojiLocalizerIndex]
);
return emojiLocalizer; return emojiLocalizer;
} }

View file

@ -8,6 +8,7 @@ import {
getEmojiParentByKey, getEmojiParentByKey,
getEmojiParentKeyByValue, getEmojiParentKeyByValue,
isEmojiParentValue, isEmojiParentValue,
normalizeShortNameCompletionQuery,
} from './data/emojis'; } from './data/emojis';
import type { LocaleEmojiListType } from '../../types/emoji'; import type { LocaleEmojiListType } from '../../types/emoji';
import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider'; import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider';
@ -23,10 +24,14 @@ export type FunEmojiSearchIndexEntry = Readonly<{
export type FunEmojiSearchIndex = ReadonlyArray<FunEmojiSearchIndexEntry>; export type FunEmojiSearchIndex = ReadonlyArray<FunEmojiSearchIndexEntry>;
export type FunEmojiSearchResult = Readonly<{
parentKey: EmojiParentKey;
}>;
export type FunEmojiSearch = ( export type FunEmojiSearch = (
query: string, query: string,
limit?: number limit?: number
) => ReadonlyArray<EmojiParentKey>; ) => ReadonlyArray<FunEmojiSearchResult>;
export function createFunEmojiSearchIndex( export function createFunEmojiSearchIndex(
localeEmojiList: LocaleEmojiListType localeEmojiList: LocaleEmojiListType
@ -44,8 +49,10 @@ export function createFunEmojiSearchIndex(
results.push({ results.push({
key: parentKey, key: parentKey,
rank: localeEmoji.rank, rank: localeEmoji.rank,
shortName: localeEmoji.shortName, shortName: normalizeShortNameCompletionQuery(localeEmoji.shortName),
shortNames: localeEmoji.tags, shortNames: localeEmoji.tags.map(tag => {
return normalizeShortNameCompletionQuery(tag);
}),
emoticon: emoji.emoticonDefault, emoticon: emoji.emoticonDefault,
emoticons: emoji.emoticons, emoticons: emoji.emoticons,
}); });
@ -67,6 +74,7 @@ const FuseFuzzyOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
minMatchCharLength: 1, minMatchCharLength: 1,
keys: FuseKeys, keys: FuseKeys,
includeScore: true, includeScore: true,
includeMatches: true,
}; };
const FuseExactOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = { const FuseExactOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
@ -75,49 +83,48 @@ const FuseExactOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
minMatchCharLength: 1, minMatchCharLength: 1,
keys: FuseKeys, keys: FuseKeys,
includeScore: true, includeScore: true,
includeMatches: true,
}; };
function createFunEmojiSearch( /** @internal exported for tests */
export function _createFunEmojiSearch(
emojiSearchIndex: FunEmojiSearchIndex emojiSearchIndex: FunEmojiSearchIndex
): FunEmojiSearch { ): FunEmojiSearch {
const fuseIndex = Fuse.createIndex(FuseKeys, emojiSearchIndex); const fuseIndex = Fuse.createIndex(FuseKeys, emojiSearchIndex);
const fuseFuzzy = new Fuse(emojiSearchIndex, FuseFuzzyOptions, fuseIndex); const fuseFuzzy = new Fuse(emojiSearchIndex, FuseFuzzyOptions, fuseIndex);
const fuseExact = new Fuse(emojiSearchIndex, FuseExactOptions, 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 // Prefer exact matches at 2 characters
const fuse = query.length < 2 ? fuseExact : fuseFuzzy; const fuse = query.length < 2 ? fuseExact : fuseFuzzy;
const rawResults = fuse.search(query.substring(0, 32), { const rawResults = fuse.search(query.substring(0, 32));
limit: limit * 2,
});
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 rank = result.item.rank ?? 1e9;
const localizedQueryMatch =
result.item.shortNames.at(0) ?? result.item.shortName;
// Exact prefix matches in [0,1] range // Exact prefix matches in [0,1] range
if (result.item.shortName.startsWith(query)) { if (localizedQueryMatch.startsWith(query)) {
return { // Note: localizedQueryMatch will always be <= in length to the query
score: result.item.shortName.length / query.length, const matchRatio = query.length / localizedQueryMatch.length; // 1-0
item: result.item, 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 // Other matches in [1,], ordered by score and rank
return { return 1 + queryScore + rankScore;
score: 1 + (result.score ?? 0) + rank / emojiSearchIndex.length,
item: result.item,
};
}); });
const sortedResults = sortBy(rankedResults, result => { return sortedResults.slice(0, limit).map(result => {
return result.score; return { parentKey: result.item.key };
});
const truncatedResults = sortedResults.slice(0, limit);
return truncatedResults.map(result => {
return result.item.key;
}); });
}; };
} }
@ -125,7 +132,7 @@ function createFunEmojiSearch(
export function useFunEmojiSearch(): FunEmojiSearch { export function useFunEmojiSearch(): FunEmojiSearch {
const { emojiSearchIndex } = useFunEmojiLocalization(); const { emojiSearchIndex } = useFunEmojiLocalization();
const emojiSearch = useMemo(() => { const emojiSearch = useMemo(() => {
return createFunEmojiSearch(emojiSearchIndex); return _createFunEmojiSearch(emojiSearchIndex);
}, [emojiSearchIndex]); }, [emojiSearchIndex]);
return emojiSearch; return emojiSearch;
} }

View file

@ -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<typeof createSearch> {
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);
}, []);
}

View file

@ -12,25 +12,27 @@ import classNames from 'classnames';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { VirtualElement } from '@popperjs/core'; import type { VirtualElement } from '@popperjs/core';
import { convertShortName, isShortName } from '../../components/emoji/lib'; import { convertShortName, isShortName } from '../../components/emoji/lib';
import type { SearchFnType } from '../../components/emoji/lib';
import type { EmojiPickDataType } from '../../components/emoji/EmojiPicker'; import type { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
import { getBlotTextPartitions, matchBlotTextPartitions } from '../util'; import { getBlotTextPartitions, matchBlotTextPartitions } from '../util';
import { handleOutsideClick } from '../../util/handleOutsideClick'; import { handleOutsideClick } from '../../util/handleOutsideClick';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { FunStaticEmoji } from '../../components/fun/FunEmoji'; import { FunStaticEmoji } from '../../components/fun/FunEmoji';
import { strictAssert } from '../../util/assert';
import { import {
EmojiSkinTone, EmojiSkinTone,
getEmojiParentKeyByEnglishShortName, getEmojiParentByKey,
getEmojiVariantByParentKeyAndSkinTone, getEmojiVariantByParentKeyAndSkinTone,
isEmojiEnglishShortName, normalizeShortNameCompletionDisplay,
} from '../../components/fun/data/emojis'; } 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 = { export type EmojiCompletionOptions = {
onPickEmoji: (emoji: EmojiPickDataType) => void; onPickEmoji: (emoji: EmojiPickDataType) => void;
setEmojiPickerElement: (element: JSX.Element | null) => void; setEmojiPickerElement: (element: JSX.Element | null) => void;
emojiSkinToneDefault: EmojiSkinTone | null; emojiSkinToneDefault: EmojiSkinTone | null;
search: SearchFnType; emojiSearch: FunEmojiSearch;
emojiLocalizer: FunEmojiLocalizer;
}; };
export type InsertEmojiOptionsType = Readonly<{ export type InsertEmojiOptionsType = Readonly<{
@ -42,7 +44,7 @@ export type InsertEmojiOptionsType = Readonly<{
}>; }>;
export class EmojiCompletion { export class EmojiCompletion {
results: Array<string>; results: ReadonlyArray<FunEmojiSearchResult>;
index: number; index: number;
@ -197,7 +199,7 @@ export class EmojiCompletion {
return PASS_THROUGH; return PASS_THROUGH;
} }
const showEmojiResults = this.options.search(leftTokenText, 10); const showEmojiResults = this.options.emojiSearch(leftTokenText, 10);
if (showEmojiResults.length > 0) { if (showEmojiResults.length > 0) {
this.results = showEmojiResults; this.results = showEmojiResults;
@ -229,7 +231,7 @@ export class EmojiCompletion {
return; return;
} }
const emoji = this.results[this.index]; const result = this.results[this.index];
const [leafText] = this.getCurrentLeafTextPartitions(); const [leafText] = this.getCurrentLeafTextPartitions();
const tokenTextMatch = /:([-+0-9\p{Alpha}_]*)(:?)$/iu.exec(leafText); const tokenTextMatch = /:([-+0-9\p{Alpha}_]*)(:?)$/iu.exec(leafText);
@ -239,9 +241,10 @@ export class EmojiCompletion {
} }
const [, tokenText] = tokenTextMatch; const [, tokenText] = tokenTextMatch;
const parent = getEmojiParentByKey(result.parentKey);
this.insertEmoji({ this.insertEmoji({
shortName: emoji, shortName: parent.englishShortNameDefault,
index: range.index - tokenText.length - 1, index: range.index - tokenText.length - 1,
range: tokenText.length + 1, range: tokenText.length + 1,
withTrailingSpace: true, withTrailingSpace: true,
@ -362,26 +365,30 @@ export class EmojiCompletion {
role="listbox" role="listbox"
aria-expanded aria-expanded
aria-activedescendant={`emoji-result--${ aria-activedescendant={`emoji-result--${
emojiResults.length ? emojiResults[emojiResultsIndex] : '' emojiResults.length
? emojiResults[emojiResultsIndex].parentKey
: ''
}`} }`}
tabIndex={0} tabIndex={0}
> >
{emojiResults.map((emoji, index) => { {emojiResults.map((result, index) => {
strictAssert(
isEmojiEnglishShortName(emoji),
'Must be valid english short name'
);
const emojiParentKey = getEmojiParentKeyByEnglishShortName(emoji);
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone( const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
emojiParentKey, result.parentKey,
this.options.emojiSkinToneDefault ?? EmojiSkinTone.None this.options.emojiSkinToneDefault ?? EmojiSkinTone.None
); );
const localeShortName = this.options.emojiLocalizer(
emojiVariant.key
);
const normalized =
normalizeShortNameCompletionDisplay(localeShortName);
return ( return (
<button <button
type="button" type="button"
key={emoji} key={result.parentKey}
id={`emoji-result--${emoji}`} id={`emoji-result--${result.parentKey}`}
role="option button" role="option button"
aria-selected={emojiResultsIndex === index} aria-selected={emojiResultsIndex === index}
onClick={() => { onClick={() => {
@ -401,7 +408,7 @@ export class EmojiCompletion {
size={16} size={16}
/> />
<div className="module-composition-input__suggestions__row__short-name"> <div className="module-composition-input__suggestions__row__short-name">
:{emoji}: :{normalized}:
</div> </div>
</button> </button>
); );

View file

@ -9,8 +9,53 @@ import type {
EmojiCompletionOptions, EmojiCompletionOptions,
InsertEmojiOptionsType, InsertEmojiOptionsType,
} from '../../../quill/emoji/completion'; } from '../../../quill/emoji/completion';
import { createSearch } from '../../../components/emoji/lib'; import {
import { EmojiSkinTone } from '../../../components/fun/data/emojis'; 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', () => { describe('emojiCompletion', () => {
let emojiCompletion: EmojiCompletion; let emojiCompletion: EmojiCompletion;
@ -29,14 +74,19 @@ describe('emojiCompletion', () => {
setSelection: sinon.stub(), setSelection: sinon.stub(),
updateContents: 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 = { const options: EmojiCompletionOptions = {
onPickEmoji: sinon.stub(), onPickEmoji: sinon.stub(),
setEmojiPickerElement: sinon.stub(), setEmojiPickerElement: sinon.stub(),
emojiSkinToneDefault: EmojiSkinTone.None, emojiSkinToneDefault: EmojiSkinTone.None,
search: createSearch([ emojiSearch,
{ shortName: 'smile', tags: [], rank: 0 }, emojiLocalizer,
{ shortName: 'smile_cat', tags: [], rank: 0 },
]),
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -64,7 +114,7 @@ describe('emojiCompletion', () => {
let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>; let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>;
beforeEach(() => { beforeEach(() => {
emojiCompletion.results = ['joy']; emojiCompletion.results = [{ parentKey: PARENT_KEYS.SMILE }];
emojiCompletion.index = 5; emojiCompletion.index = 5;
insertEmojiStub = sinon insertEmojiStub = sinon
.stub(emojiCompletion, 'insertEmoji') .stub(emojiCompletion, 'insertEmoji')
@ -171,7 +221,7 @@ describe('emojiCompletion', () => {
}); });
it('stores the results and renders', () => { 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); assert.equal((emojiCompletion.render as sinon.SinonStub).called, true);
}); });
}); });
@ -353,7 +403,10 @@ describe('emojiCompletion', () => {
let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>; let insertEmojiStub: sinon.SinonStub<[InsertEmojiOptionsType], void>;
beforeEach(() => { beforeEach(() => {
emojiCompletion.results = ['smile', 'smile_cat']; emojiCompletion.results = [
{ parentKey: PARENT_KEYS.SMILE },
{ parentKey: PARENT_KEYS.SMILE_CAT },
];
emojiCompletion.index = 1; emojiCompletion.index = 1;
insertEmojiStub = sinon.stub(emojiCompletion, 'insertEmoji'); insertEmojiStub = sinon.stub(emojiCompletion, 'insertEmoji');
}); });

View file

@ -1994,6 +1994,27 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2023-01-18T22:32:43.901Z" "updated": "2023-01-18T22:32:43.901Z"
}, },
{
"rule": "React-useRef",
"path": "ts/components/fun/FunGif.tsx",
"line": " const ref = useRef<HTMLVideoElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-03-21T23:22:07.920Z"
},
{
"rule": "React-useRef",
"path": "ts/components/fun/FunGif.tsx",
"line": " const timerRef = useRef<ReturnType<typeof setTimeout>>();",
"reasonCategory": "usageTrusted",
"updated": "2025-03-21T23:22:07.920Z"
},
{
"rule": "React-useRef",
"path": "ts/components/fun/FunGif.tsx",
"line": " const videoRef = useRef<HTMLVideoElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-03-24T18:57:50.198Z"
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/fun/base/FunImage.tsx", "path": "ts/components/fun/base/FunImage.tsx",
@ -2127,27 +2148,6 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2025-02-19T20:14:46.879Z" "updated": "2025-02-19T20:14:46.879Z"
}, },
{
"rule": "React-useRef",
"path": "ts/components/fun/FunGif.tsx",
"line": " const ref = useRef<HTMLVideoElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-03-21T23:22:07.920Z"
},
{
"rule": "React-useRef",
"path": "ts/components/fun/FunGif.tsx",
"line": " const timerRef = useRef<ReturnType<typeof setTimeout>>();",
"reasonCategory": "usageTrusted",
"updated": "2025-03-21T23:22:07.920Z"
},
{
"rule": "React-useRef",
"path": "ts/components/fun/FunGif.tsx",
"line": " const videoRef = useRef<HTMLVideoElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-03-24T18:57:50.198Z"
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/stickers/StickerButton.tsx", "path": "ts/components/stickers/StickerButton.tsx",
@ -2156,14 +2156,6 @@
"updated": "2022-06-14T22:04:43.988Z", "updated": "2022-06-14T22:04:43.988Z",
"reasonDetail": "Handling outside click" "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", "rule": "React-useRef",
"path": "ts/hooks/useIntersectionObserver.ts", "path": "ts/hooks/useIntersectionObserver.ts",