diff --git a/package.json b/package.json index 07c461375047..7f4f7c1a06ed 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "test:debug": "TZ=utc NODE_OPTIONS='--inspect-brk --experimental-vm-modules' jest --runInBand", "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc", - "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=283 --cache --cache-location=node_modules/.cache/eslint", + "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=282 --cache --cache-location=node_modules/.cache/eslint", "lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 ./scripts/lintChanged.sh", "lint-watch": "npx eslint-watch --watch --changed", "shellcheck": "./scripts/shellCheck.sh", diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts index 42fd62fc4af7..ac9152bb8405 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts +++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts @@ -8,14 +8,14 @@ import usePreferredEmojiSkinTone from '@hooks/usePreferredEmojiSkinTone'; import useStyleUtils from '@hooks/useStyleUtils'; import useWindowDimensions from '@hooks/useWindowDimensions'; import type {EmojiPickerList, EmojiPickerListItem} from '@libs/EmojiUtils'; -import {getHeaderEmojis, getSpacersIndexes, mergeEmojisWithFrequentlyUsedEmojis, suggestEmojis} from '@libs/EmojiUtils'; +import {getHeaderEmojis, getSpacersIndexes, mergeEmojisWithFrequentlyUsedEmojis, processFrequentlyUsedEmojis, suggestEmojis} from '@libs/EmojiUtils'; import ONYXKEYS from '@src/ONYXKEYS'; const useEmojiPickerMenu = () => { const emojiListRef = useRef>(null); const [frequentlyUsedEmojis] = useOnyx(ONYXKEYS.FREQUENTLY_USED_EMOJIS, {canBeMissing: true}); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - const allEmojis = useMemo(() => mergeEmojisWithFrequentlyUsedEmojis(emojis), [frequentlyUsedEmojis]); + const allEmojis = useMemo(() => mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(frequentlyUsedEmojis)), [frequentlyUsedEmojis]); const headerEmojis = useMemo(() => getHeaderEmojis(allEmojis), [allEmojis]); const headerRowIndices = useMemo(() => headerEmojis.map((headerEmoji) => headerEmoji.index), [headerEmojis]); const spacersIndexes = useMemo(() => getSpacersIndexes(allEmojis), [allEmojis]); diff --git a/src/libs/EmojiUtils.tsx b/src/libs/EmojiUtils.tsx index cfe8c8dcd3b1..7658da6c6a44 100644 --- a/src/libs/EmojiUtils.tsx +++ b/src/libs/EmojiUtils.tsx @@ -2,14 +2,12 @@ import {Str} from 'expensify-common'; import lodashSortBy from 'lodash/sortBy'; import React from 'react'; import type {StyleProp, TextStyle} from 'react-native'; -import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import * as Emojis from '@assets/emojis'; import type {Emoji, HeaderEmoji, PickerEmojis} from '@assets/emojis/types'; import Text from '@components/Text'; import CONST from '@src/CONST'; import {isFullySupportedLocale} from '@src/CONST/LOCALES'; -import ONYXKEYS from '@src/ONYXKEYS'; import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx'; import type {ReportActionReaction, UsersReactions} from '@src/types/onyx/ReportActionReactions'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -33,52 +31,48 @@ const findEmojiByCode = (code: string): Emoji => Emojis.emojiCodeTableWithSkinTo const sortByName = (emoji: Emoji, emojiData: RegExpMatchArray) => !emoji.name.includes(emojiData[0].toLowerCase().slice(1)); -let frequentlyUsedEmojis: FrequentlyUsedEmoji[] = []; -Onyx.connect({ - key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, - callback: (val) => { - if (!val) { - return; +const processFrequentlyUsedEmojis = (emojiList?: FrequentlyUsedEmoji[]) => { + if (!emojiList) { + return []; + } + const processedFrequentlyUsedEmojis = + emojiList + ?.map((item) => { + let emoji = item; + if (!item.code) { + emoji = {...emoji, ...findEmojiByName(item.name)}; + } + if (!item.name) { + emoji = {...emoji, ...findEmojiByCode(item.code)}; + } + const emojiWithSkinTones = Emojis.emojiCodeTableWithSkinTones[emoji.code]; + if (!emojiWithSkinTones) { + return null; + } + return {...emojiWithSkinTones, count: item.count, lastUpdatedAt: item.lastUpdatedAt}; + }) + .filter((emoji): emoji is FrequentlyUsedEmoji => !!emoji) ?? []; + + // On AddComment API response, each variant of the same emoji (with different skin tones) is + // treated as a separate entry due to unique emoji codes for each variant. + // So merge duplicate emojis, sum their counts, and use the latest lastUpdatedAt timestamp, then sort accordingly. + const frequentlyUsedEmojiCodesToObjects = new Map(); + processedFrequentlyUsedEmojis.forEach((emoji) => { + const existingEmoji = frequentlyUsedEmojiCodesToObjects.get(emoji.code); + if (existingEmoji) { + existingEmoji.count += emoji.count; + existingEmoji.lastUpdatedAt = Math.max(existingEmoji.lastUpdatedAt, emoji.lastUpdatedAt); + } else { + frequentlyUsedEmojiCodesToObjects.set(emoji.code, emoji); } - frequentlyUsedEmojis = - val - ?.map((item) => { - let emoji = item; - if (!item.code) { - emoji = {...emoji, ...findEmojiByName(item.name)}; - } - if (!item.name) { - emoji = {...emoji, ...findEmojiByCode(item.code)}; - } - const emojiWithSkinTones = Emojis.emojiCodeTableWithSkinTones[emoji.code]; - if (!emojiWithSkinTones) { - return null; - } - return {...emojiWithSkinTones, count: item.count, lastUpdatedAt: item.lastUpdatedAt}; - }) - .filter((emoji): emoji is FrequentlyUsedEmoji => !!emoji) ?? []; - - // On AddComment API response, each variant of the same emoji (with different skin tones) is - // treated as a separate entry due to unique emoji codes for each variant. - // So merge duplicate emojis, sum their counts, and use the latest lastUpdatedAt timestamp, then sort accordingly. - const frequentlyUsedEmojiCodesToObjects = new Map(); - frequentlyUsedEmojis.forEach((emoji) => { - const existingEmoji = frequentlyUsedEmojiCodesToObjects.get(emoji.code); - if (existingEmoji) { - existingEmoji.count += emoji.count; - existingEmoji.lastUpdatedAt = Math.max(existingEmoji.lastUpdatedAt, emoji.lastUpdatedAt); - } else { - frequentlyUsedEmojiCodesToObjects.set(emoji.code, emoji); - } - }); - frequentlyUsedEmojis = Array.from(frequentlyUsedEmojiCodesToObjects.values()).sort((a, b) => { - if (a.count !== b.count) { - return b.count - a.count; - } - return b.lastUpdatedAt - a.lastUpdatedAt; - }); - }, -}); + }); + return Array.from(frequentlyUsedEmojiCodesToObjects.values()).sort((a, b) => { + if (a.count !== b.count) { + return b.count - a.count; + } + return b.lastUpdatedAt - a.lastUpdatedAt; + }); +}; /** * Given an English emoji name, get its localized version @@ -232,7 +226,7 @@ function addSpacesToEmojiCategories(emojis: PickerEmojis): EmojiPickerList { /** * Get a merged array with frequently used emojis */ -function mergeEmojisWithFrequentlyUsedEmojis(emojis: PickerEmojis): EmojiPickerList { +function mergeEmojisWithFrequentlyUsedEmojis(emojis: PickerEmojis, frequentlyUsedEmojis: FrequentlyUsedEmoji[]): EmojiPickerList { if (frequentlyUsedEmojis.length === 0) { return addSpacesToEmojiCategories(emojis); } @@ -706,4 +700,5 @@ export { splitTextWithEmojis, containsCustomEmoji, containsOnlyCustomEmoji, + processFrequentlyUsedEmojis, }; diff --git a/tests/unit/libs/EmojiUtilsTest.ts b/tests/unit/libs/EmojiUtilsTest.ts new file mode 100644 index 000000000000..f9e27ef92043 --- /dev/null +++ b/tests/unit/libs/EmojiUtilsTest.ts @@ -0,0 +1,304 @@ +import {processFrequentlyUsedEmojis} from '@libs/EmojiUtils'; +import type {FrequentlyUsedEmoji} from '@src/types/onyx'; + +// Mock the Emojis module +jest.mock('@assets/emojis', () => ({ + emojiCodeTableWithSkinTones: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '😀': { + code: '😀', + name: 'grinning_face', + keywords: ['face', 'grin', 'grinning'], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '😃': { + code: '😃', + name: 'grinning_face_with_big_eyes', + keywords: ['face', 'grin', 'grinning'], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '😄': { + code: '😄', + name: 'grinning_face_with_smiling_eyes', + keywords: ['face', 'grin', 'grinning'], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '👋': { + code: '👋', + name: 'waving_hand', + keywords: ['hand', 'wave', 'waving'], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + '👍': { + code: '👍', + name: 'thumbs_up', + keywords: ['hand', 'thumb', 'up'], + }, + }, + emojiNameTable: { + // eslint-disable-next-line @typescript-eslint/naming-convention + grinning_face: { + code: '😀', + name: 'grinning_face', + keywords: ['face', 'grin', 'grinning'], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + waving_hand: { + code: '👋', + name: 'waving_hand', + keywords: ['hand', 'wave', 'waving'], + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + thumbs_up: { + code: '👍', + name: 'thumbs_up', + keywords: ['hand', 'thumb', 'up'], + }, + }, +})); + +describe('processFrequentlyUsedEmojis', () => { + it('should return empty array when input is undefined', () => { + const result = processFrequentlyUsedEmojis(undefined); + expect(result).toEqual([]); + }); + + it('should return empty array when input is empty array', () => { + const result = processFrequentlyUsedEmojis([]); + expect(result).toEqual([]); + }); + + it('should process valid emoji list correctly', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '😀', + name: 'grinning_face', + count: 5, + lastUpdatedAt: 1000, + }, + { + code: '👋', + name: 'waving_hand', + count: 3, + lastUpdatedAt: 2000, + }, + ]; + + const result = processFrequentlyUsedEmojis(input); + + expect(result).toHaveLength(2); + expect(result.at(0)).toEqual({ + code: '😀', + name: 'grinning_face', + keywords: ['face', 'grin', 'grinning'], + count: 5, + lastUpdatedAt: 1000, + }); + expect(result.at(1)).toEqual({ + code: '👋', + name: 'waving_hand', + keywords: ['hand', 'wave', 'waving'], + count: 3, + lastUpdatedAt: 2000, + }); + }); + + it('should fill in missing code using name lookup', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '', + name: 'grinning_face', + count: 5, + lastUpdatedAt: 1000, + }, + ]; + + const result = processFrequentlyUsedEmojis(input); + + expect(result).toHaveLength(1); + expect(result.at(0)).toEqual({ + code: '😀', + name: 'grinning_face', + keywords: ['face', 'grin', 'grinning'], + count: 5, + lastUpdatedAt: 1000, + }); + }); + + it('should fill in missing name using code lookup', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '👋', + name: '', + count: 3, + lastUpdatedAt: 2000, + }, + ]; + + const result = processFrequentlyUsedEmojis(input); + + expect(result).toHaveLength(1); + expect(result.at(0)).toEqual({ + code: '👋', + name: 'waving_hand', + keywords: ['hand', 'wave', 'waving'], + count: 3, + lastUpdatedAt: 2000, + }); + }); + + it('should filter out emojis that do not exist in emojiCodeTableWithSkinTones', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '😀', + name: 'grinning_face', + count: 5, + lastUpdatedAt: 1000, + }, + { + code: 'invalid_emoji', + name: 'invalid_emoji', + count: 3, + lastUpdatedAt: 2000, + }, + ]; + + const result = processFrequentlyUsedEmojis(input); + + expect(result).toHaveLength(1); + expect(result.at(0)?.code).toBe('😀'); + }); + + it('should merge duplicate emojis and sum their counts', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '😀', + name: 'grinning_face', + count: 5, + lastUpdatedAt: 1000, + }, + { + code: '😀', + name: 'grinning_face', + count: 3, + lastUpdatedAt: 2000, + }, + ]; + + const result = processFrequentlyUsedEmojis(input); + + expect(result).toHaveLength(1); + expect(result.at(0)).toEqual({ + code: '😀', + name: 'grinning_face', + keywords: ['face', 'grin', 'grinning'], + count: 8, + lastUpdatedAt: 2000, + }); + }); + + it('should sort by count in descending order', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '😀', + name: 'grinning_face', + count: 3, + lastUpdatedAt: 1000, + }, + { + code: '👋', + name: 'waving_hand', + count: 5, + lastUpdatedAt: 2000, + }, + { + code: '👍', + name: 'thumbs_up', + count: 1, + lastUpdatedAt: 3000, + }, + ]; + + const result = processFrequentlyUsedEmojis(input); + + expect(result).toHaveLength(3); + expect(result.at(0)?.code).toBe('👋'); + expect(result.at(1)?.code).toBe('😀'); + expect(result.at(2)?.code).toBe('👍'); + }); + + it('should sort by lastUpdatedAt in descending order when counts are equal', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '😀', + name: 'grinning_face', + count: 5, + lastUpdatedAt: 1000, + }, + { + code: '👋', + name: 'waving_hand', + count: 5, + lastUpdatedAt: 3000, + }, + { + code: '👍', + name: 'thumbs_up', + count: 5, + lastUpdatedAt: 2000, + }, + ]; + + const result = processFrequentlyUsedEmojis(input); + + expect(result).toHaveLength(3); + expect(result.at(0)?.code).toBe('👋'); + expect(result.at(1)?.code).toBe('👍'); + expect(result.at(2)?.code).toBe('😀'); + }); + + it('should handle complex scenario with mixed data quality', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '😀', + name: 'grinning_face', + count: 5, + lastUpdatedAt: 1000, + }, + { + code: '', + name: 'waving_hand', + count: 3, + lastUpdatedAt: 2000, + }, + { + code: '👍', + name: '', + count: 7, + lastUpdatedAt: 3000, + }, + { + code: '😀', + name: 'grinning_face', + count: 2, + lastUpdatedAt: 1500, + }, + { + code: 'invalid_emoji', + name: 'invalid_emoji', + count: 1, + lastUpdatedAt: 4000, + }, + ]; + + const result = processFrequentlyUsedEmojis(input); + + expect(result).toHaveLength(3); + + expect(result.at(0)?.code).toBe('👍'); + expect(result.at(1)?.code).toBe('😀'); + expect(result.at(2)?.code).toBe('👋'); + expect(result.at(1)?.count).toBe(7); + expect(result.at(1)?.lastUpdatedAt).toBe(1500); + }); +});