mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-21 14:20:41 +08:00
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:
co-authored by
Nevyana Angelova
parent
5a1ea95044
commit
24f726fa37
@@ -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', () => {
|
||||
|
||||
+3
-3
@@ -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("${Cypress.config('baseUrl')}/static/emoji/1f600.png");">: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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user