diff --git a/src/hooks/useFrozenScroll.js b/src/hooks/useFrozenScroll.js new file mode 100644 index 000000000000..a37dcbf1ee4a --- /dev/null +++ b/src/hooks/useFrozenScroll.js @@ -0,0 +1,10 @@ +import {useContext} from 'react'; +import {ReportActionListFrozenScrollContext} from '../pages/home/report/ReportActionListFrozenScrollContext'; + +/** + * Hook for getting current state of scroll freeze and a function to set whether the scroll should be frozen + * @returns {Object} + */ +export default function useFrozenScroll() { + return useContext(ReportActionListFrozenScrollContext); +} diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 15bf25695fd3..83c10f6e4f1e 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -39,6 +39,7 @@ import DragAndDropProvider from '../../components/DragAndDrop/Provider'; import usePrevious from '../../hooks/usePrevious'; import CONST from '../../CONST'; import withCurrentReportID, {withCurrentReportIDPropTypes, withCurrentReportIDDefaultProps} from '../../components/withCurrentReportID'; +import {ReportActionListFrozenScrollContextProvider} from './report/ReportActionListFrozenScrollContext'; const propTypes = { /** Navigation route context info provided by react navigation */ @@ -357,98 +358,100 @@ function ReportScreen({ reactionListRef, }} > - - + - - {headerView} - {ReportUtils.isTaskReport(report) && isSmallScreenWidth && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( - - - - + + {headerView} + {ReportUtils.isTaskReport(report) && isSmallScreenWidth && ReportUtils.isOpenTaskReport(report, parentReportAction) && ( + + + + + - - )} - - {Boolean(accountManagerReportID) && ReportUtils.isConciergeChatReport(report) && isBannerVisible && ( - - )} - - { - // Rounding this value for comparison because they can look like this: 411.9999694824219 - const newSkeletonViewContainerHeight = Math.round(event.nativeEvent.layout.height); - - // The height can be 0 if the component unmounts - we are not interested in this value and want to know how much space it - // takes up so we can set the skeleton view container height. - if (newSkeletonViewContainerHeight === 0) { - return; - } - setSkeletonViewContainerHeight(newSkeletonViewContainerHeight); - }} - > - {isReportReadyForDisplay && !isLoadingInitialReportActions && !isLoading && ( - )} - - {/* Note: The report should be allowed to mount even if the initial report actions are not loaded. If we prevent rendering the report while they are loading then - we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} - {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && } - - {isReportReadyForDisplay && ( - <> - + {Boolean(accountManagerReportID) && ReportUtils.isConciergeChatReport(report) && isBannerVisible && ( + + )} + + { + // Rounding this value for comparison because they can look like this: 411.9999694824219 + const newSkeletonViewContainerHeight = Math.round(event.nativeEvent.layout.height); + + // The height can be 0 if the component unmounts - we are not interested in this value and want to know how much space it + // takes up so we can set the skeleton view container height. + if (newSkeletonViewContainerHeight === 0) { + return; + } + setSkeletonViewContainerHeight(newSkeletonViewContainerHeight); + }} + > + {isReportReadyForDisplay && !isLoadingInitialReportActions && !isLoading && ( + - - )} + )} - {!isReportReadyForDisplay && ( - - )} - - - - + {/* Note: The report should be allowed to mount even if the initial report actions are not loaded. If we prevent rendering the report while they are loading then + we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */} + {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && } + + {isReportReadyForDisplay && ( + <> + + + )} + + {!isReportReadyForDisplay && ( + + )} + + + + + ); } diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 6ce826a2a34c..dfd2758f8f42 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -40,6 +40,7 @@ import * as EmojiPickerAction from '../../../libs/actions/EmojiPickerAction'; import focusWithDelay from '../../../libs/focusWithDelay'; import ONYXKEYS from '../../../ONYXKEYS'; import * as Browser from '../../../libs/Browser'; +import useFrozenScroll from '../../../hooks/useFrozenScroll'; const propTypes = { /** All the data of the action */ @@ -122,6 +123,8 @@ function ReportActionItemMessageEdit(props) { const isFocusedRef = useRef(false); const insertedEmojis = useRef([]); + const {setShouldFreezeScroll} = useFrozenScroll(); + useEffect(() => { // required for keeping last state of isFocused variable isFocusedRef.current = isFocused; @@ -235,6 +238,7 @@ function ReportActionItemMessageEdit(props) { * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. */ const deleteDraft = useCallback(() => { + setShouldFreezeScroll(false); debouncedSaveDraft.cancel(); Report.saveReportActionDraft(props.reportID, props.action.reportActionID, ''); @@ -250,7 +254,7 @@ function ReportActionItemMessageEdit(props) { keyboardDidHideListener.remove(); }); } - }, [props.action.reportActionID, debouncedSaveDraft, props.index, props.reportID, reportScrollManager, isActive]); + }, [setShouldFreezeScroll, props.action.reportActionID, debouncedSaveDraft, props.index, props.reportID, reportScrollManager, isActive]); /** * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with @@ -382,8 +386,9 @@ function ReportActionItemMessageEdit(props) { maxLines={isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES} // This is the same that slack has style={[styles.textInputCompose, styles.flex1, styles.bgTransparent]} onFocus={() => { - setIsFocused(true); reportScrollManager.scrollToIndex({animated: true, index: props.index}, true); + setIsFocused(true); + setShouldFreezeScroll(true); setShouldShowComposeInputKeyboardAware(false); // Clear active report action when another action gets focused @@ -396,6 +401,7 @@ function ReportActionItemMessageEdit(props) { }} onBlur={(event) => { setIsFocused(false); + setShouldFreezeScroll(false); const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id'); if (_.contains([messageEditInput, emojiButtonID], relatedTargetId)) { return; diff --git a/src/pages/home/report/ReportActionListFrozenScrollContext.js b/src/pages/home/report/ReportActionListFrozenScrollContext.js new file mode 100644 index 000000000000..7e8fef346a90 --- /dev/null +++ b/src/pages/home/report/ReportActionListFrozenScrollContext.js @@ -0,0 +1,58 @@ +import React, {createContext, forwardRef, useMemo, useState} from 'react'; +import PropTypes from 'prop-types'; +import getComponentDisplayName from '../../../libs/getComponentDisplayName'; + +const withScrollFrozenPropTypes = { + /** flag determining if we should freeze the scroll */ + shouldFreezeScroll: PropTypes.bool, + + /** Function to update the state */ + setShouldFreezeScroll: PropTypes.func, +}; + +const ReportActionListFrozenScrollContext = createContext(null); + +function ReportActionListFrozenScrollContextProvider(props) { + const [shouldFreezeScroll, setShouldFreezeScroll] = useState(false); + + /** + * The context this component exposes to child components + * @returns {Object} flag and a flag setter + */ + const contextValue = useMemo( + () => ({ + shouldFreezeScroll, + setShouldFreezeScroll, + }), + [shouldFreezeScroll, setShouldFreezeScroll], + ); + + return {props.children}; +} + +ReportActionListFrozenScrollContextProvider.displayName = 'ReportActionListFrozenScrollContextProvider'; +ReportActionListFrozenScrollContextProvider.propTypes = { + /** Actual content wrapped by this component */ + children: PropTypes.node.isRequired, +}; + +function withScrollFrozen(WrappedComponent) { + const WithScrollFrozenState = forwardRef((props, ref) => ( + + {(scrollFrozenProps) => ( + + )} + + )); + + WithScrollFrozenState.displayName = `WithScrollFrozenState(${getComponentDisplayName(WrappedComponent)})`; + return WithScrollFrozenState; +} + +export {ReportActionListFrozenScrollContext, ReportActionListFrozenScrollContextProvider, withScrollFrozenPropTypes, withScrollFrozen}; diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index f3f40d34a0f5..4520f4f42f76 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -21,6 +21,7 @@ import reportPropTypes from '../../reportPropTypes'; import FloatingMessageCounter from './FloatingMessageCounter'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; import reportActionPropTypes from './reportActionPropTypes'; +import useFrozenScroll from '../../../hooks/useFrozenScroll'; const propTypes = { /** The report currently being looked at */ @@ -90,6 +91,10 @@ function keyExtractor(item) { return item.reportActionID; } +const maintainVisibleContentPositionOptions = { + minIndexForVisible: 1, +}; + function isMessageUnread(message, lastReadTime) { return Boolean(message && lastReadTime && message.created && lastReadTime < message.created); } @@ -130,6 +135,7 @@ function ReportActionsList({ opacity.value = withTiming(1, {duration: 100}); }, [opacity]); const [skeletonViewHeight, setSkeletonViewHeight] = useState(0); + const {shouldFreezeScroll} = useFrozenScroll(); useEffect(() => { // If the reportID changes, we reset the userActiveSince to null, we need to do it because @@ -360,6 +366,7 @@ function ReportActionsList({ }} onScroll={trackVerticalScrolling} extraData={extraData} + maintainVisibleContentPosition={shouldFreezeScroll ? maintainVisibleContentPositionOptions : null} />