Skip to content
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@
"jsSrcsDir",
"Kearny",
"keyalg",
"keycap",
"keycommand",
"keyevent",
"keypass",
Expand Down
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3756,6 +3756,9 @@ 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,

TAX_ID: /^\d{9}$/,
NON_NUMERIC: /\D/g,
ANY_SPACE: /\s/g,
Expand Down
36 changes: 36 additions & 0 deletions src/libs/EmojiUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {isFullySupportedLocale} from '@src/CONST/LOCALES';
import type {FrequentlyUsedEmoji, Locale} from '@src/types/onyx';
import type {ReportActionReaction, UsersReactions} from '@src/types/onyx/ReportActionReactions';
import type IconAsset from '@src/types/utils/IconAsset';
import {isSafari} from './Browser';
import type EmojiTrie from './EmojiTrie';
import memoize from './memoize';

Expand Down Expand Up @@ -662,6 +663,39 @@ function containsOnlyCustomEmoji(text?: string): boolean {
return privateUseAreaRegex.test(text);
}

/**
* Insert ZWNJ (Zero-Width Non-Joiner) between digits 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.
*
* 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.
*
* Example: "234😄" becomes "234\u200C😄" (ZWNJ is invisible but prevents Safari's corruption)
*/
function insertZWNJBetweenDigitAndEmoji(input: string): string {
if (!isSafari()) {
return input;
}
return input.replaceAll(CONST.REGEX.DIGIT_FOLLOWED_BY_EMOJI, '$1\u200C$2');
}

/**
* Calculate the ZWNJ offset for cursor position adjustment.
* Returns the number of ZWNJ characters inserted before the cursor position.
*/
function getZWNJCursorOffset(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;
}

export type {HeaderIndices, EmojiPickerList, EmojiPickerListItem};

