feat: pass unicode emojis from emoji picker to textare (#35419)

* feat: pass unicode emojis from emoji picker to textare

* fi test

* PR feedback

* fix unit tests

* fix linter

* fix emoji test

* fix e2e test

* e2e test

* Fix MM-T155 emoji test to use flexible recently used assertions

Made-with: Cursor

---------

Co-authored-by: Nevyana Angelova <nevyangelova@Nevy-Macbook-16-2025.local>
This commit is contained in:
Just Nev
2026-03-10 20:37:53 +07:00
committed by GitHub
co-authored by Nevyana Angelova
parent 5a1ea95044
commit 24f726fa37
9 changed files with 100 additions and 60 deletions
@@ -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', () => {
@@ -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 <span data-emoticon="grinning"><span alt=":grinning:" class="emoticon" data-testid="postEmoji.:grinning:" style="background-image: url(&quot;${Cypress.config('baseUrl')}/static/emoji/1f600.png&quot;);">:grinning:</span></span> World!`);
cy.getLastPost().find('p').should('contain', 'Hello').and('contain', 'World!');
});
});
@@ -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');
},
);
@@ -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);
});
});
@@ -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);
@@ -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 = {
@@ -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<HTMLLIElement, SuggestionProps<EmojiItem>>((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<HTMLLIElement, SuggestionProps<Emoji
/>
</div>
<div className='pull-left'>
{text}
{displayName}
</div>
</SuggestionContainer>
);
@@ -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.
+19 -1
View File
@@ -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)!];
}
@@ -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;
}