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) => void) | undefined; @@ -135,7 +135,7 @@ index 9adbfe9..b46437d 100644 * The string that will be rendered before text input has been entered. */ diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js -index 481938f..a74fda7 100644 +index 346acaa..3ab56a2 100644 --- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js +++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js @@ -132,6 +132,18 @@ export type EditingEvent = SyntheticEvent< @@ -157,18 +157,6 @@ index 481938f..a74fda7 100644 type DataDetectorTypesType = | 'phoneNumber' | 'link' -@@ -838,6 +850,11 @@ export type Props = $ReadOnly<{| - */ - onScroll?: ?(e: ScrollEvent) => mixed, - -+ /** -+ * Invoked when the user performs the paste action. -+ */ -+ onPaste?: ?(e: PasteEvent) => mixed, -+ - /** - * The string that will be rendered before text input has been entered. - */ diff --git a/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm index 582b49c..ac31cb1 100644 --- a/node_modules/react-native/Libraries/Text/TextInput/Multiline/RCTUITextView.mm @@ -259,7 +247,7 @@ index f1c32e6..0ce9dfe 100644 @end diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm -index 9dca6a5..b2c6b53 100644 +index 9dca6a5..bc43ab8 100644 --- a/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm +++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBackedTextInputDelegateAdapter.mm @@ -147,6 +147,11 @@ - (void)selectedTextRangeWasSet @@ -274,8 +262,8 @@ index 9dca6a5..b2c6b53 100644 #pragma mark - Generalization - (void)textFieldProbablyDidChangeSelection -@@ -290,6 +295,11 @@ - (void)skipNextTextInputDidChangeSelectionEventWithTextRange:(UITextRange *)tex - _previousSelectedTextRange = textRange; +@@ -302,4 +307,9 @@ - (void)textViewProbablyDidChangeSelection + [_backedTextInputView.textInputDelegate textInputDidChangeSelection]; } +- (void)didPaste:(NSString *)type withData:(NSString *)data @@ -283,9 +271,7 @@ index 9dca6a5..b2c6b53 100644 + [_backedTextInputView.textInputDelegate textInputDidPaste:type withData:data]; +} + - #pragma mark - Generalization - - - (void)textViewProbablyDidChangeSelection + @end diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h index 209947d..5092dbd 100644 --- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.h @@ -330,10 +316,10 @@ index b0d71dc..2e42fc9 100644 { [self enforceTextAttributesIfNeeded]; diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm -index a19b555..8146c0d 100644 +index 4785987..16a9b8e 100644 --- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm +++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputViewManager.mm -@@ -66,6 +66,7 @@ @implementation RCTBaseTextInputViewManager { +@@ -67,6 +67,7 @@ @implementation RCTBaseTextInputViewManager { RCT_EXPORT_VIEW_PROPERTY(onSelectionChange, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onTextInput, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onScroll, RCTDirectEventBlock) @@ -399,7 +385,7 @@ index 4d0afd9..a6afc7b 100644 #pragma mark - Layout 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 123968f..9626d49 100644 +index 70754bf..3ab2c6a 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 @@ -426,6 +426,13 @@ - (void)textInputDidChangeSelection @@ -418,7 +404,7 @@ index 123968f..9626d49 100644 - (void)scrollViewDidScroll:(UIScrollView *)scrollView diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.java new file mode 100644 -index 0000000..bfb5819 +index 0000000..62f7e35 --- /dev/null +++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/PasteWatcher.java @@ -0,0 +1,17 @@ @@ -439,6 +425,7 @@ index 0000000..bfb5819 +interface PasteWatcher { + public void onPaste(String type, String data); +} +\ No newline at end of file diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java index 081f2b8..ff91d47 100644 --- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java @@ -524,20 +511,20 @@ index 081f2b8..ff91d47 100644 * Attempt to set a selection or fail silently. Intentionally meant to handle bad inputs. * EventCounter is the same one used as with text. 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 e6bcfc4..902985d 100644 +index 53e5c49..26dc163 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 -@@ -273,6 +273,9 @@ public class ReactTextInputManager extends BaseViewManager = ['mentionReport']; function Composer( { - shouldClear = false, - onClear = () => {}, + onClear: onClearProp = () => {}, onPasteFile = () => {}, isDisabled = false, maxLines, @@ -66,6 +65,12 @@ function Composer( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); + const onClear = useCallback( + ({nativeEvent}: NativeSyntheticEvent) => { + onClearProp(nativeEvent.text); + }, + [onClearProp], + ); const pasteFile = useCallback( (e: NativeSyntheticEvent) => { const clipboardContent = e.nativeEvent.items[0]; @@ -80,14 +85,6 @@ function Composer( [onPasteFile], ); - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current?.clear(); - onClear(); - }, [shouldClear, onClear]); - const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); @@ -116,6 +113,7 @@ function Composer( } props?.onBlur?.(e); }} + onClear={onClear} /> ); } diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 3889c8597843..306319f061ca 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,52 @@ 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(), + get scrollTop() { + return textInputRef.scrollTop; + }, + }; + }, + [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 a2d062021205..8287e2de3f2d 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -12,7 +12,7 @@ type CustomSelectionChangeEvent = NativeSyntheticEvent & { /** identify id in the text input */ id?: string; @@ -28,6 +28,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; @@ -38,12 +44,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 4281d1ddebd3..95c8017e1161 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 useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; 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 {shouldUseNarrowLayout} = useResponsiveLayout(); const maxComposerLines = shouldUseNarrowLayout ? 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 dbfe684ea062..005824fa949f 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,11 +1,10 @@ import {useNavigation} from '@react-navigation/native'; -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'; @@ -56,8 +55,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 = { @@ -134,7 +138,6 @@ function ReportActionCompose({ const {translate} = useLocalize(); const {isMediumScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const {isOffline} = useNetwork(); - const animatedRef = useAnimatedRef(); const actionButtonRef = useRef(null); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const navigation = useNavigation(); @@ -169,10 +172,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)*$/); @@ -191,7 +190,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 ?? {}) @@ -233,7 +232,7 @@ function ReportActionCompose({ if (composerRef.current === null) { return; } - composerRef.current.focus(true); + composerRef.current?.focus(true); }; const isKeyboardVisibleWhenShowingModalRef = useRef(false); @@ -277,15 +276,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. @@ -300,18 +300,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(() => { @@ -339,15 +340,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( () => () => { @@ -377,19 +369,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; @@ -492,8 +491,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} @@ -510,8 +514,7 @@ function ReportActionCompose({ inputPlaceholder={inputPlaceholder} isComposerFullSize={isComposerFullSize} displayFileInModal={displayFileInModal} - textInputShouldClear={textInputShouldClear} - setTextInputShouldClear={setTextInputShouldClear} + onCleared={submitForm} isBlockedFromConcierge={isBlockedFromConcierge} disabled={!!disabled} isFullComposerAvailable={isFullComposerAvailable}