diff --git a/patches/react-native+0.73.4+017.patch b/patches/react-native+0.73.4+017.patch new file mode 100644 index 000000000000..aad2700391ed --- /dev/null +++ b/patches/react-native+0.73.4+017.patch @@ -0,0 +1,137 @@ +diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts +index 2c0c099..472877a 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts ++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts +@@ -451,6 +451,9 @@ export interface TextInputKeyPressEventData { + export interface TextInputChangeEventData extends TargetedEvent { + eventCount: number; + text: string; ++ before: number; ++ start: number; ++ count: number; + } + + /** +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +index 7ce04da..b7c3b6f 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm +@@ -55,6 +55,10 @@ @implementation RCTTextInputComponentView { + */ + BOOL _comingFromJS; + BOOL _didMoveToWindow; ++ ++ NSInteger changeStart; ++ NSInteger changeBefore; ++ NSInteger changeCount; + } + + #pragma mark - UIView overrides +@@ -344,6 +348,10 @@ - (NSString *)textInputShouldChangeText:(NSString *)text inRange:(NSRange)range + { + const auto &props = static_cast(*_props); + ++ changeStart = range.location; ++ changeBefore = range.length; ++ changeCount = text.length; ++ + if (!_backedTextInputView.textWasPasted) { + if (_eventEmitter) { + KeyPressMetrics keyPressMetrics; +@@ -540,6 +548,9 @@ - (TextInputMetrics)_textInputMetrics + metrics.text = RCTStringFromNSString(_backedTextInputView.attributedText.string); + metrics.selectionRange = [self _selectionRange]; + metrics.eventCount = _mostRecentEventCount; ++ metrics.count = static_cast(changeCount); ++ metrics.before = static_cast(changeBefore); ++ metrics.start = static_cast(changeStart); + + CGPoint contentOffset = _backedTextInputView.contentOffset; + metrics.contentOffset = {contentOffset.x, contentOffset.y}; +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextChangedEvent.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextChangedEvent.java +index 4540b90..c44a11f 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextChangedEvent.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextChangedEvent.java +@@ -23,16 +23,23 @@ public class ReactTextChangedEvent extends Event { + + private String mText; + private int mEventCount; ++ // See https://developer.android.com/reference/android/text/TextWatcher#onTextChanged(java.lang.CharSequence,%20int,%20int,%20int) ++ private int mStart; ++ private int mCount; ++ private int mBefore; + + @Deprecated +- public ReactTextChangedEvent(int viewId, String text, int eventCount) { +- this(ViewUtil.NO_SURFACE_ID, viewId, text, eventCount); ++ public ReactTextChangedEvent(int viewId, String text, int eventCount, int start, int count, int before) { ++ this(ViewUtil.NO_SURFACE_ID, viewId, text, eventCount, start, count, before); + } + +- public ReactTextChangedEvent(int surfaceId, int viewId, String text, int eventCount) { ++ public ReactTextChangedEvent(int surfaceId, int viewId, String text, int eventCount, int start, int count, int before) { + super(surfaceId, viewId); + mText = text; + mEventCount = eventCount; ++ mStart = start; ++ mCount = count; ++ mBefore = before; + } + + @Override +@@ -47,6 +54,9 @@ public class ReactTextChangedEvent extends Event { + eventData.putString("text", mText); + eventData.putInt("eventCount", mEventCount); + eventData.putInt("target", getViewTag()); ++ eventData.putInt("start", mStart); ++ eventData.putInt("count", mCount); ++ eventData.putInt("before", mBefore); + return eventData; + } + } +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +index 8496a7d..fd5076b 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java +@@ -1068,7 +1068,12 @@ public class ReactTextInputManager extends BaseViewManager (textInput.current = el)} - selection={selection} - style={[inputStyleMemo]} + style={inputStyleMemo} markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} autoFocus={autoFocus} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} + selection={selection} onSelectionChange={addCursorPositionToSelectionChange} onContentSizeChange={(e) => { setTextInputWidth(`${e.nativeEvent.contentSize.width}px`); diff --git a/src/libs/SuggestionUtils.ts b/src/libs/SuggestionUtils.ts index 96379ce49ef3..2e2b1fbedbf4 100644 --- a/src/libs/SuggestionUtils.ts +++ b/src/libs/SuggestionUtils.ts @@ -1,3 +1,5 @@ +import type {TextSelection} from '@components/Composer/types'; +import type {HandleComposerUpdateArgs} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestions/types'; import CONST from '@src/CONST'; /** @@ -20,4 +22,24 @@ function hasEnoughSpaceForLargeSuggestionMenu(listHeight: number, composerHeight return availableHeight > menuHeight; } -export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu}; +/** + * Replaces the selection range in currentText with the insert value + */ +function getComposerUpdateArgsForSuggestionToInsert(currentText: string, insert: string, selection: TextSelection): HandleComposerUpdateArgs { + const textBefore = currentText.slice(0, selection.start); + const textAfter = currentText.slice(selection.end); + + const newText = `${insert} `; + const firstPart = textBefore + newText; + const fullNewText = firstPart + trimLeadingSpace(textAfter); + const endPosition = firstPart.length; + + return { + diffText: newText, + fullNewText, + endPositionOfNewAddedText: endPosition, + shouldDebounceSaveComment: true, + }; +} + +export {trimLeadingSpace, hasEnoughSpaceForLargeSuggestionMenu, getComposerUpdateArgsForSuggestionToInsert}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 72d387b07f52..b03918784df8 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -7,6 +7,7 @@ import type { MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInput, + TextInputChangeEventData, TextInputFocusEventData, TextInputKeyPressEventData, TextInputScrollEventData, @@ -57,16 +58,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; - -type SyncSelection = { - position: number; - value: string; -}; +import type {HandleComposerUpdateCallback} from './types'; type AnimatedRef = ReturnType; -type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; - type ComposerWithSuggestionsOnyxProps = { /** The parent report actions for the report */ parentReportActions: OnyxEntry; @@ -274,15 +269,18 @@ function ComposerWithSuggestions( const mobileInputScrollPosition = useRef(0); const cursorPositionValue = useSharedValue({x: 0, y: 0}); const tag = useSharedValue(-1); - const draftComment = getDraftComment(reportID) ?? ''; - const [value, setValue] = useState(() => { + const [value, setValueInternal] = useState(() => { + const draftComment = getDraftComment(reportID) ?? ''; if (draftComment) { emojisPresentBefore.current = EmojiUtils.extractEmojis(draftComment); } return draftComment; }); - const commentRef = useRef(value); - const lastTextRef = useRef(value); + const valueRef = useRef(value); + const setValue = useCallback((newValue: string) => { + valueRef.current = newValue; + setValueInternal(newValue); + }, []); const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; @@ -295,17 +293,12 @@ function ComposerWithSuggestions( (shouldFocusInputOnScreenFocus || (isEmptyChat && !ReportActionsUtils.isTransactionThread(parentReportAction))) && shouldShowComposeInput; - const valueRef = useRef(value); - valueRef.current = value; - const [selection, setSelection] = useState(() => ({start: 0, end: 0, positionX: 0, positionY: 0})); const [composerHeight, setComposerHeight] = useState(0); const textInputRef = useRef(null); - const syncSelectionWithOnChangeTextRef = useRef(null); - // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); @@ -341,11 +334,11 @@ function ComposerWithSuggestions( useEffect(() => { const switchToCurrentReport = DeviceEventEmitter.addListener(`switchToPreExistingReport_${reportID}`, ({preexistingReportID, callback}: SwitchToCurrentReportProps) => { - if (!commentRef.current) { + if (!valueRef.current) { callback(); return; } - Report.saveReportDraftComment(preexistingReportID, commentRef.current, callback); + Report.saveReportDraftComment(preexistingReportID, valueRef.current, callback); }); return () => { @@ -353,106 +346,13 @@ function ComposerWithSuggestions( }; }, [reportID]); - /** - * Find the newly added characters between the previous text and the new text based on the selection. - * - * @param prevText - The previous text. - * @param newText - The new text. - * @returns An object containing information about the newly added characters. - * @property startIndex - The start index of the newly added characters in the new text. - * @property endIndex - The end index of the newly added characters in the new text. - * @property diff - The newly added characters. - */ - const findNewlyAddedChars = useCallback( - (prevText: string, newText: string): NewlyAddedChars => { - let startIndex = -1; - let endIndex = -1; - let currentIndex = 0; - - // Find the first character mismatch with newText - while (currentIndex < newText.length && prevText.charAt(currentIndex) === newText.charAt(currentIndex) && selection.start > currentIndex) { - currentIndex++; - } - - if (currentIndex < newText.length) { - startIndex = currentIndex; - const commonSuffixLength = ComposerUtils.findCommonSuffixLength(prevText, newText, selection?.end ?? 0); - // if text is getting pasted over find length of common suffix and subtract it from new text length - if (commonSuffixLength > 0 || (selection?.end ?? 0) - selection.start > 0) { - endIndex = newText.length - commonSuffixLength; - } else { - endIndex = currentIndex + newText.length; - } - } - return { - startIndex, - endIndex, - diff: newText.substring(startIndex, endIndex), - }; - }, - [selection.start, selection.end], - ); - - /** - * Update the value of the comment in Onyx - */ - const updateComment = useCallback( - (commentValue: string, shouldDebounceSaveComment?: boolean) => { - raiseIsScrollLikelyLayoutTriggered(); - const {startIndex, endIndex, diff} = findNewlyAddedChars(lastTextRef.current, commentValue); - const isEmojiInserted = diff.length && endIndex > startIndex && diff.trim() === diff && EmojiUtils.containsOnlyEmojis(diff); - const commentWithSpaceInserted = isEmojiInserted ? ComposerUtils.insertWhiteSpaceAtIndex(commentValue, endIndex) : commentValue; - const {text: newComment, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale); - if (emojis.length) { - const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); - if (newEmojis.length) { - // Ensure emoji suggestions are hidden after inserting emoji even when the selection is not changed - if (suggestionsRef.current) { - suggestionsRef.current.resetSuggestions(); - } - } - } - const newCommentConverted = convertToLTRForComposer(newComment); - const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/); - const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/); - - /** Only update isCommentEmpty state if it's different from previous one */ - if (isNewCommentEmpty !== isPrevCommentEmpty) { - setIsCommentEmpty(isNewCommentEmpty); - } - emojisPresentBefore.current = emojis; - setValue(newCommentConverted); - if (commentValue !== newComment) { - const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), cursorPosition ?? 0); - - if (commentWithSpaceInserted !== newComment && isIOSNative) { - syncSelectionWithOnChangeTextRef.current = {position, value: newComment}; - } - - setSelection((prevSelection) => ({ - start: position, - end: position, - positionX: prevSelection.positionX, - positionY: prevSelection.positionY, - })); - } - - commentRef.current = newCommentConverted; - if (shouldDebounceSaveComment) { - isCommentPendingSaved.current = true; - debouncedSaveReportComment(reportID, newCommentConverted); - } else { - Report.saveReportDraftComment(reportID, newCommentConverted); - } - if (newCommentConverted) { - debouncedBroadcastUserIsTyping(reportID); - } - }, - [findNewlyAddedChars, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, selection.end], - ); + useEffect(() => { + const isCommentEmpty = !!value.match(/^(\s)*$/); + setIsCommentEmpty(isCommentEmpty); + }, [setIsCommentEmpty, value]); const prepareCommentAndResetComposer = useCallback((): string => { - const trimmedComment = commentRef.current.trim(); + const trimmedComment = valueRef.current.trim(); const commentLength = ReportUtils.getCommentLength(trimmedComment, {reportID}); // Don't submit empty comments or comments that exceed the character limit @@ -467,26 +367,16 @@ function ComposerWithSuggestions( isCommentPendingSaved.current = false; setSelection({start: 0, end: 0, positionX: 0, positionY: 0}); - updateComment(''); + setValue(''); setTextInputShouldClear(true); if (isComposerFullSize) { Report.setIsComposerFullSize(reportID, false); } setIsFullComposerAvailable(false); - return trimmedComment; - }, [updateComment, setTextInputShouldClear, isComposerFullSize, setIsFullComposerAvailable, reportID, debouncedSaveReportComment]); + Report.saveReportDraftComment(reportID, ''); - /** - * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) - */ - const replaceSelectionWithText = useCallback( - (text: string) => { - // selection replacement should be debounced to avoid conflicts with text typing - // (f.e. when emoji is being picked and 1 second still did not pass after user finished typing) - updateComment(ComposerUtils.insertText(commentRef.current, selection, text), true); - }, - [selection, updateComment], - ); + return trimmedComment; + }, [reportID, debouncedSaveReportComment, setValue, setTextInputShouldClear, isComposerFullSize, setIsFullComposerAvailable]); const triggerHotkeyActions = useCallback( (event: NativeSyntheticEvent) => { @@ -526,23 +416,128 @@ function ComposerWithSuggestions( [isSmallScreenWidth, isKeyboardShown, suggestionsRef, includeChronos, handleSendMessage, lastReportAction, reportID], ); - const onChangeText = useCallback( - (commentValue: string) => { - updateComment(commentValue, true); + /** + * Composer updates are partial text updates. Meaning if a event occurs (such as inserting an emoji, or the user pressing a character on their keyboard), + * we only append/insert the text that has changed. This is to avoid descynchronization issues where text updates are coming from another thread (RN), see issue #37896. + */ + const handleComposerUpdate: HandleComposerUpdateCallback = useCallback( + ({fullNewText, diffText, endPositionOfNewAddedText, shouldDebounceSaveComment}) => { + raiseIsScrollLikelyLayoutTriggered(); + // Check for emojis: + // - Either add a whitespace if the user typed an emoji + // - Or insert an emoji when the user types :emojiCode: + // - Extract all emojis from the updated text and update the frequently used emojis + const isEmojiInserted = diffText.trim() === diffText && EmojiUtils.containsOnlyEmojis(diffText); + const commentWithSpaceInserted = isEmojiInserted ? ComposerUtils.insertWhiteSpaceAtIndex(fullNewText, endPositionOfNewAddedText) : fullNewText; + const {text: newComment, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(commentWithSpaceInserted, preferredSkinTone, preferredLocale); + if (emojis.length) { + const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current); + if (newEmojis.length) { + // Ensure emoji suggestions are hidden after inserting emoji even when the selection is not changed + if (suggestionsRef.current) { + suggestionsRef.current.resetSuggestions(); + } + } + } + emojisPresentBefore.current = emojis; + + // Make LTR compatible if needed + const newCommentConverted = convertToLTRForComposer(newComment); - if (isIOSNative && syncSelectionWithOnChangeTextRef.current) { - const positionSnapshot = syncSelectionWithOnChangeTextRef.current.position; - syncSelectionWithOnChangeTextRef.current = null; + // Update state + setValue(newCommentConverted); - // ensure that selection is set imperatively after all state changes are effective - InteractionManager.runAfterInteractions(() => { - // note: this implementation is only available on non-web RN, thus the wrapping - // 'if' block contains a redundant (since the ref is only used on iOS) platform check - textInputRef.current?.setSelection(positionSnapshot, positionSnapshot); - }); + // Update selection eventually + if (newComment !== fullNewText) { + const position = Math.max((endPositionOfNewAddedText ?? 0) + (newComment.length - fullNewText.length), cursorPosition ?? 0); + setSelection((prevSelection) => ({ + start: position, + end: position, + positionX: prevSelection.positionX, + positionY: prevSelection.positionY, + })); + if (isIOSNative) { + // ensure that selection is set imperatively after all state changes are effective + InteractionManager.runAfterInteractions(() => { + // note: this implementation is only available on non-web RN, thus the wrapping + // 'if' block contains a redundant (since the ref is only used on iOS) platform check + textInputRef.current?.setSelection(position, position); + }); + } + } + + // Update onyx related state: + if (shouldDebounceSaveComment) { + isCommentPendingSaved.current = true; + debouncedSaveReportComment(reportID, newCommentConverted); + } else { + Report.saveReportDraftComment(reportID, newCommentConverted); + } + if (newCommentConverted) { + debouncedBroadcastUserIsTyping(reportID); } }, - [updateComment], + [debouncedSaveReportComment, preferredLocale, preferredSkinTone, raiseIsScrollLikelyLayoutTriggered, reportID, setValue, suggestionsRef], + ); + + // This contains the previous value that we receive directly from the native text input (not our formatted value) + const prevNativeTextRef = useRef(value); + /** + * This is called by the input when the input value changes. It prepares the diff update for calling handleComposerUpdate. + */ + const handleInputChange = useCallback( + ({nativeEvent, target}: NativeSyntheticEvent) => { + const {count, start, before} = nativeEvent; + let nativeText = nativeEvent.text; + if (nativeText === undefined) { + // Assume we are on a platform where the text is stored in another field called value (e.g. web) + // @ts-expect-error Not properly typed + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + nativeText = target.value; + } + + const previousNativeText = prevNativeTextRef.current; + prevNativeTextRef.current = nativeText; + + if (nativeText === valueRef.current || nativeText === previousNativeText) { + // The text hasn't changed (note: the handler gets called for selection changes as well) + return; + } + + // Within "nativeText", the "count" characters beginning at "start" have just replaced old text (valueRef.current) that had length "before". + const endPosition = start + count; + const diffText = nativeText.substring(start, endPosition); + // Replace newText in the original text: + const currentText = valueRef.current; + const fullNewText = currentText.substring(0, start) + diffText + currentText.substring(start + before); + + handleComposerUpdate({ + fullNewText, + diffText, + endPositionOfNewAddedText: endPosition, + shouldDebounceSaveComment: true, + }); + }, + [handleComposerUpdate], + ); + + /** + * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) + */ + const replaceSelectionWithText = useCallback( + (text: string) => { + const newFullText = ComposerUtils.insertText(valueRef.current, selection, text); + const endPositionOfNewAddedText = selection.start + text.length; + handleComposerUpdate({ + fullNewText: newFullText, + diffText: text, + endPositionOfNewAddedText, + // selection replacement should be debounced to avoid conflicts with text typing + // (f.e. when emoji is being picked and 1 second still did not pass after user finished typing) + shouldDebounceSaveComment: true, + }); + }, + [selection, handleComposerUpdate], ); const onSelectionChange = useCallback( @@ -697,11 +692,6 @@ function ComposerWithSuggestions( }), [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText], ); - - useEffect(() => { - lastTextRef.current = value; - }, [value]); - useEffect(() => { onValueChange(value); }, [onValueChange, value]); @@ -772,7 +762,7 @@ function ComposerWithSuggestions( ref={setTextInputRef} placeholder={inputPlaceholder} placeholderTextColor={theme.placeholderText} - onChangeText={onChangeText} + onChange={handleInputChange} onKeyPress={triggerHotkeyActions} textAlignVertical="top" style={[styles.textInputCompose, isComposerFullSize ? styles.textInputFullCompose : styles.textInputCollapseCompose]} @@ -803,13 +793,12 @@ function ComposerWithSuggestions( )} diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/types.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/types.tsx new file mode 100644 index 000000000000..e2b7378af4df --- /dev/null +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/types.tsx @@ -0,0 +1,14 @@ +type HandleComposerUpdateArgs = { + // The full new text you'd like to display in the composer + fullNewText: string; + // The difference between the new text and the previous text + diffText: string; + // The position of the end of the newly added text + endPositionOfNewAddedText: number; + // Whether to debounce the saving of the comment + shouldDebounceSaveComment: boolean; +}; + +type HandleComposerUpdateCallback = (args: HandleComposerUpdateArgs) => void; + +export type {HandleComposerUpdateArgs, HandleComposerUpdateCallback}; diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 6ff163f6ec37..43c8ca741e2d 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -4,7 +4,7 @@ import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFo import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated'; +import {useAnimatedRef} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; @@ -357,18 +357,15 @@ function ReportActionCompose({ const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || hasExceededMaxCommentLength; const handleSendMessage = useCallback(() => { - 'worklet'; - if (isSendDisabled || !isReportReadyForDisplay) { return; } // We are setting the isCommentEmpty flag to true so the status of it will be in sync of the native text input state - runOnJS(setIsCommentEmpty)(true); - runOnJS(resetFullComposerSize)(); - setNativeProps(animatedRef, {text: ''}); // clears native text input on the UI thread - runOnJS(submitForm)(); - }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); + setIsCommentEmpty(true); + resetFullComposerSize(); + submitForm(); + }, [isSendDisabled, resetFullComposerSize, submitForm, isReportReadyForDisplay]); const emojiShiftVertical = useMemo(() => { const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; diff --git a/src/pages/home/report/ReportActionCompose/SendButton.tsx b/src/pages/home/report/ReportActionCompose/SendButton.tsx index 4b902e2c6246..9928795d9ba3 100644 --- a/src/pages/home/report/ReportActionCompose/SendButton.tsx +++ b/src/pages/home/report/ReportActionCompose/SendButton.tsx @@ -1,12 +1,10 @@ import React, {memo} from 'react'; import {View} from 'react-native'; -import {Gesture, GestureDetector} from 'react-native-gesture-handler'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -23,10 +21,6 @@ function SendButton({isDisabled: isDisabledProp, handleSendMessage}: SendButtonP const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const {isSmallScreenWidth} = useResponsiveLayout(); - const Tap = Gesture.Tap().onEnd(() => { - handleSendMessage(); - }); return ( e.preventDefault()} > - - - [ - styles.chatItemSubmitButton, - isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, - isDisabledProp ? styles.cursorDisabled : undefined, - ]} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('common.send')} - > - {({pressed}) => ( - - )} - - - + + [ + styles.chatItemSubmitButton, + isDisabledProp || pressed || isDisabled ? undefined : styles.buttonSuccess, + isDisabledProp ? styles.cursorDisabled : undefined, + ]} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('common.send')} + > + {({pressed}) => ( + + )} + + ); } diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx index 6cce050710c7..1089036bbcdd 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/index.tsx @@ -10,7 +10,7 @@ import type {SilentCommentUpdaterOnyxProps, SilentCommentUpdaterProps} from './t * It is connected to the actual draft comment in onyx. The comment in onyx might updates multiple times, and we want to avoid * re-rendering a UI component for that. That's why the side effect was moved down to a separate component. */ -function SilentCommentUpdater({comment, commentRef, reportID, value, updateComment, isCommentPendingSaved}: SilentCommentUpdaterProps) { +function SilentCommentUpdater({comment, reportID, value, updateComment, isCommentPendingSaved}: SilentCommentUpdaterProps) { const prevCommentProp = usePrevious(comment); const prevReportId = usePrevious(reportID); const {preferredLocale} = useLocalize(); @@ -34,7 +34,7 @@ function SilentCommentUpdater({comment, commentRef, reportID, value, updateComme } updateComment(comment ?? ''); - }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, commentRef, isCommentPendingSaved]); + }, [prevCommentProp, prevPreferredLocale, prevReportId, comment, preferredLocale, reportID, updateComment, value, isCommentPendingSaved]); return null; } diff --git a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts index 6f9e8b8a6d42..87ccb57b8d21 100644 --- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts +++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater/types.ts @@ -15,9 +15,6 @@ type SilentCommentUpdaterProps = SilentCommentUpdaterOnyxProps & { /** The value of the comment */ value: string; - /** The ref of the comment */ - commentRef: React.RefObject; - /** The ref to check whether the comment saving is in progress */ isCommentPendingSaved: React.RefObject; }; diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx index 8d5a544afd42..a57b994642e1 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.tsx @@ -51,7 +51,7 @@ function SuggestionEmoji( value, selection, setSelection, - updateComment, + updateComposer, isAutoSuggestionPickerLarge, resetKeyboardInput, measureParentContainerAndReportCursor, @@ -80,12 +80,14 @@ function SuggestionEmoji( */ const insertSelectedEmoji = useCallback( (highlightedEmojiIndexInner: number) => { - const commentBeforeColon = value.slice(0, suggestionValues.colonIndex); const emojiObject = suggestionValues.suggestedEmojis[highlightedEmojiIndexInner]; const emojiCode = emojiObject.types?.[preferredSkinTone] ? emojiObject.types[preferredSkinTone] : emojiObject.code; - const commentAfterColonWithEmojiNameRemoved = value.slice(selection.end); - updateComment(`${commentBeforeColon}${emojiCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); + const updateCommentArgs = SuggestionsUtils.getComposerUpdateArgsForSuggestionToInsert(value, emojiCode, { + start: suggestionValues.colonIndex, + end: selection.end, + }); + updateComposer(updateCommentArgs); // In some Android phones keyboard, the text to search for the emoji is not cleared // will be added after the user starts typing again on the keyboard. This package is @@ -98,7 +100,7 @@ function SuggestionEmoji( }); setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []})); }, - [preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComment, value], + [preferredSkinTone, resetKeyboardInput, selection.end, setSelection, suggestionValues.colonIndex, suggestionValues.suggestedEmojis, updateComposer, value], ); /** diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx index 86a05bad1994..43ccfd4f3f4b 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx @@ -57,7 +57,7 @@ type SuggestionPersonalDetailsList = Record< >; function SuggestionMention( - {value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainerAndReportCursor, isComposerFocused, isGroupPolicyReport, policyID}: SuggestionProps, + {value, selection, setSelection, updateComposer, isAutoSuggestionPickerLarge, measureParentContainerAndReportCursor, isComposerFocused, isGroupPolicyReport, policyID}: SuggestionProps, ref: ForwardedRef, ) { const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; @@ -165,12 +165,16 @@ function SuggestionMention( */ const insertSelectedMention = useCallback( (highlightedMentionIndexInner: number) => { - const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex); const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner]; const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType); - const commentAfterMention = value.slice(suggestionValues.atSignIndex + suggestionValues.mentionPrefix.length + 1); - updateComment(`${commentBeforeAtSign}${mentionCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterMention)}`, true); + const replaceUntil = suggestionValues.atSignIndex + suggestionValues.mentionPrefix.length + 1; + const updateCommentArgs = SuggestionsUtils.getComposerUpdateArgsForSuggestionToInsert(value, mentionCode, { + start: suggestionValues.atSignIndex, + end: replaceUntil, + }); + updateComposer(updateCommentArgs); + const selectionPosition = suggestionValues.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH; setSelection({ start: selectionPosition, @@ -190,7 +194,7 @@ function SuggestionMention( suggestionValues.prefixType, suggestionValues.mentionPrefix.length, getMentionCode, - updateComment, + updateComposer, setSelection, ], ); diff --git a/src/pages/home/report/ReportActionCompose/Suggestions.tsx b/src/pages/home/report/ReportActionCompose/Suggestions.tsx index 158c60b0e89a..cfa90005ad9f 100644 --- a/src/pages/home/report/ReportActionCompose/Suggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/Suggestions.tsx @@ -6,6 +6,7 @@ import type {MeasureParentContainerAndCursorCallback} from '@components/AutoComp import type {TextSelection} from '@components/Composer/types'; import {DragAndDropContext} from '@components/DragAndDrop/Provider'; import usePrevious from '@hooks/usePrevious'; +import type {HandleComposerUpdateCallback} from './ComposerWithSuggestions/types'; import type {SuggestionsRef} from './ReportActionCompose'; import SuggestionEmoji from './SuggestionEmoji'; import SuggestionMention from './SuggestionMention'; @@ -14,9 +15,6 @@ type SuggestionProps = { /** The current input value */ value: string; - /** Callback to update the current input value */ - setValue: (newValue: string) => void; - /** The current selection value */ selection: TextSelection; @@ -24,7 +22,7 @@ type SuggestionProps = { setSelection: (newSelection: TextSelection) => void; /** Callback to update the comment draft */ - updateComment: (newComment: string, shouldDebounceSaveComment?: boolean) => void; + updateComposer: HandleComposerUpdateCallback; /** Measures the parent container's position and dimensions. Also add cursor coordinates */ measureParentContainerAndReportCursor: (callback: MeasureParentContainerAndCursorCallback) => void; @@ -56,10 +54,9 @@ type SuggestionProps = { function Suggestions( { value, - setValue, selection, setSelection, - updateComment, + updateComposer, resetKeyboardInput, measureParentContainerAndReportCursor, isAutoSuggestionPickerLarge = true, @@ -153,10 +150,9 @@ function Suggestions( const baseProps = { value, - setValue, setSelection, selection, - updateComment, + updateComposer, isAutoSuggestionPickerLarge, measureParentContainerAndReportCursor, isComposerFocused, diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index b34cd68730f0..09c4d357391f 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -45,6 +45,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; +import type {HandleComposerUpdateCallback} from './ReportActionCompose/ComposerWithSuggestions/types'; import getCursorPosition from './ReportActionCompose/getCursorPosition'; import getScrollPosition from './ReportActionCompose/getScrollPosition'; import type {SuggestionsRef} from './ReportActionCompose/ReportActionCompose'; @@ -403,6 +404,13 @@ function ReportActionItemMessageEdit( [cursorPositionValue.value, measureContainer, selection], ); + const handleComposerUpdate: HandleComposerUpdateCallback = useCallback( + ({fullNewText}) => { + updateDraft(fullNewText); + }, + [updateDraft], + ); + useEffect(() => { // We use the tag to store the native ID of the text input. Later, we use it in onSelectionChange to pick up the proper text input data. @@ -535,11 +543,10 @@ function ReportActionItemMessageEdit(