Add localized emoji search

This commit is contained in:
Fedor Indutny 2024-03-21 09:35:54 -07:00 committed by GitHub
parent ce0fb22041
commit e90553b3b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 878 additions and 97 deletions

View file

@ -52,6 +52,7 @@ import { isNotNil } from '../util/isNotNil';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { useRefMerger } from '../hooks/useRefMerger';
import { useEmojiSearch } from '../hooks/useEmojiSearch';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import type { DraftEditMessageType } from '../model-types.d';
@ -688,6 +689,8 @@ export function CompositionInput(props: Props): React.ReactElement {
const callbacksRef = React.useRef(unstaleCallbacks);
callbacksRef.current = unstaleCallbacks;
const search = useEmojiSearch(i18n.getLocale());
const reactQuill = React.useMemo(
() => {
const delta = generateDelta(draftText || '', draftBodyRanges || []);
@ -739,6 +742,7 @@ export function CompositionInput(props: Props): React.ReactElement {
onPickEmoji: (emoji: EmojiPickDataType) =>
callbacksRef.current.onPickEmoji(emoji),
skinTone,
search,
},
autoSubstituteAsciiEmojis: {
skinTone,

View file

@ -21,10 +21,11 @@ import {
import FocusTrap from 'focus-trap-react';
import { Emoji } from './Emoji';
import { dataByCategory, search } from './lib';
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';
export type EmojiPickDataType = {
skinTone?: number;
@ -108,6 +109,8 @@ export const EmojiPicker = React.memo(
const [scrollToRow, setScrollToRow] = React.useState(0);
const [selectedTone, setSelectedTone] = React.useState(skinTone);
const search = useEmojiSearch(i18n.getLocale());
const handleToggleSearch = React.useCallback(
(
e:
@ -261,10 +264,7 @@ export const EmojiPicker = React.memo(
const emojiGrid = React.useMemo(() => {
if (searchText) {
return chunk(
search(searchText).map(e => e.short_name),
COL_COUNT
);
return chunk(search(searchText), COL_COUNT);
}
const chunks = flatMap(renderableCategories, cat =>
@ -275,7 +275,7 @@ export const EmojiPicker = React.memo(
);
return [...chunk(firstRecent, COL_COUNT), ...chunks];
}, [firstRecent, renderableCategories, searchText]);
}, [firstRecent, renderableCategories, searchText, search]);
const rowCount = emojiGrid.length;

View file

@ -23,6 +23,7 @@ import { getOwn } from '../../util/getOwn';
import * as log from '../../logging/log';
import { MINUTE } from '../../util/durations';
import { drop } from '../../util/drop';
import type { LocaleEmojiType } from '../../types/emoji';
export const skinTones = ['1F3FB', '1F3FC', '1F3FD', '1F3FE', '1F3FF'];
@ -218,34 +219,69 @@ export function getImagePath(
return makeImagePath(emojiData.image);
}
const fuse = new Fuse(data, {
shouldSort: true,
threshold: 0.2,
minMatchCharLength: 1,
keys: ['short_name', 'short_names'],
});
export type SearchFnType = (query: string, count?: number) => Array<string>;
const fuseExactPrefix = new Fuse(data, {
shouldSort: true,
threshold: 0, // effectively a prefix search
minMatchCharLength: 2,
keys: ['short_name', 'short_names'],
});
export type SearchEmojiListType = ReadonlyArray<
Pick<LocaleEmojiType, 'shortName' | 'rank' | 'tags'>
>;
export function search(query: string, count = 0): Array<EmojiData> {
// 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;
type CachedSearchFnType = Readonly<{
localeEmoji: SearchEmojiListType;
fn: SearchFnType;
}>;
const results = fuseIndex
.search(query.substr(0, 32))
.map(result => result.item);
let cachedSearchFn: CachedSearchFnType | undefined;
if (count) {
return take(results, count);
export function createSearch(localeEmoji: SearchEmojiListType): SearchFnType {
if (cachedSearchFn && cachedSearchFn.localeEmoji === localeEmoji) {
return cachedSearchFn.fn;
}
return results;
const fuse = new Fuse(localeEmoji, {
shouldSort: true,
threshold: 0.2,
minMatchCharLength: 1,
keys: ['shortName', 'tags'],
includeScore: true,
});
const fuseExactPrefix = new Fuse(localeEmoji, {
shouldSort: true,
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;
return {
score: (entry.score ?? 0) + rank / localeEmoji.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([