diff --git a/src/libs/convertToLTR/index.android.ts b/src/libs/convertToLTR/index.android.ts index d73fd3de7a21..0de503e3b6c6 100644 --- a/src/libs/convertToLTR/index.android.ts +++ b/src/libs/convertToLTR/index.android.ts @@ -1,10 +1,11 @@ -import CONST from '@src/CONST'; -import ConvertToLTR from './types'; - /** * Android only - convert RTL text to a LTR text using Unicode controls. - * https://www.w3.org/International/questions/qa-bidi-unicode-controls + * + * In React Native, when working with bidirectional text (RTL - Right-to-Left or LTR - Left-to-Right), you may encounter issues related to text rendering, especially on Android devices. These issues arise because Android's default behavior for text direction might not always align with the desired directionality of your app. */ +import CONST from '@src/CONST'; +import ConvertToLTR from './types'; + const convertToLTR: ConvertToLTR = (text) => `${CONST.UNICODE.LTR}${text}`; export default convertToLTR; diff --git a/src/libs/convertToLTR/index.ts b/src/libs/convertToLTR/index.ts index 58d8be93836e..0dca0d0b3ace 100644 --- a/src/libs/convertToLTR/index.ts +++ b/src/libs/convertToLTR/index.ts @@ -1,3 +1,4 @@ +// The Android platform has to handle switching between LTR and RTL languages a bit differently (https://developer.android.com/training/basics/supporting-devices/languages). For all other platforms, these can simply be no-op functions. import ConvertToLTR from './types'; const convertToLTR: ConvertToLTR = (text) => text; diff --git a/src/libs/convertToLTRForComposer/index.android.ts b/src/libs/convertToLTRForComposer/index.android.ts index 09e7f2e5cd87..0d68baa80e3a 100644 --- a/src/libs/convertToLTRForComposer/index.android.ts +++ b/src/libs/convertToLTRForComposer/index.android.ts @@ -1,8 +1,69 @@ +import CONST from '@src/CONST'; import ConvertToLTRForComposer from './types'; +/** + * Android only - The composer can be converted to LTR if its content is the LTR character followed by an @ or space + * because to mention sugggestion works the @ character must not have any character at the beginning e.g.: \u2066@ doesn't work + * also to avoid sending empty messages the unicode character with space could enable the send button. + */ +function canComposerBeConvertedToLTR(text: string): boolean { + // This regex handles the case when a user only types spaces into the composer. + const containOnlySpaces = /^\s*$/; + // This regex handles the case where someone has RTL enabled and they began typing an @mention for someone. + const startsWithLTRAndAt = new RegExp(`^${CONST.UNICODE.LTR}@$`); + // This regex handles the case where the composer can contain multiple lines of whitespace + const startsWithLTRAndSpace = new RegExp(`${CONST.UNICODE.LTR}\\s*$`); + const emptyExpressions = [containOnlySpaces, startsWithLTRAndAt, startsWithLTRAndSpace]; + return emptyExpressions.some((exp) => exp.test(text)); +} + +/** + * Android only - We should remove the LTR unicode when the input is empty to prevent: + * Sending an empty message; + * Mention suggestions not works if @ or \s (at or space) is the first character; + * Placeholder is not displayed if the unicode character is the only character remaining; + * + * @param {String} newComment - the comment written by the user + * @param {Boolean} force - always remove the LTR unicode, going to be used when composer is consider as empty + * @return {String} + */ + +const resetLTRWhenEmpty = (newComment: string, force?: boolean) => { + const result = newComment.length <= 1 || force ? newComment.replaceAll(CONST.UNICODE.LTR, '') : newComment; + return result; +}; + /** * Android only - Do not convert RTL text to a LTR text for input box using Unicode controls. * Android does not properly support bidirectional text for mixed content for input box */ -const convertToLTRForComposer: ConvertToLTRForComposer = (text) => text; +const convertToLTRForComposer: ConvertToLTRForComposer = (text, isComposerEmpty) => { + const shouldComposerMaintainAsLTR = canComposerBeConvertedToLTR(text); + const newText = resetLTRWhenEmpty(text, shouldComposerMaintainAsLTR); + if (shouldComposerMaintainAsLTR) { + return newText; + } + return isComposerEmpty ? `${CONST.UNICODE.LTR}${newText}` : newText; +}; + +/** + * This is necessary to convert the input to LTR, there is a delay that causes the cursor not to go to the end of the input line when pasting text or typing fast. The delay is caused for the time that takes the input to convert from RTL to LTR and viceversa. + */ +const moveCursorToEndOfLine = ( + commentLength: number, + setSelection: ( + value: React.SetStateAction<{ + start: number; + end: number; + }>, + ) => void, +) => { + setSelection({ + start: commentLength + 1, + end: commentLength + 1, + }); +}; + +export {moveCursorToEndOfLine}; + export default convertToLTRForComposer; diff --git a/src/libs/convertToLTRForComposer/index.ts b/src/libs/convertToLTRForComposer/index.ts index dd6ee50d862e..9286a41a6712 100644 --- a/src/libs/convertToLTRForComposer/index.ts +++ b/src/libs/convertToLTRForComposer/index.ts @@ -30,4 +30,9 @@ const convertToLTRForComposer: ConvertToLTRForComposer = (text) => { // Add the LTR marker to the beginning of the text. return `${CONST.UNICODE.LTR}${text}`; }; + +const moveCursorToEndOfLine = (commentLength: number) => commentLength; + +export {moveCursorToEndOfLine}; + export default convertToLTRForComposer; diff --git a/src/libs/convertToLTRForComposer/types.ts b/src/libs/convertToLTRForComposer/types.ts index c6edeaaba446..88f468ef1843 100644 --- a/src/libs/convertToLTRForComposer/types.ts +++ b/src/libs/convertToLTRForComposer/types.ts @@ -1,3 +1,3 @@ -type ConvertToLTRForComposer = (text: string) => string; +type ConvertToLTRForComposer = (text: string, isComposerEmpty?: boolean) => string; export default ConvertToLTRForComposer; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js index ea48f9cc931e..926a2e75384e 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js @@ -14,7 +14,7 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import compose from '@libs/compose'; import * as ComposerUtils from '@libs/ComposerUtils'; import getDraftComment from '@libs/ComposerUtils/getDraftComment'; -import convertToLTRForComposer from '@libs/convertToLTRForComposer'; +import convertToLTRForComposer, {moveCursorToEndOfLine} from '@libs/convertToLTRForComposer'; import * as EmojiUtils from '@libs/EmojiUtils'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; import * as KeyDownListener from '@libs/KeyboardShortcut/KeyDownPressListener'; @@ -105,6 +105,7 @@ function ComposerWithSuggestions({ const styles = useThemeStyles(); const {preferredLocale} = useLocalize(); const isFocused = useIsFocused(); + const composerIsEmpty = useRef(true); const navigation = useNavigation(); const emojisPresentBefore = useRef([]); const [value, setValue] = useState(() => { @@ -220,18 +221,33 @@ function ComposerWithSuggestions({ debouncedUpdateFrequentlyUsedEmojis(); } } - const newCommentConverted = convertToLTRForComposer(newComment); - const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); - const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); + + let newCommentConvertedToLTR = newComment; + const prevComment = commentRef.current; + + // This prevent the double execution of setting input value that could affect the place holder and could send an empty message or draft messages in android + if (prevComment !== newComment) { + newCommentConvertedToLTR = convertToLTRForComposer(newCommentConvertedToLTR, composerIsEmpty.current); + setValue(newCommentConvertedToLTR); + moveCursorToEndOfLine(newComment.length, setSelection); + composerIsEmpty.current = false; + } + + const isNewCommentEmpty = !!newCommentConvertedToLTR.match(/^(\s)*$/); + const isPrevCommentEmpty = !!prevComment.match(/^(\s)*$/); /** Only update isCommentEmpty state if it's different from previous one */ if (isNewCommentEmpty !== isPrevCommentEmpty) { setIsCommentEmpty(isNewCommentEmpty); + if (isNewCommentEmpty) { + composerIsEmpty.current = true; + } } + emojisPresentBefore.current = emojis; - setValue(newCommentConverted); + if (commentValue !== newComment) { - const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0); + const position = Math.max(selection.end + (newComment.length - prevComment.length), cursorPosition || 0); setSelection({ start: position, end: position, @@ -239,22 +255,22 @@ function ComposerWithSuggestions({ } // Indicate that draft has been created. - if (commentRef.current.length === 0 && newCommentConverted.length !== 0) { + if (prevComment.length === 0 && newCommentConvertedToLTR.length !== 0) { Report.setReportWithDraft(reportID, true); } // The draft has been deleted. - if (newCommentConverted.length === 0) { + if (newCommentConvertedToLTR.length === 0) { Report.setReportWithDraft(reportID, false); } - commentRef.current = newCommentConverted; + commentRef.current = newCommentConvertedToLTR; if (shouldDebounceSaveComment) { - debouncedSaveReportComment(reportID, newCommentConverted); + debouncedSaveReportComment(reportID, newCommentConvertedToLTR); } else { - Report.saveReportComment(reportID, newCommentConverted || ''); + Report.saveReportComment(reportID, newCommentConvertedToLTR || ''); } - if (newCommentConverted) { + if (newCommentConvertedToLTR) { debouncedBroadcastUserIsTyping(reportID); } }, @@ -268,6 +284,7 @@ function ComposerWithSuggestions({ raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, selection.end, + composerIsEmpty, ], );