diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4eb625affacd..e9ad41514955 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3970,8 +3970,10 @@ const CONST = { ONLY_PRIVATE_USER_AREA: /^[\uE000-\uF8FF\u{F0000}-\u{FFFFD}\u{100000}-\u{10FFFD}]+$/u, - // Regex pattern to match a digit followed by an emoji (used for Safari ZWNJ insertion) - DIGIT_FOLLOWED_BY_EMOJI: /(\d)([\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F9FF}\u2600-\u27BF])/gu, + // Regex pattern to match a digit (#, *, or 0-9) followed by an emoji (used for Safari FE0E insertion to prevent keycap corruption) + DIGIT_OR_SYMBOL_FOLLOWED_BY_EMOJI: /([\d#*])([\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F9FF}\u2600-\u27BF])/gu, + // Regex pattern to match a corrupted keycap sequence followed by an emoji + CORRUPTED_KEYCAP_FOLLOWED_BY_EMOJI: /([\d#*])\uFE0F?\u20E3([\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F9FF}\u2600-\u27BF])/gu, TAX_ID: /^\d{9}$/, NON_NUMERIC: /\D/g, diff --git a/src/libs/EmojiUtils.tsx b/src/libs/EmojiUtils.tsx index 11095bcab3e3..bfe5a67c35d5 100644 --- a/src/libs/EmojiUtils.tsx +++ b/src/libs/EmojiUtils.tsx @@ -755,36 +755,52 @@ function containsOnlyCustomEmoji(text?: string): boolean { } /** - * Insert ZWNJ (Zero-Width Non-Joiner) between digits and emojis to prevent Safari's automatic keycap sequence bug. + * Insert Variation Selector 15 (FE0E) between digits/symbols and emojis to prevent Safari's automatic keycap sequence bug. * - * Safari has a browser-specific behavior where it automatically converts a digit immediately followed by an emoji - * into a Unicode keycap sequence (e.g., "1" + "😄" becomes "1️⃣"). This happens at the browser's input handling level - * before React can process the text, causing character corruption or unexpected joining. + * Safari has a browser-specific behavior where it automatically converts a digit or symbol (#, *) + * immediately followed by an emoji into a Unicode keycap sequence (e.g., "1" + "😄" becomes "1️⃣"). + * This happens at the browser's input handling level before React can process the text, + * causing character corruption or unexpected joining. * - * The ZWNJ character (U+200C) is a non-printing Unicode character that prevents the formation of ligatures or - * unwanted character joining. By inserting it between digits and emojis, we break Safari's automatic keycap - * sequence detection, ensuring the text displays correctly. + * FE0E (Variation Selector 15) is a non-printing Unicode character that forces text presentation. + * Unlike ZWNJ (U+200C) which has Grapheme_Cluster_Break=Control and creates a separate grapheme cluster + * (causing cursor navigation and deletion issues), FE0E has Grapheme_Cluster_Break=Extend which makes it + * attach to the preceding character as part of the same grapheme cluster. This means: + * - No invisible extra cursor stops between digit and emoji + * - Backspace correctly deletes the digit (not an invisible character) + * - Arrow keys move smoothly past the digit-emoji boundary * - * Example: "234😄" becomes "234\u200C😄" (ZWNJ is invisible but prevents Safari's corruption) + * Example: "234😄" becomes "234\uFE0E😄" (FE0E is invisible but prevents Safari's corruption) */ -function insertZWNJBetweenDigitAndEmoji(input: string): string { +function insertTextVSBetweenDigitAndEmoji(input: string): string { if (!isSafari()) { return input; } - return input.replaceAll(CONST.REGEX.DIGIT_FOLLOWED_BY_EMOJI, '$1\u200C$2'); + + // Fix corrupted key caps that Safari created (key cap followed by emoji indicates corruption) + let result = input.replaceAll(CONST.REGEX.CORRUPTED_KEYCAP_FOLLOWED_BY_EMOJI, '$1\uFE0E$2'); + // Insert FE0E between digit/symbol and emoji (the main fix to prevent corruption) + result = result.replaceAll(CONST.REGEX.DIGIT_OR_SYMBOL_FOLLOWED_BY_EMOJI, '$1\uFE0E$2'); + + return result; } /** - * Calculate the ZWNJ offset for cursor position adjustment. - * Returns the number of ZWNJ characters inserted before the cursor position. + * Calculate the text VS (FE0E) offset for cursor position adjustment. + * Returns the number of FE0E characters that would be inserted before the cursor position. */ -function getZWNJCursorOffset(text: string, cursorPosition: number | undefined | null): number { +function getTextVSCursorOffset(text: string, cursorPosition: number | undefined | null): number { if (!isSafari() || cursorPosition === undefined || cursorPosition === null) { return 0; } - const textBeforeCursor = text.substring(0, cursorPosition); - const textWithZWNJBeforeCursor = insertZWNJBetweenDigitAndEmoji(textBeforeCursor); - return textWithZWNJBeforeCursor.length - textBeforeCursor.length; + + const beforeCursor = text.substring(0, cursorPosition); + + let processed = beforeCursor; + processed = processed.replaceAll(CONST.REGEX.CORRUPTED_KEYCAP_FOLLOWED_BY_EMOJI, '$1\uFE0E$2'); + processed = processed.replaceAll(CONST.REGEX.DIGIT_OR_SYMBOL_FOLLOWED_BY_EMOJI, '$1\uFE0E$2'); + + return processed.length - beforeCursor.length; } export type {HeaderIndices, EmojiPickerList, EmojiPickerListItem}; @@ -814,8 +830,8 @@ export { containsCustomEmoji, containsOnlyCustomEmoji, processFrequentlyUsedEmojis, - insertZWNJBetweenDigitAndEmoji, - getZWNJCursorOffset, + insertTextVSBetweenDigitAndEmoji, + getTextVSCursorOffset, isPositionInsideCodeBlock, getEmojiCodeForInsertion, }; diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 549bfef79223..b153dc096107 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -27,7 +27,7 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import {forceClearInput} from '@libs/ComponentUtils'; import {canSkipTriggerHotkeys, findCommonSuffixLength, insertText, insertWhiteSpaceAtIndex} from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; -import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getZWNJCursorOffset, insertZWNJBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; +import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getTextVSCursorOffset, insertTextVSBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import getPlatform from '@libs/getPlatform'; @@ -429,8 +429,8 @@ function ComposerWithSuggestions({ const commentWithSpaceInserted = isEmojiInserted ? insertWhiteSpaceAtIndex(effectiveCommentValue, endIndex) : effectiveCommentValue; const {text: emojiConvertedText, emojis, cursorPosition} = replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale); - const newComment = insertZWNJBetweenDigitAndEmoji(emojiConvertedText); - const zwnjOffset = getZWNJCursorOffset(emojiConvertedText, cursorPosition); + const newComment = insertTextVSBetweenDigitAndEmoji(emojiConvertedText); + const textVSOffset = getTextVSCursorOffset(emojiConvertedText, cursorPosition); if (emojis.length) { const newEmojis = getAddedEmojis(emojis, emojisPresentBefore.current); @@ -453,7 +453,7 @@ function ComposerWithSuggestions({ setValue(newCommentConverted); if (commentValue !== newComment) { - const adjustedCursorPosition = cursorPosition !== undefined && cursorPosition !== null ? cursorPosition + zwnjOffset : undefined; + const adjustedCursorPosition = cursorPosition !== undefined && cursorPosition !== null ? cursorPosition + textVSOffset : undefined; const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), adjustedCursorPosition ?? 0); if (commentWithSpaceInserted !== newComment && isIOSNative) { diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 35ec68e2b8dd..9870a471d77b 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -37,7 +37,7 @@ import {deleteReportActionDraft, editReportComment, saveReportActionDraft} from import {isMobileChrome} from '@libs/Browser'; import {canSkipTriggerHotkeys, insertText} from '@libs/ComposerUtils'; import DomUtils from '@libs/DomUtils'; -import {extractEmojis, getZWNJCursorOffset, insertZWNJBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; +import {extractEmojis, getTextVSCursorOffset, insertTextVSBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import type {Selection} from '@libs/focusComposerWithDelay/types'; import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete'; @@ -246,15 +246,15 @@ function ReportActionItemMessageEdit({ (newDraftInput: string) => { raiseIsScrollLayoutTriggered(); const {text: emojiConvertedText, emojis, cursorPosition} = replaceAndExtractEmojis(newDraftInput, preferredSkinTone, preferredLocale); - const newDraft = insertZWNJBetweenDigitAndEmoji(emojiConvertedText); - const zwnjOffset = getZWNJCursorOffset(emojiConvertedText, cursorPosition); + const newDraft = insertTextVSBetweenDigitAndEmoji(emojiConvertedText); + const textVSOffset = getTextVSCursorOffset(emojiConvertedText, cursorPosition); emojisPresentBefore.current = emojis; setDraft(newDraft); if (newDraftInput !== newDraft) { - const adjustedCursorPosition = cursorPosition !== undefined && cursorPosition !== null ? cursorPosition + zwnjOffset : undefined; + const adjustedCursorPosition = cursorPosition !== undefined && cursorPosition !== null ? cursorPosition + textVSOffset : undefined; const position = Math.max((selection?.end ?? 0) + (newDraft.length - draftRef.current.length), adjustedCursorPosition ?? 0); setSelection({ start: position, diff --git a/tests/unit/EmojiTest.ts b/tests/unit/EmojiTest.ts index 86f8295fbcc3..191a97b47608 100644 --- a/tests/unit/EmojiTest.ts +++ b/tests/unit/EmojiTest.ts @@ -460,9 +460,9 @@ describe('EmojiTest', () => { }); }); - describe('insertZWNJBetweenDigitAndEmoji', () => { - // ZWNJ character for comparison - const ZWNJ = '\u200C'; + describe('insertTextVSBetweenDigitAndEmoji', () => { + // FE0E (Variation Selector 15 - text presentation) for comparison + const FE0E = '\uFE0E'; // Mock isSafari to return true for these tests since the function only applies on Safari beforeEach(() => { @@ -473,38 +473,38 @@ describe('EmojiTest', () => { jest.restoreAllMocks(); }); - it('should insert ZWNJ between a single digit and emoji', () => { + it('should insert FE0E between a single digit and emoji', () => { // Given a digit immediately followed by an emoji const input = '1😄'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); - // Then ZWNJ should be inserted between the digit and emoji - expect(result).toBe(`1${ZWNJ}😄`); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then FE0E should be inserted between the digit and emoji + expect(result).toBe(`1${FE0E}😄`); }); - it('should insert ZWNJ between multiple digits and emoji', () => { + it('should insert FE0E between multiple digits and emoji', () => { // Given multiple digits immediately followed by an emoji const input = '234😄'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); - // Then ZWNJ should be inserted only between the last digit and emoji - expect(result).toBe(`234${ZWNJ}😄`); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then FE0E should be inserted only between the last digit and emoji + expect(result).toBe(`234${FE0E}😄`); }); it('should handle multiple digit-emoji pairs in the same string', () => { // Given a string with multiple digit-emoji pairs const input = '1😄 2🚀 3👍'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); - // Then ZWNJ should be inserted for each pair - expect(result).toBe(`1${ZWNJ}😄 2${ZWNJ}🚀 3${ZWNJ}👍`); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then FE0E should be inserted for each pair + expect(result).toBe(`1${FE0E}😄 2${FE0E}🚀 3${FE0E}👍`); }); it('should not modify text with space between digit and emoji', () => { // Given a digit followed by a space and then an emoji const input = '1 😄'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); // Then the text should remain unchanged expect(result).toBe('1 😄'); }); @@ -512,8 +512,8 @@ describe('EmojiTest', () => { it('should not modify text with only digits', () => { // Given text with only digits const input = '12345'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); // Then the text should remain unchanged expect(result).toBe('12345'); }); @@ -521,8 +521,8 @@ describe('EmojiTest', () => { it('should not modify text with only emojis', () => { // Given text with only emojis const input = '😄🚀👍'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); // Then the text should remain unchanged expect(result).toBe('😄🚀👍'); }); @@ -530,8 +530,8 @@ describe('EmojiTest', () => { it('should not modify emoji followed by digit', () => { // Given an emoji followed by a digit const input = '😄1'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); // Then the text should remain unchanged expect(result).toBe('😄1'); }); @@ -539,8 +539,8 @@ describe('EmojiTest', () => { it('should handle empty string', () => { // Given an empty string const input = ''; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); // Then the result should be an empty string expect(result).toBe(''); }); @@ -548,8 +548,8 @@ describe('EmojiTest', () => { it('should handle text without digits or emojis', () => { // Given regular text without digits or emojis const input = 'Hello World'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); // Then the text should remain unchanged expect(result).toBe('Hello World'); }); @@ -557,55 +557,55 @@ describe('EmojiTest', () => { it('should handle mixed content with digit-emoji pairs', () => { // Given mixed content with text, digits, and emojis const input = 'Hello 5😄 World'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); - // Then ZWNJ should be inserted only between digit and emoji - expect(result).toBe(`Hello 5${ZWNJ}😄 World`); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then FE0E should be inserted only between digit and emoji + expect(result).toBe(`Hello 5${FE0E}😄 World`); }); it('should handle all digit types (0-9)', () => { // Given all digit types followed by emojis const inputs = ['0😄', '1😄', '2😄', '3😄', '4😄', '5😄', '6😄', '7😄', '8😄', '9😄']; - // When we process each with insertZWNJBetweenDigitAndEmoji - // Then ZWNJ should be inserted for each + // When we process each with insertTextVSBetweenDigitAndEmoji + // Then FE0E should be inserted for each for (const input of inputs) { - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); - expect(result).toBe(`${input[0]}${ZWNJ}${input.slice(1)}`); + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + expect(result).toBe(`${input[0]}${FE0E}${input.slice(1)}`); } }); it('should handle various emoji types from different Unicode ranges', () => { // Given digits followed by emojis from different Unicode ranges // Miscellaneous Symbols (U+2600-U+27BF) - expect(EmojiUtils.insertZWNJBetweenDigitAndEmoji('1☀')).toBe(`1${ZWNJ}☀`); + expect(EmojiUtils.insertTextVSBetweenDigitAndEmoji('1☀')).toBe(`1${FE0E}☀`); // Miscellaneous Symbols and Pictographs (U+1F300-U+1F5FF) - expect(EmojiUtils.insertZWNJBetweenDigitAndEmoji('1🌟')).toBe(`1${ZWNJ}🌟`); + expect(EmojiUtils.insertTextVSBetweenDigitAndEmoji('1🌟')).toBe(`1${FE0E}🌟`); // Emoticons (U+1F600-U+1F64F) - expect(EmojiUtils.insertZWNJBetweenDigitAndEmoji('1😀')).toBe(`1${ZWNJ}😀`); + expect(EmojiUtils.insertTextVSBetweenDigitAndEmoji('1😀')).toBe(`1${FE0E}😀`); // Transport and Map Symbols (U+1F680-U+1F6FF) - expect(EmojiUtils.insertZWNJBetweenDigitAndEmoji('1🚀')).toBe(`1${ZWNJ}🚀`); + expect(EmojiUtils.insertTextVSBetweenDigitAndEmoji('1🚀')).toBe(`1${FE0E}🚀`); }); it('should handle consecutive digit-emoji pairs without spaces', () => { // Given consecutive digit-emoji pairs const input = '1😄2🚀3👍'; - // When we process it with insertZWNJBetweenDigitAndEmoji - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); - // Then ZWNJ should be inserted for each pair - expect(result).toBe(`1${ZWNJ}😄2${ZWNJ}🚀3${ZWNJ}👍`); + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then FE0E should be inserted for each pair + expect(result).toBe(`1${FE0E}😄2${FE0E}🚀3${FE0E}👍`); }); it('should simulate the Safari keycap bug scenario - typing "234:smile:"', () => { // Given the scenario where a user types "234" then adds :smile: emoji // After emoji shortcode conversion, we get "234😄" const afterEmojiConversion = '234😄'; - // When we apply the ZWNJ fix - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(afterEmojiConversion); - // Then ZWNJ should be inserted to prevent Safari's keycap sequence detection - expect(result).toBe(`234${ZWNJ}😄`); - // Verify the ZWNJ is actually in the string - expect(result.includes(ZWNJ)).toBe(true); - // Verify the result is different from input (ZWNJ was added) + // When we apply the FE0E fix + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(afterEmojiConversion); + // Then FE0E should be inserted to prevent Safari's keycap sequence detection + expect(result).toBe(`234${FE0E}😄`); + // Verify the FE0E is actually in the string + expect(result.includes(FE0E)).toBe(true); + // Verify the result is different from input (FE0E was added) expect(result.length).toBe(afterEmojiConversion.length + 1); }); @@ -614,10 +614,55 @@ describe('EmojiTest', () => { jest.spyOn(Browser, 'isSafari').mockReturnValue(false); // When we process a digit + emoji string const input = '234😄'; - const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input); - // Then the text should remain unchanged (no ZWNJ inserted) + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then the text should remain unchanged (no FE0E inserted) expect(result).toBe('234😄'); - expect(result.includes(ZWNJ)).toBe(false); + expect(result.includes(FE0E)).toBe(false); + }); + + it('should insert FE0E between hash symbol (#) and emoji', () => { + // Given a hash symbol immediately followed by an emoji + const input = '#😄'; + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then FE0E should be inserted between the hash and emoji + expect(result).toBe(`#${FE0E}😄`); + }); + + it('should insert FE0E between asterisk symbol (*) and emoji', () => { + // Given an asterisk symbol immediately followed by an emoji + const input = '*😄'; + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then FE0E should be inserted between the asterisk and emoji + expect(result).toBe(`*${FE0E}😄`); + }); + + it('should handle mixed digits and symbols (#, *) followed by emojis', () => { + // Given a string with digits, hash, and asterisk followed by emojis + const input = '1😄 #🚀 *👍'; + // When we process it with insertTextVSBetweenDigitAndEmoji + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then FE0E should be inserted between each symbol/digit and emoji + expect(result).toBe(`1${FE0E}😄 #${FE0E}🚀 *${FE0E}👍`); + }); + + it('should fix corrupted keycap sequence followed by emoji', () => { + // Given Safari has created "*️⃣😄" (corrupted keycap + emoji) + const corruptedKeycapWithEmoji = '*\uFE0F\u20E3😄'; + // When we process it + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(corruptedKeycapWithEmoji); + // Then it should be converted to "*\uFE0E😄" + expect(result).toBe(`*${FE0E}😄`); + }); + + it('should preserve legitimate standalone keycap emojis', () => { + // Given a legitimate standalone keycap emoji (like "*️⃣") + const input = '*\uFE0F\u20E3'; + // When we process it + const result = EmojiUtils.insertTextVSBetweenDigitAndEmoji(input); + // Then the keycap should be preserved (not followed by another emoji) + expect(result).toBe('*\uFE0F\u20E3'); }); }); });