Translate emoji names everywhere

This commit is contained in:
Jamie Kyle
2025-04-09 11:10:54 -07:00
committed by GitHub
parent 85bcfb2176
commit c722e9f277
30 changed files with 559 additions and 355 deletions

View File

@@ -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 = {

View File

@@ -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,
]);
}

View File

@@ -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: {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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 (

View File

@@ -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}
/>

View File

@@ -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 {

View File

@@ -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}
/>

View File

@@ -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);
}

View File

@@ -335,7 +335,7 @@ export const EmojiPicker = React.memo(
const parentKey = getEmojiParentKeyByEnglishShortName(shortName);
const variantKey = getEmojiVariantByParentKeyAndSkinTone(
parentKey,
selectedTone
selectedTone ?? EmojiSkinTone.None
);
return (

View File

@@ -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" />

View 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;
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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}
/>

View File

@@ -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>
);
});

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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 === '';
}
/**

View File

@@ -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>
);
}

View File

@@ -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} />

View File

@@ -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(() => {

View File

@@ -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(

View 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;
}

View 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;
}

View File

@@ -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 (

View File

@@ -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>