mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-02-15 07:28:59 +00:00
Translate emoji names everywhere
This commit is contained in:
@@ -23,6 +23,11 @@ import {
|
||||
createScrollerLock,
|
||||
} from '../ts/hooks/useScrollLock';
|
||||
import { Environment, setEnvironment } from '../ts/environment.ts';
|
||||
import { parseUnknown } from '../ts/util/schemas.ts';
|
||||
import { LocaleEmojiListSchema } from '../ts/types/emoji.ts';
|
||||
import { FunProvider } from '../ts/components/fun/FunProvider.tsx';
|
||||
import { EmojiSkinTone } from '../ts/components/fun/data/emojis.ts';
|
||||
import { MOCK_GIFS_PAGINATED_ONE_PAGE } from '../ts/components/fun/mocks.tsx';
|
||||
|
||||
setEnvironment(Environment.Development, true);
|
||||
|
||||
@@ -206,10 +211,33 @@ function withScrollLockProvider(Story, context) {
|
||||
);
|
||||
}
|
||||
|
||||
function withFunProvider(Story, context) {
|
||||
return (
|
||||
<FunProvider
|
||||
i18n={window.SignalContext.i18n}
|
||||
recentEmojis={[]}
|
||||
recentStickers={[]}
|
||||
recentGifs={[]}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={noop}
|
||||
installedStickerPacks={[]}
|
||||
showStickerPickerHint={false}
|
||||
onClearStickerPickerHint={noop}
|
||||
onOpenCustomizePreferredReactionsModal={noop}
|
||||
fetchGifsSearch={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
|
||||
fetchGifsFeatured={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
|
||||
fetchGif={() => Promise.resolve(new Blob([new Uint8Array(1)]))}
|
||||
>
|
||||
<Story {...context} />
|
||||
</FunProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const decorators = [
|
||||
withGlobalTypesProvider,
|
||||
withMockStoreProvider,
|
||||
withScrollLockProvider,
|
||||
withFunProvider,
|
||||
];
|
||||
|
||||
export const parameters = {
|
||||
|
||||
@@ -91,12 +91,11 @@ import { CallingPendingParticipants } from './CallingPendingParticipants';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
import { FunStaticEmoji } from './fun/FunEmoji';
|
||||
import {
|
||||
getEmojiParentByKey,
|
||||
getEmojiParentKeyByVariantKey,
|
||||
getEmojiVariantByKey,
|
||||
getEmojiVariantKeyByValue,
|
||||
isEmojiVariantValue,
|
||||
} from './fun/data/emojis';
|
||||
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer';
|
||||
|
||||
export type PropsType = {
|
||||
activeCall: ActiveCallType;
|
||||
@@ -1243,6 +1242,7 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
||||
>(new Map());
|
||||
const burstsShown = useRef<Map<string, number>>(new Map());
|
||||
const { showToast } = useCallingToasts();
|
||||
const emojiLocalizer = useFunEmojiLocalizer();
|
||||
|
||||
useEffect(() => {
|
||||
setPreviousReactions(reactions);
|
||||
@@ -1262,8 +1262,6 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
||||
strictAssert(isEmojiVariantValue(value), 'Expected a valid emoji value');
|
||||
const emojiVariantKey = getEmojiVariantKeyByValue(value);
|
||||
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
|
||||
const emojiParentKey = getEmojiParentKeyByVariantKey(emojiVariantKey);
|
||||
const emojiParent = getEmojiParentByKey(emojiParentKey);
|
||||
|
||||
showToast({
|
||||
key,
|
||||
@@ -1273,7 +1271,7 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
||||
<span className="CallingReactionsToasts__reaction">
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
aria-label={emojiLocalizer(emojiVariantKey)}
|
||||
size={28}
|
||||
emoji={emojiVariant}
|
||||
/>
|
||||
@@ -1390,6 +1388,7 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
||||
localDemuxId,
|
||||
i18n,
|
||||
ourServiceId,
|
||||
emojiLocalizer,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import { ConversationColors } from '../types/Colors';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { PaymentEventKind } from '../types/Payment';
|
||||
import { EmojiSkinTone } from './fun/data/emojis';
|
||||
import { MockFunProvider } from './fun/mocks';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
@@ -25,7 +24,6 @@ export default {
|
||||
decorators: [
|
||||
// necessary for the add attachment button to render properly
|
||||
storyFn => <div className="file-input">{storyFn()}</div>,
|
||||
storyFn => <MockFunProvider>{storyFn()}</MockFunProvider>,
|
||||
],
|
||||
argTypes: {
|
||||
recordingState: {
|
||||
|
||||
@@ -694,7 +694,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||
React.useEffect(() => {
|
||||
const emojiCompletion = emojiCompletionRef.current;
|
||||
|
||||
if (emojiCompletion == null || emojiSkinToneDefault == null) {
|
||||
if (emojiCompletion == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const ModalHost = React.memo(function ModalHostInner({
|
||||
if (
|
||||
modalContainer === document.body &&
|
||||
node instanceof Element &&
|
||||
node.closest('.module-calling__modal-container')
|
||||
node.closest('.module-calling__modal-container, .FunPopover')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
SettingsRow,
|
||||
} from './PreferencesUtil';
|
||||
import { PreferencesBackups } from './PreferencesBackups';
|
||||
import { FunEmojiLocalizationProvider } from './fun/FunEmojiLocalizationProvider';
|
||||
|
||||
type CheckboxChangeHandlerType = (value: boolean) => unknown;
|
||||
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
|
||||
@@ -1730,7 +1731,7 @@ export function Preferences({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FunEmojiLocalizationProvider i18n={i18n}>
|
||||
<div className="module-title-bar-drag-area" />
|
||||
<div className="Preferences">
|
||||
<div className="Preferences__page-selector">
|
||||
@@ -1845,7 +1846,7 @@ export function Preferences({
|
||||
containerWidthBreakpoint={WidthBreakpoint.Narrow}
|
||||
isInFullScreenCall={false}
|
||||
/>
|
||||
</>
|
||||
</FunEmojiLocalizationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,9 +57,7 @@ import { FunStaticEmoji } from './fun/FunEmoji';
|
||||
import type { EmojiVariantKey } from './fun/data/emojis';
|
||||
import {
|
||||
EmojiSkinTone,
|
||||
getEmojiParentByKey,
|
||||
getEmojiParentKeyByEnglishShortName,
|
||||
getEmojiParentKeyByVariantKey,
|
||||
getEmojiVariantByKey,
|
||||
getEmojiVariantByParentKeyAndSkinTone,
|
||||
getEmojiVariantKeyByValue,
|
||||
@@ -70,6 +68,7 @@ import { FunEmojiPicker } from './fun/FunEmojiPicker';
|
||||
import { FunEmojiPickerButton } from './fun/FunButton';
|
||||
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis';
|
||||
import { isFunPickerEnabled } from './fun/isFunPickerEnabled';
|
||||
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer';
|
||||
|
||||
export enum EditState {
|
||||
None = 'None',
|
||||
@@ -162,13 +161,12 @@ function getDefaultBios(i18n: LocalizerType): Array<DefaultBio> {
|
||||
}
|
||||
|
||||
function BioEmoji(props: { emoji: EmojiVariantKey }) {
|
||||
const emojiLocalizer = useFunEmojiLocalizer();
|
||||
const emojiVariant = getEmojiVariantByKey(props.emoji);
|
||||
const emojiParentKey = getEmojiParentKeyByVariantKey(props.emoji);
|
||||
const emojiParent = getEmojiParentByKey(emojiParentKey);
|
||||
return (
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
aria-label={emojiLocalizer(props.emoji)}
|
||||
emoji={emojiVariant}
|
||||
size={24}
|
||||
/>
|
||||
@@ -537,7 +535,7 @@ export function ProfileEditor({
|
||||
);
|
||||
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
|
||||
emojiParentKey,
|
||||
emojiSkinToneDefault
|
||||
emojiSkinToneDefault ?? EmojiSkinTone.None
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,14 +6,13 @@ import { splitByEmoji } from '../../util/emoji';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { FunInlineEmoji } from '../fun/FunEmoji';
|
||||
import {
|
||||
getEmojiParentByKey,
|
||||
getEmojiParentKeyByVariantKey,
|
||||
getEmojiVariantByKey,
|
||||
getEmojiVariantKeyByValue,
|
||||
isEmojiVariantValue,
|
||||
isEmojiVariantValueNonQualified,
|
||||
} from '../fun/data/emojis';
|
||||
import * as log from '../../logging/log';
|
||||
import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer';
|
||||
|
||||
export type Props = {
|
||||
fontSizeOverride?: number | null;
|
||||
@@ -31,6 +30,7 @@ export function Emojify({
|
||||
text,
|
||||
renderNonEmoji = defaultRenderNonEmoji,
|
||||
}: Props): JSX.Element {
|
||||
const emojiLocalizer = useFunEmojiLocalizer();
|
||||
return (
|
||||
<>
|
||||
{splitByEmoji(text).map(({ type, value: match }, index) => {
|
||||
@@ -48,15 +48,13 @@ export function Emojify({
|
||||
|
||||
const variantKey = getEmojiVariantKeyByValue(match);
|
||||
const variant = getEmojiVariantByKey(variantKey);
|
||||
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
|
||||
const parent = getEmojiParentByKey(parentKey);
|
||||
|
||||
return (
|
||||
<FunInlineEmoji
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
role="img"
|
||||
aria-label={parent.englishShortNameDefault}
|
||||
aria-label={emojiLocalizer(variantKey)}
|
||||
emoji={variant}
|
||||
size={fontSizeOverride}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ReactionPicker } from './ReactionPicker';
|
||||
import { EmojiPicker } from '../emoji/EmojiPicker';
|
||||
import { DEFAULT_PREFERRED_REACTION_EMOJI } from '../../reactions/constants';
|
||||
import { EmojiSkinTone } from '../fun/data/emojis';
|
||||
import { MockFunProvider } from '../fun/mocks';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
@@ -32,7 +31,6 @@ const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({
|
||||
|
||||
export default {
|
||||
title: 'Components/Conversation/ReactionPicker',
|
||||
decorators: [storyFn => <MockFunProvider>{storyFn()}</MockFunProvider>],
|
||||
} satisfies Meta<ReactionPickerProps>;
|
||||
|
||||
export function Base(): JSX.Element {
|
||||
|
||||
@@ -15,14 +15,13 @@ import { emojiToData } from '../emoji/lib';
|
||||
import { useEscapeHandling } from '../../hooks/useEscapeHandling';
|
||||
import type { ThemeType } from '../../types/Util';
|
||||
import {
|
||||
getEmojiParentByKey,
|
||||
getEmojiParentKeyByVariantKey,
|
||||
getEmojiVariantByKey,
|
||||
getEmojiVariantKeyByValue,
|
||||
isEmojiVariantValue,
|
||||
} from '../fun/data/emojis';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { FunStaticEmoji } from '../fun/FunEmoji';
|
||||
import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer';
|
||||
|
||||
export type Reaction = {
|
||||
emoji: string;
|
||||
@@ -76,6 +75,7 @@ type ReactionWithEmojiData = Reaction & EmojiData;
|
||||
function ReactionViewerEmoji(props: {
|
||||
emojiVariantValue: string | undefined;
|
||||
}): JSX.Element {
|
||||
const emojiLocalizer = useFunEmojiLocalizer();
|
||||
strictAssert(props.emojiVariantValue != null, 'Expected an emoji');
|
||||
strictAssert(
|
||||
isEmojiVariantValue(props.emojiVariantValue),
|
||||
@@ -83,14 +83,10 @@ function ReactionViewerEmoji(props: {
|
||||
);
|
||||
const emojiVariantKey = getEmojiVariantKeyByValue(props.emojiVariantValue);
|
||||
const emojiVariant = getEmojiVariantByKey(emojiVariantKey);
|
||||
|
||||
const emojiParentKey = getEmojiParentKeyByVariantKey(emojiVariantKey);
|
||||
const emojiParent = getEmojiParentByKey(emojiParentKey);
|
||||
|
||||
return (
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
aria-label={emojiLocalizer(emojiVariantKey)}
|
||||
size={18}
|
||||
emoji={emojiVariant}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useRefMerger } from '../../hooks/useRefMerger';
|
||||
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
||||
import * as KeyboardLayout from '../../services/keyboardLayout';
|
||||
import { FunStaticEmoji } from '../fun/FunEmoji';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type { EmojiVariantData } from '../fun/data/emojis';
|
||||
import {
|
||||
getEmojiVariantByKey,
|
||||
@@ -161,8 +160,7 @@ export const EmojiButton = React.memo(function EmojiButtonInner({
|
||||
}, [open, setOpen]);
|
||||
|
||||
let emojiVariant: EmojiVariantData;
|
||||
if (emoji != null) {
|
||||
strictAssert(isEmojiVariantValue(emoji), 'Must be emoji variant value');
|
||||
if (emoji != null && isEmojiVariantValue(emoji)) {
|
||||
const emojiVariantKey = getEmojiVariantKeyByValue(emoji);
|
||||
emojiVariant = getEmojiVariantByKey(emojiVariantKey);
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ export const EmojiPicker = React.memo(
|
||||
const parentKey = getEmojiParentKeyByEnglishShortName(shortName);
|
||||
const variantKey = getEmojiVariantByParentKeyAndSkinTone(
|
||||
parentKey,
|
||||
selectedTone
|
||||
selectedTone ?? EmojiSkinTone.None
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,13 +4,9 @@ import React, { useMemo } from 'react';
|
||||
import { VisuallyHidden } from 'react-aria';
|
||||
import { Button } from 'react-aria-components';
|
||||
import type { LocalizerType } from '../../types/I18N';
|
||||
import {
|
||||
type EmojiVariantKey,
|
||||
getEmojiParentByKey,
|
||||
getEmojiParentKeyByVariantKey,
|
||||
getEmojiVariantByKey,
|
||||
} from './data/emojis';
|
||||
import { type EmojiVariantKey, getEmojiVariantByKey } from './data/emojis';
|
||||
import { FunStaticEmoji } from './FunEmoji';
|
||||
import { useFunEmojiLocalizer } from './useFunEmojiLocalizer';
|
||||
|
||||
/**
|
||||
* Fun Picker Button
|
||||
@@ -43,27 +39,26 @@ export function FunEmojiPickerButton(
|
||||
props: FunEmojiPickerButtonProps
|
||||
): JSX.Element {
|
||||
const { i18n } = props;
|
||||
const emojiLocalizer = useFunEmojiLocalizer();
|
||||
|
||||
const selectedEmojiData = useMemo(() => {
|
||||
const emojiVarant = useMemo(() => {
|
||||
if (props.selectedEmoji == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const variantKey = props.selectedEmoji;
|
||||
const variant = getEmojiVariantByKey(variantKey);
|
||||
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
|
||||
const parent = getEmojiParentByKey(parentKey);
|
||||
return { variant, parent };
|
||||
return variant;
|
||||
}, [props.selectedEmoji]);
|
||||
|
||||
return (
|
||||
<Button className="FunButton">
|
||||
{selectedEmojiData ? (
|
||||
{emojiVarant ? (
|
||||
<FunStaticEmoji
|
||||
role="img"
|
||||
size={20}
|
||||
aria-label={selectedEmojiData.parent.englishShortNameDefault}
|
||||
emoji={selectedEmojiData.variant}
|
||||
aria-label={emojiLocalizer(emojiVarant.key)}
|
||||
emoji={emojiVarant}
|
||||
/>
|
||||
) : (
|
||||
<span className="FunButton__Icon FunButton__Icon--EmojiPicker" />
|
||||
|
||||
120
ts/components/fun/FunEmojiLocalizationProvider.tsx
Normal file
120
ts/components/fun/FunEmojiLocalizationProvider.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React, {
|
||||
createContext,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { LocaleEmojiListType } from '../../types/emoji';
|
||||
import * as log from '../../logging/log';
|
||||
import * as Errors from '../../types/errors';
|
||||
import { drop } from '../../util/drop';
|
||||
import {
|
||||
getEmojiDefaultEnglishLocalizerIndex,
|
||||
getEmojiDefaultEnglishSearchIndex,
|
||||
} from './data/emojis';
|
||||
import {
|
||||
createFunEmojiLocalizerIndex,
|
||||
type FunEmojiLocalizerIndex,
|
||||
} from './useFunEmojiLocalizer';
|
||||
import {
|
||||
createFunEmojiSearchIndex,
|
||||
type FunEmojiSearchIndex,
|
||||
} from './useFunEmojiSearch';
|
||||
import type { LocalizerType } from '../../types/I18N';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
|
||||
export type FunEmojiLocalizationContextType = Readonly<{
|
||||
emojiSearchIndex: FunEmojiSearchIndex;
|
||||
emojiLocalizerIndex: FunEmojiLocalizerIndex;
|
||||
}>;
|
||||
|
||||
export const FunEmojiLocalizationContext =
|
||||
createContext<FunEmojiLocalizationContextType | null>(null);
|
||||
|
||||
export function useFunEmojiLocalization(): FunEmojiLocalizationContextType {
|
||||
const fun = useContext(FunEmojiLocalizationContext);
|
||||
strictAssert(fun != null, 'Must be wrapped with <FunProvider>');
|
||||
return fun;
|
||||
}
|
||||
|
||||
export type FunEmojiLocalizationProviderProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
children: ReactNode;
|
||||
}>;
|
||||
|
||||
export const FunEmojiLocalizationProvider = memo(
|
||||
function FunEmojiLocalizationProvider(
|
||||
props: FunEmojiLocalizationProviderProps
|
||||
) {
|
||||
const localeEmojiList = useLocaleEmojiList(props.i18n);
|
||||
const emojiSearchIndex = useFunEmojiSearchIndex(localeEmojiList);
|
||||
const emojiLocalizerIndex = useFunEmojiLocalizerIndex(localeEmojiList);
|
||||
|
||||
const context = useMemo((): FunEmojiLocalizationContextType => {
|
||||
return { emojiSearchIndex, emojiLocalizerIndex };
|
||||
}, [emojiSearchIndex, emojiLocalizerIndex]);
|
||||
|
||||
return (
|
||||
<FunEmojiLocalizationContext.Provider value={context}>
|
||||
{props.children}
|
||||
</FunEmojiLocalizationContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
function useLocaleEmojiList(i18n: LocalizerType): LocaleEmojiListType | null {
|
||||
const locale = useMemo(() => i18n.getLocale(), [i18n]);
|
||||
|
||||
const [localeEmojiList, setLocaleEmojiList] =
|
||||
useState<LocaleEmojiListType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
const list = await window.SignalContext.getLocalizedEmojiList(locale);
|
||||
if (!canceled) {
|
||||
setLocaleEmojiList(list);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`FunProvider: Failed to get localized emoji list for "${locale}"`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(run());
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
return localeEmojiList;
|
||||
}
|
||||
|
||||
function useFunEmojiSearchIndex(
|
||||
localeEmojiList: LocaleEmojiListType | null
|
||||
): FunEmojiSearchIndex {
|
||||
const funEmojiSearchIndex = useMemo(() => {
|
||||
return localeEmojiList != null
|
||||
? createFunEmojiSearchIndex(localeEmojiList)
|
||||
: getEmojiDefaultEnglishSearchIndex();
|
||||
}, [localeEmojiList]);
|
||||
return funEmojiSearchIndex;
|
||||
}
|
||||
|
||||
function useFunEmojiLocalizerIndex(
|
||||
localeEmojiList: LocaleEmojiListType | null
|
||||
): FunEmojiLocalizerIndex {
|
||||
const funEmojiLocalizerIndex = useMemo(() => {
|
||||
return localeEmojiList != null
|
||||
? createFunEmojiLocalizerIndex(localeEmojiList)
|
||||
: getEmojiDefaultEnglishLocalizerIndex();
|
||||
}, [localeEmojiList]);
|
||||
return funEmojiLocalizerIndex;
|
||||
}
|
||||
@@ -37,14 +37,17 @@ function Template(props: TemplateProps): JSX.Element {
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={() => null}
|
||||
onOpenCustomizePreferredReactionsModal={() => null}
|
||||
onSelectEmoji={() => null}
|
||||
// Stickers
|
||||
installedStickerPacks={packs}
|
||||
showStickerPickerHint={false}
|
||||
onClearStickerPickerHint={() => null}
|
||||
onSelectSticker={() => null}
|
||||
// Gifs
|
||||
fetchGifsSearch={() => Promise.reject()}
|
||||
fetchGifsFeatured={() => Promise.reject()}
|
||||
fetchGif={() => Promise.reject()}
|
||||
onSelectGif={() => null}
|
||||
>
|
||||
<FunEmojiPicker {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Button>Open EmojiPicker</Button>
|
||||
|
||||
@@ -46,7 +46,7 @@ export const FunEmojiPicker = memo(function FunEmojiPicker(
|
||||
<FunPopover placement={props.placement} theme={props.theme}>
|
||||
<FunErrorBoundary>
|
||||
<FunPanelEmojis
|
||||
onEmojiSelect={props.onSelectEmoji}
|
||||
onSelectEmoji={props.onSelectEmoji}
|
||||
onClose={handleClose}
|
||||
showCustomizePreferredReactionsButton={
|
||||
props.showCustomizePreferredReactionsButton ?? false
|
||||
|
||||
@@ -34,14 +34,17 @@ function Template(props: TemplateProps) {
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={() => null}
|
||||
onOpenCustomizePreferredReactionsModal={() => null}
|
||||
onSelectEmoji={() => null}
|
||||
// Stickers
|
||||
installedStickerPacks={packs}
|
||||
showStickerPickerHint={false}
|
||||
onClearStickerPickerHint={() => null}
|
||||
onSelectSticker={() => null}
|
||||
// Gifs
|
||||
fetchGifsSearch={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
|
||||
fetchGifsFeatured={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
|
||||
fetchGif={() => Promise.resolve(new Blob([new Uint8Array(1)]))}
|
||||
onSelectGif={() => null}
|
||||
>
|
||||
<FunPicker {...props} open={open} onOpenChange={handleOpenChange}>
|
||||
<Button>Open FunPicker</Button>
|
||||
|
||||
@@ -71,7 +71,7 @@ export const FunPicker = memo(function FunPicker(
|
||||
<FunTabPanel id={FunPickerTabKey.Emoji}>
|
||||
<FunErrorBoundary>
|
||||
<FunPanelEmojis
|
||||
onEmojiSelect={props.onSelectEmoji}
|
||||
onSelectEmoji={props.onSelectEmoji}
|
||||
onClose={handleClose}
|
||||
showCustomizePreferredReactionsButton={false}
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { LocalizerType } from '../../types/I18N';
|
||||
import type { StickerPackType, StickerType } from '../../state/ducks/stickers';
|
||||
import type { EmojiSkinTone } from './data/emojis';
|
||||
import { EmojiPickerCategory, type EmojiParentKey } from './data/emojis';
|
||||
import type { GifType } from './panels/FunPanelGifs';
|
||||
import type { FunGifSelection, GifType } from './panels/FunPanelGifs';
|
||||
import type { FunGifsSection, FunStickersSection } from './constants';
|
||||
import {
|
||||
type FunEmojisSection,
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
} from './constants';
|
||||
import type { fetchGifsFeatured, fetchGifsSearch } from './data/gifs';
|
||||
import type { tenorDownload } from './data/tenor';
|
||||
import type { FunEmojiSelection } from './panels/FunPanelEmojis';
|
||||
import type { FunStickerSelection } from './panels/FunPanelStickers';
|
||||
import { FunEmojiLocalizationProvider } from './FunEmojiLocalizationProvider';
|
||||
|
||||
export type FunContextSmartProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
@@ -38,16 +41,19 @@ export type FunContextSmartProps = Readonly<{
|
||||
emojiSkinToneDefault: EmojiSkinTone | null;
|
||||
onEmojiSkinToneDefaultChange: (emojiSkinTone: EmojiSkinTone) => void;
|
||||
onOpenCustomizePreferredReactionsModal: () => void;
|
||||
onSelectEmoji: (emojiSelection: FunEmojiSelection) => void;
|
||||
|
||||
// Stickers
|
||||
installedStickerPacks: ReadonlyArray<StickerPackType>;
|
||||
showStickerPickerHint: boolean;
|
||||
onClearStickerPickerHint: () => unknown;
|
||||
onSelectSticker: (stickerSelection: FunStickerSelection) => void;
|
||||
|
||||
// GIFs
|
||||
fetchGifsFeatured: typeof fetchGifsFeatured;
|
||||
fetchGifsSearch: typeof fetchGifsSearch;
|
||||
fetchGif: typeof tenorDownload;
|
||||
onSelectGif: (gifSelection: FunGifSelection) => void;
|
||||
}>;
|
||||
|
||||
export type FunContextProps = FunContextSmartProps &
|
||||
@@ -206,45 +212,50 @@ export const FunProvider = memo(function FunProvider(
|
||||
);
|
||||
|
||||
return (
|
||||
<FunProviderInner
|
||||
i18n={props.i18n}
|
||||
// Open state
|
||||
onOpenChange={handleOpenChange}
|
||||
// Current Tab
|
||||
tab={tab}
|
||||
onChangeTab={handleChangeTab}
|
||||
// Search Input
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={handleSearchInputChange}
|
||||
shouldAutoFocus={shouldAutoFocus}
|
||||
onChangeShouldAutoFocus={handleChangeShouldAutofocus}
|
||||
// Current Sections
|
||||
selectedEmojisSection={selectedEmojisSection}
|
||||
selectedStickersSection={selectedStickersSection}
|
||||
selectedGifsSection={selectedGifsSection}
|
||||
onChangeSelectedEmojisSection={handleChangeSelectedEmojisSection}
|
||||
onChangeSelectedStickersSection={handleChangeSelectedStickersSection}
|
||||
onChangeSelectedSelectGifsSection={handleChangeSelectedGifsSection}
|
||||
// Recents
|
||||
recentEmojis={props.recentEmojis}
|
||||
recentStickers={props.recentStickers}
|
||||
recentGifs={props.recentGifs}
|
||||
// Emojis
|
||||
emojiSkinToneDefault={props.emojiSkinToneDefault}
|
||||
onEmojiSkinToneDefaultChange={props.onEmojiSkinToneDefaultChange}
|
||||
onOpenCustomizePreferredReactionsModal={
|
||||
props.onOpenCustomizePreferredReactionsModal
|
||||
}
|
||||
// Stickers
|
||||
installedStickerPacks={props.installedStickerPacks}
|
||||
showStickerPickerHint={props.showStickerPickerHint}
|
||||
onClearStickerPickerHint={props.onClearStickerPickerHint}
|
||||
// GIFs
|
||||
fetchGifsFeatured={props.fetchGifsFeatured}
|
||||
fetchGifsSearch={props.fetchGifsSearch}
|
||||
fetchGif={props.fetchGif}
|
||||
>
|
||||
{props.children}
|
||||
</FunProviderInner>
|
||||
<FunEmojiLocalizationProvider i18n={props.i18n}>
|
||||
<FunProviderInner
|
||||
i18n={props.i18n}
|
||||
// Open state
|
||||
onOpenChange={handleOpenChange}
|
||||
// Current Tab
|
||||
tab={tab}
|
||||
onChangeTab={handleChangeTab}
|
||||
// Search Input
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={handleSearchInputChange}
|
||||
shouldAutoFocus={shouldAutoFocus}
|
||||
onChangeShouldAutoFocus={handleChangeShouldAutofocus}
|
||||
// Current Sections
|
||||
selectedEmojisSection={selectedEmojisSection}
|
||||
selectedStickersSection={selectedStickersSection}
|
||||
selectedGifsSection={selectedGifsSection}
|
||||
onChangeSelectedEmojisSection={handleChangeSelectedEmojisSection}
|
||||
onChangeSelectedStickersSection={handleChangeSelectedStickersSection}
|
||||
onChangeSelectedSelectGifsSection={handleChangeSelectedGifsSection}
|
||||
// Recents
|
||||
recentEmojis={props.recentEmojis}
|
||||
recentStickers={props.recentStickers}
|
||||
recentGifs={props.recentGifs}
|
||||
// Emojis
|
||||
emojiSkinToneDefault={props.emojiSkinToneDefault}
|
||||
onEmojiSkinToneDefaultChange={props.onEmojiSkinToneDefaultChange}
|
||||
onOpenCustomizePreferredReactionsModal={
|
||||
props.onOpenCustomizePreferredReactionsModal
|
||||
}
|
||||
onSelectEmoji={props.onSelectEmoji}
|
||||
// Stickers
|
||||
installedStickerPacks={props.installedStickerPacks}
|
||||
showStickerPickerHint={props.showStickerPickerHint}
|
||||
onClearStickerPickerHint={props.onClearStickerPickerHint}
|
||||
onSelectSticker={props.onSelectSticker}
|
||||
// GIFs
|
||||
fetchGifsFeatured={props.fetchGifsFeatured}
|
||||
fetchGifsSearch={props.fetchGifsSearch}
|
||||
fetchGif={props.fetchGif}
|
||||
onSelectGif={props.onSelectGif}
|
||||
>
|
||||
{props.children}
|
||||
</FunProviderInner>
|
||||
</FunEmojiLocalizationProvider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -39,14 +39,17 @@ function Template(props: TemplateProps): JSX.Element {
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={() => null}
|
||||
onOpenCustomizePreferredReactionsModal={() => null}
|
||||
onSelectEmoji={() => null}
|
||||
// Stickers
|
||||
installedStickerPacks={packs}
|
||||
showStickerPickerHint={false}
|
||||
onClearStickerPickerHint={() => null}
|
||||
onSelectSticker={() => null}
|
||||
// Gifs
|
||||
fetchGifsSearch={() => Promise.reject()}
|
||||
fetchGifsFeatured={() => Promise.reject()}
|
||||
fetchGif={() => Promise.reject()}
|
||||
onSelectGif={() => null}
|
||||
>
|
||||
<FunStickerPicker
|
||||
{...props}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import Fuse from 'fuse.js';
|
||||
import { sortBy } from 'lodash';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import * as log from '../../../logging/log';
|
||||
import * as Errors from '../../../types/errors';
|
||||
import type { LocaleEmojiListType } from '../../../types/emoji';
|
||||
import type { LocalizerType } from '../../../types/I18N';
|
||||
import { strictAssert } from '../../../util/assert';
|
||||
import { drop } from '../../../util/drop';
|
||||
import { parseUnknown } from '../../../util/schemas';
|
||||
import type {
|
||||
FunEmojiSearchIndex,
|
||||
FunEmojiSearchIndexEntry,
|
||||
} from '../useFunEmojiSearch';
|
||||
import type { FunEmojiLocalizerIndex } from '../useFunEmojiLocalizer';
|
||||
|
||||
// Import emoji-datasource dynamically to avoid costly typechecking.
|
||||
// eslint-disable-next-line import/no-dynamic-require, @typescript-eslint/no-var-requires
|
||||
@@ -237,44 +234,36 @@ const RAW_EMOJI_DATA = parseUnknown(
|
||||
return a.sort_order - b.sort_order;
|
||||
});
|
||||
|
||||
type EmojiSearchIndexEntry = Readonly<{
|
||||
key: EmojiParentKey;
|
||||
rank: number | null;
|
||||
shortName: string;
|
||||
shortNames: ReadonlyArray<string>;
|
||||
emoticon: string | null;
|
||||
emoticons: ReadonlyArray<string>;
|
||||
}>;
|
||||
|
||||
type EmojiSearchIndex = ReadonlyArray<EmojiSearchIndexEntry>;
|
||||
|
||||
/** @internal */
|
||||
type EmojiIndex = Readonly<{
|
||||
// raw data
|
||||
parentByKey: Record<EmojiParentKey, EmojiParentData>;
|
||||
parentKeysByName: Record<EmojiEnglishShortName, EmojiParentKey>;
|
||||
parentKeysByValue: Record<EmojiParentValue, EmojiParentKey>;
|
||||
parentKeysByValueNonQualified: Record<EmojiParentValue, EmojiParentKey>;
|
||||
parentKeysByVariantKeys: Record<EmojiVariantKey, EmojiParentKey>;
|
||||
parentByKey: Map<EmojiParentKey, EmojiParentData>;
|
||||
parentKeysByName: Map<EmojiEnglishShortName, EmojiParentKey>;
|
||||
parentKeysByValue: Map<EmojiParentValue, EmojiParentKey>;
|
||||
parentKeysByValueNonQualified: Map<EmojiParentValue, EmojiParentKey>;
|
||||
parentKeysByVariantKeys: Map<EmojiVariantKey, EmojiParentKey>;
|
||||
|
||||
variantByKey: Record<EmojiVariantKey, EmojiVariantData>;
|
||||
variantKeysByValue: Record<EmojiVariantValue, EmojiVariantKey>;
|
||||
variantKeysByValueNonQualified: Record<EmojiVariantValue, EmojiVariantKey>;
|
||||
variantByKey: Map<EmojiVariantKey, EmojiVariantData>;
|
||||
variantKeysByValue: Map<EmojiVariantValue, EmojiVariantKey>;
|
||||
variantKeysByValueNonQualified: Map<EmojiVariantValue, EmojiVariantKey>;
|
||||
|
||||
unicodeCategories: Record<EmojiUnicodeCategory, Array<EmojiParentKey>>;
|
||||
pickerCategories: Record<EmojiPickerCategory, Array<EmojiParentKey>>;
|
||||
|
||||
defaultEnglishSearchIndex: Array<EmojiSearchIndexEntry>;
|
||||
defaultEnglishSearchIndex: Array<FunEmojiSearchIndexEntry>;
|
||||
defaultEnglishLocalizerIndex: Map<EmojiParentKey, string>;
|
||||
}>;
|
||||
|
||||
/** @internal */
|
||||
const EMOJI_INDEX: EmojiIndex = {
|
||||
parentByKey: {},
|
||||
parentKeysByValue: {},
|
||||
parentKeysByValueNonQualified: {},
|
||||
parentKeysByName: {},
|
||||
parentKeysByVariantKeys: {},
|
||||
variantByKey: {},
|
||||
variantKeysByValue: {},
|
||||
variantKeysByValueNonQualified: {},
|
||||
parentByKey: new Map(),
|
||||
parentKeysByValue: new Map(),
|
||||
parentKeysByValueNonQualified: new Map(),
|
||||
parentKeysByName: new Map(),
|
||||
parentKeysByVariantKeys: new Map(),
|
||||
variantByKey: new Map(),
|
||||
variantKeysByValue: new Map(),
|
||||
variantKeysByValueNonQualified: new Map(),
|
||||
unicodeCategories: {
|
||||
[EmojiUnicodeCategory.SmileysAndEmotion]: [],
|
||||
[EmojiUnicodeCategory.PeopleAndBody]: [],
|
||||
@@ -298,24 +287,27 @@ const EMOJI_INDEX: EmojiIndex = {
|
||||
[EmojiPickerCategory.Flags]: [],
|
||||
},
|
||||
defaultEnglishSearchIndex: [],
|
||||
defaultEnglishLocalizerIndex: new Map(),
|
||||
};
|
||||
|
||||
function addParent(parent: EmojiParentData, rank: number) {
|
||||
EMOJI_INDEX.parentByKey[parent.key] = parent;
|
||||
EMOJI_INDEX.parentKeysByValue[parent.value] = parent.key;
|
||||
EMOJI_INDEX.parentByKey.set(parent.key, parent);
|
||||
EMOJI_INDEX.parentKeysByValue.set(parent.value, parent.key);
|
||||
if (parent.valueNonqualified != null) {
|
||||
EMOJI_INDEX.parentKeysByValue[parent.valueNonqualified] = parent.key;
|
||||
EMOJI_INDEX.parentKeysByValueNonQualified[parent.valueNonqualified] =
|
||||
parent.key;
|
||||
EMOJI_INDEX.parentKeysByValue.set(parent.valueNonqualified, parent.key);
|
||||
EMOJI_INDEX.parentKeysByValueNonQualified.set(
|
||||
parent.valueNonqualified,
|
||||
parent.key
|
||||
);
|
||||
}
|
||||
EMOJI_INDEX.parentKeysByName[parent.englishShortNameDefault] = parent.key;
|
||||
EMOJI_INDEX.parentKeysByName.set(parent.englishShortNameDefault, parent.key);
|
||||
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
|
||||
if (parent.pickerCategory != null) {
|
||||
EMOJI_INDEX.pickerCategories[parent.pickerCategory].push(parent.key);
|
||||
}
|
||||
|
||||
for (const englishShortName of parent.englishShortNames) {
|
||||
EMOJI_INDEX.parentKeysByName[englishShortName] = parent.key;
|
||||
EMOJI_INDEX.parentKeysByName.set(englishShortName, parent.key);
|
||||
}
|
||||
|
||||
EMOJI_INDEX.defaultEnglishSearchIndex.push({
|
||||
@@ -326,16 +318,23 @@ function addParent(parent: EmojiParentData, rank: number) {
|
||||
emoticon: parent.emoticonDefault,
|
||||
emoticons: parent.emoticons,
|
||||
});
|
||||
|
||||
EMOJI_INDEX.defaultEnglishLocalizerIndex.set(
|
||||
parent.key,
|
||||
parent.englishShortNameDefault
|
||||
);
|
||||
}
|
||||
|
||||
function addVariant(parentKey: EmojiParentKey, variant: EmojiVariantData) {
|
||||
EMOJI_INDEX.parentKeysByVariantKeys[variant.key] = parentKey;
|
||||
EMOJI_INDEX.variantByKey[variant.key] = variant;
|
||||
EMOJI_INDEX.variantKeysByValue[variant.value] = variant.key;
|
||||
EMOJI_INDEX.parentKeysByVariantKeys.set(variant.key, parentKey);
|
||||
EMOJI_INDEX.variantByKey.set(variant.key, variant);
|
||||
EMOJI_INDEX.variantKeysByValue.set(variant.value, variant.key);
|
||||
if (variant.valueNonqualified) {
|
||||
EMOJI_INDEX.variantKeysByValue[variant.valueNonqualified] = variant.key;
|
||||
EMOJI_INDEX.variantKeysByValueNonQualified[variant.valueNonqualified] =
|
||||
variant.key;
|
||||
EMOJI_INDEX.variantKeysByValue.set(variant.valueNonqualified, variant.key);
|
||||
EMOJI_INDEX.variantKeysByValueNonQualified.set(
|
||||
variant.valueNonqualified,
|
||||
variant.key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,42 +412,42 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
|
||||
}
|
||||
|
||||
export function isEmojiParentKey(input: string): input is EmojiParentKey {
|
||||
return Object.hasOwn(EMOJI_INDEX.parentByKey, input);
|
||||
return EMOJI_INDEX.parentByKey.has(input as EmojiParentKey);
|
||||
}
|
||||
|
||||
export function isEmojiVariantKey(input: string): input is EmojiVariantKey {
|
||||
return Object.hasOwn(EMOJI_INDEX.variantByKey, input);
|
||||
return EMOJI_INDEX.variantByKey.has(input as EmojiVariantKey);
|
||||
}
|
||||
|
||||
export function isEmojiParentValue(input: string): input is EmojiParentValue {
|
||||
return Object.hasOwn(EMOJI_INDEX.parentKeysByValue, input);
|
||||
return EMOJI_INDEX.parentKeysByValue.has(input as EmojiParentValue);
|
||||
}
|
||||
|
||||
export function isEmojiVariantValue(input: string): input is EmojiVariantValue {
|
||||
return Object.hasOwn(EMOJI_INDEX.variantKeysByValue, input);
|
||||
return EMOJI_INDEX.variantKeysByValue.has(input as EmojiVariantValue);
|
||||
}
|
||||
|
||||
export function isEmojiVariantValueNonQualified(
|
||||
input: EmojiVariantValue
|
||||
): boolean {
|
||||
return Object.hasOwn(EMOJI_INDEX.variantKeysByValueNonQualified, input);
|
||||
return EMOJI_INDEX.variantKeysByValueNonQualified.has(input);
|
||||
}
|
||||
|
||||
/** @deprecated Prefer EmojiKey for refs, load short names from translations */
|
||||
export function isEmojiEnglishShortName(
|
||||
input: string
|
||||
): input is EmojiEnglishShortName {
|
||||
return Object.hasOwn(EMOJI_INDEX.parentKeysByName, input);
|
||||
return EMOJI_INDEX.parentKeysByName.has(input as EmojiEnglishShortName);
|
||||
}
|
||||
|
||||
export function getEmojiParentByKey(key: EmojiParentKey): EmojiParentData {
|
||||
const data = EMOJI_INDEX.parentByKey[key];
|
||||
const data = EMOJI_INDEX.parentByKey.get(key);
|
||||
strictAssert(data, `Missing emoji parent data for key "${key}"`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
|
||||
const data = EMOJI_INDEX.variantByKey[key];
|
||||
const data = EMOJI_INDEX.variantByKey.get(key);
|
||||
strictAssert(data, `Missing emoji variant data for key "${key}"`);
|
||||
return data;
|
||||
}
|
||||
@@ -456,7 +455,7 @@ export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
|
||||
export function getEmojiParentKeyByValue(
|
||||
value: EmojiParentValue
|
||||
): EmojiParentKey {
|
||||
const key = EMOJI_INDEX.parentKeysByValue[value];
|
||||
const key = EMOJI_INDEX.parentKeysByValue.get(value);
|
||||
strictAssert(key, `Missing emoji parent key for value "${value}"`);
|
||||
return key;
|
||||
}
|
||||
@@ -464,7 +463,7 @@ export function getEmojiParentKeyByValue(
|
||||
export function getEmojiVariantKeyByValue(
|
||||
value: EmojiVariantValue
|
||||
): EmojiVariantKey {
|
||||
const key = EMOJI_INDEX.variantKeysByValue[value];
|
||||
const key = EMOJI_INDEX.variantKeysByValue.get(value);
|
||||
strictAssert(key, `Missing emoji variant key for value "${value}"`);
|
||||
return key;
|
||||
}
|
||||
@@ -472,7 +471,7 @@ export function getEmojiVariantKeyByValue(
|
||||
export function getEmojiParentKeyByVariantKey(
|
||||
key: EmojiVariantKey
|
||||
): EmojiParentKey {
|
||||
const parentKey = EMOJI_INDEX.parentKeysByVariantKeys[key];
|
||||
const parentKey = EMOJI_INDEX.parentKeysByVariantKeys.get(key);
|
||||
strictAssert(parentKey, `Missing parent key for variant key "${key}"`);
|
||||
return parentKey;
|
||||
}
|
||||
@@ -498,16 +497,12 @@ export function getEmojiPickerCategoryParentKeys(
|
||||
*/
|
||||
export function getEmojiVariantByParentKeyAndSkinTone(
|
||||
key: EmojiParentKey,
|
||||
skinTone: EmojiSkinTone | null
|
||||
skinTone: EmojiSkinTone
|
||||
): EmojiVariantData {
|
||||
const parent = getEmojiParentByKey(key);
|
||||
const skinToneVariants = parent.defaultSkinToneVariants;
|
||||
|
||||
if (
|
||||
skinTone == null ||
|
||||
skinTone === EmojiSkinTone.None ||
|
||||
skinToneVariants == null
|
||||
) {
|
||||
if (skinTone === EmojiSkinTone.None || skinToneVariants == null) {
|
||||
return getEmojiVariantByKey(parent.defaultVariant);
|
||||
}
|
||||
|
||||
@@ -521,11 +516,19 @@ export function getEmojiVariantByParentKeyAndSkinTone(
|
||||
export function getEmojiParentKeyByEnglishShortName(
|
||||
englishShortName: EmojiEnglishShortName
|
||||
): EmojiParentKey {
|
||||
const emojiKey = EMOJI_INDEX.parentKeysByName[englishShortName];
|
||||
const emojiKey = EMOJI_INDEX.parentKeysByName.get(englishShortName);
|
||||
strictAssert(emojiKey, `Missing emoji info for ${englishShortName}`);
|
||||
return emojiKey;
|
||||
}
|
||||
|
||||
export function getEmojiDefaultEnglishSearchIndex(): FunEmojiSearchIndex {
|
||||
return EMOJI_INDEX.defaultEnglishSearchIndex;
|
||||
}
|
||||
|
||||
export function getEmojiDefaultEnglishLocalizerIndex(): FunEmojiLocalizerIndex {
|
||||
return EMOJI_INDEX.defaultEnglishLocalizerIndex;
|
||||
}
|
||||
|
||||
/** Exported for testing */
|
||||
export function* _allEmojiVariantKeys(): Iterable<EmojiVariantKey> {
|
||||
yield* Object.keys(EMOJI_INDEX.variantByKey) as Array<EmojiVariantKey>;
|
||||
@@ -549,148 +552,7 @@ export function emojiVariantConstant(input: string): EmojiVariantData {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search
|
||||
*/
|
||||
|
||||
export type EmojiSearch = (
|
||||
query: string,
|
||||
limit?: number
|
||||
) => Array<EmojiParentKey>;
|
||||
|
||||
function createEmojiSearchIndex(
|
||||
localeEmojiList: LocaleEmojiListType
|
||||
): EmojiSearchIndex {
|
||||
const results: Array<EmojiSearchIndexEntry> = [];
|
||||
|
||||
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<EmojiSearchIndexEntry> = {
|
||||
shouldSort: false,
|
||||
threshold: 0.2,
|
||||
minMatchCharLength: 1,
|
||||
keys: FuseKeys,
|
||||
includeScore: true,
|
||||
};
|
||||
|
||||
const FuseExactOptions: Fuse.IFuseOptions<EmojiSearchIndexEntry> = {
|
||||
shouldSort: false,
|
||||
threshold: 0,
|
||||
minMatchCharLength: 1,
|
||||
keys: FuseKeys,
|
||||
includeScore: true,
|
||||
};
|
||||
|
||||
function createEmojiSearch(emojiSearchIndex: EmojiSearchIndex): EmojiSearch {
|
||||
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 useEmojiSearch(i18n: LocalizerType): EmojiSearch {
|
||||
const locale = i18n.getLocale();
|
||||
const [localeIndex, setLocaleIndex] = useState<EmojiSearchIndex | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const list = await window.SignalContext.getLocalizedEmojiList(locale);
|
||||
if (!canceled) {
|
||||
const result = createEmojiSearchIndex(list);
|
||||
setLocaleIndex(result);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`Failed to get localized emoji list for ${locale}`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
drop(run());
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
const searchIndex = useMemo(() => {
|
||||
return localeIndex ?? EMOJI_INDEX.defaultEnglishSearchIndex;
|
||||
}, [localeIndex]);
|
||||
|
||||
const emojiSearch = useMemo(() => {
|
||||
return createEmojiSearch(searchIndex);
|
||||
}, [searchIndex]);
|
||||
|
||||
return emojiSearch;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Emojify
|
||||
*/
|
||||
|
||||
function isSafeEmojifyEmoji(value: string): value is EmojiVariantValue {
|
||||
|
||||
@@ -15,7 +15,7 @@ const API_KEY = 'AIzaSyBt6SUfSsCQic2P2VkNkLjsGI7HGWZI95g';
|
||||
*/
|
||||
|
||||
export type TenorNextCursor = string & { TenorHasNextCursor: never };
|
||||
export type TenorTailCursor = '0' & { TenorHasEndCursor: never };
|
||||
export type TenorTailCursor = ('0' | '') & { TenorHasEndCursor: never };
|
||||
export type TenorCursor = TenorNextCursor | TenorTailCursor;
|
||||
|
||||
const TenorCursorSchema = z.custom<TenorCursor>(
|
||||
@@ -35,7 +35,7 @@ export type TenorSearchFilter = 'sticker' | 'static' | '-static';
|
||||
export function isTenorTailCursor(
|
||||
cursor: TenorCursor
|
||||
): cursor is TenorTailCursor {
|
||||
return cursor === '0';
|
||||
return cursor === '0' || cursor === '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type { EmojiParentKey } from './data/emojis';
|
||||
import {
|
||||
EmojiSkinTone,
|
||||
getEmojiParentKeyByEnglishShortName,
|
||||
isEmojiEnglishShortName,
|
||||
} from './data/emojis';
|
||||
import type { GifsPaginated } from './data/gifs';
|
||||
import { FunProvider } from './FunProvider';
|
||||
|
||||
function getEmoji(input: string): EmojiParentKey {
|
||||
strictAssert(
|
||||
@@ -80,28 +77,3 @@ export const MOCK_GIFS_PAGINATED_ONE_PAGE: GifsPaginated = {
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = () => {};
|
||||
|
||||
export function MockFunProvider(props: { children: ReactNode }): JSX.Element {
|
||||
return (
|
||||
<FunProvider
|
||||
i18n={window.SignalContext.i18n}
|
||||
recentEmojis={[]}
|
||||
recentStickers={[]}
|
||||
recentGifs={[]}
|
||||
emojiSkinToneDefault={EmojiSkinTone.None}
|
||||
onEmojiSkinToneDefaultChange={noop}
|
||||
installedStickerPacks={[]}
|
||||
showStickerPickerHint={false}
|
||||
onClearStickerPickerHint={noop}
|
||||
onOpenCustomizePreferredReactionsModal={noop}
|
||||
fetchGifsSearch={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
|
||||
fetchGifsFeatured={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)}
|
||||
fetchGif={() => Promise.resolve(new Blob([new Uint8Array(1)]))}
|
||||
>
|
||||
{props.children}
|
||||
</FunProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ import {
|
||||
getEmojiPickerCategoryParentKeys,
|
||||
getEmojiVariantByParentKeyAndSkinTone,
|
||||
isEmojiParentKey,
|
||||
useEmojiSearch,
|
||||
} from '../data/emojis';
|
||||
import { useFunEmojiSearch } from '../useFunEmojiSearch';
|
||||
import { FunKeyboard } from '../keyboard/FunKeyboard';
|
||||
import type { GridKeyboardState } from '../keyboard/GridKeyboardDelegate';
|
||||
import { GridKeyboardDelegate } from '../keyboard/GridKeyboardDelegate';
|
||||
@@ -61,6 +61,7 @@ import { FunSkinTonesList } from '../FunSkinTones';
|
||||
import { FunStaticEmoji } from '../FunEmoji';
|
||||
import { useFunContext } from '../FunProvider';
|
||||
import { FunResults, FunResultsHeader } from '../base/FunResults';
|
||||
import { useFunEmojiLocalizer } from '../useFunEmojiLocalizer';
|
||||
|
||||
function getTitleForSection(
|
||||
i18n: LocalizerType,
|
||||
@@ -134,13 +135,13 @@ export type FunEmojiSelection = Readonly<{
|
||||
}>;
|
||||
|
||||
export type FunPanelEmojisProps = Readonly<{
|
||||
onEmojiSelect: (emojiSelection: FunEmojiSelection) => void;
|
||||
onSelectEmoji: (emojiSelection: FunEmojiSelection) => void;
|
||||
onClose: () => void;
|
||||
showCustomizePreferredReactionsButton: boolean;
|
||||
}>;
|
||||
|
||||
export function FunPanelEmojis({
|
||||
onEmojiSelect,
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
showCustomizePreferredReactionsButton,
|
||||
}: FunPanelEmojisProps): JSX.Element {
|
||||
@@ -153,23 +154,20 @@ export function FunPanelEmojis({
|
||||
onChangeSelectedEmojisSection,
|
||||
onOpenCustomizePreferredReactionsModal,
|
||||
recentEmojis,
|
||||
emojiSkinToneDefault,
|
||||
onSelectEmoji: onFunSelectEmoji,
|
||||
} = fun;
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [focusedCellKey, setFocusedCellKey] = useState<CellKey | null>(null);
|
||||
|
||||
const [skinTonePopoverOpen, setSkinTonePopoverOpen] = useState(() => {
|
||||
// Open first time
|
||||
return emojiSkinToneDefault == null;
|
||||
});
|
||||
const [skinTonePopoverOpen, setSkinTonePopoverOpen] = useState(false);
|
||||
|
||||
const handleSkinTonePopoverOpenChange = useCallback((open: boolean) => {
|
||||
setSkinTonePopoverOpen(open);
|
||||
}, []);
|
||||
|
||||
const searchEmojis = useEmojiSearch(i18n);
|
||||
const searchEmojis = useFunEmojiSearch();
|
||||
const searchQuery = useMemo(() => fun.searchInput.trim(), [fun.searchInput]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
@@ -252,13 +250,15 @@ export function FunPanelEmojis({
|
||||
const handlePressEmoji = useCallback(
|
||||
(event: MouseEvent, emojiSelection: FunEmojiSelection) => {
|
||||
event.stopPropagation();
|
||||
onEmojiSelect(emojiSelection);
|
||||
onFunSelectEmoji(emojiSelection);
|
||||
onSelectEmoji(emojiSelection);
|
||||
// TODO(jamie): Quill is stealing focus updating the selection
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
onClose();
|
||||
setFocusedCellKey(null);
|
||||
}
|
||||
},
|
||||
[onEmojiSelect, onClose]
|
||||
[onFunSelectEmoji, onSelectEmoji, onClose]
|
||||
);
|
||||
|
||||
const handleOpenCustomizePreferredReactionsModal = useCallback(() => {
|
||||
@@ -511,6 +511,7 @@ type CellProps = Readonly<{
|
||||
|
||||
const Cell = memo(function Cell(props: CellProps): JSX.Element {
|
||||
const { onPressEmoji } = props;
|
||||
const emojiLocalizer = useFunEmojiLocalizer();
|
||||
|
||||
const emojiParent = useMemo(() => {
|
||||
strictAssert(
|
||||
@@ -549,8 +550,7 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
|
||||
>
|
||||
<FunItemButton
|
||||
tabIndex={props.isTabbable ? 0 : -1}
|
||||
// TODO(jamie): Translate short name
|
||||
aria-label={emojiParent.englishShortNameDefault}
|
||||
aria-label={emojiLocalizer(emojiVariant.key)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<FunStaticEmoji role="presentation" size={32} emoji={emojiVariant} />
|
||||
|
||||
@@ -139,6 +139,7 @@ export function FunPanelGifs({
|
||||
fetchGifsFeatured,
|
||||
fetchGifsSearch,
|
||||
fetchGif,
|
||||
onSelectGif: onFunSelectGif,
|
||||
} = fun;
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -361,11 +362,13 @@ export function FunPanelGifs({
|
||||
|
||||
const handlePressGif = useCallback(
|
||||
(_event: MouseEvent, gifSelection: FunGifSelection) => {
|
||||
onFunSelectGif(gifSelection);
|
||||
onSelectGif(gifSelection);
|
||||
// Should always close, cannot select multiple
|
||||
onClose();
|
||||
setSelectedItemKey(null);
|
||||
},
|
||||
[onSelectGif, onClose]
|
||||
[onFunSelectGif, onSelectGif, onClose]
|
||||
);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
emojiVariantConstant,
|
||||
getEmojiParentKeyByValue,
|
||||
isEmojiParentValue,
|
||||
useEmojiSearch,
|
||||
} from '../data/emojis';
|
||||
import { FunKeyboard } from '../keyboard/FunKeyboard';
|
||||
import type { GridKeyboardState } from '../keyboard/GridKeyboardDelegate';
|
||||
@@ -82,6 +81,7 @@ import {
|
||||
import { FunSticker } from '../FunSticker';
|
||||
import { getAnalogTime } from '../../../util/getAnalogTime';
|
||||
import { getDateTimeFormatter } from '../../../util/formatTimestamp';
|
||||
import { useFunEmojiSearch } from '../useFunEmojiSearch';
|
||||
|
||||
const STICKER_GRID_COLUMNS = 4;
|
||||
const STICKER_GRID_CELL_WIDTH = 80;
|
||||
@@ -191,6 +191,7 @@ export function FunPanelStickers({
|
||||
onChangeSelectedStickersSection,
|
||||
recentStickers,
|
||||
installedStickerPacks,
|
||||
onSelectSticker: onFunSelectSticker,
|
||||
} = fun;
|
||||
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -221,7 +222,7 @@ export function FunPanelStickers({
|
||||
|
||||
const [focusedCellKey, setFocusedCellKey] = useState<CellKey | null>(null);
|
||||
|
||||
const searchEmojis = useEmojiSearch(i18n);
|
||||
const searchEmojis = useFunEmojiSearch();
|
||||
const searchQuery = useMemo(() => searchInput.trim(), [searchInput]);
|
||||
|
||||
const sections = useMemo(() => {
|
||||
@@ -339,12 +340,14 @@ export function FunPanelStickers({
|
||||
|
||||
const handlePressSticker = useCallback(
|
||||
(event: MouseEvent, stickerSelection: FunStickerSelection) => {
|
||||
onFunSelectSticker(stickerSelection);
|
||||
onSelectSticker(stickerSelection);
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
onClose();
|
||||
setFocusedCellKey(null);
|
||||
}
|
||||
},
|
||||
[onSelectSticker, onClose]
|
||||
[onFunSelectSticker, onSelectSticker, onClose]
|
||||
);
|
||||
|
||||
const handlePressTimeSticker = useCallback(
|
||||
|
||||
51
ts/components/fun/useFunEmojiLocalizer.tsx
Normal file
51
ts/components/fun/useFunEmojiLocalizer.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { useCallback } from 'react';
|
||||
import type { EmojiParentKey, EmojiVariantKey } from './data/emojis';
|
||||
import {
|
||||
getEmojiParentKeyByVariantKey,
|
||||
getEmojiVariantKeyByValue,
|
||||
isEmojiVariantValue,
|
||||
} from './data/emojis';
|
||||
import type { LocaleEmojiListType } from '../../types/emoji';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { useFunEmojiLocalization } from './FunEmojiLocalizationProvider';
|
||||
|
||||
export type FunEmojiLocalizerIndex = ReadonlyMap<EmojiParentKey, string>;
|
||||
export type FunEmojiLocalizer = (key: EmojiVariantKey) => string;
|
||||
|
||||
export function createFunEmojiLocalizerIndex(
|
||||
localeEmojiList: LocaleEmojiListType
|
||||
): FunEmojiLocalizerIndex {
|
||||
const index = new Map<EmojiParentKey, string>();
|
||||
|
||||
for (const entry of localeEmojiList) {
|
||||
strictAssert(
|
||||
isEmojiVariantValue(entry.emoji),
|
||||
'createFunEmojiLocalizerIndex: Must be emoji variant value'
|
||||
);
|
||||
|
||||
const variantKey = getEmojiVariantKeyByValue(entry.emoji);
|
||||
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
|
||||
index.set(parentKey, entry.tags.at(0) ?? entry.shortName);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
export function useFunEmojiLocalizer(): FunEmojiLocalizer {
|
||||
const { emojiLocalizerIndex } = useFunEmojiLocalization();
|
||||
const emojiLocalizer: FunEmojiLocalizer = useCallback(
|
||||
variantKey => {
|
||||
const parentKey = getEmojiParentKeyByVariantKey(variantKey);
|
||||
const localeShortName = emojiLocalizerIndex.get(parentKey);
|
||||
strictAssert(
|
||||
localeShortName,
|
||||
`useFunEmojiLocalizer: Missing translation for ${variantKey}`
|
||||
);
|
||||
return localeShortName;
|
||||
},
|
||||
[emojiLocalizerIndex]
|
||||
);
|
||||
return emojiLocalizer;
|
||||
}
|
||||
131
ts/components/fun/useFunEmojiSearch.tsx
Normal file
131
ts/components/fun/useFunEmojiSearch.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -19,8 +19,8 @@ import { handleOutsideClick } from '../../util/handleOutsideClick';
|
||||
import * as log from '../../logging/log';
|
||||
import { FunStaticEmoji } from '../../components/fun/FunEmoji';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type { EmojiSkinTone } from '../../components/fun/data/emojis';
|
||||
import {
|
||||
EmojiSkinTone,
|
||||
getEmojiParentKeyByEnglishShortName,
|
||||
getEmojiVariantByParentKeyAndSkinTone,
|
||||
isEmojiEnglishShortName,
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
export type EmojiCompletionOptions = {
|
||||
onPickEmoji: (emoji: EmojiPickDataType) => void;
|
||||
setEmojiPickerElement: (element: JSX.Element | null) => void;
|
||||
emojiSkinToneDefault: EmojiSkinTone;
|
||||
emojiSkinToneDefault: EmojiSkinTone | null;
|
||||
search: SearchFnType;
|
||||
};
|
||||
|
||||
@@ -257,7 +257,7 @@ export class EmojiCompletion {
|
||||
}: InsertEmojiOptionsType): void {
|
||||
const emoji = convertShortName(
|
||||
shortName,
|
||||
this.options.emojiSkinToneDefault
|
||||
this.options.emojiSkinToneDefault ?? EmojiSkinTone.None
|
||||
);
|
||||
|
||||
let source = this.quill.getText(index, range);
|
||||
@@ -284,7 +284,7 @@ export class EmojiCompletion {
|
||||
|
||||
this.options.onPickEmoji({
|
||||
shortName,
|
||||
skinTone: this.options.emojiSkinToneDefault,
|
||||
skinTone: this.options.emojiSkinToneDefault ?? EmojiSkinTone.None,
|
||||
});
|
||||
|
||||
this.reset();
|
||||
@@ -374,7 +374,7 @@ export class EmojiCompletion {
|
||||
const emojiParentKey = getEmojiParentKeyByEnglishShortName(emoji);
|
||||
const emojiVariant = getEmojiVariantByParentKeyAndSkinTone(
|
||||
emojiParentKey,
|
||||
this.options.emojiSkinToneDefault
|
||||
this.options.emojiSkinToneDefault ?? EmojiSkinTone.None
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,10 @@ import { useSelector } from 'react-redux';
|
||||
import { FunProvider } from '../../components/fun/FunProvider';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
import type { GifType } from '../../components/fun/panels/FunPanelGifs';
|
||||
import type {
|
||||
FunGifSelection,
|
||||
GifType,
|
||||
} from '../../components/fun/panels/FunPanelGifs';
|
||||
import {
|
||||
getInstalledStickerPacks,
|
||||
getRecentStickers,
|
||||
@@ -29,6 +32,10 @@ import {
|
||||
} from '../../components/fun/data/gifs';
|
||||
import { tenorDownload } from '../../components/fun/data/tenor';
|
||||
import { usePreferredReactionsActions } from '../ducks/preferredReactions';
|
||||
import { useEmojisActions } from '../ducks/emojis';
|
||||
import { useStickersActions } from '../ducks/stickers';
|
||||
import type { FunStickerSelection } from '../../components/fun/panels/FunPanelStickers';
|
||||
import type { FunEmojiSelection } from '../../components/fun/panels/FunPanelEmojis';
|
||||
|
||||
export type SmartFunProviderProps = Readonly<{
|
||||
children: ReactNode;
|
||||
@@ -38,17 +45,18 @@ export const SmartFunProvider = memo(function SmartFunProvider(
|
||||
props: SmartFunProviderProps
|
||||
) {
|
||||
const i18n = useSelector(getIntl);
|
||||
|
||||
// Redux
|
||||
const installedStickerPacks = useSelector(getInstalledStickerPacks);
|
||||
const recentEmojis = useSelector(selectRecentEmojis);
|
||||
const recentStickers = useSelector(getRecentStickers);
|
||||
const recentGifs: Array<GifType> = useMemo(() => [], []);
|
||||
const emojiSkinToneDefault = useSelector(getEmojiSkinToneDefault);
|
||||
const showStickerPickerHint = useSelector(getShowStickerPickerHint);
|
||||
|
||||
const { removeItem, setEmojiSkinToneDefault } = useItemsActions();
|
||||
const { openCustomizePreferredReactionsModal } =
|
||||
usePreferredReactionsActions();
|
||||
const { onUseEmoji } = useEmojisActions();
|
||||
const { useSticker: onUseSticker } = useStickersActions();
|
||||
|
||||
// Translate recent emojis to keys
|
||||
const recentEmojisKeys = useMemo(() => {
|
||||
@@ -73,11 +81,33 @@ export const SmartFunProvider = memo(function SmartFunProvider(
|
||||
openCustomizePreferredReactionsModal();
|
||||
}, [openCustomizePreferredReactionsModal]);
|
||||
|
||||
const handleSelectEmoji = useCallback(
|
||||
(emojiSelection: FunEmojiSelection) => {
|
||||
onUseEmoji({
|
||||
shortName: emojiSelection.englishShortName,
|
||||
skinTone: emojiSelection.skinTone,
|
||||
});
|
||||
},
|
||||
[onUseEmoji]
|
||||
);
|
||||
|
||||
// Stickers
|
||||
const handleClearStickerPickerHint = useCallback(() => {
|
||||
removeItem('showStickerPickerHint');
|
||||
}, [removeItem]);
|
||||
|
||||
const handleSelectSticker = useCallback(
|
||||
(stickerSelection: FunStickerSelection) => {
|
||||
onUseSticker(stickerSelection.stickerPackId, stickerSelection.stickerId);
|
||||
},
|
||||
[onUseSticker]
|
||||
);
|
||||
|
||||
// GIFs
|
||||
const handleSelectGif = useCallback((_gifSelection: FunGifSelection) => {
|
||||
// TODO(jamie): Save recently used GIFs
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FunProvider
|
||||
i18n={i18n}
|
||||
@@ -91,14 +121,17 @@ export const SmartFunProvider = memo(function SmartFunProvider(
|
||||
onOpenCustomizePreferredReactionsModal={
|
||||
handleOpenCustomizePreferredReactionsModal
|
||||
}
|
||||
onSelectEmoji={handleSelectEmoji}
|
||||
// Stickers
|
||||
installedStickerPacks={installedStickerPacks}
|
||||
showStickerPickerHint={showStickerPickerHint}
|
||||
onClearStickerPickerHint={handleClearStickerPickerHint}
|
||||
onSelectSticker={handleSelectSticker}
|
||||
// Gifs
|
||||
fetchGifsSearch={fetchGifsSearch}
|
||||
fetchGifsFeatured={fetchGifsFeatured}
|
||||
fetchGif={tenorDownload}
|
||||
onSelectGif={handleSelectGif}
|
||||
>
|
||||
{props.children}
|
||||
</FunProvider>
|
||||
|
||||
Reference in New Issue
Block a user