diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.android.js deleted file mode 100644 index af64831df117..000000000000 --- a/src/components/Composer/index.android.js +++ /dev/null @@ -1,147 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import {StyleSheet} from 'react-native'; -import _ from 'underscore'; -import RNTextInput from '@components/RNTextInput'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ComposerUtils from '@libs/ComposerUtils'; - -const propTypes = { - /** Maximum number of lines in the text input */ - maxLines: PropTypes.number, - - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear: PropTypes.bool, - - /** A ref to forward to the text input */ - forwardedRef: PropTypes.func, - - /** When the input has cleared whoever owns this input should know about it */ - onClear: PropTypes.func, - - /** Set focus to this component the first time it renders. - * Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ - autoFocus: PropTypes.bool, - - /** Prevent edits and interactions like focus for this input. */ - isDisabled: PropTypes.bool, - - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - - /** Whether the full composer can be opened */ - isFullComposerAvailable: PropTypes.bool, - - /** Allow the full composer to be opened */ - setIsFullComposerAvailable: PropTypes.func, - - /** Whether the composer is full size */ - isComposerFullSize: PropTypes.bool, - - /** General styles to apply to the text input */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, -}; - -const defaultProps = { - shouldClear: false, - onClear: () => {}, - autoFocus: false, - isDisabled: false, - forwardedRef: null, - selection: { - start: 0, - end: 0, - }, - maxLines: undefined, - isFullComposerAvailable: false, - setIsFullComposerAvailable: () => {}, - isComposerFullSize: false, - style: null, -}; - -function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isComposerFullSize, setIsFullComposerAvailable, ...props}) { - const textInput = useRef(null); - const theme = useTheme(); - const styles = useThemeStyles(); - - /** - * Set the TextInput Ref - * @param {Element} el - */ - const setTextInputRef = useCallback((el) => { - textInput.current = el; - if (!_.isFunction(forwardedRef) || textInput.current === null) { - return; - } - - // This callback prop is used by the parent component using the constructor to - // get a ref to the inner textInput element e.g. if we do - // this.textInput = el} /> this will not - // return a ref to the component, but rather the HTML element by default - forwardedRef(textInput.current); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current.clear(); - onClear(); - }, [shouldClear, onClear]); - - /** - * Set maximum number of lines - * @return {Number} - */ - const maxNumberOfLines = useMemo(() => { - if (isComposerFullSize) { - return 1000000; - } - return maxLines; - }, [isComposerFullSize, maxLines]); - - const composerStyles = useMemo(() => { - StyleSheet.flatten(props.style); - }, [props.style]); - - return ( - ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} - rejectResponderTermination={false} - // Setting a really high number here fixes an issue with the `maxNumberOfLines` prop on TextInput, where on Android the text input would collapse to only one line, - // when it should actually expand to the container (https://github.com/Expensify/App/issues/11694#issuecomment-1560520670) - // @Szymon20000 is working on fixing this (android-only) issue in the in the upstream PR (https://github.com/facebook/react-native/pulls?q=is%3Apr+is%3Aopen+maxNumberOfLines) - // TODO: remove this comment once upstream PR is merged and available in a future release - maxNumberOfLines={maxNumberOfLines} - textAlignVertical="center" - style={[composerStyles]} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...props} - readOnly={isDisabled} - /> - ); -} - -Composer.propTypes = propTypes; -Composer.defaultProps = defaultProps; - -const ComposerWithRef = React.forwardRef((props, ref) => ( - -)); - -ComposerWithRef.displayName = 'ComposerWithRef'; - -export default ComposerWithRef; diff --git a/src/components/Composer/index.android.tsx b/src/components/Composer/index.android.tsx new file mode 100644 index 000000000000..46c2a5f06ded --- /dev/null +++ b/src/components/Composer/index.android.tsx @@ -0,0 +1,96 @@ +import React, {ForwardedRef, useCallback, useEffect, useMemo, useRef} from 'react'; +import {StyleSheet, TextInput} from 'react-native'; +import RNTextInput from '@components/RNTextInput'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ComposerUtils from '@libs/ComposerUtils'; +import {ComposerProps} from './types'; + +function Composer( + { + shouldClear = false, + onClear = () => {}, + isDisabled = false, + maxLines, + isComposerFullSize = false, + setIsFullComposerAvailable = () => {}, + style, + autoFocus = false, + selection = { + start: 0, + end: 0, + }, + isFullComposerAvailable = false, + ...props + }: ComposerProps, + ref: ForwardedRef, +) { + const textInput = useRef(null); + + const styles = useThemeStyles(); + const theme = useTheme(); + + /** + * Set the TextInput Ref + */ + const setTextInputRef = useCallback((el: TextInput) => { + textInput.current = el; + if (typeof ref !== 'function' || textInput.current === null) { + return; + } + + // This callback prop is used by the parent component using the constructor to + // get a ref to the inner textInput element e.g. if we do + // this.textInput = el} /> this will not + // return a ref to the component, but rather the HTML element by default + ref(textInput.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!shouldClear) { + return; + } + textInput.current?.clear(); + onClear(); + }, [shouldClear, onClear]); + + /** + * Set maximum number of lines + */ + const maxNumberOfLines = useMemo(() => { + if (isComposerFullSize) { + return 1000000; + } + return maxLines; + }, [isComposerFullSize, maxLines]); + + const composerStyles = useMemo(() => StyleSheet.flatten(style), [style]); + + return ( + ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} + rejectResponderTermination={false} + // Setting a really high number here fixes an issue with the `maxNumberOfLines` prop on TextInput, where on Android the text input would collapse to only one line, + // when it should actually expand to the container (https://github.com/Expensify/App/issues/11694#issuecomment-1560520670) + // @Szymon20000 is working on fixing this (android-only) issue in the in the upstream PR (https://github.com/facebook/react-native/pulls?q=is%3Apr+is%3Aopen+maxNumberOfLines) + // TODO: remove this comment once upstream PR is merged and available in a future release + maxNumberOfLines={maxNumberOfLines} + textAlignVertical="center" + style={[composerStyles]} + autoFocus={autoFocus} + selection={selection} + isFullComposerAvailable={isFullComposerAvailable} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...props} + readOnly={isDisabled} + /> + ); +} + +Composer.displayName = 'Composer'; + +export default React.forwardRef(Composer); diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js deleted file mode 100644 index c9947999b273..000000000000 --- a/src/components/Composer/index.ios.js +++ /dev/null @@ -1,147 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import {StyleSheet} from 'react-native'; -import _ from 'underscore'; -import RNTextInput from '@components/RNTextInput'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ComposerUtils from '@libs/ComposerUtils'; - -const propTypes = { - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear: PropTypes.bool, - - /** A ref to forward to the text input */ - forwardedRef: PropTypes.func, - - /** When the input has cleared whoever owns this input should know about it */ - onClear: PropTypes.func, - - /** Set focus to this component the first time it renders. - * Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ - autoFocus: PropTypes.bool, - - /** Prevent edits and interactions like focus for this input. */ - isDisabled: PropTypes.bool, - - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - - /** Whether the full composer can be opened */ - isFullComposerAvailable: PropTypes.bool, - - /** Maximum number of lines in the text input */ - maxLines: PropTypes.number, - - /** Allow the full composer to be opened */ - setIsFullComposerAvailable: PropTypes.func, - - /** Whether the composer is full size */ - isComposerFullSize: PropTypes.bool, - - /** General styles to apply to the text input */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, -}; - -const defaultProps = { - shouldClear: false, - onClear: () => {}, - autoFocus: false, - isDisabled: false, - forwardedRef: null, - selection: { - start: 0, - end: 0, - }, - maxLines: undefined, - isFullComposerAvailable: false, - setIsFullComposerAvailable: () => {}, - isComposerFullSize: false, - style: null, -}; - -function Composer({shouldClear, onClear, isDisabled, maxLines, forwardedRef, isComposerFullSize, setIsFullComposerAvailable, ...props}) { - const textInput = useRef(null); - const theme = useTheme(); - const styles = useThemeStyles(); - - /** - * Set the TextInput Ref - * @param {Element} el - */ - const setTextInputRef = useCallback((el) => { - textInput.current = el; - if (!_.isFunction(forwardedRef) || textInput.current === null) { - return; - } - - // This callback prop is used by the parent component using the constructor to - // get a ref to the inner textInput element e.g. if we do - // this.textInput = el} /> this will not - // return a ref to the component, but rather the HTML element by default - forwardedRef(textInput.current); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current.clear(); - onClear(); - }, [shouldClear, onClear]); - - /** - * Set maximum number of lines - * @return {Number} - */ - const maxNumberOfLines = useMemo(() => { - if (isComposerFullSize) { - return; - } - return maxLines; - }, [isComposerFullSize, maxLines]); - - const composerStyles = useMemo(() => { - StyleSheet.flatten(props.style); - }, [props.style]); - - // On native layers we like to have the Text Input not focused so the - // user can read new chats without the keyboard in the way of the view. - // On Android the selection prop is required on the TextInput but this prop has issues on IOS - const propsToPass = _.omit(props, 'selection'); - return ( - ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} - rejectResponderTermination={false} - smartInsertDelete={false} - maxNumberOfLines={maxNumberOfLines} - style={[composerStyles, styles.verticalAlignMiddle]} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...propsToPass} - readOnly={isDisabled} - /> - ); -} - -Composer.propTypes = propTypes; -Composer.defaultProps = defaultProps; - -const ComposerWithRef = React.forwardRef((props, ref) => ( - -)); - -ComposerWithRef.displayName = 'ComposerWithRef'; - -export default ComposerWithRef; diff --git a/src/components/Composer/index.ios.tsx b/src/components/Composer/index.ios.tsx new file mode 100644 index 000000000000..240dfabded0b --- /dev/null +++ b/src/components/Composer/index.ios.tsx @@ -0,0 +1,91 @@ +import React, {ForwardedRef, useCallback, useEffect, useMemo, useRef} from 'react'; +import {StyleSheet, TextInput} from 'react-native'; +import RNTextInput from '@components/RNTextInput'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ComposerUtils from '@libs/ComposerUtils'; +import {ComposerProps} from './types'; + +function Composer( + { + shouldClear = false, + onClear = () => {}, + isDisabled = false, + maxLines, + isComposerFullSize = false, + setIsFullComposerAvailable = () => {}, + autoFocus = false, + isFullComposerAvailable = false, + style, + // On native layers we like to have the Text Input not focused so the + // user can read new chats without the keyboard in the way of the view. + // On Android the selection prop is required on the TextInput but this prop has issues on IOS + selection, + ...props + }: ComposerProps, + ref: ForwardedRef, +) { + const textInput = useRef(null); + + const styles = useThemeStyles(); + const theme = useTheme(); + + /** + * Set the TextInput Ref + */ + const setTextInputRef = useCallback((el: TextInput) => { + textInput.current = el; + if (typeof ref !== 'function' || textInput.current === null) { + return; + } + + // This callback prop is used by the parent component using the constructor to + // get a ref to the inner textInput element e.g. if we do + // this.textInput = el} /> this will not + // return a ref to the component, but rather the HTML element by default + ref(textInput.current); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!shouldClear) { + return; + } + textInput.current?.clear(); + onClear(); + }, [shouldClear, onClear]); + + /** + * Set maximum number of lines + */ + const maxNumberOfLines = useMemo(() => { + if (isComposerFullSize) { + return; + } + return maxLines; + }, [isComposerFullSize, maxLines]); + + const composerStyles = useMemo(() => StyleSheet.flatten(style), [style]); + + return ( + ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} + rejectResponderTermination={false} + smartInsertDelete={false} + style={[composerStyles, styles.verticalAlignMiddle]} + maxNumberOfLines={maxNumberOfLines} + autoFocus={autoFocus} + isFullComposerAvailable={isFullComposerAvailable} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...props} + readOnly={isDisabled} + /> + ); +} + +Composer.displayName = 'Composer'; + +export default React.forwardRef(Composer); diff --git a/src/components/Composer/index.js b/src/components/Composer/index.tsx similarity index 61% rename from src/components/Composer/index.js rename to src/components/Composer/index.tsx index 3af22b63ed69..4ff5c6dbd75f 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.tsx @@ -1,198 +1,107 @@ +import {useNavigation} from '@react-navigation/native'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {BaseSyntheticEvent, ForwardedRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; -import {StyleSheet, View} from 'react-native'; -import _ from 'underscore'; +import {DimensionValue, NativeSyntheticEvent, Text as RNText, StyleSheet, TextInput, TextInputKeyPressEventData, TextInputProps, TextInputSelectionChangeEventData, View} from 'react-native'; +import {AnimatedProps} from 'react-native-reanimated'; import RNTextInput from '@components/RNTextInput'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withNavigation from '@components/withNavigation'; import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; -import compose from '@libs/compose'; import * as ComposerUtils from '@libs/ComposerUtils'; import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable'; import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import CONST from '@src/CONST'; - -const propTypes = { - /** Maximum number of lines in the text input */ - maxLines: PropTypes.number, - - /** The default value of the comment box */ - defaultValue: PropTypes.string, - - /** The value of the comment box */ - value: PropTypes.string, - - /** Number of lines for the comment */ - numberOfLines: PropTypes.number, - - /** Callback method to update number of lines for the comment */ - onNumberOfLinesChange: PropTypes.func, - - /** Callback method to handle pasting a file */ - onPasteFile: PropTypes.func, - - /** A ref to forward to the text input */ - forwardedRef: PropTypes.func, - - /** General styles to apply to the text input */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, - - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear: PropTypes.bool, - - /** When the input has cleared whoever owns this input should know about it */ - onClear: PropTypes.func, - - /** Whether or not this TextInput is disabled. */ - isDisabled: PropTypes.bool, - - /** Set focus to this component the first time it renders. - Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ - autoFocus: PropTypes.bool, - - /** Update selection position on change */ - onSelectionChange: PropTypes.func, - - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - - /** Whether the full composer can be opened */ - isFullComposerAvailable: PropTypes.bool, - - /** Allow the full composer to be opened */ - setIsFullComposerAvailable: PropTypes.func, - - /** Should we calculate the caret position */ - shouldCalculateCaretPosition: PropTypes.bool, - - /** Function to check whether composer is covered up or not */ - checkComposerVisibility: PropTypes.func, - - /** Whether this is the report action compose */ - isReportActionCompose: PropTypes.bool, - - /** Whether the sull composer is open */ - isComposerFullSize: PropTypes.bool, - - /** Should make the input only scroll inside the element avoid scroll out to parent */ - shouldContainScroll: PropTypes.bool, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - defaultValue: undefined, - value: undefined, - numberOfLines: 0, - onNumberOfLinesChange: () => {}, - maxLines: -1, - onPasteFile: () => {}, - shouldClear: false, - onClear: () => {}, - style: null, - isDisabled: false, - autoFocus: false, - forwardedRef: null, - onSelectionChange: () => {}, - selection: { - start: 0, - end: 0, - }, - isFullComposerAvailable: false, - setIsFullComposerAvailable: () => {}, - shouldCalculateCaretPosition: false, - checkComposerVisibility: () => false, - isReportActionCompose: false, - isComposerFullSize: false, - shouldContainScroll: false, -}; +import {ComposerProps} from './types'; /** * Retrieves the characters from the specified cursor position up to the next space or new line. * - * @param {string} str - The input string. - * @param {number} cursorPos - The position of the cursor within the input string. - * @returns {string} - The substring from the cursor position up to the next space or new line. + * @param inputString - The input string. + * @param cursorPosition - The position of the cursor within the input string. + * @returns - The substring from the cursor position up to the next space or new line. * If no space or new line is found, returns the substring from the cursor position to the end of the input string. */ -const getNextChars = (str, cursorPos) => { +const getNextChars = (inputString: string, cursorPosition: number): string => { // Get the substring starting from the cursor position - const substr = str.substring(cursorPos); + const subString = inputString.substring(cursorPosition); // Find the index of the next space or new line character - const spaceIndex = substr.search(/[ \n]/); + const spaceIndex = subString.search(/[ \n]/); if (spaceIndex === -1) { - return substr; + return subString; } // If there is a space or new line, return the substring up to the space or new line - return substr.substring(0, spaceIndex); + return subString.substring(0, spaceIndex); }; // Enable Markdown parsing. // On web we like to have the Text Input field always focused so the user can easily type a new chat -function Composer({ - value, - defaultValue, - maxLines, - onKeyPress, - style, - shouldClear, - autoFocus, - translate, - isFullComposerAvailable, - shouldCalculateCaretPosition, - numberOfLines: numberOfLinesProp, - isDisabled, - forwardedRef, - navigation, - onClear, - onPasteFile, - onSelectionChange, - onNumberOfLinesChange, - setIsFullComposerAvailable, - checkComposerVisibility, - selection: selectionProp, - isReportActionCompose, - isComposerFullSize, - shouldContainScroll, - ...props -}) { +function Composer( + { + value, + defaultValue, + maxLines = -1, + onKeyPress = () => {}, + style, + shouldClear = false, + autoFocus = false, + isFullComposerAvailable = false, + shouldCalculateCaretPosition = false, + numberOfLines: numberOfLinesProp = 0, + isDisabled = false, + onClear = () => {}, + onPasteFile = () => {}, + onSelectionChange = () => {}, + onNumberOfLinesChange = () => {}, + setIsFullComposerAvailable = () => {}, + checkComposerVisibility = () => false, + selection: selectionProp = { + start: 0, + end: 0, + }, + isReportActionCompose = false, + isComposerFullSize = false, + shouldContainScroll = false, + ...props + }: ComposerProps, + ref: ForwardedRef>>, +) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {windowWidth} = useWindowDimensions(); - const textRef = useRef(null); - const textInput = useRef(null); + const navigation = useNavigation(); + const textRef = useRef(null); + const textInput = useRef<(HTMLTextAreaElement & TextInput) | null>(null); const [numberOfLines, setNumberOfLines] = useState(numberOfLinesProp); - const [selection, setSelection] = useState({ + const [selection, setSelection] = useState< + | { + start: number; + end?: number; + } + | undefined + >({ start: selectionProp.start, end: selectionProp.end, }); const [caretContent, setCaretContent] = useState(''); const [valueBeforeCaret, setValueBeforeCaret] = useState(''); const [textInputWidth, setTextInputWidth] = useState(''); - const isScrollBarVisible = useIsScrollBarVisible(textInput, value); + const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); useEffect(() => { if (!shouldClear) { return; } - textInput.current.clear(); + textInput.current?.clear(); setNumberOfLines(1); onClear(); }, [shouldClear, onClear]); @@ -208,55 +117,55 @@ function Composer({ /** * Adds the cursor position to the selection change event. - * - * @param {Event} event */ - const addCursorPositionToSelectionChange = (event) => { + const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => { + const webEvent = event as BaseSyntheticEvent; + if (shouldCalculateCaretPosition) { // we do flushSync to make sure that the valueBeforeCaret is updated before we calculate the caret position to receive a proper position otherwise we will calculate position for the previous state flushSync(() => { - setValueBeforeCaret(event.target.value.slice(0, event.nativeEvent.selection.start)); - setCaretContent(getNextChars(value, event.nativeEvent.selection.start)); + setValueBeforeCaret(webEvent.target.value.slice(0, webEvent.nativeEvent.selection.start)); + setCaretContent(getNextChars(value ?? '', webEvent.nativeEvent.selection.start)); }); const selectionValue = { - start: event.nativeEvent.selection.start, - end: event.nativeEvent.selection.end, - positionX: textRef.current.offsetLeft - CONST.SPACE_CHARACTER_WIDTH, - positionY: textRef.current.offsetTop, + start: webEvent.nativeEvent.selection.start, + end: webEvent.nativeEvent.selection.end, + positionX: (textRef.current?.offsetLeft ?? 0) - CONST.SPACE_CHARACTER_WIDTH, + positionY: textRef.current?.offsetTop, }; + onSelectionChange({ + ...webEvent, nativeEvent: { + ...webEvent.nativeEvent, selection: selectionValue, }, }); setSelection(selectionValue); } else { - onSelectionChange(event); - setSelection(event.nativeEvent.selection); + onSelectionChange(webEvent); + setSelection(webEvent.nativeEvent.selection); } }; /** * Set pasted text to clipboard - * @param {String} text */ - const paste = useCallback((text) => { + const paste = useCallback((text?: string) => { try { document.execCommand('insertText', false, text); // Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view. - textInput.current.blur(); - textInput.current.focus(); + textInput.current?.blur(); + textInput.current?.focus(); // eslint-disable-next-line no-empty } catch (e) {} }, []); /** * Manually place the pasted HTML into Composer - * - * @param {String} html - pasted HTML */ const handlePastedHTML = useCallback( - (html) => { + (html: string) => { const parser = new ExpensiMark(); paste(parser.htmlToMarkdown(html)); }, @@ -265,12 +174,10 @@ function Composer({ /** * Paste the plaintext content into Composer. - * - * @param {ClipboardEvent} event */ const handlePastePlainText = useCallback( - (event) => { - const plainText = event.clipboardData.getData('text/plain'); + (event: ClipboardEvent) => { + const plainText = event.clipboardData?.getData('text/plain'); paste(plainText); }, [paste], @@ -279,44 +186,43 @@ function Composer({ /** * Check the paste event for an attachment, parse the data and call onPasteFile from props with the selected file, * Otherwise, convert pasted HTML to Markdown and set it on the composer. - * - * @param {ClipboardEvent} event */ const handlePaste = useCallback( - (event) => { + (event: ClipboardEvent) => { const isVisible = checkComposerVisibility(); - const isFocused = textInput.current.isFocused(); + const isFocused = textInput.current?.isFocused(); if (!(isVisible || isFocused)) { return; } if (textInput.current !== event.target) { + const eventTarget = event.target as HTMLInputElement | HTMLTextAreaElement | null; + // To make sure the composer does not capture paste events from other inputs, we check where the event originated // If it did originate in another input, we return early to prevent the composer from handling the paste - const isTargetInput = event.target.nodeName === 'INPUT' || event.target.nodeName === 'TEXTAREA' || event.target.contentEditable === 'true'; + const isTargetInput = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; if (isTargetInput) { return; } - textInput.current.focus(); + textInput.current?.focus(); } event.preventDefault(); - const {files, types} = event.clipboardData; const TEXT_HTML = 'text/html'; // If paste contains files, then trigger file management - if (files.length > 0) { + if (event.clipboardData?.files.length && event.clipboardData.files.length > 0) { // Prevent the default so we do not post the file name into the text box - onPasteFile(event.clipboardData.files[0]); + onPasteFile(event.clipboardData?.files[0]); return; } // If paste contains HTML - if (types.includes(TEXT_HTML)) { - const pastedHTML = event.clipboardData.getData(TEXT_HTML); + if (event.clipboardData?.types.includes(TEXT_HTML)) { + const pastedHTML = event.clipboardData?.getData(TEXT_HTML); const domparser = new DOMParser(); const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images; @@ -342,11 +248,11 @@ function Composer({ * divide by line height to get the total number of rows for the textarea. */ const updateNumberOfLines = useCallback(() => { - if (textInput.current === null) { + if (!textInput.current) { return; } // we reset the height to 0 to get the correct scrollHeight - textInput.current.style.height = 0; + textInput.current.style.height = '0'; const computedStyle = window.getComputedStyle(textInput.current); const lineHeight = parseInt(computedStyle.lineHeight, 10) || 20; const paddingTopAndBottom = parseInt(computedStyle.paddingBottom, 10) + parseInt(computedStyle.paddingTop, 10); @@ -372,8 +278,8 @@ function Composer({ const unsubscribeFocus = navigation.addListener('focus', () => document.addEventListener('paste', handlePaste)); const unsubscribeBlur = navigation.addListener('blur', () => document.removeEventListener('paste', handlePaste)); - if (_.isFunction(forwardedRef)) { - forwardedRef(textInput.current); + if (typeof ref === 'function') { + ref(textInput.current); } if (textInput.current) { @@ -392,9 +298,9 @@ function Composer({ }, []); const handleKeyPress = useCallback( - (e) => { + (e: NativeSyntheticEvent) => { // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed - if (!onKeyPress || isEnterWhileComposition(e)) { + if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { return; } onKeyPress(e); @@ -410,10 +316,7 @@ function Composer({ opacity: 0, }} > - + {`${valueBeforeCaret} `} (textInput.current = el)} + ref={(el: TextInput & HTMLTextAreaElement) => (textInput.current = el)} selection={selection} style={inputStyleMemo} value={value} - forwardedRef={forwardedRef} defaultValue={defaultValue} autoFocus={autoFocus} /* eslint-disable-next-line react/jsx-props-no-spreading */ @@ -474,9 +376,8 @@ function Composer({ textInput.current.focus(); }); - if (props.onFocus) { - props.onFocus(e); - } + + props.onFocus?.(e); }} /> {shouldCalculateCaretPosition && renderElementForCaretPosition} @@ -484,18 +385,6 @@ function Composer({ ); } -Composer.propTypes = propTypes; -Composer.defaultProps = defaultProps; Composer.displayName = 'Composer'; -const ComposerWithRef = React.forwardRef((props, ref) => ( - -)); - -ComposerWithRef.displayName = 'ComposerWithRef'; - -export default compose(withLocalize, withNavigation)(ComposerWithRef); +export default React.forwardRef(Composer); diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts new file mode 100644 index 000000000000..cc0654b68019 --- /dev/null +++ b/src/components/Composer/types.ts @@ -0,0 +1,76 @@ +import {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; + +type TextSelection = { + start: number; + end?: number; +}; + +type ComposerProps = { + /** Maximum number of lines in the text input */ + maxLines?: number; + + /** The default value of the comment box */ + defaultValue?: string; + + /** The value of the comment box */ + value?: string; + + /** Number of lines for the comment */ + numberOfLines?: number; + + /** Callback method to update number of lines for the comment */ + onNumberOfLinesChange?: (numberOfLines: number) => void; + + /** Callback method to handle pasting a file */ + onPasteFile?: (file?: File) => void; + + /** General styles to apply to the text input */ + // 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; + + /** Set focus to this component the first time it renders. + Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ + autoFocus?: boolean; + + /** Update selection position on change */ + onSelectionChange?: (event: NativeSyntheticEvent) => void; + + /** Selection Object */ + selection?: TextSelection; + + /** Whether the full composer can be opened */ + isFullComposerAvailable?: boolean; + + /** Allow the full composer to be opened */ + setIsFullComposerAvailable?: (value: boolean) => void; + + /** Should we calculate the caret position */ + shouldCalculateCaretPosition?: boolean; + + /** Function to check whether composer is covered up or not */ + checkComposerVisibility?: () => boolean; + + /** Whether this is the report action compose */ + isReportActionCompose?: boolean; + + /** Whether the sull composer is open */ + isComposerFullSize?: boolean; + + onKeyPress?: (event: NativeSyntheticEvent) => void; + + onFocus?: (event: NativeSyntheticEvent) => void; + + /** Should make the input only scroll inside the element avoid scroll out to parent */ + shouldContainScroll?: boolean; +}; + +export type {TextSelection, ComposerProps}; diff --git a/src/libs/ComposerUtils/types.ts b/src/libs/ComposerUtils/types.ts deleted file mode 100644 index a417d951ff51..000000000000 --- a/src/libs/ComposerUtils/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -type ComposerProps = { - isFullComposerAvailable: boolean; - setIsFullComposerAvailable: (isFullComposerAvailable: boolean) => void; -}; - -export default ComposerProps; diff --git a/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts b/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts index 761abb8c9c8f..64c526484760 100644 --- a/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts +++ b/src/libs/ComposerUtils/updateIsFullComposerAvailable.ts @@ -1,5 +1,5 @@ +import {ComposerProps} from '@components/Composer/types'; import CONST from '@src/CONST'; -import ComposerProps from './types'; /** * Update isFullComposerAvailable if needed @@ -8,7 +8,7 @@ import ComposerProps from './types'; function updateIsFullComposerAvailable(props: ComposerProps, numberOfLines: number) { const isFullComposerAvailable = numberOfLines >= CONST.COMPOSER.FULL_COMPOSER_MIN_LINES; if (isFullComposerAvailable !== props.isFullComposerAvailable) { - props.setIsFullComposerAvailable(isFullComposerAvailable); + props.setIsFullComposerAvailable?.(isFullComposerAvailable); } } diff --git a/src/libs/ComposerUtils/updateNumberOfLines/types.ts b/src/libs/ComposerUtils/updateNumberOfLines/types.ts index c121eaaef319..2fe1465fa194 100644 --- a/src/libs/ComposerUtils/updateNumberOfLines/types.ts +++ b/src/libs/ComposerUtils/updateNumberOfLines/types.ts @@ -1,5 +1,5 @@ import {NativeSyntheticEvent, TextInputContentSizeChangeEventData} from 'react-native'; -import ComposerProps from '@libs/ComposerUtils/types'; +import {ComposerProps} from '@components/Composer/types'; import {type ThemeStyles} from '@styles/index'; type UpdateNumberOfLines = (props: ComposerProps, event: NativeSyntheticEvent, styles: ThemeStyles) => void; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index c392e61f0814..479a3c60d933 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -867,7 +867,7 @@ function getEmojiPickerListHeight(hasAdditionalSpace: boolean, windowHeight: num /** * Returns padding vertical based on number of lines */ -function getComposeTextAreaPadding(numberOfLines: number, isComposerFullSize: boolean): ViewStyle { +function getComposeTextAreaPadding(numberOfLines: number, isComposerFullSize: boolean): TextStyle { let paddingValue = 5; // Issue #26222: If isComposerFullSize paddingValue will always be 5 to prevent padding jumps when adding multiple lines. if (!isComposerFullSize) { diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index 5d67da0b885e..27cc16b912d5 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -283,7 +283,11 @@ declare module 'react-native' { enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'; readOnly?: boolean; } - interface TextInputProps extends WebTextInputProps {} + interface TextInputProps extends WebTextInputProps { + // TODO: remove once the app is updated to RN 0.73 + smartInsertDelete?: boolean; + isFullComposerAvailable?: boolean; + } /** * Image