signal-desktop/ts/components/fun/useFunEmojiSearch.tsx

132 lines
3.6 KiB
TypeScript
Raw Normal View History

2025-04-09 11:10:54 -07:00
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import Fuse from 'fuse.js';
import { sortBy } from 'lodash';
import { useMemo } from 'react';
import type { EmojiParentKey } from './data/emojis';
import {
getEmojiParentByKey,
getEmojiParentKeyByValue,
isEmojiParentValue,
} from './data/emojis';
import type { LocaleEmojiListType } from '../../types/emoji';
import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider';
export type FunEmojiSearchIndexEntry = Readonly<{
key: EmojiParentKey;
rank: number | null;
shortName: string;
shortNames: ReadonlyArray<string>;
emoticon: string | null;
emoticons: ReadonlyArray<string>;
}>;
export type FunEmojiSearchIndex = ReadonlyArray<FunEmojiSearchIndexEntry>;
export type FunEmojiSearch = (
query: string,
limit?: number
) => ReadonlyArray<EmojiParentKey>;
export function createFunEmojiSearchIndex(
localeEmojiList: LocaleEmojiListType
): FunEmojiSearchIndex {
const results: Array<FunEmojiSearchIndexEntry> = [];
for (const localeEmoji of localeEmojiList) {
if (!isEmojiParentValue(localeEmoji.emoji)) {
// Skipping unknown emoji, most likely apple doesn't support it
continue;
}
const parentKey = getEmojiParentKeyByValue(localeEmoji.emoji);
const emoji = getEmojiParentByKey(parentKey);
results.push({
key: parentKey,
rank: localeEmoji.rank,
shortName: localeEmoji.shortName,
shortNames: localeEmoji.tags,
emoticon: emoji.emoticonDefault,
emoticons: emoji.emoticons,
});
}
return results;
}
const FuseKeys: Array<Fuse.FuseOptionKey> = [
{ name: 'shortName', weight: 100 },
{ name: 'shortNames', weight: 1 },
{ name: 'emoticon', weight: 50 },
{ name: 'emoticons', weight: 1 },
];
const FuseFuzzyOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
shouldSort: false,
threshold: 0.2,
minMatchCharLength: 1,
keys: FuseKeys,
includeScore: true,
};
const FuseExactOptions: Fuse.IFuseOptions<FunEmojiSearchIndexEntry> = {
shouldSort: false,
threshold: 0,
minMatchCharLength: 1,
keys: FuseKeys,
includeScore: true,
};
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) {
// 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 rankedResults = rawResults.map(result => {
const rank = result.item.rank ?? 1e9;
// Exact prefix matches in [0,1] range
if (result.item.shortName.startsWith(query)) {
return {
score: result.item.shortName.length / query.length,
item: result.item,
};
}
// Other matches in [1,], ordered by score and rank
return {
score: 1 + (result.score ?? 0) + rank / emojiSearchIndex.length,
item: result.item,
};
});
const sortedResults = sortBy(rankedResults, result => {
return result.score;
});
const truncatedResults = sortedResults.slice(0, limit);
return truncatedResults.map(result => {
return result.item.key;
});
};
}
export function useFunEmojiSearch(): FunEmojiSearch {
const { emojiSearchIndex } = useFunEmojiLocalization();
const emojiSearch = useMemo(() => {
return createFunEmojiSearch(emojiSearchIndex);
}, [emojiSearchIndex]);
return emojiSearch;
}