From 9d594511d68a5b3e05e1633a27a9892d3f0d2a24 Mon Sep 17 00:00:00 2001 From: Rory Abraham <47436092+roryabraham@users.noreply.github.com> Date: Thu, 1 Aug 2024 11:06:46 -0700 Subject: [PATCH] Merge pull request #46626 from Expensify/revert-46091-fix/37896-composer-not-cleared Revert "Composer: add clear command that bypasses the event count" (cherry picked from commit 7ef49127dc6224fff6fa965a0e165e3753a096f1) --- ...act-native+0.73.4+022+textInputClear.patch | 66 +++++ ...djustFontSizeToFit-new-architecture.patch} | 0 ...e+0.73.4+023+textinput-clear-command.patch | 269 ------------------ src/components/Composer/index.native.tsx | 21 +- src/components/Composer/index.tsx | 57 +--- src/components/Composer/types.ts | 14 +- src/libs/ComponentUtils/index.native.ts | 15 +- src/libs/ComponentUtils/index.ts | 11 +- .../ComposerWithSuggestions.tsx | 87 +++--- .../ReportActionCompose.tsx | 97 +++---- 10 files changed, 199 insertions(+), 438 deletions(-) create mode 100644 patches/react-native+0.73.4+022+textInputClear.patch rename patches/{react-native+0.73.4+022+iOS-fix-adjustFontSizeToFit-new-architecture.patch => react-native+0.73.4+023+iOS-fix-adjustFontSizeToFit-new-architecture.patch} (100%) delete mode 100644 patches/react-native+0.73.4+023+textinput-clear-command.patch diff --git a/patches/react-native+0.73.4+022+textInputClear.patch b/patches/react-native+0.73.4+022+textInputClear.patch new file mode 100644 index 000000000000..1cadce6a0783 --- /dev/null +++ b/patches/react-native+0.73.4+022+textInputClear.patch @@ -0,0 +1,66 @@ +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( { - onClear: onClearProp = () => {}, + shouldClear = false, + onClear = () => {}, isDisabled = false, maxLines, isComposerFullSize = false, @@ -63,12 +64,13 @@ function Composer( // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, []); - const onClear = useCallback( - ({nativeEvent}: NativeSyntheticEvent) => { - onClearProp(nativeEvent.text); - }, - [onClearProp], - ); + 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]); @@ -97,7 +99,6 @@ function Composer( } props?.onBlur?.(e); }} - onClear={onClear} /> ); } diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 35f805c5ea0f..3889c8597843 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, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, 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,6 +59,7 @@ function Composer( maxLines = -1, onKeyPress = () => {}, style, + shouldClear = false, autoFocus = false, shouldCalculateCaretPosition = false, isDisabled = false, @@ -106,6 +107,14 @@ 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; @@ -275,6 +284,9 @@ function Composer( useHtmlPaste(textInput, handlePaste, true); useEffect(() => { + if (typeof ref === 'function') { + ref(textInput.current); + } setIsRendered(true); return () => { @@ -286,49 +298,6 @@ 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 e6d8a882f3b8..9c7a5a215c1c 100644 --- a/src/components/Composer/types.ts +++ b/src/components/Composer/types.ts @@ -11,7 +11,7 @@ type CustomSelectionChangeEvent = NativeSyntheticEvent & { +type ComposerProps = TextInputProps & { /** identify id in the text input */ id?: string; @@ -27,12 +27,6 @@ type ComposerProps = Omit & { /** 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; @@ -43,6 +37,12 @@ type ComposerProps = Omit & { // 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 7a3492c20ded..5ad39162e1a0 100644 --- a/src/libs/ComponentUtils/index.native.ts +++ b/src/libs/ComponentUtils/index.native.ts @@ -1,20 +1,7 @@ -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'; -/** - * 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}; +export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE}; diff --git a/src/libs/ComponentUtils/index.ts b/src/libs/ComponentUtils/index.ts index f7c48f87af5a..38abb98594da 100644 --- a/src/libs/ComponentUtils/index.ts +++ b/src/libs/ComponentUtils/index.ts @@ -1,6 +1,3 @@ -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'; /** @@ -10,10 +7,4 @@ const PASSWORD_AUTOCOMPLETE_TYPE: PasswordAutocompleteType = 'current-password'; const NEW_PASSWORD_AUTOCOMPLETE_TYPE: NewPasswordAutocompleteType = 'new-password'; const ACCESSIBILITY_ROLE_FORM: AccessibilityRoleForm = 'form'; -function forceClearInput(_: AnimatedRef, textInputRef: React.RefObject) { - 'worklet'; - - textInputRef.current?.clear(); -} - -export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE, forceClearInput}; +export {PASSWORD_AUTOCOMPLETE_TYPE, ACCESSIBILITY_ROLE_FORM, NEW_PASSWORD_AUTOCOMPLETE_TYPE}; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index 19efb2c2968e..5b89f09718c5 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -15,7 +15,8 @@ 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 {useAnimatedRef, useSharedValue} from 'react-native-reanimated'; +import {useSharedValue} from 'react-native-reanimated'; +import type {useAnimatedRef} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; import type {MeasureParentContainerAndCursorCallback} from '@components/AutoCompleteSuggestions/types'; @@ -30,7 +31,6 @@ 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,6 +63,8 @@ type SyncSelection = { value: string; }; +type AnimatedRef = ReturnType; + type NewlyAddedChars = {startIndex: number; endIndex: number; diff: string}; type ComposerWithSuggestionsOnyxProps = { @@ -93,9 +95,6 @@ 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; @@ -108,6 +107,12 @@ 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; @@ -141,6 +146,9 @@ 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; @@ -231,6 +239,8 @@ function ComposerWithSuggestions( isMenuVisible, inputPlaceholder, displayFileInModal, + textInputShouldClear, + setTextInputShouldClear, isBlockedFromConcierge, disabled, isFullComposerAvailable, @@ -241,10 +251,10 @@ function ComposerWithSuggestions( measureParentContainer = () => {}, isScrollLikelyLayoutTriggered, raiseIsScrollLikelyLayoutTriggered, - onCleared = () => {}, // Refs suggestionsRef, + animatedRef, isNextModalWillOpenRef, editFocused, @@ -272,11 +282,7 @@ 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; @@ -303,7 +309,6 @@ function ComposerWithSuggestions( // The ref to check whether the comment saving is in progress const isCommentPendingSaved = useRef(false); - const animatedRef = useAnimatedRef(); /** * Set the TextInput Ref */ @@ -416,7 +421,6 @@ 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); @@ -447,6 +451,31 @@ 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) */ @@ -604,16 +633,6 @@ 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', () => { @@ -673,13 +692,16 @@ function ComposerWithSuggestions( blur, focus, replaceSelectionWithText, + prepareCommentAndResetComposer, isFocused: () => !!textInputRef.current?.isFocused(), - clear, - getCurrentText, }), - [blur, clear, focus, replaceSelectionWithText, getCurrentText], + [blur, focus, prepareCommentAndResetComposer, replaceSelectionWithText], ); + useEffect(() => { + lastTextRef.current = value; + }, [value]); + useEffect(() => { onValueChange(value); }, [onValueChange, value]); @@ -695,15 +717,11 @@ function ComposerWithSuggestions( [composerHeight], ); - 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], - ); + const onClear = useCallback(() => { + mobileInputScrollPosition.current = 0; + setTextInputShouldClear(false); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, []); 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. @@ -766,6 +784,7 @@ 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 8bba87b8a838..3a57f057a938 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,9 +1,10 @@ +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 {runOnUI, useSharedValue} from 'react-native-reanimated'; +import {runOnJS, setNativeProps, useAnimatedRef} from 'react-native-reanimated'; import type {Emoji} from '@assets/emojis/types'; import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; @@ -48,13 +49,8 @@ type ComposerRef = { blur: () => void; focus: (shouldDelay?: boolean) => void; replaceSelectionWithText: EmojiPickerActions.OnEmojiSelected; - getCurrentText: () => string; + prepareCommentAndResetComposer: () => 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 = { @@ -126,6 +122,7 @@ 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; @@ -158,6 +155,10 @@ 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)*$/); @@ -176,7 +177,7 @@ function ReportActionCompose({ const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength(); const suggestionsRef = useRef(null); - const composerRef = useRef(); + const composerRef = useRef(null); const reportParticipantIDs = useMemo( () => Object.keys(report?.participants ?? {}) @@ -218,7 +219,7 @@ function ReportActionCompose({ if (composerRef.current === null) { return; } - composerRef.current?.focus(true); + composerRef.current.focus(true); }; const isKeyboardVisibleWhenShowingModalRef = useRef(false); @@ -262,16 +263,15 @@ function ReportActionCompose({ suggestionsRef.current.updateShouldShowSuggestionMenuToFalse(false); }, []); - 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)(); - }, []); + const addAttachment = useCallback( + (file: FileObject) => { + playSound(SOUNDS.DONE); + const newComment = composerRef?.current?.prepareCommentAndResetComposer(); + Report.addAttachment(reportID, file, newComment); + setTextInputShouldClear(false); + }, + [reportID], + ); /** * Event handler to update the state after the attachment preview is closed. @@ -286,19 +286,18 @@ function ReportActionCompose({ * Add a new comment to this chat */ const submitForm = useCallback( - (newComment: string) => { - playSound(SOUNDS.DONE); - - const newCommentTrimmed = newComment.trim(); + (event?: SyntheticEvent) => { + event?.preventDefault(); - if (attachmentFileRef.current) { - Report.addAttachment(reportID, attachmentFileRef.current, newCommentTrimmed); - attachmentFileRef.current = null; - } else { - onSubmit(newCommentTrimmed); + const newComment = composerRef.current?.prepareCommentAndResetComposer(); + if (!newComment) { + return; } + + playSound(SOUNDS.DONE); + onSubmit(newComment); }, - [onSubmit, reportID], + [onSubmit], ); const onTriggerAttachmentPicker = useCallback(() => { @@ -326,6 +325,15 @@ 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( () => () => { @@ -348,26 +356,19 @@ 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; } - // This will cause onCleared to be triggered where we actually send the message - clearComposer(); - }, [isSendDisabled, isReportReadyForDisplay, composerRefShared]); + // 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]); const emojiShiftVertical = useMemo(() => { const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; @@ -429,13 +430,8 @@ function ReportActionCompose({ actionButtonRef={actionButtonRef} /> { - composerRef.current = ref ?? undefined; - // eslint-disable-next-line react-compiler/react-compiler - composerRefShared.value = { - clear: ref?.clear, - }; - }} + ref={composerRef} + animatedRef={animatedRef} suggestionsRef={suggestionsRef} isNextModalWillOpenRef={isNextModalWillOpenRef} isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered} @@ -452,6 +448,8 @@ function ReportActionCompose({ inputPlaceholder={inputPlaceholder} isComposerFullSize={isComposerFullSize} displayFileInModal={displayFileInModal} + textInputShouldClear={textInputShouldClear} + setTextInputShouldClear={setTextInputShouldClear} isBlockedFromConcierge={isBlockedFromConcierge} disabled={!!disabled} isFullComposerAvailable={isFullComposerAvailable} @@ -461,7 +459,6 @@ function ReportActionCompose({ shouldShowComposeInput={shouldShowComposeInput} onFocus={onFocus} onBlur={onBlur} - onCleared={submitForm} measureParentContainer={measureContainer} onValueChange={(value) => { if (value.length === 0 && isComposerFullSize) {