export {
Expand Down Expand Up @@ -689,4 +723,6 @@ export {
containsCustomEmoji,
containsOnlyCustomEmoji,
processFrequentlyUsedEmojis,
insertZWNJBetweenDigitAndEmoji,
getZWNJCursorOffset,
};
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,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, replaceAndExtractEmojis} from '@libs/EmojiUtils';
import {containsOnlyEmojis, extractEmojis, getAddedEmojis, getZWNJCursorOffset, insertZWNJBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils';
import focusComposerWithDelay from '@libs/focusComposerWithDelay';
import type {ForwardedFSClassProps} from '@libs/Fullstory/types';
import getPlatform from '@libs/getPlatform';
Expand Down Expand Up @@ -395,7 +395,11 @@ function ComposerWithSuggestions({
diff.trim() === diff &&
containsOnlyEmojis(diff);
const commentWithSpaceInserted = isEmojiInserted ? insertWhiteSpaceAtIndex(effectiveCommentValue, endIndex) : effectiveCommentValue;
const {text: newComment, emojis, cursorPosition} = replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale);
const {text: emojiConvertedText, emojis, cursorPosition} = replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale);

const newComment = insertZWNJBetweenDigitAndEmoji(emojiConvertedText);
const zwnjOffset = getZWNJCursorOffset(emojiConvertedText, cursorPosition);

if (emojis.length) {
const newEmojis = getAddedEmojis(emojis, emojisPresentBefore.current);
if (newEmojis.length) {
Expand All @@ -417,7 +421,8 @@ function ComposerWithSuggestions({

setValue(newCommentConverted);
if (commentValue !== newComment) {
const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), cursorPosition ?? 0);
const adjustedCursorPosition = cursorPosition !== undefined && cursorPosition !== null ? cursorPosition + zwnjOffset : undefined;
const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), adjustedCursorPosition ?? 0);

if (commentWithSpaceInserted !== newComment && isIOSNative) {
syncSelectionWithOnChangeTextRef.current = {position, value: newComment};
Expand Down
11 changes: 7 additions & 4 deletions src/pages/home/report/ReportActionItemMessageEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton';
import ExceededCommentLength from '@components/ExceededCommentLength';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';

Check warning on line 15 in src/pages/home/report/ReportActionItemMessageEdit.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'@components/Icon/Expensicons' import is restricted from being used by a pattern. Direct imports from Icon/Expensicons are deprecated. Please use lazy loading hooks instead. Use `useMemoizedLazyExpensifyIcons` from @hooks/useLazyAsset. See docs/LAZY_ICONS_AND_ILLUSTRATIONS.md for details

Check warning on line 15 in src/pages/home/report/ReportActionItemMessageEdit.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'@components/Icon/Expensicons' import is restricted from being used. Direct imports from @components/Icon/Expensicons are deprecated. Please use lazy loading hooks instead. Use `useMemoizedLazyExpensifyIcons` from @hooks/useLazyAsset. See docs/LAZY_ICONS_AND_ILLUSTRATIONS.md for details
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Tooltip from '@components/Tooltip';
import useAncestors from '@hooks/useAncestors';
Expand All @@ -33,10 +33,10 @@
import {clearActive, isActive as isEmojiPickerActive, isEmojiPickerVisible} from '@libs/actions/EmojiPickerAction';
import {composerFocusKeepFocusOn} from '@libs/actions/InputFocus';
import {deleteReportActionDraft, editReportComment, saveReportActionDraft} from '@libs/actions/Report';
import {isMobileChrome} from '@libs/Browser/index.website';
import {isMobileChrome} from '@libs/Browser';
import {canSkipTriggerHotkeys, insertText} from '@libs/ComposerUtils';
import DomUtils from '@libs/DomUtils';
import {extractEmojis, replaceAndExtractEmojis} from '@libs/EmojiUtils';
import {extractEmojis, getZWNJCursorOffset, insertZWNJBetweenDigitAndEmoji, replaceAndExtractEmojis} from '@libs/EmojiUtils';
import focusComposerWithDelay from '@libs/focusComposerWithDelay';
import type {Selection} from '@libs/focusComposerWithDelay/types';
import focusEditAfterCancelDelete from '@libs/focusEditAfterCancelDelete';
Expand Down Expand Up @@ -242,14 +242,17 @@
const updateDraft = useCallback(
(newDraftInput: string) => {
raiseIsScrollLayoutTriggered();
const {text: newDraft, emojis, cursorPosition} = replaceAndExtractEmojis(newDraftInput, preferredSkinTone, preferredLocale);
const {text: emojiConvertedText, emojis, cursorPosition} = replaceAndExtractEmojis(newDraftInput, preferredSkinTone, preferredLocale);
const newDraft = insertZWNJBetweenDigitAndEmoji(emojiConvertedText);
Comment thread
abbasifaizan70 marked this conversation as resolved.
const zwnjOffset = getZWNJCursorOffset(emojiConvertedText, cursorPosition);

emojisPresentBefore.current = emojis;

setDraft(newDraft);

if (newDraftInput !== newDraft) {
const position = Math.max((selection?.end ?? 0) + (newDraft.length - draftRef.current.length), cursorPosition ?? 0);
const adjustedCursorPosition = cursorPosition !== undefined && cursorPosition !== null ? cursorPosition + zwnjOffset : undefined;
const position = Math.max((selection?.end ?? 0) + (newDraft.length - draftRef.current.length), adjustedCursorPosition ?? 0);
setSelection({
start: position,
end: position,
Expand Down
163 changes: 163 additions & 0 deletions tests/unit/EmojiTest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import Emojis, {importEmojiLocale} from '@assets/emojis';
import type {Emoji} from '@assets/emojis/types';
// eslint-disable-next-line no-restricted-syntax
import * as Browser from '@libs/Browser';
import {buildEmojisTrie} from '@libs/EmojiTrie';
// eslint-disable-next-line no-restricted-syntax
import * as EmojiUtils from '@libs/EmojiUtils';
Expand Down Expand Up @@ -280,4 +282,165 @@ describe('EmojiTest', () => {
]);
});
});

describe('insertZWNJBetweenDigitAndEmoji', () => {
// ZWNJ character for comparison
const ZWNJ = '\u200C';

// Mock isSafari to return true for these tests since the function only applies on Safari
beforeEach(() => {
jest.spyOn(Browser, 'isSafari').mockReturnValue(true);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('should insert ZWNJ 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}😄`);
});

it('should insert ZWNJ 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}😄`);
});

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}👍`);
});

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);
// Then the text should remain unchanged
expect(result).toBe('1 😄');
});

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);
// Then the text should remain unchanged
expect(result).toBe('12345');
});

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);
// Then the text should remain unchanged
expect(result).toBe('😄🚀👍');
});

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);
// Then the text should remain unchanged
expect(result).toBe('😄1');
});

it('should handle empty string', () => {
// Given an empty string
const input = '';
// When we process it with insertZWNJBetweenDigitAndEmoji
const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input);
// Then the result should be an empty string
expect(result).toBe('');
});

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);
// Then the text should remain unchanged
expect(result).toBe('Hello World');
});

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`);
});

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
for (const input of inputs) {
const result = EmojiUtils.insertZWNJBetweenDigitAndEmoji(input);
expect(result).toBe(`${input[0]}${ZWNJ}${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}☀`);
// Miscellaneous Symbols and Pictographs (U+1F300-U+1F5FF)
expect(EmojiUtils.insertZWNJBetweenDigitAndEmoji('1🌟')).toBe(`1${ZWNJ}🌟`);
// Emoticons (U+1F600-U+1F64F)
expect(EmojiUtils.insertZWNJBetweenDigitAndEmoji('1😀')).toBe(`1${ZWNJ}😀`);
// Transport and Map Symbols (U+1F680-U+1F6FF)
expect(EmojiUtils.insertZWNJBetweenDigitAndEmoji('1🚀')).toBe(`1${ZWNJ}🚀`);
});

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}👍`);
});

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)
expect(result.length).toBe(afterEmojiConversion.length + 1);
});

it('should return input unchanged on non-Safari browsers', () => {
// Given we're not on Safari
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)
expect(result).toBe('234😄');
expect(result.includes(ZWNJ)).toBe(false);
});
});
});
Loading