diff --git a/apps/meteor/app/emoji-native/lib/generateEmojiData.ts b/apps/meteor/app/emoji-native/lib/generateEmojiData.ts index 9fc5a5bc0f46c..5198db5a5eed5 100644 --- a/apps/meteor/app/emoji-native/lib/generateEmojiData.ts +++ b/apps/meteor/app/emoji-native/lib/generateEmojiData.ts @@ -17,6 +17,7 @@ const groupToCategory: Record = { }; export type EmojiEntry = { + name: string; uc_base: string; uc_output: string; uc_match: string; @@ -73,6 +74,7 @@ function buildEmojiData() { const hex = hexFromEmoji(emojiData.emoji); const entry: EmojiEntry = { + name: primaryShortcode, uc_base: hex, uc_output: hex, uc_match: hex, @@ -108,6 +110,7 @@ function buildEmojiData() { const skinHex = hexFromEmoji(skin.emoji); const skinEntry: EmojiEntry = { + name: primaryShortcode, uc_base: skinHex, uc_output: skinHex, uc_match: skinHex, diff --git a/apps/meteor/app/emoji/client/helpers.ts b/apps/meteor/app/emoji/client/helpers.ts index 15f46499f8ace..bc2dc3167ebf0 100644 --- a/apps/meteor/app/emoji/client/helpers.ts +++ b/apps/meteor/app/emoji/client/helpers.ts @@ -157,57 +157,55 @@ export const getEmojisBySearchTerm = ( setRecentEmojis: (emojis: string[]) => void, ) => { const emojis = []; + const seenEmojis = new Set<(typeof emoji.list)[string]>(); const searchRegExp = new RegExp(escapeRegExp(searchTerm.replace(/:/g, '')), 'i'); for (let current in emoji.list) { - if (!emoji.list.hasOwnProperty(current)) { + if (!emoji.list.hasOwnProperty(current) || !searchRegExp.test(current)) { continue; } - if (searchRegExp.test(current)) { - const emojiObject = emoji.list[current]; - const { emojiPackage, shortnames = [] } = emojiObject; - let tone = ''; - current = current.replace(/:/g, ''); - const alias = shortnames[0] !== undefined ? shortnames[0].replace(/:/g, '') : shortnames[0]; + const emojiObject = emoji.list[current]; - if (actualTone > 0 && emoji.packages[emojiPackage].toneList.hasOwnProperty(current)) { - tone = `_tone${actualTone}`; - } + // Skip duplicates (same emoji in different packages) + if (seenEmojis.has(emojiObject)) { + continue; + } - const mixedTones = current.match(MIXED_TONE_SUFFIX); - const categoryName = current.replace(MIXED_TONE_SUFFIX, ''); - if (mixedTones && !(actualTone > 0 && Number(mixedTones[1]) === actualTone)) { - continue; - } + const { emojiPackage, shortnames = [], name } = emojiObject; + let tone = ''; + current = current.replace(/:/g, ''); - let emojiFound = false; - - for (const key in emoji.packages[emojiPackage].emojisByCategory) { - if (emoji.packages[emojiPackage].emojisByCategory.hasOwnProperty(key)) { - const contents = emoji.packages[emojiPackage].emojisByCategory[key]; - const searchValArray = alias !== undefined ? alias.replace(/:/g, '').split('_') : alias; - if (contents.indexOf(categoryName) !== -1 || searchValArray?.includes(searchTerm)) { - emojiFound = true; - break; - } - } - } + if (actualTone > 0 && emoji.packages[emojiPackage].toneList.hasOwnProperty(name ?? current)) { + tone = `_tone${actualTone}`; + } - if (emojiFound) { - const emojiToRender = `:${current}${tone}:`; + const mixedTones = current.match(MIXED_TONE_SUFFIX); + const categoryName = current.replace(MIXED_TONE_SUFFIX, ''); + if (mixedTones && !(actualTone > 0 && Number(mixedTones[1]) === actualTone)) { + continue; + } - const actualEmoji = emoji.list[emojiToRender]; - if (!actualEmoji) { - removeFromRecent(emojiToRender, recentEmojis, setRecentEmojis); - break; - } + const isCategoryEmoji = Object.values(emoji.packages[emojiPackage].emojisByCategory).some( + (contents) => contents.indexOf(categoryName) !== -1, + ); + if (!isCategoryEmoji && shortnames.length === 0) { + continue; + } - const actualPackage = actualEmoji.emojiPackage; + const emojiToRender = `:${current}${tone}:`; - emojis.push({ emoji: current, image: emoji.packages[actualPackage].renderPicker(emojiToRender) }); - } + const actualEmoji = emoji.list[emojiToRender]; + if (!actualEmoji) { + removeFromRecent(emojiToRender, recentEmojis, setRecentEmojis); + continue; } + + seenEmojis.add(emojiObject); + + const actualPackage = actualEmoji.emojiPackage; + + emojis.push({ emoji: current, image: emoji.packages[actualPackage].renderPicker(emojiToRender) }); } return emojis; diff --git a/apps/meteor/app/emoji/lib/rocketchat.ts b/apps/meteor/app/emoji/lib/rocketchat.ts index 6e96cceeaa81d..b5cbcda4163c3 100644 --- a/apps/meteor/app/emoji/lib/rocketchat.ts +++ b/apps/meteor/app/emoji/lib/rocketchat.ts @@ -20,6 +20,7 @@ export type EmojiPackages = { list: { [key: keyof NonNullable]: | { + name?: string; category: string; emojiPackage: string; shortnames: string[]; @@ -34,6 +35,7 @@ export type EmojiPackages = { unicode?: string; } | { + name?: undefined; emojiPackage: string; aliasOf: string; extension?: undefined; diff --git a/apps/meteor/tests/unit/app/emoji/helpers.spec.ts b/apps/meteor/tests/unit/app/emoji/helpers.spec.ts index c7d83b5a36c27..e5c990f01b93c 100644 --- a/apps/meteor/tests/unit/app/emoji/helpers.spec.ts +++ b/apps/meteor/tests/unit/app/emoji/helpers.spec.ts @@ -1,14 +1,79 @@ import { expect } from 'chai'; -import { describe, it, beforeEach } from 'mocha'; +import { describe, it, beforeEach, before } from 'mocha'; -import { updateRecent, removeFromRecent, replaceEmojiInRecent } from '../../../../app/emoji/client/helpers'; +import { getEmojisBySearchTerm, updateRecent, removeFromRecent, replaceEmojiInRecent } from '../../../../app/emoji/client/helpers'; import { emoji } from '../../../../app/emoji/client/lib'; +import { getEmojiConfig } from '../../../../app/emoji-native/lib/getEmojiConfig'; + +const registerNativeEmojis = () => { + const config = getEmojiConfig(emoji); + + emoji.packages.native = { + emojiCategories: config.emojiCategories as any, + emojisByCategory: config.emojisByCategory, + toneList: config.toneList, + render: config.render, + renderPicker: config.renderPicker, + sprites: config.sprites, + }; + + for (const [key, currentEmoji] of Object.entries(config.emojiList)) { + currentEmoji.emojiPackage = 'native'; + emoji.list[key] = currentEmoji as any; + + if (currentEmoji.shortnames) { + currentEmoji.shortnames.forEach((shortname: string) => { + emoji.list[shortname] = currentEmoji as any; + }); + } + } +}; describe('Emoji Client Helpers', () => { beforeEach(() => { emoji.packages.base.emojisByCategory.recent = []; }); + describe('getEmojisBySearchTerm', () => { + before(registerNativeEmojis); + + const search = (term: string) => getEmojisBySearchTerm(term, 0, [], () => undefined); + const names = (term: string) => search(term).map((result) => result.emoji); + const rendersThumbsUp = (term: string) => search(term).some((result) => result.image?.includes('👍')); + + it('finds an emoji by its primary shortcode', () => { + expect(names('+1')).to.include('+1'); + }); + + it('finds an emoji by its first alias (thumbsup -> 👍)', () => { + expect(rendersThumbsUp('thumbsup')).to.be.true; + }); + + it('finds an emoji by a secondary alias (yes -> 👍)', () => { + expect(rendersThumbsUp('yes')).to.be.true; + }); + + it('matches aliases partially (thumb -> thumbsup)', () => { + expect(names('thumb')).to.include('thumbsup'); + }); + + it('does not list the same emoji more than once', () => { + const images = search('grinning').map((result) => result.image); + expect(images.length).to.equal(new Set(images).size); + }); + + it('excludes skin-tone variants from the results', () => { + expect(names('+1').some((name) => /_tone[1-5]/.test(name))).to.be.false; + }); + + it('applies the selected skin tone when searching by an alias', () => { + const result = getEmojisBySearchTerm('thumbsup', 2, [], () => undefined).find(({ image }) => image?.includes('👍')); + + expect(result).to.exist; + expect(result?.image).to.include('👍🏼'); + }); + }); + describe('updateRecent', () => { it('should update recent emojis with the provided emojis', () => { const recentEmojis = ['emoji1', 'emoji2'];