diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/implementation/index.native.tsx similarity index 99% rename from src/components/Composer/index.native.tsx rename to src/components/Composer/implementation/index.native.tsx index e542ed56bdd3..325a3ebd609d 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -5,6 +5,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeSyntheticEvent, TextInput, TextInputChangeEventData, TextInputPasteEventData} from 'react-native'; import {StyleSheet} from 'react-native'; import type {FileObject} from '@components/AttachmentModal'; +import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; @@ -18,7 +19,6 @@ import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullCompo import * as EmojiUtils from '@libs/EmojiUtils'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import CONST from '@src/CONST'; -import type {ComposerProps} from './types'; const excludeNoStyles: Array = []; const excludeReportMentionStyle: Array = ['mentionReport']; diff --git a/src/components/Composer/implementation/index.tsx b/src/components/Composer/implementation/index.tsx new file mode 100755 index 000000000000..e40bb716e0a0 --- /dev/null +++ b/src/components/Composer/implementation/index.tsx @@ -0,0 +1,370 @@ +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'; +// eslint-disable-next-line no-restricted-imports +import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; +import {DeviceEventEmitter, StyleSheet} from 'react-native'; +import type {ComposerProps} from '@components/Composer/types'; +import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; +import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; +import useHtmlPaste from '@hooks/useHtmlPaste'; +import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; +import useMarkdownStyle from '@hooks/useMarkdownStyle'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import * as Browser from '@libs/Browser'; +import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import * as FileUtils from '@libs/fileDownload/FileUtils'; +import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; +import CONST from '@src/CONST'; + +const excludeNoStyles: Array = []; +const excludeReportMentionStyle: Array = ['mentionReport']; +const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL]; + +// 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 = -1, + onKeyPress = () => {}, + style, + autoFocus = false, + shouldCalculateCaretPosition = false, + isDisabled = false, + onClear = () => {}, + onPasteFile = () => {}, + onSelectionChange = () => {}, + setIsFullComposerAvailable = () => {}, + checkComposerVisibility = () => false, + selection: selectionProp = { + start: 0, + end: 0, + }, + isComposerFullSize = false, + shouldContainScroll = true, + isGroupPolicyReport = false, + showSoftInputOnFocus = true, + ...props + }: ComposerProps, + ref: ForwardedRef, +) { + const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); + const theme = useTheme(); + const styles = useThemeStyles(); + const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); + const StyleUtils = useStyleUtils(); + const textInput = useRef(null); + const [selection, setSelection] = useState< + | { + start: number; + end?: number; + positionX?: number; + positionY?: number; + } + | undefined + >({ + start: selectionProp.start, + end: selectionProp.end, + }); + const [isRendered, setIsRendered] = useState(false); + const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); + const [prevScroll, setPrevScroll] = useState(); + const isReportFlatListScrolling = useRef(false); + + useEffect(() => { + if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) { + return; + } + setSelection(selectionProp); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [selectionProp]); + + /** + * Adds the cursor position to the selection change event. + */ + const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => { + const webEvent = event as BaseSyntheticEvent; + const sel = window.getSelection(); + if (shouldCalculateCaretPosition && isRendered && sel) { + const range = sel.getRangeAt(0).cloneRange(); + range.collapse(true); + const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0]; + const containerRect = textInput.current?.getBoundingClientRect(); + + let x = 0; + let y = 0; + if (rect && containerRect) { + x = rect.left - containerRect.left; + y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2; + } + + const selectionValue = { + start: webEvent.nativeEvent.selection.start, + end: webEvent.nativeEvent.selection.end, + positionX: x - CONST.SPACE_CHARACTER_WIDTH, + positionY: y, + }; + + onSelectionChange({ + ...webEvent, + nativeEvent: { + ...webEvent.nativeEvent, + selection: selectionValue, + }, + }); + setSelection(selectionValue); + } else { + onSelectionChange(webEvent); + setSelection(webEvent.nativeEvent.selection); + } + }; + + /** + * 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. + */ + const handlePaste = useCallback( + (event: ClipboardEvent) => { + const isVisible = checkComposerVisibility(); + const isFocused = textInput.current?.isFocused(); + const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable'); + + if (!(isVisible || isFocused)) { + return true; + } + + if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { + 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 = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; + if (isTargetInput || (!isFocused && isContenteditableDivFocused && event.clipboardData?.files.length)) { + return true; + } + + textInput.current?.focus(); + } + + event.preventDefault(); + + const TEXT_HTML = 'text/html'; + + const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? ''; + + // If paste contains files, then trigger file management + 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]); + return true; + } + + // If paste contains base64 image + if (clipboardDataHtml?.includes(CONST.IMAGE_BASE64_MATCH)) { + const domparser = new DOMParser(); + const pastedHTML = clipboardDataHtml; + const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML)?.images; + + if (embeddedImages.length > 0 && embeddedImages[0].src) { + const src = embeddedImages[0].src; + const file = FileUtils.base64ToFile(src, 'image.png'); + onPasteFile(file); + return true; + } + } + + // If paste contains image from Google Workspaces ex: Sheets, Docs, Slide, etc + if (clipboardDataHtml?.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) { + const domparser = new DOMParser(); + const pastedHTML = clipboardDataHtml; + const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images; + + if (embeddedImages.length > 0 && embeddedImages[0]?.src) { + const src = embeddedImages[0].src; + if (src.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) { + fetch(src) + .then((response) => response.blob()) + .then((blob) => { + const file = new File([blob], 'image.jpg', {type: 'image/jpeg'}); + onPasteFile(file); + }); + return true; + } + } + } + return false; + }, + [onPasteFile, checkComposerVisibility], + ); + + useEffect(() => { + if (!textInput.current) { + return; + } + const debouncedSetPrevScroll = lodashDebounce(() => { + if (!textInput.current) { + return; + } + setPrevScroll(textInput.current.scrollTop); + }, 100); + + textInput.current.addEventListener('scroll', debouncedSetPrevScroll); + return () => { + textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll); + }; + }, []); + + useEffect(() => { + const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => { + isReportFlatListScrolling.current = scrolling; + }); + + return () => scrollingListener.remove(); + }, []); + + useEffect(() => { + const handleWheel = (e: MouseEvent) => { + if (isReportFlatListScrolling.current) { + e.preventDefault(); + return; + } + e.stopPropagation(); + }; + textInput.current?.addEventListener('wheel', handleWheel, {passive: false}); + + return () => { + textInput.current?.removeEventListener('wheel', handleWheel); + }; + }, []); + + useEffect(() => { + if (!textInput.current || prevScroll === undefined) { + return; + } + // eslint-disable-next-line react-compiler/react-compiler + textInput.current.scrollTop = prevScroll; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [isComposerFullSize]); + + useHtmlPaste(textInput, handlePaste, true); + + useEffect(() => { + setIsRendered(true); + }, []); + + const clear = useCallback(() => { + if (!textInput.current) { + return; + } + + const currentText = textInput.current.value; + 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 + if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { + return; + } + + onKeyPress(e); + }, + [onKeyPress], + ); + + const scrollStyleMemo = useMemo(() => { + if (shouldContainScroll) { + return isScrollBarVisible ? [styles.overflowScroll, styles.overscrollBehaviorContain] : styles.overflowHidden; + } + return styles.overflowAuto; + }, [shouldContainScroll, styles.overflowAuto, styles.overflowScroll, styles.overscrollBehaviorContain, styles.overflowHidden, isScrollBarVisible]); + + const inputStyleMemo = useMemo( + () => [ + StyleSheet.flatten([style, {outline: 'none'}]), + StyleUtils.getComposeTextAreaPadding(isComposerFullSize), + Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {}, + scrollStyleMemo, + StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), + isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined, + textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}, + ], + + [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis], + ); + + return ( + (textInput.current = el)} + selection={selection} + style={[inputStyleMemo]} + markdownStyle={markdownStyle} + value={value} + defaultValue={defaultValue} + autoFocus={autoFocus} + inputMode={showSoftInputOnFocus ? 'text' : 'none'} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...props} + onSelectionChange={addCursorPositionToSelectionChange} + onContentSizeChange={(e) => { + updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles); + }} + disabled={isDisabled} + onKeyPress={handleKeyPress} + addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL} + imagePreviewAuthRequiredURLs={imagePreviewAuthRequiredURLs} + /> + ); +} + +Composer.displayName = 'Composer'; + +export default React.forwardRef(Composer); diff --git a/src/components/Composer/index.e2e.tsx b/src/components/Composer/index.e2e.tsx new file mode 100644 index 000000000000..f28f13fe0ecf --- /dev/null +++ b/src/components/Composer/index.e2e.tsx @@ -0,0 +1,18 @@ +import type {ForwardedRef} from 'react'; +import type {TextInput} from 'react-native'; +import Composer from './implementation'; +import type {ComposerProps} from './types'; + +function ComposerE2e(props: ComposerProps, ref: ForwardedRef) { + return ( + + ); +} + +export default ComposerE2e; diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx old mode 100755 new mode 100644 index 26eb0f960c61..d9474effa478 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,370 +1,3 @@ -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'; -// eslint-disable-next-line no-restricted-imports -import type {NativeSyntheticEvent, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData} from 'react-native'; -import {DeviceEventEmitter, StyleSheet} from 'react-native'; -import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; -import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; -import useHtmlPaste from '@hooks/useHtmlPaste'; -import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; -import useMarkdownStyle from '@hooks/useMarkdownStyle'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; -import * as Browser from '@libs/Browser'; -import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import * as FileUtils from '@libs/fileDownload/FileUtils'; -import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; -import CONST from '@src/CONST'; -import type {ComposerProps} from './types'; +import Composer from './implementation'; -const excludeNoStyles: Array = []; -const excludeReportMentionStyle: Array = ['mentionReport']; -const imagePreviewAuthRequiredURLs = [CONST.EXPENSIFY_URL, CONST.STAGING_EXPENSIFY_URL]; - -// 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 = -1, - onKeyPress = () => {}, - style, - autoFocus = false, - shouldCalculateCaretPosition = false, - isDisabled = false, - onClear = () => {}, - onPasteFile = () => {}, - onSelectionChange = () => {}, - setIsFullComposerAvailable = () => {}, - checkComposerVisibility = () => false, - selection: selectionProp = { - start: 0, - end: 0, - }, - isComposerFullSize = false, - shouldContainScroll = true, - isGroupPolicyReport = false, - showSoftInputOnFocus = true, - ...props - }: ComposerProps, - ref: ForwardedRef, -) { - const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); - const theme = useTheme(); - const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); - const StyleUtils = useStyleUtils(); - const textInput = useRef(null); - const [selection, setSelection] = useState< - | { - start: number; - end?: number; - positionX?: number; - positionY?: number; - } - | undefined - >({ - start: selectionProp.start, - end: selectionProp.end, - }); - const [isRendered, setIsRendered] = useState(false); - const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); - const [prevScroll, setPrevScroll] = useState(); - const isReportFlatListScrolling = useRef(false); - - useEffect(() => { - if (!!selection && selectionProp.start === selection.start && selectionProp.end === selection.end) { - return; - } - setSelection(selectionProp); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [selectionProp]); - - /** - * Adds the cursor position to the selection change event. - */ - const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => { - const webEvent = event as BaseSyntheticEvent; - const sel = window.getSelection(); - if (shouldCalculateCaretPosition && isRendered && sel) { - const range = sel.getRangeAt(0).cloneRange(); - range.collapse(true); - const rect = range.getClientRects()[0] || range.startContainer.parentElement?.getClientRects()[0]; - const containerRect = textInput.current?.getBoundingClientRect(); - - let x = 0; - let y = 0; - if (rect && containerRect) { - x = rect.left - containerRect.left; - y = rect.top - containerRect.top + (textInput?.current?.scrollTop ?? 0) - rect.height / 2; - } - - const selectionValue = { - start: webEvent.nativeEvent.selection.start, - end: webEvent.nativeEvent.selection.end, - positionX: x - CONST.SPACE_CHARACTER_WIDTH, - positionY: y, - }; - - onSelectionChange({ - ...webEvent, - nativeEvent: { - ...webEvent.nativeEvent, - selection: selectionValue, - }, - }); - setSelection(selectionValue); - } else { - onSelectionChange(webEvent); - setSelection(webEvent.nativeEvent.selection); - } - }; - - /** - * 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. - */ - const handlePaste = useCallback( - (event: ClipboardEvent) => { - const isVisible = checkComposerVisibility(); - const isFocused = textInput.current?.isFocused(); - const isContenteditableDivFocused = document.activeElement?.nodeName === 'DIV' && document.activeElement?.hasAttribute('contenteditable'); - - if (!(isVisible || isFocused)) { - return true; - } - - if (textInput.current !== event.target && !(isContenteditableDivFocused && !event.clipboardData?.files.length)) { - 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 = eventTarget?.nodeName === 'INPUT' || eventTarget?.nodeName === 'TEXTAREA' || eventTarget?.contentEditable === 'true'; - if (isTargetInput || (!isFocused && isContenteditableDivFocused && event.clipboardData?.files.length)) { - return true; - } - - textInput.current?.focus(); - } - - event.preventDefault(); - - const TEXT_HTML = 'text/html'; - - const clipboardDataHtml = event.clipboardData?.getData(TEXT_HTML) ?? ''; - - // If paste contains files, then trigger file management - 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]); - return true; - } - - // If paste contains base64 image - if (clipboardDataHtml?.includes(CONST.IMAGE_BASE64_MATCH)) { - const domparser = new DOMParser(); - const pastedHTML = clipboardDataHtml; - const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML)?.images; - - if (embeddedImages.length > 0 && embeddedImages[0].src) { - const src = embeddedImages[0].src; - const file = FileUtils.base64ToFile(src, 'image.png'); - onPasteFile(file); - return true; - } - } - - // If paste contains image from Google Workspaces ex: Sheets, Docs, Slide, etc - if (clipboardDataHtml?.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) { - const domparser = new DOMParser(); - const pastedHTML = clipboardDataHtml; - const embeddedImages = domparser.parseFromString(pastedHTML, TEXT_HTML).images; - - if (embeddedImages.length > 0 && embeddedImages[0]?.src) { - const src = embeddedImages[0].src; - if (src.includes(CONST.GOOGLE_DOC_IMAGE_LINK_MATCH)) { - fetch(src) - .then((response) => response.blob()) - .then((blob) => { - const file = new File([blob], 'image.jpg', {type: 'image/jpeg'}); - onPasteFile(file); - }); - return true; - } - } - } - return false; - }, - [onPasteFile, checkComposerVisibility], - ); - - useEffect(() => { - if (!textInput.current) { - return; - } - const debouncedSetPrevScroll = lodashDebounce(() => { - if (!textInput.current) { - return; - } - setPrevScroll(textInput.current.scrollTop); - }, 100); - - textInput.current.addEventListener('scroll', debouncedSetPrevScroll); - return () => { - textInput.current?.removeEventListener('scroll', debouncedSetPrevScroll); - }; - }, []); - - useEffect(() => { - const scrollingListener = DeviceEventEmitter.addListener(CONST.EVENTS.SCROLLING, (scrolling: boolean) => { - isReportFlatListScrolling.current = scrolling; - }); - - return () => scrollingListener.remove(); - }, []); - - useEffect(() => { - const handleWheel = (e: MouseEvent) => { - if (isReportFlatListScrolling.current) { - e.preventDefault(); - return; - } - e.stopPropagation(); - }; - textInput.current?.addEventListener('wheel', handleWheel, {passive: false}); - - return () => { - textInput.current?.removeEventListener('wheel', handleWheel); - }; - }, []); - - useEffect(() => { - if (!textInput.current || prevScroll === undefined) { - return; - } - // eslint-disable-next-line react-compiler/react-compiler - textInput.current.scrollTop = prevScroll; - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [isComposerFullSize]); - - useHtmlPaste(textInput, handlePaste, true); - - useEffect(() => { - setIsRendered(true); - }, []); - - const clear = useCallback(() => { - if (!textInput.current) { - return; - } - - const currentText = textInput.current.value; - 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 - if (!onKeyPress || isEnterWhileComposition(e as unknown as KeyboardEvent)) { - return; - } - - onKeyPress(e); - }, - [onKeyPress], - ); - - const scrollStyleMemo = useMemo(() => { - if (shouldContainScroll) { - return isScrollBarVisible ? [styles.overflowScroll, styles.overscrollBehaviorContain] : styles.overflowHidden; - } - return styles.overflowAuto; - }, [shouldContainScroll, styles.overflowAuto, styles.overflowScroll, styles.overscrollBehaviorContain, styles.overflowHidden, isScrollBarVisible]); - - const inputStyleMemo = useMemo( - () => [ - StyleSheet.flatten([style, {outline: 'none'}]), - StyleUtils.getComposeTextAreaPadding(isComposerFullSize), - Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {}, - scrollStyleMemo, - StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), - isComposerFullSize ? {height: '100%', maxHeight: 'none'} : undefined, - textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}, - ], - - [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis], - ); - - return ( - (textInput.current = el)} - selection={selection} - style={[inputStyleMemo]} - markdownStyle={markdownStyle} - value={value} - defaultValue={defaultValue} - autoFocus={autoFocus} - inputMode={showSoftInputOnFocus ? 'text' : 'none'} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...props} - onSelectionChange={addCursorPositionToSelectionChange} - onContentSizeChange={(e) => { - updateIsFullComposerAvailable({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles); - }} - disabled={isDisabled} - onKeyPress={handleKeyPress} - addAuthTokenToImageURLCallback={addEncryptedAuthTokenToURL} - imagePreviewAuthRequiredURLs={imagePreviewAuthRequiredURLs} - /> - ); -} - -Composer.displayName = 'Composer'; - -export default React.forwardRef(Composer); +export default Composer; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx index a64513e5d3a3..f325ef10b56f 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e.tsx @@ -40,7 +40,9 @@ function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: Fo hasFocusBeenRequested.current = true; const setFocus = () => { + console.debug('[E2E] Requesting focus for ComposerWithSuggestions'); if (!(textInputRef && 'current' in textInputRef)) { + console.error('[E2E] textInputRef is not available, failed to focus'); return; } @@ -54,12 +56,11 @@ function ComposerWithSuggestionsE2e(props: ComposerWithSuggestionsProps, ref: Fo textInputRef.current?.blur(); setFocus(); - // 1000ms is enough time for any keyboard to open - }, 1000); + // Simulate user behavior and don't set focus immediately + }, 5_000); }; - // Simulate user behavior and don't set focus immediately - setTimeout(setFocus, 2000); + setFocus(); }, []); return ( diff --git a/tests/e2e/README.md b/tests/e2e/README.md index a47d9d8e8631..46477c19d725 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -46,7 +46,7 @@ npm run android ```diff { "private": true, -+ "main": "src/libs/E2E/reactNativeEntry.ts" ++ "main": "src/libs/E2E/reactNativeLaunchingTest.ts" } ```