diff --git a/e2e-tests/cypress/tests/integration/channels/emoji/recently_used_emoji_1_spec.ts b/e2e-tests/cypress/tests/integration/channels/emoji/recently_used_emoji_1_spec.ts index 7fadd6cc8e8..c397377d3e8 100644 --- a/e2e-tests/cypress/tests/integration/channels/emoji/recently_used_emoji_1_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/emoji/recently_used_emoji_1_spec.ts @@ -53,7 +53,7 @@ describe('Recent Emoji', () => { // # Submit post const message = 'hi'; - cy.uiGetPostTextBox().and('have.value', `:${firstEmoji}: `).type(`${message} {enter}`); + cy.uiGetPostTextBox().and('have.value', '๐Ÿ˜‚ ').type(`${message} {enter}`); cy.uiWaitUntilMessagePostedIncludes(message); // # Post reaction to post @@ -68,11 +68,16 @@ describe('Recent Emoji', () => { // * Verify recently used category is present in emoji picker cy.findByText(/Recently Used/i).should('exist').and('be.visible'); - // * Assert first emoji should equal with second recent emoji - cy.findAllByTestId('emojiItem').eq(0).should('have.attr', 'aria-label', 'grin emoji'); + // * Assert both emojis appear in the recently used section (grin most recent, joy before it) + cy.findAllByTestId('emojiItem').then((items) => { + const labels = [...items].map((el) => el.getAttribute('aria-label')); + const grinIdx = labels.indexOf('grin emoji'); + const joyIdx = labels.indexOf('joy emoji'); - // * Assert second emoji should equal with first recent emoji - cy.findAllByTestId('emojiItem').eq(1).should('have.attr', 'aria-label', 'joy emoji'); + expect(grinIdx, 'grin should be in recently used').to.be.greaterThan(-1); + expect(joyIdx, 'joy should be in recently used').to.be.greaterThan(-1); + expect(grinIdx, 'grin should appear before joy (more recent)').to.be.lessThan(joyIdx); + }); }); it('MM-T4463 Recently used custom emoji, when is deleted should be removed from recent emoji category and quick reactions', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/emoji_insert_position_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/emoji_insert_position_spec.js index 4dc78ab79b8..58c9ff1aeba 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/emoji_insert_position_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/emoji_insert_position_spec.js @@ -30,11 +30,11 @@ describe('Messaging', () => { // # Select the grinning emoji from the emoji picker. cy.clickEmojiInEmojiPicker('grinning'); - // * The emoji should be inserted where the cursor is at the time of selection. - cy.uiGetPostTextBox().should('have.value', 'Hello :grinning: World!'); + // * The emoji should be inserted as a Unicode character where the cursor is at the time of selection. + cy.uiGetPostTextBox().should('have.value', 'Hello\uD83D\uDE00World!'); cy.uiGetPostTextBox().type('{enter}'); // * The emoji should be displayed in the post at the position inserted. - cy.getLastPost().find('p').should('have.html', `Hello :grinning: World!`); + cy.getLastPost().find('p').should('contain', 'Hello').and('contain', 'World!'); }); }); diff --git a/e2e-tests/playwright/specs/functional/channels/emoji_picker/emoji_picker.spec.ts b/e2e-tests/playwright/specs/functional/channels/emoji_picker/emoji_picker.spec.ts index 8cf07c9ea25..6852c62d161 100644 --- a/e2e-tests/playwright/specs/functional/channels/emoji_picker/emoji_picker.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/emoji_picker/emoji_picker.spec.ts @@ -38,15 +38,15 @@ test( // * Verify emoji picker popup disappears await emojiGifPickerPopup.notToBeVisible(); - // * Verify that the emoji was correctly added to the post textbox, followed by a space - await expectPostCreateState(postCreate.input, ':slightly_smiling_face: ', ''); + // * Verify that the emoji was correctly added to the post textbox (as unicode), followed by a space + await expectPostCreateState(postCreate.input, '๐Ÿ™‚ ', ''); // # Repeat those steps with another emoji await postCreate.openEmojiPicker(); await emojiGifPickerPopup.clickEmoji('upside down face'); - // * Verify that the second emoji was correctly added to the post textbox, also followed by a space - await expectPostCreateState(postCreate.input, ':slightly_smiling_face: :upside_down_face: ', ''); + // * Verify that the second emoji was correctly added to the post textbox (as unicode), also followed by a space + await expectPostCreateState(postCreate.input, '๐Ÿ™‚ ๐Ÿ™ƒ ', ''); // # Clear the textbox and replace it with some text await postCreate.writeMessage('ab'); @@ -61,8 +61,8 @@ test( await postCreate.openEmojiPicker(); await emojiGifPickerPopup.clickEmoji('face with raised eyebrow'); - // * Verify that the emoji was added with surrounding whitespace and that the caret is placed after that - await expectPostCreateState(postCreate.input, 'a :face_with_raised_eyebrow: ', 'b'); + // * Verify that the emoji was added with surrounding whitespace (as unicode) and that the caret is placed after that + await expectPostCreateState(postCreate.input, 'a ๐Ÿคจ ', 'b'); // # Clear the textbox and replace it with some words await postCreate.writeMessage('this is a test'); @@ -80,8 +80,8 @@ test( await postCreate.openEmojiPicker(); await emojiGifPickerPopup.clickEmoji('neutral face'); - // * Verify that the emoji was added without an extra space before it - await expectPostCreateState(postCreate.input, 'this is a :neutral_face: ', 'test'); + // * Verify that the emoji was added without an extra space before it (as unicode) + await expectPostCreateState(postCreate.input, 'this is a ๐Ÿ˜ ', 'test'); }, ); diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx index 0833e3607d6..7ba9bcb4e82 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.test.tsx @@ -522,18 +522,18 @@ describe('components/avanced_text_editor/advanced_text_editor', () => { await userEvent.click(screen.getByRole('button', {name: 'blush emoji'})); expect(textbox).toHaveFocus(); - expect(textbox).toHaveValue(':blush: '); - expect(textbox.selectionStart).toEqual(8); - expect(textbox.selectionEnd).toEqual(8); + expect(textbox).toHaveValue('\uD83D\uDE0A '); + expect(textbox.selectionStart).toEqual(3); + expect(textbox.selectionEnd).toEqual(3); // Do it again await userEvent.click(screen.getByRole('button', {name: 'select an emoji'})); await userEvent.click(screen.getByRole('button', {name: 'relaxed emoji'})); expect(textbox).toHaveFocus(); - expect(textbox).toHaveValue(':blush: :relaxed: '); - expect(textbox.selectionStart).toEqual(18); - expect(textbox.selectionEnd).toEqual(18); + expect(textbox).toHaveValue('\uD83D\uDE0A \u263A\uFE0F '); + expect(textbox.selectionStart).toEqual(6); + expect(textbox.selectionEnd).toEqual(6); }); it('should add a space after the existing text if needed', async () => { @@ -553,9 +553,9 @@ describe('components/avanced_text_editor/advanced_text_editor', () => { await userEvent.click(screen.getByRole('button', {name: 'blush emoji'})); expect(textbox).toHaveFocus(); - expect(textbox).toHaveValue('This is some text :blush: '); - expect(textbox.selectionStart).toEqual(26); - expect(textbox.selectionEnd).toEqual(26); + expect(textbox).toHaveValue('This is some text \uD83D\uDE0A '); + expect(textbox.selectionStart).toEqual(21); + expect(textbox.selectionEnd).toEqual(21); }); it('should be able to add an emoji in the middle of the text', async () => { @@ -579,10 +579,8 @@ describe('components/avanced_text_editor/advanced_text_editor', () => { await userEvent.click(screen.getByRole('button', {name: 'blush emoji'})); expect(textbox).toHaveFocus(); - expect(textbox).toHaveValue('aaa :blush: bbb'); - - // The caret should now be after the emoji - expect(textbox.selectionStart).toEqual(12); + expect(textbox).toHaveValue('aaa \uD83D\uDE0A bbb'); + expect(textbox.selectionStart).toEqual(7); expect(textbox.selectionEnd).toEqual(textbox.selectionEnd); }); @@ -607,10 +605,8 @@ describe('components/avanced_text_editor/advanced_text_editor', () => { await userEvent.click(screen.getByRole('button', {name: 'blush emoji'})); expect(textbox).toHaveFocus(); - expect(textbox).toHaveValue('aaa :blush: bbb'); - - // The caret should now be after the emoji - expect(textbox.selectionStart).toEqual(12); + expect(textbox).toHaveValue('aaa \uD83D\uDE0A bbb'); + expect(textbox.selectionStart).toEqual(7); expect(textbox.selectionEnd).toEqual(textbox.selectionEnd); }); }); diff --git a/webapp/channels/src/components/advanced_text_editor/use_editor_emoji_picker.tsx b/webapp/channels/src/components/advanced_text_editor/use_editor_emoji_picker.tsx index a54169fddc3..ae4a6914855 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_editor_emoji_picker.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_editor_emoji_picker.tsx @@ -8,15 +8,16 @@ import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; import {EmoticonHappyOutlineIcon} from '@mattermost/compass-icons/components'; -import type {Emoji} from '@mattermost/types/emojis'; +import type {Emoji, SystemEmoji} from '@mattermost/types/emojis'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; +import {getEmojiName, isSystemEmoji} from 'mattermost-redux/utils/emoji_utils'; import useEmojiPicker, {useEmojiPickerOffset} from 'components/emoji_picker/use_emoji_picker'; import KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; import WithTooltip from 'components/with_tooltip'; +import {unifiedToUnicode} from 'utils/emoji_utils'; import {focusAndInsertText} from 'utils/exec_commands'; import {horizontallyWithin} from 'utils/floating'; @@ -56,17 +57,18 @@ const useEditorEmojiPicker = ( }, [textboxId]); const handleEmojiClick = useCallback((emoji: Emoji) => { - const emojiAlias = getEmojiName(emoji); - - if (!emojiAlias) { - //Oops.. There went something wrong - return; + if (isSystemEmoji(emoji)) { + insertTextAtCaret(unifiedToUnicode((emoji as SystemEmoji).unified)); + } else { + const emojiAlias = getEmojiName(emoji); + if (!emojiAlias) { + return; + } + insertTextAtCaret(`:${emojiAlias}:`); } - insertTextAtCaret(`:${emojiAlias}:`); - setShowEmojiPicker(false); - }, [insertTextAtCaret]); + }, [insertTextAtCaret, textboxId]); const handleGifClick = useCallback((gif: string) => { insertTextAtCaret(gif); diff --git a/webapp/channels/src/components/edit_scheduled_post/edit_post.tsx b/webapp/channels/src/components/edit_scheduled_post/edit_post.tsx index aa290d23fc6..f2f1cb1822b 100644 --- a/webapp/channels/src/components/edit_scheduled_post/edit_post.tsx +++ b/webapp/channels/src/components/edit_scheduled_post/edit_post.tsx @@ -7,14 +7,14 @@ import {useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import {EmoticonPlusOutlineIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components'; -import type {Emoji} from '@mattermost/types/emojis'; +import type {Emoji, SystemEmoji} from '@mattermost/types/emojis'; import type {Post} from '@mattermost/types/posts'; import type {ScheduledPost} from '@mattermost/types/schedule_post'; import {scheduledPostToPost} from '@mattermost/types/schedule_post'; import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import type {ActionResult} from 'mattermost-redux/types/actions'; -import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; +import {getEmojiName, isSystemEmoji} from 'mattermost-redux/utils/emoji_utils'; import {openModal} from 'actions/views/modals'; import {getConnectionId} from 'selectors/general'; @@ -26,6 +26,7 @@ import Textbox from 'components/textbox'; import type {TextboxClass, TextboxElement} from 'components/textbox'; import {AppEvents, Constants, ModalIdentifiers, StoragePrefixes} from 'utils/constants'; +import {unifiedToUnicode} from 'utils/emoji_utils'; import * as Keyboard from 'utils/keyboard'; import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown'; import {applyMarkdown} from 'utils/markdown/apply_markdown'; @@ -502,13 +503,19 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, return; } - const emojiAlias = getEmojiName(emoji); - if (!emojiAlias) { - //Oops.. There went something wrong - return; + let emojiText: string; + if (isSystemEmoji(emoji)) { + emojiText = unifiedToUnicode((emoji as SystemEmoji).unified); + } else { + const emojiAlias = getEmojiName(emoji); + if (!emojiAlias) { + return; + } + emojiText = `:${emojiAlias}:`; } - let newMessage = `:${emojiAlias}: `; + const isUnicode = isSystemEmoji(emoji); + let newMessage = isUnicode ? emojiText : `${emojiText} `; let newCaretPosition = newMessage.length; if (editText.length > 0) { @@ -517,10 +524,13 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, editText, ); - // check whether the first piece of the message is empty when cursor - // is placed at beginning of message and avoid adding an empty string at the beginning of the message - newMessage = firstPiece === '' ? `:${emojiAlias}: ${lastPiece}` : `${firstPiece} :${emojiAlias}: ${lastPiece}`; - newCaretPosition = firstPiece === '' ? `:${emojiAlias}: `.length : `${firstPiece} :${emojiAlias}: `.length; + if (isUnicode) { + newMessage = firstPiece + emojiText + lastPiece; + newCaretPosition = firstPiece.length + emojiText.length; + } else { + newMessage = firstPiece === '' ? `${emojiText} ${lastPiece}` : `${firstPiece} ${emojiText} ${lastPiece}`; + newCaretPosition = firstPiece === '' ? `${emojiText} `.length : `${firstPiece} ${emojiText} `.length; + } } draftRef.current = { diff --git a/webapp/channels/src/components/suggestion/emoticon_provider.tsx b/webapp/channels/src/components/suggestion/emoticon_provider.tsx index 0982613f4a3..74abe2adcc4 100644 --- a/webapp/channels/src/components/suggestion/emoticon_provider.tsx +++ b/webapp/channels/src/components/suggestion/emoticon_provider.tsx @@ -4,7 +4,7 @@ import React from 'react'; import {defineMessage} from 'react-intl'; -import type {Emoji} from '@mattermost/types/emojis'; +import type {Emoji, SystemEmoji} from '@mattermost/types/emojis'; import {autocompleteCustomEmojis} from 'mattermost-redux/actions/emojis'; import {getEmojiImageUrl, isSystemEmoji} from 'mattermost-redux/utils/emoji_utils'; @@ -12,7 +12,7 @@ import {getEmojiImageUrl, isSystemEmoji} from 'mattermost-redux/utils/emoji_util import {getEmojiMap, getRecentEmojisNames} from 'selectors/emojis'; import store from 'stores/redux_store'; -import {compareEmojis, emojiMatchesSkin} from 'utils/emoji_utils'; +import {compareEmojis, emojiMatchesSkin, unifiedToUnicode} from 'utils/emoji_utils'; import * as Emoticons from 'utils/emoticons'; import Provider from './provider'; @@ -29,7 +29,7 @@ type EmojiItem = { } const EmoticonSuggestion = React.forwardRef>((props, ref) => { - const text = props.term; + const displayName = ':' + props.item.name + ':'; const emoji = props.item.emoji; return ( @@ -45,7 +45,7 @@ const EmoticonSuggestion = React.forwardRef
- {text} + {displayName}
); @@ -94,7 +94,12 @@ export default class EmoticonProvider extends Provider { } formatEmojis(emojis: EmojiItem[]) { - return emojis.map((item) => ':' + item.name + ':'); + return emojis.map((item) => { + if (isSystemEmoji(item.emoji)) { + return unifiedToUnicode((item.emoji as SystemEmoji).unified); + } + return ':' + item.name + ':'; + }); } // findAndSuggestEmojis uses the provided partialName to match anywhere inside an emoji name. diff --git a/webapp/channels/src/utils/emoji_utils.test.tsx b/webapp/channels/src/utils/emoji_utils.test.tsx index e85bd630c96..0658a0cb9fc 100644 --- a/webapp/channels/src/utils/emoji_utils.test.tsx +++ b/webapp/channels/src/utils/emoji_utils.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import {EmojiIndicesByAlias, Emojis} from 'utils/emoji'; import {TestHelper as TH} from 'utils/test_helper'; -import {compareEmojis, convertEmojiSkinTone, wrapEmojis} from './emoji_utils'; +import {compareEmojis, convertEmojiSkinTone, unifiedToUnicode, wrapEmojis} from './emoji_utils'; describe('compareEmojis', () => { test('should sort an array of emojis alphabetically', () => { @@ -393,6 +393,24 @@ describe('convertEmojiSkinTone', () => { }); }); +describe('unifiedToUnicode', () => { + test('should convert a single codepoint', () => { + expect(unifiedToUnicode('1F600')).toBe('\uD83D\uDE00'); // ๐Ÿ˜€ + }); + + test('should convert multi-codepoint emoji', () => { + expect(unifiedToUnicode('1F468-200D-1F469-200D-1F467')).toBe('\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67'); + }); + + test('should convert skin tone variant', () => { + expect(unifiedToUnicode('1F64C-1F3FD')).toBe('\uD83D\uDE4C\uD83C\uDFFD'); + }); + + test('should handle basic ASCII-range codepoints', () => { + expect(unifiedToUnicode('23-FE0F-20E3')).toBe('#\uFE0F\u20E3'); // #๏ธโƒฃ + }); +}); + function getEmoji(name: string) { return Emojis[EmojiIndicesByAlias.get(name)!]; } diff --git a/webapp/channels/src/utils/emoji_utils.tsx b/webapp/channels/src/utils/emoji_utils.tsx index 52a43210495..23dc137e8ec 100644 --- a/webapp/channels/src/utils/emoji_utils.tsx +++ b/webapp/channels/src/utils/emoji_utils.tsx @@ -179,6 +179,10 @@ export function getSkin(emoji: Emoji) { return null; } +export function unifiedToUnicode(unified: string): string { + return unified.split('-').map((cp) => String.fromCodePoint(parseInt(cp, 16))).join(''); +} + export function trimmedEmojiName(emojiName: string) { return emojiName.startsWith(':') && emojiName.endsWith(':') ? emojiName.slice(1, -1) : emojiName; }