diff --git a/apps/webapp/package.json b/apps/webapp/package.json index de8bf8b2e0a..9e127ba5838 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -54,7 +54,7 @@ "dexie-batch": "0.4.3", "dexie-encrypted": "2.0.0", "dotenv-extended": "2.9.0", - "emoji-picker-react": "4.16.1", + "emoji-picker-react": "4.18.0", "immer": "10.2.0", "jimp": "0.22.12", "js-cookie": "3.0.5", diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx index 7da8a6edd20..5c3627fe627 100644 --- a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EmojiPickerPlugin/EmojiPickerPlugin.tsx @@ -27,6 +27,7 @@ import {$createTextNode, TextNode} from 'lexical'; import * as ReactDOM from 'react-dom'; import {StorageKey} from 'Repositories/storage'; +import {extractEmojiDataEntries} from 'Util/EmojiDataSource'; import {loadValue, storeValue} from 'Util/StorageUtil'; import {sortByPriority} from 'Util/StringUtil'; @@ -91,9 +92,9 @@ type Props = { openStateRef: MutableRefObject; }; -const emojies: {n: string[]; u: string}[] = Object.values(emojiList).flat(); +const emojiEntries = extractEmojiDataEntries(emojiList); -const emojiOptions = emojies.map(({n: aliases, u: codepoint}) => { +const emojiOptions = emojiEntries.map(({n: aliases, u: codepoint}) => { const codepoints = codepoint.split('-').map(code => parseInt(code, 16)); return new EmojiOption(aliases[0], String.fromCodePoint(...codepoints), { keywords: aliases, diff --git a/apps/webapp/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.test.tsx b/apps/webapp/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.test.tsx index 47ac3081563..1f8585d1713 100644 --- a/apps/webapp/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.test.tsx +++ b/apps/webapp/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactionsList.test.tsx @@ -53,35 +53,21 @@ describe('MessageReactionsList', () => { }); test('renders a button for each reaction and user count', () => { - const {getAllByTitle} = render(withTheme()); + const {container} = render(withTheme()); + const reactionButtons = Array.from(container.querySelectorAll('button[data-uie-name="emoji-pill"]')); - const winkButton = getAllByTitle('wink'); - const smileyFace1 = getAllByTitle('innocent'); - const thumbsUpButton = getAllByTitle('+1'); - const smileyFace2 = getAllByTitle('blush'); - - expect(smileyFace1).toHaveLength(1); - expect(smileyFace2).toHaveLength(1); - expect(winkButton).toHaveLength(1); - expect(thumbsUpButton).toHaveLength(1); - - const smileyFaceCount = within(smileyFace1[0]).getByText('3'); - expect(smileyFaceCount).toBeDefined(); - - const winkFaceCount = within(winkButton[0]).getByText('1'); - expect(winkFaceCount).toBeDefined(); - - const thumbsUpButtonCount = within(winkButton[0]).getByText('1'); - expect(thumbsUpButtonCount).toBeDefined(); - - const smileyFace2Count = within(winkButton[0]).getByText('1'); - expect(smileyFace2Count).toBeDefined(); + expect(reactionButtons).toHaveLength(reactions.length); + expect(within(reactionButtons[0]).getByText('3')).toBeDefined(); + expect(within(reactionButtons[1]).getByText('2')).toBeDefined(); + expect(within(reactionButtons[2]).getByText('1')).toBeDefined(); + expect(within(reactionButtons[3]).getByText('1')).toBeDefined(); }); test('handles click on reaction button', () => { - const {getByTitle} = render(withTheme()); + const {container} = render(withTheme()); + const reactionButtons = Array.from(container.querySelectorAll('button[data-uie-name="emoji-pill"]')); - fireEvent.click(getByTitle('+1')); + fireEvent.click(reactionButtons[2]); const {handleReactionClick} = defaultProps; expect(handleReactionClick).toHaveBeenCalled(); expect(handleReactionClick).toHaveBeenCalledWith('👍'); diff --git a/apps/webapp/src/script/util/EmojiDataSource.test.ts b/apps/webapp/src/script/util/EmojiDataSource.test.ts new file mode 100644 index 00000000000..df96c0ac0ff --- /dev/null +++ b/apps/webapp/src/script/util/EmojiDataSource.test.ts @@ -0,0 +1,61 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {extractEmojiDataEntries} from './EmojiDataSource'; + +describe('EmojiDataSource', () => { + it('extracts emoji entries from the nested emoji-picker-react data shape', () => { + const emojiDataSource = { + categories: { + smileys_people: {category: 'smileys_people', name: 'people & body'}, + }, + emojis: { + smileys_people: [{n: ['grinning face'], u: '1f600'}], + objects: [{n: ['watch'], u: '231a'}], + }, + }; + + expect(extractEmojiDataEntries(emojiDataSource)).toEqual([ + {n: ['grinning face'], u: '1f600'}, + {n: ['watch'], u: '231a'}, + ]); + }); + + it('extracts emoji entries from the legacy top-level category map shape', () => { + const emojiDataSource = { + smileys_people: [{n: ['grinning face'], u: '1f600'}], + objects: [{n: ['watch'], u: '231a'}], + }; + + expect(extractEmojiDataEntries(emojiDataSource)).toEqual([ + {n: ['grinning face'], u: '1f600'}, + {n: ['watch'], u: '231a'}, + ]); + }); + + it('returns an empty list for invalid emoji data structures', () => { + const invalidEmojiDataSource = { + emojis: { + smileys_people: [{name: 'grinning face', unicode: '1f600'}], + }, + }; + + expect(extractEmojiDataEntries(invalidEmojiDataSource)).toEqual([]); + }); +}); diff --git a/apps/webapp/src/script/util/EmojiDataSource.ts b/apps/webapp/src/script/util/EmojiDataSource.ts new file mode 100644 index 00000000000..c78212923c0 --- /dev/null +++ b/apps/webapp/src/script/util/EmojiDataSource.ts @@ -0,0 +1,72 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import is from '@sindresorhus/is'; + +export type EmojiDataEntry = { + n: string[]; + u: string; +}; + +type GenericRecord = Record; +type EmojiEntriesByCategory = Record; + +function isGenericRecord(value: unknown): value is GenericRecord { + return is.object(value) && !is.array(value); +} + +function isEmojiDataEntry(value: unknown): value is EmojiDataEntry { + if (!isGenericRecord(value)) { + return false; + } + + return is.string(value.u) && is.array(value.n) && value.n.every(is.string); +} + +function isEmojiDataEntryList(value: unknown): value is EmojiDataEntry[] { + return is.array(value) && value.every(isEmojiDataEntry); +} + +function isEmojiEntriesByCategory(value: unknown): value is EmojiEntriesByCategory { + if (!isGenericRecord(value)) { + return false; + } + + return Object.values(value).every(isEmojiDataEntryList); +} + +function flattenEmojiEntriesByCategory(emojiEntriesByCategory: EmojiEntriesByCategory): EmojiDataEntry[] { + return Object.values(emojiEntriesByCategory).flat(); +} + +export function extractEmojiDataEntries(emojiDataSource: unknown): EmojiDataEntry[] { + if (!isGenericRecord(emojiDataSource)) { + return []; + } + + if (isEmojiEntriesByCategory(emojiDataSource.emojis)) { + return flattenEmojiEntriesByCategory(emojiDataSource.emojis); + } + + if (isEmojiEntriesByCategory(emojiDataSource)) { + return flattenEmojiEntriesByCategory(emojiDataSource); + } + + return []; +} diff --git a/apps/webapp/src/script/util/EmojiUtil.ts b/apps/webapp/src/script/util/EmojiUtil.ts index ce5d93839ad..6b26fbbf48d 100644 --- a/apps/webapp/src/script/util/EmojiUtil.ts +++ b/apps/webapp/src/script/util/EmojiUtil.ts @@ -17,9 +17,11 @@ * */ -import emojies from 'emoji-picker-react/src/data/emojis.json'; +import emojiData from 'emoji-picker-react/src/data/emojis.json'; import {groupBy} from 'underscore'; +import {extractEmojiDataEntries} from './EmojiDataSource'; + // http://www.unicode.org/Public/emoji/11.0/emoji-data.txt // This is the exact copy of unicode-range definition for `emoji` font in CSS. export const EMOJI_RANGES = @@ -43,16 +45,16 @@ const isValidString = (string: string) => typeof string === 'string' && string.l export const includesOnlyEmojis = (text: string) => isValidString(text) && removeEmojis(removeWhitespace(text)).length === 0; -const emojiesFlatten = Object.values(emojies).flat(); -const emojiesList = groupBy(emojiesFlatten, 'u'); +const emojiEntries = extractEmojiDataEntries(emojiData); +const emojiEntriesByCodepoint = groupBy(emojiEntries, 'u'); const emojiDictionary: Map = new Map(); -Object.keys(emojiesList).forEach(key => { +Object.keys(emojiEntriesByCodepoint).forEach(key => { // return if already existing in the dictionary if (emojiDictionary.has(key)) { return; } - const emojiValue = emojiesList[key]; + const emojiValue = emojiEntriesByCodepoint[key]; // return if not found in list if (!emojiValue) { return; diff --git a/yarn.lock b/yarn.lock index bc2c1147bee..9f909ebd7f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11966,7 +11966,7 @@ __metadata: dexie-batch: "npm:0.4.3" dexie-encrypted: "npm:2.0.0" dotenv-extended: "npm:2.9.0" - emoji-picker-react: "npm:4.16.1" + emoji-picker-react: "npm:4.18.0" html-webpack-plugin: "npm:5.6.6" i18next-scanner: "npm:4.6.0" immer: "npm:10.2.0" @@ -15648,14 +15648,14 @@ __metadata: languageName: node linkType: hard -"emoji-picker-react@npm:4.16.1": - version: 4.16.1 - resolution: "emoji-picker-react@npm:4.16.1" +"emoji-picker-react@npm:4.18.0": + version: 4.18.0 + resolution: "emoji-picker-react@npm:4.18.0" dependencies: flairup: "npm:1.0.0" peerDependencies: react: ">=16" - checksum: 10/04c932b0890b89c62e982780288bcd71abc547c2d781c370b19948dfab4081ddaefcf8e4efbd38f8bb1ba8f29169bff4cebd62aea7e17adf30989e44104bdb64 + checksum: 10/a42192f5ee5310e44f759b79e567ceace63ae83d7b339ce18d562bedff5d0c91b229bf221ea7d64a7cd90608916cb5128fe03f032d1d155d6730443da1c0cd46 languageName: node linkType: hard