diff --git a/patches/react-native+0.73.4+023+iOS-fix-adjustFontSizeToFit-new-architecture.patch b/patches/react-native+0.73.4+022+iOS-fix-adjustFontSizeToFit-new-architecture.patch similarity index 100% rename from patches/react-native+0.73.4+023+iOS-fix-adjustFontSizeToFit-new-architecture.patch rename to patches/react-native+0.73.4+022+iOS-fix-adjustFontSizeToFit-new-architecture.patch diff --git a/patches/react-native+0.73.4+022+textInputClear.patch b/patches/react-native+0.73.4+022+textInputClear.patch deleted file mode 100644 index 1cadce6a0783..000000000000 --- a/patches/react-native+0.73.4+022+textInputClear.patch +++ /dev/null @@ -1,66 +0,0 @@ -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..123968f 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 -@@ -452,6 +452,12 @@ - (void)blur - [_backedTextInputView resignFirstResponder]; - } - -+- (void)clear -+{ -+ [self setTextAndSelection:_mostRecentEventCount value:@"" start:0 end:0]; -+ _mostRecentEventCount++; -+} -+ - - (void)setTextAndSelection:(NSInteger)eventCount - value:(NSString *__nullable)value - start:(NSInteger)start -diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h -index fe3376a..6a9a45f 100644 ---- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h -+++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h -@@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN - @protocol RCTTextInputViewProtocol - - (void)focus; - - (void)blur; -+- (void)clear; - - (void)setTextAndSelection:(NSInteger)eventCount - value:(NSString *__nullable)value - start:(NSInteger)start -@@ -49,6 +50,19 @@ RCTTextInputHandleCommand(id componentView, const NSSt - return; - } - -+ if ([commandName isEqualToString:@"clear"]) { -+#if RCT_DEBUG -+ if ([args count] != 0) { -+ RCTLogError( -+ @"%@ command %@ received %d arguments, expected %d.", @"TextInput", commandName, (int)[args count], 0); -+ return; -+ } -+#endif -+ -+ [componentView clear]; -+ return; -+ } -+ - if ([commandName isEqualToString:@"setTextAndSelection"]) { - #if RCT_DEBUG - if ([args count] != 4) { -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..e6bcfc4 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 -@@ -331,6 +331,12 @@ public class ReactTextInputManager extends BaseViewManager) => void) + | undefined; + ++ /** ++ * Callback that is called when the text input was cleared using the native clear command. ++ */ ++ onClear?: ++ | ((e: NativeSyntheticEvent) => void) ++ | undefined; ++ + /** + * Callback that is called when the text input's text changes. + */ +diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +index 481938f..346acaa 100644 +--- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js ++++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +@@ -1329,6 +1329,11 @@ function InternalTextInput(props: Props): React.Node { + }); + }; + ++ const _onClear = (event: ChangeEvent) => { ++ setMostRecentEventCount(event.nativeEvent.eventCount); ++ props.onClear && props.onClear(event); ++ }; ++ + const _onFocus = (event: FocusEvent) => { + TextInputState.focusInput(inputRef.current); + if (props.onFocus) { +@@ -1462,6 +1467,7 @@ function InternalTextInput(props: Props): React.Node { + nativeID={id ?? props.nativeID} + onBlur={_onBlur} + onKeyPressSync={props.unstable_onKeyPressSync} ++ onClear={_onClear} + onChange={_onChange} + onChangeSync={useOnChangeSync === true ? _onChangeSync : null} + onContentSizeChange={props.onContentSizeChange} +@@ -1516,6 +1522,7 @@ function InternalTextInput(props: Props): React.Node { + nativeID={id ?? props.nativeID} + numberOfLines={props.rows ?? props.numberOfLines} + onBlur={_onBlur} ++ onClear={_onClear} + onChange={_onChange} + onFocus={_onFocus} + /* $FlowFixMe[prop-missing] the types for AndroidTextInput don't match +diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +index a19b555..4785987 100644 +--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm ++++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +@@ -62,6 +62,7 @@ @implementation RCTBaseTextInputViewManager { + + RCT_EXPORT_VIEW_PROPERTY(onChange, RCTBubblingEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onKeyPressSync, RCTDirectEventBlock) ++RCT_EXPORT_VIEW_PROPERTY(onClear, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onChangeSync, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onSelectionChange, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onTextInput, RCTDirectEventBlock) +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..70754bf 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 +@@ -452,6 +452,19 @@ - (void)blur + [_backedTextInputView resignFirstResponder]; + } + ++- (void)clear ++{ ++ auto metrics = [self _textInputMetrics]; ++ [self setTextAndSelection:_mostRecentEventCount value:@"" start:0 end:0]; ++ ++ _mostRecentEventCount++; ++ metrics.eventCount = _mostRecentEventCount; ++ ++ // Notify JS that the event counter has changed ++ const auto &textInputEventEmitter = static_cast(*_eventEmitter); ++ textInputEventEmitter.onClear(metrics); ++} ++ + - (void)setTextAndSelection:(NSInteger)eventCount + value:(NSString *__nullable)value + start:(NSInteger)start +diff --git a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h +index fe3376a..6889eed 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h ++++ b/node_modules/react-native/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputNativeCommands.h +@@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN + @protocol RCTTextInputViewProtocol + - (void)focus; + - (void)blur; ++- (void)clear; + - (void)setTextAndSelection:(NSInteger)eventCount + value:(NSString *__nullable)value + start:(NSInteger)start +@@ -49,6 +50,19 @@ RCTTextInputHandleCommand(id componentView, const NSSt + return; + } + ++ if ([commandName isEqualToString:@"clear"]) { ++#if RCT_DEBUG ++ if ([args count] != 0) { ++ RCTLogError( ++ @"%@ command %@ received %d arguments, expected %d.", @"TextInput", commandName, (int)[args count], 0); ++ return; ++ } ++#endif ++ ++ [componentView clear]; ++ return; ++ } ++ + if ([commandName isEqualToString:@"setTextAndSelection"]) { + #if RCT_DEBUG + if ([args count] != 4) { +diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextClearEvent.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextClearEvent.java +new file mode 100644 +index 0000000..0c142a0 +--- /dev/null ++++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextClearEvent.java +@@ -0,0 +1,53 @@ ++/* ++ * Copyright (c) Meta Platforms, Inc. and affiliates. ++ * ++ * This source code is licensed under the MIT license found in the ++ * LICENSE file in the root directory of this source tree. ++ */ ++ ++package com.facebook.react.views.textinput; ++ ++import androidx.annotation.Nullable; ++ ++import com.facebook.react.bridge.Arguments; ++import com.facebook.react.bridge.WritableMap; ++import com.facebook.react.uimanager.common.ViewUtil; ++import com.facebook.react.uimanager.events.Event; ++ ++/** ++ * Event emitted by EditText native view when text changes. VisibleForTesting from {@link ++ * TextInputEventsTestCase}. ++ */ ++public class ReactTextClearEvent extends Event { ++ ++ public static final String EVENT_NAME = "topClear"; ++ ++ private String mText; ++ private int mEventCount; ++ ++ @Deprecated ++ public ReactTextClearEvent(int viewId, String text, int eventCount) { ++ this(ViewUtil.NO_SURFACE_ID, viewId, text, eventCount); ++ } ++ ++ public ReactTextClearEvent(int surfaceId, int viewId, String text, int eventCount) { ++ super(surfaceId, viewId); ++ mText = text; ++ mEventCount = eventCount; ++ } ++ ++ @Override ++ public String getEventName() { ++ return EVENT_NAME; ++ } ++ ++ @Nullable ++ @Override ++ protected WritableMap getEventData() { ++ WritableMap eventData = Arguments.createMap(); ++ eventData.putString("text", mText); ++ eventData.putInt("eventCount", mEventCount); ++ eventData.putInt("target", getViewTag()); ++ 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..53e5c49 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 +@@ -8,6 +8,7 @@ + package com.facebook.react.views.textinput; + + import static com.facebook.react.uimanager.UIManagerHelper.getReactContext; ++import static com.facebook.react.uimanager.UIManagerHelper.getSurfaceId; + + import android.content.Context; + import android.content.res.ColorStateList; +@@ -273,6 +274,9 @@ public class ReactTextInputManager extends BaseViewManager = ['mentionReport']; function Composer( { - shouldClear = false, - onClear = () => {}, + onClear: onClearProp = () => {}, isDisabled = false, maxLines, isComposerFullSize = false, @@ -64,13 +63,12 @@ function Composer( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current?.clear(); - onClear(); - }, [shouldClear, onClear]); + const onClear = useCallback( + ({nativeEvent}: NativeSyntheticEvent) => { + onClearProp(nativeEvent.text); + }, + [onClearProp], + ); const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); @@ -99,6 +97,7 @@ function Composer( } props?.onBlur?.(e); }} + onClear={onClear} /> ); } diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 3889c8597843..35f805c5ea0f 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,7 +1,7 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import lodashDebounce from 'lodash/debounce'; import type {BaseSyntheticEvent, ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; // eslint-disable-next-line no-restricted-imports import type {DimensionValue, NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; @@ -59,7 +59,6 @@ function Composer( maxLines = -1, onKeyPress = () => {}, style, - shouldClear = false, autoFocus = false, shouldCalculateCaretPosition = false, isDisabled = false, @@ -107,14 +106,6 @@ function Composer( const [prevScroll, setPrevScroll] = useState(); const isReportFlatListScrolling = useRef(false); - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current?.clear(); - onClear(); - }, [shouldClear, onClear]); - useEffect(() => { if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) { return; @@ -284,9 +275,6 @@ function Composer( useHtmlPaste(textInput, handlePaste, true); useEffect(() => { - if (typeof ref === 'function') { - ref(textInput.current); - } setIsRendered(true); return () => { @@ -298,6 +286,49 @@ function Composer( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); + const clear = useCallback(() => { + if (!textInput.current) { + return; + } + + const currentText = textInput.current.innerText; + textInput.current.clear(); + + // We need to reset the selection to 0,0 manually after clearing the text input on web + const selectionEvent = { + nativeEvent: { + selection: { + start: 0, + end: 0, + }, + }, + } as NativeSyntheticEvent; + onSelectionChange(selectionEvent); + setSelection({start: 0, end: 0}); + + onClear(currentText); + }, [onClear, onSelectionChange]); + + useImperativeHandle( + ref, + () => { + const textInputRef = textInput.current; + if (!textInputRef) { + throw new Error('textInputRef is not available. This should never happen and indicates a developer error.'); + } + + return { + ...textInputRef, + // Overwrite clear with our custom implementation, which mimics how the native TextInput's clear method works + clear, + // We have to redefine these methods as they are inherited by prototype chain and are not accessible directly + blur: () => textInputRef.blur(), + focus: () => textInputRef.focus(), + }; + }, + [clear], + ); + const handleKeyPress = useCallback( (e: NativeSyntheticEvent) => { // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts index 9c7a5a215c1c..e6d8a882f3b8 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -11,7 +11,7 @@ type CustomSelectionChangeEvent = NativeSyntheticEvent & { /** identify id in the text input */ id?: string; @@ -27,6 +27,12 @@ type ComposerProps = TextInputProps & { /** The value of the comment box */ value?: string; + /** + * Callback when the input was cleared using the .clear ref method. + * The text parameter will be the value of the text that was cleared. + */ + onClear?: (text: string) => void; + /** Callback method handle when the input is changed */ onChangeText?: (numberOfLines: string) => void; @@ -37,12 +43,6 @@ type ComposerProps = TextInputProps & { // eslint-disable-next-line react/forbid-prop-types style?: StyleProp; - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear?: boolean; - - /** When the input has cleared whoever owns this input should know about it */ - onClear?: () => void; - /** Whether or not this TextInput is disabled. */ isDisabled?: boolean; diff --git a/src/libs/ComponentUtils/index.native.ts b/src/libs/ComponentUtils/index.native.ts index 5ad39162e1a0..7a3492c20ded 100644 --- a/src/libs/ComponentUtils/index.native.ts +++ b/src/libs/ComponentUtils/index.native.ts @@ -1,7 +1,20 @@ +import type {Component} from 'react'; +import type {AnimatedRef} from 'react-native-reanimated'; +import {dispatchCommand} from 'react-native-reanimated'; import type {AccessibilityRoleForm, NewPasswordAutocompleteType, PasswordAutocompleteType} from './types'; const PASSWORD_AUTOCOMPLETE_TYPE: PasswordAutocompleteType = 'password'; const NEW_PASSWORD_AUTOCOMPLETE_TYPE: NewPasswordAutocompleteType = 'password-new'; const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'none'; -export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE}; +/** + * Clears a text input on the UI thread using a custom clear command + * that bypasses the event count check. + */ +function forceClearInput(animatedInputRef: AnimatedRef) { + 'worklet'; + + dispatchCommand(animatedInputRef, 'clear'); +} + +export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE, forceClearInput}; diff --git a/src/libs/ComponentUtils/index.ts b/src/libs/ComponentUtils/index.ts index 38abb98594da..f7c48f87af5a 100644 --- a/src/libs/ComponentUtils/index.ts +++ b/src/libs/ComponentUtils/index.ts @@ -1,3 +1,6 @@ +import type {Component} from 'react'; +import type {TextInput} from 'react-native'; +import type {AnimatedRef} from 'react-native-reanimated'; import type {AccessibilityRoleForm, NewPasswordAutocompleteType, PasswordAutocompleteType} from './types'; /** @@ -7,4 +10,10 @@ const PASSWORD_AUTOCOMPLETE_TYPE: PasswordAutocompleteType = 'current-password'; const NEW_PASSWORD_AUTOCOMPLETE_TYPE: NewPasswordAutocompleteType = 'new-password'; const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'form'; -export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE}; +function forceClearInput(_: AnimatedRef, textInputRef: React.RefObject) { + 'worklet'; + + textInputRef.current?.clear(); +} + +export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE, forceClearInput}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index c6e56811ea50..496a621404c7 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -15,8 +15,7 @@ import {DeviceEventEmitter, findNodeHandle, InteractionManager, NativeModules, V import {useFocusedInputHandler} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import {useSharedValue} from 'react-native-reanimated'; -import type {useAnimatedRef} from 'react-native-reanimated'; +import {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; @@ -31,6 +30,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; +import {forceClearInput} from '@libs/ComponentUtils'; import * as ComposerUtils from '@libs/ComposerUtils'; import convertToLTRForComposer from '@libs/convertToLTRForComposer'; import {getDraftComment} from '@libs/DraftCommentUtils'; @@ -63,8 +63,6 @@ type SyncSelection = { value: string; }; -type AnimatedRef = ReturnType; - type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; type ComposerWithSuggestionsOnyxProps = { @@ -95,6 +93,9 @@ type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & /** Callback to update the value of the composer */ onValueChange: (value: string) => void; + /** Callback when the composer got cleared on the UI thread */ + onCleared?: (text: string) => void; + /** Whether the composer is full size */ isComposerFullSize: boolean; @@ -107,12 +108,6 @@ type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & /** Function to display a file in a modal */ displayFileInModal: (file: FileObject) => void; - /** Whether the text input should clear */ - textInputShouldClear: boolean; - - /** Function to set the text input should clear */ - setTextInputShouldClear: (shouldClear: boolean) => void; - /** Whether the user is blocked from concierge */ isBlockedFromConcierge: boolean; @@ -146,9 +141,6 @@ type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps & /** The ref to the suggestions */ suggestionsRef: React.RefObject; - /** The ref to the animated input */ - animatedRef: AnimatedRef; - /** The ref to the next modal will open */ isNextModalWillOpenRef: MutableRefObject; @@ -239,8 +231,6 @@ function ComposerWithSuggestions( isMenuVisible, inputPlaceholder, displayFileInModal, - textInputShouldClear, - setTextInputShouldClear, isBlockedFromConcierge, disabled, isFullComposerAvailable, @@ -251,10 +241,10 @@ function ComposerWithSuggestions( measureParentContainer = () => {}, isScrollLikelyLayoutTriggered, raiseIsScrollLikelyLayoutTriggered, + onCleared = () => {}, // Refs suggestionsRef, - animatedRef, isNextModalWillOpenRef, editFocused, @@ -282,7 +272,11 @@ function ComposerWithSuggestions( return draftComment; }); const commentRef = useRef(value); + const lastTextRef = useRef(value); + useEffect(() => { + lastTextRef.current = value; + }, [value]); const {isSmallScreenWidth} = useWindowDimensions(); const maxComposerLines = isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES; @@ -309,6 +303,7 @@ function ComposerWithSuggestions( // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); + const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref */ @@ -421,6 +416,7 @@ function ComposerWithSuggestions( setIsCommentEmpty(isNewCommentEmpty); } emojisPresentBefore.current = emojis; + setValue(newCommentConverted); if (commentValue !== newComment) { const position = Math.max((selection.end ?? 0) + (newComment.length - commentRef.current.length), cursorPosition ?? 0); @@ -451,31 +447,6 @@ function ComposerWithSuggestions( [findNewlyAddedChars, preferredLocale, preferredSkinTone, reportID, setIsCommentEmpty, suggestionsRef, raiseIsScrollLikelyLayoutTriggered, debouncedSaveReportComment, selection.end], ); - const prepareCommentAndResetComposer = useCallback((): string => { - const trimmedComment = commentRef.current.trim(); - const commentLength = ReportUtils.getCommentLength(trimmedComment, {reportID}); - - // Don't submit empty comments or comments that exceed the character limit - if (!commentLength || commentLength > CONST.MAX_COMMENT_LENGTH) { - return ''; - } - - // Since we're submitting the form here which should clear the composer - // We don't really care about saving the draft the user was typing - // We need to make sure an empty draft gets saved instead - debouncedSaveReportComment.cancel(); - isCommentPendingSaved.current = false; - - setSelection({start: 0, end: 0, positionX: 0, positionY: 0}); - updateComment(''); - setTextInputShouldClear(true); - if (isComposerFullSize) { - Report.setIsComposerFullSize(reportID, false); - } - setIsFullComposerAvailable(false); - return trimmedComment; - }, [updateComment, setTextInputShouldClear, isComposerFullSize, setIsFullComposerAvailable, reportID, debouncedSaveReportComment]); - /** * Callback to add whatever text is chosen into the main input (used f.e as callback for the emoji picker) */ @@ -633,6 +604,16 @@ function ComposerWithSuggestions( textInputRef.current.blur(); }, []); + const clear = useCallback(() => { + 'worklet'; + + forceClearInput(animatedRef, textInputRef); + }, [animatedRef]); + + const getCurrentText = useCallback(() => { + return commentRef.current; + }, []); + useEffect(() => { const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListener(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { @@ -692,16 +673,13 @@ function ComposerWithSuggestions( blur, focus, replaceSelectionWithText, - prepareCommentAndResetComposer, isFocused: () => !!textInputRef.current?.isFocused(), + clear, + getCurrentText, }), - [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText], + [blur, clear, focus, replaceSelectionWithText, getCurrentText], ); - useEffect(() => { - lastTextRef.current = value; - }, [value]); - useEffect(() => { onValueChange(value); }, [onValueChange, value]); @@ -717,11 +695,15 @@ function ComposerWithSuggestions( [composerHeight], ); - const onClear = useCallback(() => { - mobileInputScrollPosition.current = 0; - setTextInputShouldClear(false); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, []); + const onClear = useCallback( + (text: string) => { + mobileInputScrollPosition.current = 0; + // Note: use the value when the clear happened, not the current value which might have changed already + onCleared(text); + updateComment('', true); + }, + [onCleared, updateComment], + ); 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. @@ -784,7 +766,6 @@ function ComposerWithSuggestions( textInputRef.current?.blur(); displayFileInModal(file); }} - shouldClear={textInputShouldClear} onClear={onClear} isDisabled={isBlockedFromConcierge || disabled} isReportActionCompose diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 6ff163f6ec37..5bb1ccddbaa4 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,10 +1,9 @@ -import type {SyntheticEvent} from 'react'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {MeasureInWindowOnSuccessCallback, NativeSyntheticEvent, TextInputFocusEventData, TextInputSelectionChangeEventData} from 'react-native'; 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 {runOnUI, useSharedValue} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; @@ -49,8 +48,13 @@ type ComposerRef = { blur: () => void; focus: (shouldDelay?: boolean) => void; replaceSelectionWithText: EmojiPickerActions.OnEmojiSelected; - prepareCommentAndResetComposer: () => string; + getCurrentText: () => string; isFocused: () => boolean; + /** + * Calling clear will immediately clear the input on the UI thread (its a worklet). + * Once the composer ahs cleared onCleared will be called with the value that was cleared. + */ + clear: () => void; }; type SuggestionsRef = { @@ -122,7 +126,6 @@ function ReportActionCompose({ const {translate} = useLocalize(); const {isMediumScreenWidth, isSmallScreenWidth} = useWindowDimensions(); const {isOffline} = useNetwork(); - const animatedRef = useAnimatedRef(); const actionButtonRef = useRef(null); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; @@ -155,10 +158,6 @@ function ReportActionCompose({ debouncedLowerIsScrollLikelyLayoutTriggered(); }, [debouncedLowerIsScrollLikelyLayoutTriggered]); - /** - * Updates the should clear state of the composer - */ - const [textInputShouldClear, setTextInputShouldClear] = useState(false); const [isCommentEmpty, setIsCommentEmpty] = useState(() => { const draftComment = getDraftComment(reportID); return !draftComment || !!draftComment.match(/^(\s)*$/); @@ -177,7 +176,7 @@ function ReportActionCompose({ const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); const suggestionsRef = useRef(null); - const composerRef = useRef(null); + const composerRef = useRef(); const reportParticipantIDs = useMemo( () => Object.keys(report?.participants ?? {}) @@ -219,7 +218,7 @@ function ReportActionCompose({ if (composerRef.current === null) { return; } - composerRef.current.focus(true); + composerRef.current?.focus(true); }; const isKeyboardVisibleWhenShowingModalRef = useRef(false); @@ -263,15 +262,16 @@ function ReportActionCompose({ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }, []); - const addAttachment = useCallback( - (file: FileObject) => { - playSound(SOUNDS.DONE); - const newComment = composerRef?.current?.prepareCommentAndResetComposer(); - Report.addAttachment(reportID, file, newComment); - setTextInputShouldClear(false); - }, - [reportID], - ); + const attachmentFileRef = useRef(null); + const addAttachment = useCallback((file: FileObject) => { + attachmentFileRef.current = file; + const clear = composerRef.current?.clear; + if (!clear) { + throw new Error('The composerRef.clear function is not set yet. This should never happen, and indicates a developer error.'); + } + + runOnUI(clear)(); + }, []); /** * Event handler to update the state after the attachment preview is closed. @@ -286,18 +286,19 @@ function ReportActionCompose({ * Add a new comment to this chat */ const submitForm = useCallback( - (event?: SyntheticEvent) => { - event?.preventDefault(); + (newComment: string) => { + playSound(SOUNDS.DONE); - const newComment = composerRef.current?.prepareCommentAndResetComposer(); - if (!newComment) { - return; - } + const newCommentTrimmed = newComment.trim(); - playSound(SOUNDS.DONE); - onSubmit(newComment); + if (attachmentFileRef.current) { + Report.addAttachment(reportID, attachmentFileRef.current, newCommentTrimmed); + attachmentFileRef.current = null; + } else { + onSubmit(newCommentTrimmed); + } }, - [onSubmit], + [onSubmit, reportID], ); const onTriggerAttachmentPicker = useCallback(() => { @@ -325,15 +326,6 @@ function ReportActionCompose({ onComposerFocus?.(); }, [onComposerFocus]); - // resets the composer to normal size when - // the send button is pressed. - const resetFullComposerSize = useCallback(() => { - if (isComposerFullSize) { - Report.setIsComposerFullSize(reportID, false); - } - setIsFullComposerAvailable(false); - }, [isComposerFullSize, reportID]); - // We are returning a callback here as we want to incoke the method on unmount only useEffect( () => () => { @@ -356,19 +348,26 @@ function ReportActionCompose({ const isSendDisabled = isCommentEmpty || isBlockedFromConcierge || !!disabled || hasExceededMaxCommentLength; + // Note: using JS refs is not well supported in reanimated, thus we need to store the function in a shared value + // useSharedValue on web doesn't support functions, so we need to wrap it in an object. + const composerRefShared = useSharedValue<{ + clear: (() => void) | undefined; + }>({clear: undefined}); const handleSendMessage = useCallback(() => { 'worklet'; + const clearComposer = composerRefShared.value.clear; + if (!clearComposer) { + throw new Error('The composerRefShared.clear function is not set yet. This should never happen, and indicates a developer error.'); + } + 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]); + // This will cause onCleared to be triggered where we actually send the message + clearComposer(); + }, [isSendDisabled, isReportReadyForDisplay, composerRefShared]); const emojiShiftVertical = useMemo(() => { const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; @@ -430,8 +429,13 @@ function ReportActionCompose({ actionButtonRef={actionButtonRef} /> { + composerRef.current = ref ?? undefined; + // eslint-disable-next-line react-compiler/react-compiler + composerRefShared.value = { + clear: ref?.clear, + }; + }} suggestionsRef={suggestionsRef} isNextModalWillOpenRef={isNextModalWillOpenRef} isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered} @@ -448,8 +452,6 @@ function ReportActionCompose({ inputPlaceholder={inputPlaceholder} isComposerFullSize={isComposerFullSize} displayFileInModal={displayFileInModal} - textInputShouldClear={textInputShouldClear} - setTextInputShouldClear={setTextInputShouldClear} isBlockedFromConcierge={isBlockedFromConcierge} disabled={!!disabled} isFullComposerAvailable={isFullComposerAvailable} @@ -459,6 +461,7 @@ function ReportActionCompose({ shouldShowComposeInput={shouldShowComposeInput} onFocus={onFocus} onBlur={onBlur} + onCleared={submitForm} measureParentContainer={measureContainer} onValueChange={(value) => { if (value.length === 0 && isComposerFullSize) {