diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 310003ee8adc..c554ead97250 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1363,7 +1363,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.5): + - RNLiveMarkdown (0.1.33): - glog - RCT-Folly (= 2022.05.16.00) - React-Core @@ -1904,7 +1904,7 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 25b969a1ffc806b9f9ad2e170d4a3b049c6af85e RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 35eeecf7e57eb26fdc279d5d4815982a9a9f7beb + RNLiveMarkdown: aaf75630fb2129db43fb5a873d33125e7173f3a0 RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: fcf7f1cbdc8bd7569c267d07284e8a5c7bee06ed RNPermissions: 9b086c8f05b2e2faa587fdc31f4c5ab4509728aa diff --git a/package-lock.json b/package-lock.json index fcebc3cd46dd..187221ab3519 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.5", + "@expensify/react-native-live-markdown": "0.1.33", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", @@ -3095,11 +3095,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.5", - "license": "MIT", - "workspaces": [ - "example" - ], + "version": "0.1.33", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.33.tgz", + "integrity": "sha512-K9WDwb7wdupGrOrZEFFQ57qNPYdGVNkF5qnhOfkhuvSL9UdZi3NLiyGzaohIIh1lXvElDgwaY0x0WtqkOXIsiw==", "engines": { "node": ">= 18.0.0" }, diff --git a/package.json b/package.json index c41afac9d570..f38f8e4a57c9 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.5", + "@expensify/react-native-live-markdown": "0.1.33", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-getcanonicallocales": "^2.2.0", diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index b6443f3ca385..82f67382c44b 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,14 +1,16 @@ +import lodashDebounce from 'lodash/debounce'; import type {BaseSyntheticEvent, ForwardedRef} 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} from 'react-native'; +import type {DimensionValue, NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputSelectionChangeEventData, TextStyle} from 'react-native'; import {StyleSheet, View} from 'react-native'; -import type {AnimatedTextInputRef} from '@components/RNTextInput'; -import RNTextInput from '@components/RNTextInput'; +import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; +import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import Text from '@components/Text'; 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'; @@ -79,10 +81,11 @@ function Composer( ) { const theme = useTheme(); const styles = useThemeStyles(); + const markdownStyle = useMarkdownStyle(); const StyleUtils = useStyleUtils(); const {windowWidth} = useWindowDimensions(); const textRef = useRef(null); - const textInput = useRef(null); + const textInput = useRef(null); const [numberOfLines, setNumberOfLines] = useState(numberOfLinesProp); const [selection, setSelection] = useState< | { @@ -97,7 +100,9 @@ function Composer( const [caretContent, setCaretContent] = useState(''); const [valueBeforeCaret, setValueBeforeCaret] = useState(''); const [textInputWidth, setTextInputWidth] = useState(''); + const [isRendered, setIsRendered] = useState(false); const isScrollBarVisible = useIsScrollBarVisible(textInput, value ?? ''); + const [prevScroll, setPrevScroll] = useState(); useEffect(() => { if (!shouldClear) { @@ -123,7 +128,7 @@ function Composer( const addCursorPositionToSelectionChange = (event: NativeSyntheticEvent) => { const webEvent = event as BaseSyntheticEvent; - if (shouldCalculateCaretPosition) { + if (shouldCalculateCaretPosition && isRendered) { // 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(webEvent.target.value.slice(0, webEvent.nativeEvent.selection.start)); @@ -158,18 +163,19 @@ function Composer( (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) { + 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) { + if (isTargetInput || (!isFocused && isContenteditableDivFocused && event.clipboardData?.files.length)) { return true; } @@ -256,12 +262,44 @@ function Composer( updateNumberOfLines(); }, [updateNumberOfLines]); + const currentNumberOfLines = useMemo( + () => (isComposerFullSize ? undefined : numberOfLines), + + [isComposerFullSize, numberOfLines], + ); + + 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(() => { + if (!textInput.current || prevScroll === undefined) { + return; + } + textInput.current.scrollTop = prevScroll; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isComposerFullSize]); + useHtmlPaste(textInput, handlePaste, true); useEffect(() => { if (typeof ref === 'function') { ref(textInput.current); } + setIsRendered(true); return () => { if (isReportActionCompose) { @@ -321,6 +359,7 @@ function Composer( StyleUtils.getComposeTextAreaPadding(numberOfLines, isComposerFullSize), Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {}, scrollStyleMemo, + isComposerFullSize ? ({height: '100%', maxHeight: 'none' as DimensionValue} as TextStyle) : undefined, ], [numberOfLines, scrollStyleMemo, styles.rtlTextRenderForSafari, style, StyleUtils, isComposerFullSize], @@ -328,20 +367,21 @@ function Composer( return ( <> - (textInput.current = el)} selection={selection} style={inputStyleMemo} + markdownStyle={markdownStyle} value={value} defaultValue={defaultValue} autoFocus={autoFocus} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} onSelectionChange={addCursorPositionToSelectionChange} - numberOfLines={numberOfLines} + numberOfLines={currentNumberOfLines} disabled={isDisabled} onKeyPress={handleKeyPress} onFocus={(e) => { diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx index e447bed67514..5bf172e82388 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.tsx @@ -9,7 +9,6 @@ import {usePersonalDetails} from '@components/OnyxProvider'; import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; -import usePrevious from '@hooks/usePrevious'; import * as LoginUtils from '@libs/LoginUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as SuggestionsUtils from '@libs/SuggestionUtils'; @@ -44,7 +43,6 @@ function SuggestionMention( ) { const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; const {translate, formatPhoneNumber} = useLocalize(); - const previousValue = usePrevious(value); const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -281,14 +279,8 @@ function SuggestionMention( ); useEffect(() => { - if (value.length < previousValue.length) { - // A workaround to not show the suggestions list when the user deletes a character before the mention. - // It is caused by a buggy behavior of the TextInput on iOS. Should be fixed after migration to Fabric. - // See: https://github.com/facebook/react-native/pull/36930#issuecomment-1593028467 - return; - } calculateMentionSuggestion(selection.end); - }, [selection, value, previousValue, calculateMentionSuggestion]); + }, [selection, calculateMentionSuggestion]); const updateShouldShowSuggestionMenuToFalse = useCallback(() => { setSuggestionValues((prevState) => {