Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
52 changes: 34 additions & 18 deletions src/libs/EmojiUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -814,8 +830,8 @@ export {
containsCustomEmoji,
containsOnlyCustomEmoji,
processFrequentlyUsedEmojis,
insertZWNJBetweenDigitAndEmoji,
getZWNJCursorOffset,
insertTextVSBetweenDigitAndEmoji,
getTextVSCursorOffset,
isPositionInsideCodeBlock,
getEmojiCodeForInsertion,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions src/pages/inbox/report/ReportActionItemMessageEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading