From d838bfb88d3a76cf18cff3d495a3a66e7163126b Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Sat, 2 Aug 2025 15:19:06 +0700 Subject: [PATCH 1/4] remove onyx connect --- .../EmojiPickerMenu/useEmojiPickerMenu.ts | 4 +- src/libs/EmojiUtils.tsx | 91 +++++++++---------- 2 files changed, 45 insertions(+), 50 deletions(-) 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, }; From dfe2471523740a385fbfe15653ad20f0ff53e719 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Sat, 2 Aug 2025 15:19:24 +0700 Subject: [PATCH 2/4] update --max-warnings --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c2618e1f7d4d..f6f34f28067f 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=327 --cache --cache-location=node_modules/.cache/eslint", + "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=326 --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", From f860210e0a8058a68cd9ec0e396cbfc435a5bde5 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Sat, 2 Aug 2025 15:29:10 +0700 Subject: [PATCH 3/4] add UTs --- tests/unit/libs/EmojiUtilsTest.ts | 328 ++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 tests/unit/libs/EmojiUtilsTest.ts diff --git a/tests/unit/libs/EmojiUtilsTest.ts b/tests/unit/libs/EmojiUtilsTest.ts new file mode 100644 index 000000000000..444376bb21ad --- /dev/null +++ b/tests/unit/libs/EmojiUtilsTest.ts @@ -0,0 +1,328 @@ +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 emojis with skin tones correctly', () => { + const input: FrequentlyUsedEmoji[] = [ + { + code: '😀', + name: 'grinning_face', + count: 5, + lastUpdatedAt: 1000, + types: ['😀', '😀🏻', '😀🏼'], + }, + ]; + + 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, + types: ['😀', '😀🏻', '😀🏼'], + }); + }); + + 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); + }); +}); \ No newline at end of file From 4323c0abe028918c3ffc2afeef2cbb618363ca87 Mon Sep 17 00:00:00 2001 From: DylanDylann Date: Sat, 2 Aug 2025 15:36:50 +0700 Subject: [PATCH 4/4] update UTs --- tests/unit/libs/EmojiUtilsTest.ts | 48 ++++++++----------------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/tests/unit/libs/EmojiUtilsTest.ts b/tests/unit/libs/EmojiUtilsTest.ts index 444376bb21ad..f9e27ef92043 100644 --- a/tests/unit/libs/EmojiUtilsTest.ts +++ b/tests/unit/libs/EmojiUtilsTest.ts @@ -37,19 +37,19 @@ jest.mock('@assets/emojis', () => ({ }, emojiNameTable: { // eslint-disable-next-line @typescript-eslint/naming-convention - 'grinning_face': { + grinning_face: { code: '😀', name: 'grinning_face', keywords: ['face', 'grin', 'grinning'], }, // eslint-disable-next-line @typescript-eslint/naming-convention - 'waving_hand': { + waving_hand: { code: '👋', name: 'waving_hand', keywords: ['hand', 'wave', 'waving'], }, // eslint-disable-next-line @typescript-eslint/naming-convention - 'thumbs_up': { + thumbs_up: { code: '👍', name: 'thumbs_up', keywords: ['hand', 'thumb', 'up'], @@ -192,8 +192,8 @@ describe('processFrequentlyUsedEmojis', () => { code: '😀', name: 'grinning_face', keywords: ['face', 'grin', 'grinning'], - count: 8, - lastUpdatedAt: 2000, + count: 8, + lastUpdatedAt: 2000, }); }); @@ -257,30 +257,6 @@ describe('processFrequentlyUsedEmojis', () => { expect(result.at(2)?.code).toBe('😀'); }); - it('should handle emojis with skin tones correctly', () => { - const input: FrequentlyUsedEmoji[] = [ - { - code: '😀', - name: 'grinning_face', - count: 5, - lastUpdatedAt: 1000, - types: ['😀', '😀🏻', '😀🏼'], - }, - ]; - - 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, - types: ['😀', '😀🏻', '😀🏼'], - }); - }); - it('should handle complex scenario with mixed data quality', () => { const input: FrequentlyUsedEmoji[] = [ { @@ -318,11 +294,11 @@ describe('processFrequentlyUsedEmojis', () => { 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); + + 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); }); -}); \ No newline at end of file +});