diff --git a/patches/react-native-keyboard-controller/details.md b/patches/react-native-keyboard-controller/details.md new file mode 100644 index 0000000000000..97affec051a92 --- /dev/null +++ b/patches/react-native-keyboard-controller/details.md @@ -0,0 +1,8 @@ +# `react-native-keyboard-controller` patches + +### [react-native-keyboard-controller+1.20.7+001+fix-invalid-onInteractive-event-calls.patch](react-native-keyboard-controller+1.20.7+001+fix-invalid-onInteractive-event-calls.patch) + +- Reason: Fixes an issue where `react-native-keyboard-controller` sends invalid `onInteractive` events in `useKeyboardHandler` +- Upstream PR/issue: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1298 +- E/App issue: 🛑 +- PR Introducing Patch: https://github.com/Expensify/App/pull/71440 diff --git a/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.20.7+001+fix-invalid-onInteractive-event-calls.patch b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.20.7+001+fix-invalid-onInteractive-event-calls.patch new file mode 100644 index 0000000000000..7bd0055f113dc --- /dev/null +++ b/patches/react-native-keyboard-controller/react-native-keyboard-controller+1.20.7+001+fix-invalid-onInteractive-event-calls.patch @@ -0,0 +1,15 @@ +diff --git a/node_modules/react-native-keyboard-controller/ios/observers/movement/observer/KeyboardMovementObserver+Interactive.swift b/node_modules/react-native-keyboard-controller/ios/observers/movement/observer/KeyboardMovementObserver+Interactive.swift +index d1ccd45..0695b74 100644 +--- a/node_modules/react-native-keyboard-controller/ios/observers/movement/observer/KeyboardMovementObserver+Interactive.swift ++++ b/node_modules/react-native-keyboard-controller/ios/observers/movement/observer/KeyboardMovementObserver+Interactive.swift +@@ -30,6 +30,10 @@ extension KeyboardMovementObserver { + return + } + ++ if KeyboardEventsIgnorer.shared.shouldIgnore { ++ return ++ } ++ + let position = keyboardTrackingView.interactive(point: changeValue) + + if position == KeyboardTrackingView.invalidPosition { diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 766a5451176da..dc5d44468341a 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2225,6 +2225,8 @@ const CONST = { BIG_SCREEN_SUGGESTION_WIDTH: 300, }, COMPOSER_MAX_HEIGHT: 125, + // Starts with this value to avoid a big jump while the container height is being calculated in case the screen is first rendered w/ a full size composer. It's based on the perceived concierge header height on the iPhone 16 Pro. + CHAT_HEADER_BASE_HEIGHT: 73, CHAT_FOOTER_SECONDARY_ROW_HEIGHT: 15, CHAT_FOOTER_SECONDARY_ROW_PADDING: 5, CHAT_FOOTER_MIN_HEIGHT: 65, diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 6251fd7d97d7c..3955cf5747627 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -33,6 +33,7 @@ function Composer({ selection, value, isGroupPolicyReport = false, + nativeID, ref, ...props }: ComposerProps) { @@ -112,7 +113,7 @@ function Composer({ return ( = Omit, 'data' | 'initialScrollIndex' | 'onContentSizeChange'> & { +type BaseFlatListWithScrollKeyProps = Omit, 'data' | 'initialScrollIndex' | 'onContentSizeChange'> & { data: T[]; initialScrollKey?: string | null | undefined; keyExtractor: (item: T, index: number) => string; @@ -11,6 +12,6 @@ type BaseFlatListWithScrollKeyProps = Omit, 'data' | 'initia ref: ForwardedRef; }; -type FlatListWithScrollKeyProps = Omit, 'onContentSizeChange'> & Pick, 'onContentSizeChange'>; +type FlatListWithScrollKeyProps = Omit, 'onContentSizeChange'> & Pick, 'onContentSizeChange'>; export type {FlatListWithScrollKeyProps, BaseFlatListWithScrollKeyProps}; diff --git a/src/components/FlatList/index.android.tsx b/src/components/FlatList/index.android.tsx index 9ac8f70b2d532..f6605859e39c8 100644 --- a/src/components/FlatList/index.android.tsx +++ b/src/components/FlatList/index.android.tsx @@ -8,7 +8,7 @@ import type {CustomFlatListProps} from './types'; // FlatList wrapped with the freeze component will lose its scroll state when frozen (only for Android). // CustomFlatList saves the offset and use it for scrollToOffset() when unfrozen. -function CustomFlatList({ref, enableAnimatedKeyboardDismissal = false, onMomentumScrollEnd, shouldHideContent = false, ...props}: CustomFlatListProps) { +function CustomFlatList({ref, enableAnimatedKeyboardDismissal = false, onMomentumScrollEnd, shouldHideContent = false, ...restProps}: CustomFlatListProps) { const lastScrollOffsetRef = useRef(0); const styles = useThemeStyles(); @@ -39,13 +39,13 @@ function CustomFlatList({ref, enableAnimatedKeyboardDismissal = false, onMome }, [onScreenFocus]), ); - const contentContainerStyle = [props.contentContainerStyle, shouldHideContent && styles.opacity0]; + const contentContainerStyle = [restProps.contentContainerStyle, shouldHideContent && styles.opacity0]; if (enableAnimatedKeyboardDismissal) { return ( ({ref, enableAnimatedKeyboardDismissal = false, onMome return ( // eslint-disable-next-line react/jsx-props-no-spreading - {...props} + {...restProps} ref={ref} onMomentumScrollEnd={handleScrollEnd} contentContainerStyle={contentContainerStyle} diff --git a/src/components/FlatList/index.ios.tsx b/src/components/FlatList/index.ios.tsx index 726310918249d..950211cda45e6 100644 --- a/src/components/FlatList/index.ios.tsx +++ b/src/components/FlatList/index.ios.tsx @@ -2,7 +2,6 @@ import React, {useCallback, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {FlatList} from 'react-native'; import KeyboardDismissibleFlatList from '@components/KeyboardDismissibleFlatList'; -import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; import useThemeStyles from '@hooks/useThemeStyles'; import type {CustomFlatListProps} from './types'; @@ -37,15 +36,6 @@ function CustomFlatList({ [onMomentumScrollEnd], ); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted}); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp], - ); - const maintainVisibleContentPosition = isScrolling || shouldDisableVisibleContentPosition ? undefined : maintainVisibleContentPositionProp; const contentContainerStyle = [restProps.contentContainerStyle, shouldHideContent && styles.opacity0]; @@ -72,7 +62,7 @@ function CustomFlatList({ {...restProps} ref={ref} maintainVisibleContentPosition={maintainVisibleContentPosition} - onScroll={handleScroll} + onScroll={onScrollProp} onMomentumScrollBegin={handleScrollBegin} onMomentumScrollEnd={handleScrollEnd} contentContainerStyle={contentContainerStyle} diff --git a/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx b/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx index c8e8605ae81e4..5cee4910eb6d5 100644 --- a/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx +++ b/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx @@ -38,6 +38,8 @@ function KeyboardDismissibleFlatListContextProvider({children}: PropsWithChildre const contentSizeHeight = useSharedValue(0); const layoutMeasurementHeight = useSharedValue(0); + const isKeyboardOpening = useSharedValue(false); + useKeyboardHandler({ onStart: (e) => { 'worklet'; @@ -45,9 +47,9 @@ function KeyboardDismissibleFlatListContextProvider({children}: PropsWithChildre const scrollYValueAtStart = scrollY.get(); const prevHeight = height.get(); - height.set(e.height); - const willKeyboardOpen = e.progress === 1; + isKeyboardOpening.set(willKeyboardOpen); + if (willKeyboardOpen) { if (e.height > 0) { @@ -90,6 +92,7 @@ function KeyboardDismissibleFlatListContextProvider({children}: PropsWithChildre onInteractive: (e) => { 'worklet'; + height.set(e.height); if (listBehavior === CONST.LIST_BEHAVIOR.REGULAR) { @@ -101,6 +104,12 @@ function KeyboardDismissibleFlatListContextProvider({children}: PropsWithChildre onMove: (e) => { 'worklet'; + // This is to fix an issue with react-native-keyboard-controller, where an `onMove` event is triggered with an invalid height value when the keyboard is opened + // react-native-keyboard-controller issue: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1298 + if (isKeyboardOpening.get() && e.height < height.get()) { + return; + } + height.set(e.height); }, onEnd: (e) => { diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx index 6fec5c54521a6..9e1f93186533b 100644 --- a/src/components/KeyboardDismissibleFlatList/index.tsx +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -1,25 +1,19 @@ import React from 'react'; -import {useAnimatedScrollHandler, useComposedEventHandler} from 'react-native-reanimated'; +import {useComposedEventHandler} from 'react-native-reanimated'; import type {AnimatedFlatListWithCellRendererProps} from '@components/AnimatedFlatListWithCellRenderer'; import AnimatedFlatListWithCellRenderer from '@components/AnimatedFlatListWithCellRenderer'; -import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatListContext'; function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); - - const additionalOnScroll = useAnimatedScrollHandler({ - onScroll: emitComposerScrollEvents, - }); - - const onScroll = useComposedEventHandler([onScrollHandleKeyboard, additionalOnScroll, onScrollProp ?? null]); + const onScroll = useComposedEventHandler([onScrollHandleKeyboard, onScrollProp ?? null]); return ( diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index c21deb495d39d..8f9086100e2c7 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -5,14 +5,15 @@ import {isUserValidatedSelector} from '@selectors/Account'; import {tierNameSelector} from '@selectors/UserWallet'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; import {DeviceEventEmitter, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {useAnimatedReaction} from 'react-native-reanimated'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import Checkbox from '@components/Checkbox'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; import FlatListWithScrollKey from '@components/FlatList/FlatListWithScrollKey'; +import useKeyboardDismissibleFlatListValues from '@components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; @@ -22,6 +23,7 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@hooks/useFlatListScrollKey'; +import useKeyboardState from '@hooks/useKeyboardState'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -32,7 +34,9 @@ import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {queueExportSearchWithTemplate} from '@libs/actions/Search'; @@ -118,8 +122,16 @@ type MoneyRequestReportListProps = { /** The type of action that's pending */ reportPendingAction?: PendingAction | null; + + /** The current composer height */ + composerHeight: number; + + /** Whether the composer is in full size */ + isComposerFullSize?: boolean; }; +const ON_SCROLL_TO_LIMITS_THRESHOLD = 0.75; + function MoneyRequestReportActionsList({ report, policy, @@ -132,6 +144,8 @@ function MoneyRequestReportActionsList({ hasPendingDeletionTransaction, showReportActionsLoadingState, reportPendingAction, + composerHeight, + isComposerFullSize, }: MoneyRequestReportListProps) { const styles = useThemeStyles(); const {translate, getLocalDateFromDatetime} = useLocalize(); @@ -141,6 +155,10 @@ function MoneyRequestReportActionsList({ const didLayout = useRef(false); const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); + const {unmodifiedPaddings} = useSafeAreaPaddings(); + const {isKeyboardActive} = useKeyboardState(); + const StyleUtils = useStyleUtils(); + const {contentSizeHeight, layoutMeasurementHeight, keyboardHeight, scrollY} = useKeyboardDismissibleFlatListValues(); const route = useRoute>(); // wrapped in useMemo to avoid unnecessary re-renders and improve performance const reportTransactionIDs = useMemo(() => transactions.map((transaction) => transaction.transactionID), [transactions]); @@ -287,6 +305,28 @@ function MoneyRequestReportActionsList({ loadNewerChats(false); }, [loadNewerChats]); + // The previous scroll tracking implementation was made via ref. This is + // to ensure it will behave the same as before. + useAnimatedReaction( + () => { + return { + offsetY: scrollY.get(), + csHeight: contentSizeHeight.get(), + lmHeight: layoutMeasurementHeight.get(), + }; + }, + ({offsetY, csHeight, lmHeight}) => { + /** + * Count the diff between current scroll position and the bottom of the list. + * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) + */ + scrollingVerticalBottomOffset.current = csHeight - lmHeight - offsetY; + + // We additionally track the top offset to be able to scroll to the new transaction when it's added + scrollingVerticalTopOffset.current = offsetY; + }, + ); + const prevUnreadMarkerReportActionID = useRef(null); const visibleActionsMap = useMemo(() => { @@ -424,25 +464,14 @@ function MoneyRequestReportActionsList({ }, [currentUserAccountID, earliestReceivedOfflineMessageIndex, prevVisibleActionsMap, visibleReportActions, unreadMarkerTime]); prevUnreadMarkerReportActionID.current = unreadMarkerReportActionID; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, - currentVerticalScrollingOffsetRef: scrollingVerticalBottomOffset, + currentVerticalScrollingOffset: scrollY, readActionSkippedRef: readActionSkipped, + hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, + keyboardHeight, unreadMarkerReportActionIndex, isInverted: false, - onTrackScrolling: (event: NativeSyntheticEvent) => { - const {layoutMeasurement, contentSize, contentOffset} = event.nativeEvent; - const fullContentHeight = contentSize.height; - - /** - * Count the diff between current scroll position and the bottom of the list. - * Diff == (height of all items in the list) - (height of the layout with the list) - (how far user scrolled) - */ - scrollingVerticalBottomOffset.current = fullContentHeight - layoutMeasurement.height - contentOffset.y; - - // We additionally track the top offset to be able to scroll to the new transaction when it's added - scrollingVerticalTopOffset.current = contentOffset.y; - }, }); useEffect(() => { @@ -662,6 +691,8 @@ function MoneyRequestReportActionsList({ // Wrapped into useCallback to stabilize children re-renders const keyExtractor = useCallback((item: OnyxTypes.ReportAction) => item.reportActionID, []); + const paddingBottom = StyleUtils.getReportPaddingBottom({composerHeight, isKeyboardActive, safePaddingBottom: unmodifiedPaddings.bottom ?? 0, isComposerFullSize}); + const {windowHeight} = useWindowDimensions(); /** * Calculates the ideal number of report actions to render in the first render, based on the screen height and on @@ -747,7 +778,7 @@ function MoneyRequestReportActionsList({ /> )} - + ) : ( } + keyboardDismissMode="interactive" keyboardShouldPersistTaps="handled" - onScroll={trackVerticalScrolling} contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt2]} ref={reportScrollManager.ref} ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index 324fd1a748b00..94b7ece6f34bf 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -1,9 +1,11 @@ import {PortalHost} from '@gorhom/portal'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; // We use Animated for all functionality related to wide RHP to make it easier // to interact with react-navigation components (e.g., CardContainer, interpolator), which also use Animated. // eslint-disable-next-line no-restricted-imports import {Animated, InteractionManager, ScrollView, View} from 'react-native'; +import {KeyboardGestureArea} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; @@ -99,6 +101,10 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe const styles = useThemeStyles(); const {isOffline} = useNetwork(); + const [composerHeight, setComposerHeight] = useState(CONST.CHAT_FOOTER_MIN_HEIGHT); + // Starts with this value to avoid a big jump while the container height is being calculated in case the screen is first rendered w/ a full size composer. It's based on the perceived concierge header height on the iPhone 16 Pro. + const [headerHeight, setHeaderHeight] = useState(73); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -148,6 +154,11 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe const isEmptyTransactionReport = visibleTransactions && visibleTransactions.length === 0 && transactionThreadReportID === undefined; const shouldDisplayMoneyRequestActionsList = !!isEmptyTransactionReport || shouldDisplayReportTableView(report, visibleTransactions ?? []); + const onComposerLayout = useCallback((height: number) => setComposerHeight(height), []); + const onHeaderLayout = useCallback((e: LayoutChangeEvent) => { + setHeaderHeight(e.nativeEvent.layout.height); + }, []); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); const shouldShowWideRHPReceipt = visibleTransactions.length === 1 && !isSmallScreenWidth && !!transactionThreadReport; @@ -218,6 +229,8 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe pendingAction={reportPendingAction} isComposerFullSize={!!isComposerFullSize} lastReportAction={lastReportAction} + onLayout={onComposerLayout} + headerHeight={headerHeight} // If the report is from the 'Send Money' flow, we add the comment to the `iou` report because for these we don't combine reportActions even if there is a single transaction (they always have a single transaction) transactionThreadReportID={isSentMoneyReport ? undefined : transactionThreadReportID} /> @@ -227,14 +240,20 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe } return ( - + - {reportHeaderView} + {reportHeaderView} ) : ( )} {shouldDisplayReportFooter ? ( @@ -296,6 +317,8 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe reportTransactions={transactions} // If the report is from the 'Send Money' flow, we add the comment to the `iou` report because for these we don't combine reportActions even if there is a single transaction (they always have a single transaction) transactionThreadReportID={isSentMoneyReport ? undefined : transactionThreadReportID} + onLayout={onComposerLayout} + headerHeight={headerHeight} /> @@ -303,7 +326,7 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe - + ); } diff --git a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx index f921dcea6de2f..ca8523c8b7870 100644 --- a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx @@ -1,7 +1,7 @@ import type {ReactNode} from 'react'; import React, {useMemo} from 'react'; -import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; +import Animated from 'react-native-reanimated'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; import OfflineIndicator from '@components/OfflineIndicator'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; @@ -10,6 +10,7 @@ import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import useOfflineIndicatorKeyboardHandlingStyles from './useOfflineIndicatorKeyboardHandlingStyles'; type ScreenWrapperOfflineIndicatorsProps = { /** Styles for the offline indicator */ @@ -62,7 +63,7 @@ function ScreenWrapperOfflineIndicators({ * By default, the background color of the small screen offline indicator is translucent. * If `isOfflineIndicatorTranslucent` is set to true, an opaque background color is applied. */ - const smallScreenOfflineIndicatorBackgroundStyle = useMemo(() => { + const smallScreenBackgroundStyle = useMemo(() => { const showOfflineIndicatorBackground = !extraContent && (isSoftKeyNavigation || isOffline); if (!showOfflineIndicatorBackground) { return undefined; @@ -78,7 +79,7 @@ function ScreenWrapperOfflineIndicators({ * two overlapping layers of translucent background. * If the device does not have soft keys, the bottom safe area padding is applied as `paddingBottom`. */ - const smallScreenOfflineIndicatorBottomSafeAreaStyle = useBottomSafeSafeAreaPaddingStyle({ + const smallScreenBottomSafeAreaStyle = useBottomSafeSafeAreaPaddingStyle({ addBottomSafeAreaPadding, addOfflineIndicatorBottomSafeAreaPadding: false, styleProperty: isSoftKeyNavigation ? 'bottom' : 'paddingBottom', @@ -89,19 +90,9 @@ function ScreenWrapperOfflineIndicators({ * It always applies the bottom safe area padding as well as the background style, if the device has soft keys. * In this case, we want the whole container (including the bottom safe area padding) to have translucent/opaque background. */ - const smallScreenOfflineIndicatorContainerStyle = useMemo( - () => [ - smallScreenOfflineIndicatorBottomSafeAreaStyle, - shouldSmallScreenOfflineIndicatorStickToBottom && styles.stickToBottom, - !isSoftKeyNavigation && smallScreenOfflineIndicatorBackgroundStyle, - ], - [ - smallScreenOfflineIndicatorBottomSafeAreaStyle, - shouldSmallScreenOfflineIndicatorStickToBottom, - styles.stickToBottom, - isSoftKeyNavigation, - smallScreenOfflineIndicatorBackgroundStyle, - ], + const smallScreenContainerStyle = useMemo( + () => [smallScreenBottomSafeAreaStyle, shouldSmallScreenOfflineIndicatorStickToBottom && styles.stickToBottom, !isSoftKeyNavigation && smallScreenBackgroundStyle], + [smallScreenBottomSafeAreaStyle, shouldSmallScreenOfflineIndicatorStickToBottom, styles.stickToBottom, isSoftKeyNavigation, smallScreenBackgroundStyle], ); /** @@ -109,20 +100,25 @@ function ScreenWrapperOfflineIndicators({ * If the device has soft keys, we only want to apply the background style to the small screen offline indicator component, * rather than the whole container, because otherwise the navigation bar would be extra opaque, since it already has a translucent background. */ - const smallScreenOfflineIndicatorStyle = useMemo( - () => [styles.pl5, isSoftKeyNavigation && smallScreenOfflineIndicatorBackgroundStyle, offlineIndicatorStyle], - [isSoftKeyNavigation, smallScreenOfflineIndicatorBackgroundStyle, offlineIndicatorStyle, styles.pl5], + const smallScreenStyle = useMemo( + () => [styles.pl5, isSoftKeyNavigation && smallScreenBackgroundStyle, offlineIndicatorStyle], + [isSoftKeyNavigation, smallScreenBackgroundStyle, offlineIndicatorStyle, styles.pl5], ); + /** + * This style includes the styles applied to the small screen offline indicator component when the keyboard is interacted with. + */ + const keyboardHandlingStyles = useOfflineIndicatorKeyboardHandlingStyles(); + return ( <> {shouldShowSmallScreenOfflineIndicator && ( <> {isOffline && ( - - + + {/* Since import state is tightly coupled to the offline state, it is safe to display it when showing offline indicator */} - + )} diff --git a/src/components/ScreenWrapper/useOfflineIndicatorKeyboardHandlingStyles/index.ios.ts b/src/components/ScreenWrapper/useOfflineIndicatorKeyboardHandlingStyles/index.ios.ts new file mode 100644 index 0000000000000..99f002d823fbb --- /dev/null +++ b/src/components/ScreenWrapper/useOfflineIndicatorKeyboardHandlingStyles/index.ios.ts @@ -0,0 +1,15 @@ +import type {ViewStyle} from 'react-native'; +import {useAnimatedStyle} from 'react-native-reanimated'; +import useKeyboardDismissibleFlatListValues from '@components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useStyleUtils from '@hooks/useStyleUtils'; + +function useOfflineIndicatorKeyboardHandlingStyles(): ViewStyle { + const StyleUtils = useStyleUtils(); + const {keyboardHeight} = useKeyboardDismissibleFlatListValues(); + const {paddingBottom} = useSafeAreaPaddings(true); + + return useAnimatedStyle(() => StyleUtils.getOfflineIndicatorKeyboardHandlingStyles(keyboardHeight, paddingBottom)); +} + +export default useOfflineIndicatorKeyboardHandlingStyles; diff --git a/src/components/ScreenWrapper/useOfflineIndicatorKeyboardHandlingStyles/index.ts b/src/components/ScreenWrapper/useOfflineIndicatorKeyboardHandlingStyles/index.ts new file mode 100644 index 0000000000000..bfe5492e98796 --- /dev/null +++ b/src/components/ScreenWrapper/useOfflineIndicatorKeyboardHandlingStyles/index.ts @@ -0,0 +1,7 @@ +import type {ViewStyle} from 'react-native'; + +function useOfflineIndicatorKeyboardHandlingStyles(): ViewStyle { + return {}; +} + +export default useOfflineIndicatorKeyboardHandlingStyles; diff --git a/src/components/SwipeableView/index.native.tsx b/src/components/SwipeableView/index.native.tsx deleted file mode 100644 index d92f3302362f0..0000000000000 --- a/src/components/SwipeableView/index.native.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, {useRef} from 'react'; -import {PanResponder, View} from 'react-native'; -import CONST from '@src/CONST'; -import type SwipeableViewProps from './types'; - -function SwipeableView({children, onSwipeDown}: SwipeableViewProps) { - const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT; - const oldYRef = useRef(0); - const panResponder = useRef( - PanResponder.create({ - // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards - onMoveShouldSetPanResponderCapture: (_event, gestureState) => { - if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) { - return true; - } - oldYRef.current = gestureState.dy; - return false; - }, - - // Calls the callback when the swipe down is released; after the completion of the gesture - onPanResponderRelease: onSwipeDown, - }), - ).current; - - // eslint-disable-next-line react/jsx-props-no-spreading - return {children}; -} - -export default SwipeableView; diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx deleted file mode 100644 index d3881d2efd212..0000000000000 --- a/src/components/SwipeableView/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import type SwipeableViewProps from './types'; - -// Swipeable View is available just on Android/iOS for now. -export default ({children}: SwipeableViewProps) => children; diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts deleted file mode 100644 index 738e21bb73eef..0000000000000 --- a/src/components/SwipeableView/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type {ReactNode} from 'react'; - -type SwipeableViewProps = { - /** The content to be rendered within the SwipeableView */ - children: ReactNode; - - /** Callback to fire when the user swipes down on the child content */ - onSwipeDown: () => void; -}; - -export default SwipeableViewProps; diff --git a/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ios.ts b/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ios.ts new file mode 100644 index 0000000000000..f1e3578720a6b --- /dev/null +++ b/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ios.ts @@ -0,0 +1,25 @@ +import type {ScrollToBottomHandlerParams} from '@hooks/useReportScrollManager/types'; + +/** + * The iOS interactive keyboard implementation uses the keyboard height in order + * to animate the Report FlatList insets and offsets. This is to avoid content to be + * covered by the keyboard when it is visible. + * */ + +function scrollToBottomHandler({flatListRef, keyboardHeight, isKeyboardActive, setScrollPosition}: ScrollToBottomHandlerParams) { + if (!flatListRef?.current) { + return; + } + + if (isKeyboardActive) { + setScrollPosition({offset: -keyboardHeight}); + flatListRef.current?.scrollToOffset({animated: false, offset: -keyboardHeight}); + return; + } + + setScrollPosition({offset: 0}); + + flatListRef.current?.scrollToOffset({animated: false, offset: 0}); +} + +export default scrollToBottomHandler; diff --git a/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ts b/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ts new file mode 100644 index 0000000000000..f0d7c744845a6 --- /dev/null +++ b/src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ts @@ -0,0 +1,13 @@ +import type {ScrollToBottomHandlerParams} from '@hooks/useReportScrollManager/types'; + +function scrollToBottomHandler({flatListRef, setScrollPosition}: ScrollToBottomHandlerParams) { + if (!flatListRef?.current) { + return; + } + + setScrollPosition({offset: 0}); + + flatListRef.current?.scrollToOffset({animated: false, offset: 0}); +} + +export default scrollToBottomHandler; diff --git a/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ios.ts b/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ios.ts new file mode 100644 index 0000000000000..e9cffce463531 --- /dev/null +++ b/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ios.ts @@ -0,0 +1,22 @@ +import type {ScrollToOffsetHandlerParams} from '@hooks/useReportScrollManager/types'; + +/** + * The iOS interactive keyboard implementation uses the keyboard height in order + * to animate the Report FlatList insets and offsets. This is to avoid content to be + * covered by the keyboard when it is visible. + * */ + +function scrollToOffsetHandler({flatListRef, offset, isKeyboardActive, keyboardHeight}: ScrollToOffsetHandlerParams) { + if (!flatListRef?.current) { + return; + } + + if (isKeyboardActive) { + flatListRef.current?.scrollToOffset({animated: false, offset: offset - keyboardHeight}); + return; + } + + flatListRef.current.scrollToOffset({offset, animated: false}); +} + +export default scrollToOffsetHandler; diff --git a/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ts b/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ts new file mode 100644 index 0000000000000..453c945a2db6f --- /dev/null +++ b/src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ts @@ -0,0 +1,11 @@ +import type {ScrollToOffsetHandlerParams} from '@hooks/useReportScrollManager/types'; + +function scrollToOffsetHandler({flatListRef, offset}: ScrollToOffsetHandlerParams) { + if (!flatListRef?.current) { + return; + } + + flatListRef.current.scrollToOffset({offset, animated: false}); +} + +export default scrollToOffsetHandler; diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 39e5a1b66ee7d..0fdb045862d5a 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -1,11 +1,15 @@ import {useCallback, useContext} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView} from 'react-native'; +import useKeyboardState from '@hooks/useKeyboardState'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; +import scrollToBottomHandler from './handlers/scrollToBottom'; +import scrollToOffsetHandler from './handlers/scrollToOffset'; import type ReportScrollManagerData from './types'; function useReportScrollManager(): ReportScrollManagerData { const {flatListRef, setScrollPosition} = useContext(ActionListContext); + const {isKeyboardActive, keyboardHeight} = useKeyboardState(); /** * Scroll to the provided index. @@ -25,15 +29,10 @@ function useReportScrollManager(): ReportScrollManagerData { * Scroll to the bottom of the inverted FlatList. * When FlatList is inverted it's "bottom" is really it's top */ - const scrollToBottom = useCallback(() => { - if (!flatListRef?.current) { - return; - } - - setScrollPosition({offset: 0}); - - flatListRef.current?.scrollToOffset({animated: false, offset: 0}); - }, [flatListRef, setScrollPosition]); + const scrollToBottom = useCallback( + () => scrollToBottomHandler({flatListRef, isKeyboardActive, keyboardHeight, setScrollPosition}), + [flatListRef, setScrollPosition, isKeyboardActive, keyboardHeight], + ); /** * Scroll to the end of the FlatList. @@ -53,16 +52,7 @@ function useReportScrollManager(): ReportScrollManagerData { flatListRef.current.scrollToEnd({animated: false}); }, [flatListRef]); - const scrollToOffset = useCallback( - (offset: number) => { - if (!flatListRef?.current) { - return; - } - - flatListRef.current.scrollToOffset({offset, animated: false}); - }, - [flatListRef], - ); + const scrollToOffset = useCallback((offset: number) => scrollToOffsetHandler({flatListRef, isKeyboardActive, keyboardHeight, offset}), [flatListRef, isKeyboardActive, keyboardHeight]); return {ref: flatListRef, scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; } diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts index 44ccda75e55f2..614f6225ed5f6 100644 --- a/src/hooks/useReportScrollManager/types.ts +++ b/src/hooks/useReportScrollManager/types.ts @@ -8,4 +8,19 @@ type ReportScrollManagerData = { scrollToOffset: (offset: number) => void; }; +type ScrollToCommonParams = { + flatListRef: FlatListRefType; + isKeyboardActive: boolean; + keyboardHeight: number; +}; + +type ScrollToOffsetHandlerParams = ScrollToCommonParams & { + offset: number; +}; + +type ScrollToBottomHandlerParams = ScrollToCommonParams & { + setScrollPosition: (position: {offset: number}) => void; +}; + +export type {ScrollToBottomHandlerParams, ScrollToOffsetHandlerParams}; export default ReportScrollManagerData; diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 7bf4b6b793ade..664d8eaac510a 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -36,6 +36,7 @@ import {isValidReportIDFromPath} from '@libs/ReportUtils'; import {isDefaultAvatar, isLetterAvatar, isPresetAvatar} from '@libs/UserAvatarUtils'; import Navigation from '@navigation/Navigation'; import ReactionListWrapper from '@pages/inbox/ReactionListWrapper'; +import includeSafeAreaPaddingBottomInReportScreen from '@pages/inbox/report/includeSafeAreaPaddingBottomInReportScreen'; import type {ActionListContextType, ScrollPosition} from '@pages/inbox/ReportScreenContext'; import {ActionListContext} from '@pages/inbox/ReportScreenContext'; import {createTransactionThreadReport, openReport, updateLastVisitTime} from '@userActions/Report'; @@ -44,6 +45,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; import type {PersonalDetailsList, Policy, Transaction, TransactionViolations} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import shouldEnableKeyboardAvoidingView from './shouldEnableKeyboardAvoidingView'; type SearchMoneyRequestPageProps = | PlatformStackScreenProps @@ -311,6 +313,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { testID="SearchMoneyRequestReportPage" shouldEnableMaxHeight offlineIndicatorStyle={styles.mtAuto} + shouldEnableKeyboardAvoidingView={shouldEnableKeyboardAvoidingView} + includeSafeAreaPaddingBottom={includeSafeAreaPaddingBottomInReportScreen} > { + return () => { + KeyboardController.dismiss(); + }; + }, []), + ); + useFocusEffect( useCallback(() => { // Don't update if there is a reportID in the params already @@ -313,6 +322,8 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const [isBannerVisible, setIsBannerVisible] = useState(true); const [scrollPosition, setScrollPosition] = useState({}); + const [composerHeight, setComposerHeight] = useState(CONST.CHAT_FOOTER_MIN_HEIGHT); + const [headerHeight, setHeaderHeight] = useState(CONST.CHAT_HEADER_BASE_HEIGHT); const viewportOffsetTop = useViewportOffsetTop(); @@ -422,6 +433,10 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr [isInSidePanel, backTo, isInNarrowPaneModal, closeSidePanel], ); + const onHeaderLayout = useCallback((e: LayoutChangeEvent) => { + setHeaderHeight(e.nativeEvent.layout.height); + }, []); + const headerView = useMemo(() => { if (isTransactionThreadView) { return ( @@ -992,6 +1007,10 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr [reportMetadata?.isLoadingInitialReportActions, reportMetadata?.hasOnceLoadedReportActions], ); + const onComposerLayout = useCallback((height: number) => setComposerHeight(height), []); + + const shouldEnableKeyboardAvoidingViewResult = shouldEnableKeyboardAvoidingViewInReportScreen({isInNarrowPaneModal, isTopMostReportId}); + // In this case we want to use this value. The shouldUseNarrowLayout will always be true as this case is handled when we display ReportScreen in RHP. // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -1022,117 +1041,133 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr } return ( - // Wide RHP overlays should be rendered only for the report screen displayed in RHP - - - - - + {/* Wide RHP overlays should be rendered only for the report screen displayed in RHP */} + + + + - - - {headerView} - - {!!accountManagerReportID && isConciergeChatReport(report) && isBannerVisible && ( - - )} - - {shouldShowWideRHP && ( - - - - - - )} - + + - {(!report || shouldWaitForTransactions) && } - {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {isCurrentReportLoadedFromOnyx ? ( - - ) : null} + {headerView} + + {!!accountManagerReportID && isConciergeChatReport(report) && isBannerVisible && ( + + )} + + {shouldShowWideRHP && ( + + + + + + )} + + {(!report || shouldWaitForTransactions) && } + {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {isCurrentReportLoadedFromOnyx ? ( + + ) : null} + - - - - - - - - + + + + + + + + ); } diff --git a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index c024fb93bc14b..307f55c8f5959 100644 --- a/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -141,6 +141,9 @@ type ComposerWithSuggestionsProps = Partial & /** Whether the main composer was hidden */ didHideComposerInput?: boolean; + /** The native ID for this component */ + nativeID?: string; + /** Reference to the outer element */ ref?: ForwardedRef; }; @@ -222,6 +225,7 @@ function ComposerWithSuggestions({ raiseIsScrollLikelyLayoutTriggered, onCleared = () => {}, onLayout: onLayoutProps, + nativeID, // Refs suggestionsRef, @@ -901,6 +905,7 @@ function ComposerWithSuggestions({ shouldContainScroll={isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} forwardedFSClass={forwardedFSClass} + nativeID={nativeID} /> diff --git a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx index 96145167da47e..9dc549e326ab0 100644 --- a/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/inbox/report/ReportActionCompose/ReportActionCompose.tsx @@ -1,7 +1,7 @@ import lodashDebounce from 'lodash/debounce'; import noop from 'lodash/noop'; import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {BlurEvent, MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; +import type {BlurEvent, LayoutChangeEvent, MeasureInWindowOnSuccessCallback, TextInputSelectionChangeEvent} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useSharedValue} from 'react-native-reanimated'; @@ -117,6 +117,12 @@ type ReportActionComposeProps = Pick void; }; // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will @@ -141,6 +147,8 @@ function ReportActionCompose({ reportTransactions, transactionThreadReportID, isInSidePanel = false, + nativeID, + onLayout, }: ReportActionComposeProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -519,7 +527,10 @@ function ReportActionCompose({ const fsClass = FS.getChatFSClass(report); return ( - + {shouldShowReportRecipientLocalTime && hasReportRecipient && } @@ -597,6 +608,7 @@ function ReportActionCompose({ onValueChange={onValueChange} didHideComposerInput={didHideComposerInput} forwardedFSClass={fsClass} + nativeID={nativeID} /> {shouldDisplayDualDropZone && ( void; - /** Callback executed on scroll */ - onScroll?: (event: NativeSyntheticEvent) => void; - /** Function to load more chats */ loadOlderChats: (force?: boolean) => void; /** Function to load newer chats */ loadNewerChats: (force?: boolean) => void; - /** Whether the composer is in full size */ - isComposerFullSize?: boolean; - /** ID of the list */ listID: number; @@ -120,6 +120,12 @@ type ReportActionsListProps = { /** Whether the optimistic CREATED report action was added */ hasCreatedActionAdded?: boolean; + + /** The current composer height */ + composerHeight: number; + + /** Whether the composer is in full size */ + isComposerFullSize?: boolean; }; // In the component we are subscribing to the arrival of new actions. @@ -141,6 +147,8 @@ function keyExtractor(item: OnyxTypes.ReportAction): string { return item.reportActionID; } +const ON_SCROLL_TO_LIMITS_THRESHOLD = 0.75; + const onScrollToIndexFailed = () => {}; function ReportActionsList({ @@ -149,16 +157,16 @@ function ReportActionsList({ parentReportAction, sortedReportActions, sortedVisibleReportActions, - onScroll, mostRecentIOUReportActionID = '', loadNewerChats, loadOlderChats, onLayout, - isComposerFullSize, listID, shouldEnableAutoScrollToTopThreshold, parentReportActionForTransactionThread, hasCreatedActionAdded, + composerHeight, + isComposerFullSize, }: ReportActionsListProps) { const prevHasCreatedActionAdded = usePrevious(hasCreatedActionAdded); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); @@ -167,6 +175,9 @@ function ReportActionsList({ const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {unmodifiedPaddings} = useSafeAreaPaddings(); + const {isKeyboardActive} = useKeyboardState(); + const StyleUtils = useStyleUtils(); const {getLocalDateFromDatetime} = useLocalize(); const {isOffline, lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); @@ -176,6 +187,7 @@ function ReportActionsList({ const lastMessageTime = useRef(null); const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); + const {scrollY, keyboardHeight} = useKeyboardDismissibleFlatListValues(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); @@ -212,11 +224,12 @@ function ReportActionsList({ return unsubscribe; }, []); - const scrollingVerticalOffset = useRef(0); const readActionSkipped = useRef(false); const hasHeaderRendered = useRef(false); const linkedReportActionID = route?.params?.reportActionID; + const scrollingVerticalOffsetRef = useScrollingVerticalOffsetRef({keyboardHeight, scrollY}); + const lastAction = sortedVisibleReportActions.at(0); const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = useMemo( () => @@ -291,7 +304,7 @@ function ReportActionsList({ currentUserAccountID, prevSortedVisibleReportActionsObjects, unreadMarkerTime, - scrollingVerticalOffset: scrollingVerticalOffset.current, + scrollingVerticalOffset: scrollingVerticalOffsetRef.current, prevUnreadMarkerReportActionID: prevUnreadMarkerReportActionID.current, }); if (shouldDisplayNewMarker) { @@ -355,24 +368,26 @@ function ReportActionsList({ const previousLastIndex = useRef(lastActionIndex); const sortedVisibleReportActionsRef = useRef(sortedVisibleReportActions); - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, trackVerticalScrolling, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ + const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, onViewableItemsChanged} = useReportUnreadMessageScrollTracking({ reportID: report.reportID, - currentVerticalScrollingOffsetRef: scrollingVerticalOffset, + currentVerticalScrollingOffset: scrollY, readActionSkippedRef: readActionSkipped, + hasUnreadMarkerReportAction: !!unreadMarkerReportActionID, unreadMarkerReportActionIndex, + keyboardHeight, isInverted: true, - onTrackScrolling: (event: NativeSyntheticEvent) => { - scrollingVerticalOffset.current = event.nativeEvent.contentOffset.y; - onScroll?.(event); - if (shouldScrollToEndAfterLayout && (!hasCreatedActionAdded || isOffline)) { - setShouldScrollToEndAfterLayout(false); - } - }, }); + const resetOnScrollToEndAfterLayout: InvertedFlatListProps['onScroll'] = useCallback(() => { + if (!shouldScrollToEndAfterLayout || (hasCreatedActionAdded && !isOffline)) { + return; + } + setShouldScrollToEndAfterLayout(false); + }, [hasCreatedActionAdded, isOffline, shouldScrollToEndAfterLayout]); + useEffect(() => { if ( - scrollingVerticalOffset.current < AUTOSCROLL_TO_TOP_THRESHOLD && + scrollingVerticalOffsetRef.current < AUTOSCROLL_TO_TOP_THRESHOLD && previousLastIndex.current !== lastActionIndex && reportActionSize.current !== sortedVisibleReportActions.length && hasNewestReportAction @@ -412,7 +427,8 @@ function ReportActionsList({ // Currently, there's no programmatic way to dismiss the notification center panel. // To handle this, we use the 'referrer' parameter to check if the current navigation is triggered from a notification. const isFromNotification = route?.params?.referrer === CONST.REFERRER.NOTIFICATION; - if ((isVisible || isFromNotification) && scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD) { + const hasNewMessagesInView = scrollingVerticalOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + if ((isVisible || isFromNotification) && hasNewMessagesInView) { readNewestAction(report.reportID); if (isFromNotification) { Navigation.setParams({referrer: undefined}); @@ -443,7 +459,7 @@ function ReportActionsList({ lastMessageTime.current = null; const isArchivedReport = isArchivedNonExpenseReport(report, isReportArchived); - const hasNewMessagesInView = scrollingVerticalOffset.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; + const hasNewMessagesInView = scrollingVerticalOffsetRef.current < CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; const hasUnreadReportAction = sortedVisibleReportActions.some( (reportAction) => newMessageTimeReference && @@ -840,6 +856,8 @@ function ReportActionsList({ loadOlderChats(false); }, [loadOlderChats]); + const reportPaddingBottom = StyleUtils.getReportPaddingBottom({composerHeight, isKeyboardActive, safePaddingBottom: unmodifiedPaddings.bottom ?? 0, isComposerFullSize}); + return ( <> {shouldScrollToEndAfterLayout && topReportAction ? renderTopReportActions() : undefined} { - trackVerticalScrolling(undefined); - }} + keyboardDismissMode="interactive" /> diff --git a/src/pages/inbox/report/ReportActionsView.tsx b/src/pages/inbox/report/ReportActionsView.tsx index a1e2c00310d79..34df18de5696c 100755 --- a/src/pages/inbox/report/ReportActionsView.tsx +++ b/src/pages/inbox/report/ReportActionsView.tsx @@ -66,6 +66,12 @@ type ReportActionsViewProps = { /** If the report is a transaction thread report */ isReportTransactionThread?: boolean; + + /** The current composer height */ + composerHeight: number; + + /** Whether the composer is in full size */ + isComposerFullSize?: boolean; }; let listOldID = Math.round(Math.random() * 100); @@ -79,6 +85,8 @@ function ReportActionsView({ hasNewerActions, hasOlderActions, isReportTransactionThread, + composerHeight, + isComposerFullSize, }: ReportActionsViewProps) { useCopySelectionHelper(); const route = useRoute>(); @@ -331,6 +339,8 @@ function ReportActionsView({ listID={listID} shouldEnableAutoScrollToTopThreshold={shouldEnableAutoScroll} hasCreatedActionAdded={shouldAddCreatedAction} + composerHeight={composerHeight} + isComposerFullSize={isComposerFullSize} /> diff --git a/src/pages/inbox/report/ReportFooter.tsx b/src/pages/inbox/report/ReportFooter.tsx index 77a3bda2c00f7..56cf60ac29b42 100644 --- a/src/pages/inbox/report/ReportFooter.tsx +++ b/src/pages/inbox/report/ReportFooter.tsx @@ -1,15 +1,16 @@ import {isBlockedFromChatSelector} from '@selectors/BlockedFromChat'; import {Str} from 'expensify-common'; import React, {memo, useCallback, useEffect, useState} from 'react'; -import {Keyboard, View} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import Animated from 'react-native-reanimated'; import AnonymousReportFooter from '@components/AnonymousReportFooter'; import ArchivedReportFooter from '@components/ArchivedReportFooter'; import Banner from '@components/Banner'; import BlockedReportFooter from '@components/BlockedReportFooter'; import OfflineIndicator from '@components/OfflineIndicator'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import SwipeableView from '@components/SwipeableView'; import useAncestors from '@hooks/useAncestors'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; @@ -43,6 +44,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import ReportActionCompose from './ReportActionCompose/ReportActionCompose'; import SystemChatReportFooterMessage from './SystemChatReportFooterMessage'; +import useReportFooterStyles from './useReportFooterStyles'; type ReportFooterProps = { /** Report object for the current report */ @@ -77,6 +79,15 @@ type ReportFooterProps = { /** Whether the report screen is being displayed in the side panel */ isInSidePanel?: boolean; + + /** The native ID for this component */ + nativeID?: string; + + /** Callback when layout of composer changes */ + onLayout: (height: number) => void; + + /** The current fixed header height of the chat */ + headerHeight: number; }; function ReportFooter({ @@ -91,13 +102,19 @@ function ReportFooter({ reportTransactions, transactionThreadReportID, isInSidePanel, + onLayout, + headerHeight, + nativeID, }: ReportFooterProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); + const [composerHeight, setComposerHeight] = useState(CONST.CHAT_FOOTER_MIN_HEIGHT); + const reportFooterStyles = useReportFooterStyles({composerHeight, headerHeight, isComposerFullSize}); + + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const personalDetail = useCurrentUserPersonalDetails(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lightbulb']); @@ -209,6 +226,16 @@ function ReportFooter({ setDidHideComposerInput(true); }, [shouldShowComposeInput, didHideComposerInput]); + const onLayoutInternal = useCallback( + (event: LayoutChangeEvent) => { + const {height} = event.nativeEvent.layout; + + setComposerHeight(height); + onLayout(height); + }, + [onLayout], + ); + return ( <> {!!shouldHideComposer && ( @@ -247,24 +274,24 @@ function ReportFooter({ )} {!shouldHideComposer && (!!shouldShowComposeInput || !isSmallScreenWidth) && ( - - - - - + + + )} ); diff --git a/src/pages/inbox/report/includeSafeAreaPaddingBottomInReportScreen/index.ios.ts b/src/pages/inbox/report/includeSafeAreaPaddingBottomInReportScreen/index.ios.ts new file mode 100644 index 0000000000000..33136544dba26 --- /dev/null +++ b/src/pages/inbox/report/includeSafeAreaPaddingBottomInReportScreen/index.ios.ts @@ -0,0 +1 @@ +export default false; diff --git a/src/pages/inbox/report/includeSafeAreaPaddingBottomInReportScreen/index.ts b/src/pages/inbox/report/includeSafeAreaPaddingBottomInReportScreen/index.ts new file mode 100644 index 0000000000000..ff3177babdde0 --- /dev/null +++ b/src/pages/inbox/report/includeSafeAreaPaddingBottomInReportScreen/index.ts @@ -0,0 +1 @@ +export default true; diff --git a/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/index.ios.ts b/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/index.ios.ts new file mode 100644 index 0000000000000..4eda884865f64 --- /dev/null +++ b/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/index.ios.ts @@ -0,0 +1,5 @@ +const shouldEnableKeyboardAvoidingView = () => { + return false; +}; + +export default shouldEnableKeyboardAvoidingView; diff --git a/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/index.ts b/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/index.ts new file mode 100644 index 0000000000000..525854581c4cd --- /dev/null +++ b/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/index.ts @@ -0,0 +1,7 @@ +import type ShouldEnableKeyboardAvoidingViewParams from './types'; + +const shouldEnableKeyboardAvoidingView = ({isInNarrowPaneModal, isTopMostReportId}: ShouldEnableKeyboardAvoidingViewParams) => { + return isTopMostReportId || isInNarrowPaneModal; +}; + +export default shouldEnableKeyboardAvoidingView; diff --git a/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/types.ts b/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/types.ts new file mode 100644 index 0000000000000..d6a389d01301f --- /dev/null +++ b/src/pages/inbox/report/shouldEnableKeyboardAvoidingViewInReportScreen/types.ts @@ -0,0 +1,6 @@ +type ShouldEnableKeyboardAvoidingViewParams = { + isTopMostReportId: boolean; + isInNarrowPaneModal: boolean; +}; + +export default ShouldEnableKeyboardAvoidingViewParams; diff --git a/src/pages/inbox/report/useReportFooterStyles/index.ios.ts b/src/pages/inbox/report/useReportFooterStyles/index.ios.ts new file mode 100644 index 0000000000000..7ea07c143a16a --- /dev/null +++ b/src/pages/inbox/report/useReportFooterStyles/index.ios.ts @@ -0,0 +1,25 @@ +import {useMemo} from 'react'; +import {useAnimatedStyle} from 'react-native-reanimated'; +import useKeyboardDismissibleFlatListValues from '@components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues'; +import useKeyboardState from '@hooks/useKeyboardState'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import type {UseReportFooterStyles} from './types'; + +const useReportFooterStyles: UseReportFooterStyles = ({composerHeight, headerHeight, isComposerFullSize}) => { + const StyleUtils = useStyleUtils(); + const {keyboardHeight} = useKeyboardDismissibleFlatListValues(); + const {unmodifiedPaddings} = useSafeAreaPaddings(); + const {isKeyboardActive} = useKeyboardState(); + const {windowHeight} = useWindowDimensions(); + + const paddingBottom = useMemo(() => unmodifiedPaddings?.bottom ?? 0, [unmodifiedPaddings.bottom]); + const paddingTop = useMemo(() => unmodifiedPaddings?.top ?? 0, [unmodifiedPaddings.top]); + + return useAnimatedStyle(() => + StyleUtils.getReportFooterIosKeyboardHandlingStyles({keyboardHeight, paddingBottom, paddingTop, isKeyboardActive, windowHeight, composerHeight, headerHeight, isComposerFullSize}), + ); +}; + +export default useReportFooterStyles; diff --git a/src/pages/inbox/report/useReportFooterStyles/index.ts b/src/pages/inbox/report/useReportFooterStyles/index.ts new file mode 100644 index 0000000000000..74cd9a89266fd --- /dev/null +++ b/src/pages/inbox/report/useReportFooterStyles/index.ts @@ -0,0 +1,9 @@ +import type {UseReportFooterStyles} from './types'; + +const useReportFooterStyles: UseReportFooterStyles = ({isComposerFullSize}) => { + return { + height: isComposerFullSize ? '100%' : 'auto', + }; +}; + +export default useReportFooterStyles; diff --git a/src/pages/inbox/report/useReportFooterStyles/types.ts b/src/pages/inbox/report/useReportFooterStyles/types.ts new file mode 100644 index 0000000000000..4afd80b9eaf12 --- /dev/null +++ b/src/pages/inbox/report/useReportFooterStyles/types.ts @@ -0,0 +1,12 @@ +import type {ViewStyle} from 'react-native'; +import type {AnimatedStyle} from 'react-native-reanimated'; + +type UseReportFooterStylesParams = { + headerHeight: number; + composerHeight: number; + isComposerFullSize?: boolean; +}; + +type UseReportFooterStyles = (params: UseReportFooterStylesParams) => ViewStyle | AnimatedStyle; + +export type {UseReportFooterStylesParams, UseReportFooterStyles}; diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/index.ios.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/index.ios.ts new file mode 100644 index 0000000000000..1ade1cc450d4d --- /dev/null +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/index.ios.ts @@ -0,0 +1,34 @@ +import {scheduleOnRN} from 'react-native-worklets'; +import CONST from '@src/CONST'; +import type FloatingMessageCounterVisibilityHandlerParams from './types'; + +function floatingMessageCounterVisibilityHandler({ + wasManuallySetRef, + isFloatingMessageCounterVisible, + offsetY, + kHeight, + setIsFloatingMessageCounterVisible, + unreadMarkerReportActionIndex, +}: FloatingMessageCounterVisibilityHandlerParams) { + 'worklet'; + + if (wasManuallySetRef.current) { + return; + } + + const correctedOffsetY = kHeight + offsetY; + + const hasUnreadMarkerReportAction = unreadMarkerReportActionIndex !== -1; + + // display floating button if we're scrolled more than the offset + if (correctedOffsetY > CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && !hasUnreadMarkerReportAction) { + scheduleOnRN(setIsFloatingMessageCounterVisible, true); + } + + // hide floating button if we're scrolled closer than the offset and mark message as read + if (correctedOffsetY < CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible && !hasUnreadMarkerReportAction) { + scheduleOnRN(setIsFloatingMessageCounterVisible, false); + } +} + +export default floatingMessageCounterVisibilityHandler; diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/index.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/index.ts new file mode 100644 index 0000000000000..75e9881f303ab --- /dev/null +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/index.ts @@ -0,0 +1,31 @@ +import {scheduleOnRN} from 'react-native-worklets'; +import CONST from '@src/CONST'; +import type FloatingMessageCounterVisibilityHandlerParams from './types'; + +function floatingMessageCounterVisibilityHandler({ + wasManuallySetRef, + isFloatingMessageCounterVisible, + offsetY, + setIsFloatingMessageCounterVisible, + unreadMarkerReportActionIndex, +}: FloatingMessageCounterVisibilityHandlerParams) { + 'worklet'; + + if (wasManuallySetRef.current) { + return; + } + + const hasUnreadMarkerReportAction = unreadMarkerReportActionIndex !== -1; + + // display floating button if we're scrolled more than the offset + if (offsetY > CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && !isFloatingMessageCounterVisible && !hasUnreadMarkerReportAction) { + scheduleOnRN(setIsFloatingMessageCounterVisible, true); + } + + // hide floating button if we're scrolled closer than the offset and mark message as read + if (offsetY < CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && isFloatingMessageCounterVisible && !hasUnreadMarkerReportAction) { + scheduleOnRN(setIsFloatingMessageCounterVisible, false); + } +} + +export default floatingMessageCounterVisibilityHandler; diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/types.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/types.ts new file mode 100644 index 0000000000000..01fe7483221a0 --- /dev/null +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/types.ts @@ -0,0 +1,12 @@ +import type {RefObject} from 'react'; + +type FloatingMessageCounterVisibilityHandlerParams = { + wasManuallySetRef: RefObject; + kHeight: number; + offsetY: number; + unreadMarkerReportActionIndex: number; + isFloatingMessageCounterVisible: boolean; + setIsFloatingMessageCounterVisible: (value: boolean) => void; +}; + +export default FloatingMessageCounterVisibilityHandlerParams; diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking/index.ts similarity index 72% rename from src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts rename to src/pages/inbox/report/useReportUnreadMessageScrollTracking/index.ts index 12016b2b56d54..fb3c545afc5b1 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking/index.ts @@ -1,9 +1,11 @@ import {useIsFocused} from '@react-navigation/native'; import {useCallback, useEffect, useRef, useState} from 'react'; import type {RefObject} from 'react'; -import type {NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; +import type {ViewToken} from 'react-native'; +import {useAnimatedReaction} from 'react-native-reanimated'; +import type {SharedValue} from 'react-native-reanimated'; import {readNewestAction} from '@userActions/Report'; -import CONST from '@src/CONST'; +import floatingMessageCounterVisibilityHandler from './floatingMessageCounterVisibilityHandler'; type Args = { /** The report ID */ @@ -13,7 +15,10 @@ type Args = { isInverted: boolean; /** The current offset of scrolling from either top or bottom of chat list */ - currentVerticalScrollingOffsetRef: RefObject; + currentVerticalScrollingOffset: SharedValue; + + /** The current keyboard height, updated on every keyboard movement frame */ + keyboardHeight: SharedValue; /** Ref for whether read action was skipped */ readActionSkippedRef: RefObject; @@ -21,17 +26,17 @@ type Args = { /** The index of the unread report action */ unreadMarkerReportActionIndex: number; - /** Callback to call on every scroll event */ - onTrackScrolling: (event: NativeSyntheticEvent) => void; + /** Whether the unread marker is displayed for any report action */ + hasUnreadMarkerReportAction: boolean; }; export default function useReportUnreadMessageScrollTracking({ reportID, - currentVerticalScrollingOffsetRef, readActionSkippedRef, - onTrackScrolling, unreadMarkerReportActionIndex, isInverted, + currentVerticalScrollingOffset, + keyboardHeight, }: Args) { const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false); const isFocused = useIsFocused(); @@ -41,6 +46,17 @@ export default function useReportUnreadMessageScrollTracking({ previousViewableItems: [], isFocused: true, }); + const wasManuallySetRef = useRef(false); + + const updateFloatingMessageCounterVisibility = useCallback((visible: boolean) => { + wasManuallySetRef.current = true; + setIsFloatingMessageCounterVisible(visible); + + requestAnimationFrame(() => { + wasManuallySetRef.current = false; + }); + }, []); + // We want to save the updated value on ref to use it in onViewableItemsChanged // because FlatList requires the callback to be stable and we cannot add a dependency on the useCallback. useEffect(() => { @@ -57,30 +73,25 @@ export default function useReportUnreadMessageScrollTracking({ * Show/hide the latest message pill when user is scrolling back/forth in the history of messages. * Call any other callback that the component might need */ - const trackVerticalScrolling = (event: NativeSyntheticEvent | undefined) => { - if (event) { - onTrackScrolling(event); - } - const hasUnreadMarkerReportAction = unreadMarkerReportActionIndex !== -1; - - // display floating button if we're scrolled more than the offset - if ( - currentVerticalScrollingOffsetRef.current > CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && - !isFloatingMessageCounterVisible && - !hasUnreadMarkerReportAction - ) { - setIsFloatingMessageCounterVisible(true); - } - // hide floating button if we're scrolled closer than the offset - if ( - currentVerticalScrollingOffsetRef.current < CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD && - isFloatingMessageCounterVisible && - !hasUnreadMarkerReportAction - ) { - setIsFloatingMessageCounterVisible(false); - } - }; + useAnimatedReaction( + () => { + return { + offsetY: currentVerticalScrollingOffset.get(), + kHeight: keyboardHeight.get(), + }; + }, + ({offsetY, kHeight}) => + floatingMessageCounterVisibilityHandler({ + isFloatingMessageCounterVisible, + kHeight, + offsetY, + setIsFloatingMessageCounterVisible, + unreadMarkerReportActionIndex, + wasManuallySetRef, + }), + [isFloatingMessageCounterVisible, reportID, readActionSkippedRef, unreadMarkerReportActionIndex], + ); const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { if (!ref.current.isFocused) { @@ -133,8 +144,7 @@ export default function useReportUnreadMessageScrollTracking({ return { isFloatingMessageCounterVisible, - setIsFloatingMessageCounterVisible, - trackVerticalScrolling, + setIsFloatingMessageCounterVisible: updateFloatingMessageCounterVisibility, onViewableItemsChanged, }; } diff --git a/src/pages/inbox/report/useScrollingVerticalOffsetRef/index.ios.ts b/src/pages/inbox/report/useScrollingVerticalOffsetRef/index.ios.ts new file mode 100644 index 0000000000000..9c1040aaeed9e --- /dev/null +++ b/src/pages/inbox/report/useScrollingVerticalOffsetRef/index.ios.ts @@ -0,0 +1,25 @@ +import {useRef} from 'react'; +import {useAnimatedReaction} from 'react-native-reanimated'; +import type UseScrollingVerticalOffsetRefParams from './types'; + +export default function useScrollingVerticalOffsetRef({keyboardHeight, scrollY}: UseScrollingVerticalOffsetRefParams) { + const scrollingVerticalOffsetRef = useRef(0); + + // The previous scroll tracking implementation was made via ref. This is + // to ensure it will behave the same as before. + useAnimatedReaction( + () => { + return { + offsetY: scrollY.get(), + kHeight: keyboardHeight.get(), + }; + }, + ({offsetY, kHeight}) => { + const correctedOffsetY = kHeight + offsetY; + + scrollingVerticalOffsetRef.current = correctedOffsetY; + }, + ); + + return scrollingVerticalOffsetRef; +} diff --git a/src/pages/inbox/report/useScrollingVerticalOffsetRef/index.ts b/src/pages/inbox/report/useScrollingVerticalOffsetRef/index.ts new file mode 100644 index 0000000000000..cb09c4d211e04 --- /dev/null +++ b/src/pages/inbox/report/useScrollingVerticalOffsetRef/index.ts @@ -0,0 +1,22 @@ +import {useRef} from 'react'; +import {useAnimatedReaction} from 'react-native-reanimated'; +import type UseScrollingVerticalOffsetRefParams from './types'; + +export default function useScrollingVerticalOffsetRef({scrollY}: UseScrollingVerticalOffsetRefParams) { + const scrollingVerticalOffsetRef = useRef(0); + + // The previous scroll tracking implementation was made via ref. This is + // to ensure it will behave the same as before. + useAnimatedReaction( + () => { + return { + offsetY: scrollY.get(), + }; + }, + ({offsetY}) => { + scrollingVerticalOffsetRef.current = offsetY; + }, + ); + + return scrollingVerticalOffsetRef; +} diff --git a/src/pages/inbox/report/useScrollingVerticalOffsetRef/types.ts b/src/pages/inbox/report/useScrollingVerticalOffsetRef/types.ts new file mode 100644 index 0000000000000..76174e5568f8a --- /dev/null +++ b/src/pages/inbox/report/useScrollingVerticalOffsetRef/types.ts @@ -0,0 +1,8 @@ +import type {SharedValue} from 'react-native-reanimated'; + +type UseScrollingVerticalOffsetRefParams = { + scrollY: SharedValue; + keyboardHeight: SharedValue; +}; + +export default UseScrollingVerticalOffsetRefParams; diff --git a/src/styles/index.ts b/src/styles/index.ts index bc1bb2ce1217f..3603e9897d5f0 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1964,7 +1964,6 @@ const staticStyles = (theme: ThemeColors) => chatContentScrollView: { flexGrow: 1, justifyContent: 'flex-start', - paddingBottom: 16, ...chatContentScrollViewPlatformStyles, }, @@ -2079,6 +2078,10 @@ const staticStyles = (theme: ThemeColors) => minHeight: variables.componentSizeMedium, }, + chatItemComposeBoxTopSpacer: { + height: 16, + }, + chatItemFullComposeBox: { ...flex.flex1, ...sizing.h100, @@ -2091,11 +2094,6 @@ const staticStyles = (theme: ThemeColors) => backgroundColor: theme.appBG, }, - chatFooterFullCompose: { - height: '100%', - paddingTop: 20, - }, - chatItemDraft: { display: 'flex', flexDirection: 'row', diff --git a/src/styles/utils/getReportPaddingBottom/index.ios.ts b/src/styles/utils/getReportPaddingBottom/index.ios.ts new file mode 100644 index 0000000000000..62cc3954a6558 --- /dev/null +++ b/src/styles/utils/getReportPaddingBottom/index.ios.ts @@ -0,0 +1,8 @@ +import type {GetReportPaddingBottom} from './types'; + +const getReportPaddingBottom: GetReportPaddingBottom = ({safePaddingBottom, isKeyboardActive, composerHeight, isComposerFullSize}) => { + const safeAreaBottom = isKeyboardActive ? 0 : safePaddingBottom; + return isComposerFullSize ? safeAreaBottom : composerHeight + safeAreaBottom; +}; + +export default getReportPaddingBottom; diff --git a/src/styles/utils/getReportPaddingBottom/index.ts b/src/styles/utils/getReportPaddingBottom/index.ts new file mode 100644 index 0000000000000..260d0eac3a801 --- /dev/null +++ b/src/styles/utils/getReportPaddingBottom/index.ts @@ -0,0 +1,5 @@ +import type {GetReportPaddingBottom} from './types'; + +const getReportPaddingBottom: GetReportPaddingBottom = () => 0; + +export default getReportPaddingBottom; diff --git a/src/styles/utils/getReportPaddingBottom/types.ts b/src/styles/utils/getReportPaddingBottom/types.ts new file mode 100644 index 0000000000000..7bf109fea4879 --- /dev/null +++ b/src/styles/utils/getReportPaddingBottom/types.ts @@ -0,0 +1,10 @@ +type GetReportPaddingBottomParams = { + isKeyboardActive: boolean; + composerHeight: number; + safePaddingBottom: number; + isComposerFullSize?: boolean; +}; + +type GetReportPaddingBottom = (params: GetReportPaddingBottomParams) => number; + +export type {GetReportPaddingBottomParams, GetReportPaddingBottom}; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 2ad5796188855..67a9b6e91f563 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -2,6 +2,7 @@ import {StyleSheet} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {AnimatableNumericValue, Animated, ColorValue, ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import type {SharedValue} from 'react-native-reanimated'; import type {EdgeInsets} from 'react-native-safe-area-context'; import type {ValueOf} from 'type-fest'; import type ImageSVGProps from '@components/ImageSVG/types'; @@ -31,6 +32,7 @@ import getHighResolutionInfoWrapperStyle from './getHighResolutionInfoWrapperSty import getMoneyRequestReportPreviewStyle from './getMoneyRequestReportPreviewStyle'; import getNavigationBarType from './getNavigationBarType/index'; import getNavigationModalCardStyle from './getNavigationModalCardStyles'; +import getReportPaddingBottom from './getReportPaddingBottom'; import getSafeAreaInsets from './getSafeAreaInsets'; import getSuccessReportCardLostIllustrationStyle from './getSuccessReportCardLostIllustrationStyle'; import {compactContentContainerStyles} from './optionRowStyles'; @@ -49,6 +51,7 @@ import type { EReceiptColorName, EreceiptColorStyle, ParsableStyle, + ReportFooterStyle, SVGAvatarColorStyle, TextColorStyle, } from './types'; @@ -1347,6 +1350,7 @@ const staticStyleUtils = { getNavigationBarType, getSuccessReportCardLostIllustrationStyle, getOptionMargin, + getReportPaddingBottom, }; const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ @@ -1972,6 +1976,61 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ containerStyle: {paddingBottom}, }; }, + getReportFooterIosKeyboardHandlingStyles: ({ + headerHeight, + isKeyboardActive, + keyboardHeight, + windowHeight, + isComposerFullSize, + paddingBottom = 0, + paddingTop = 0, + composerHeight, + }: ReportFooterStyle): ViewStyle => { + 'worklet'; + + const correctedHeaderHeight = paddingTop + headerHeight; + + const keyboardHeightValue = keyboardHeight.get(); + + const getComposerHeight = (): number => { + if (isComposerFullSize) { + if (isKeyboardActive) { + return windowHeight - keyboardHeightValue - correctedHeaderHeight; + } + + return windowHeight - correctedHeaderHeight - 24; + } + + return composerHeight; + }; + + const getTransform = () => { + if (keyboardHeightValue > paddingBottom) { + return [{translateY: -keyboardHeightValue}]; + } + + return [{translateY: -paddingBottom}]; + }; + + return { + position: 'absolute', + bottom: 0, + width: '100%', + transform: getTransform(), + height: getComposerHeight(), + }; + }, + getOfflineIndicatorKeyboardHandlingStyles: (keyboardHeight: SharedValue, paddingBottom: number): ViewStyle => { + 'worklet'; + + const keyboardHeightValue = keyboardHeight.get(); + + return { + position: 'absolute', + bottom: 0, + transform: [{translateY: keyboardHeightValue > paddingBottom ? -keyboardHeightValue + paddingBottom : 0}], + }; + }, }); type StyleUtilsType = ReturnType; diff --git a/src/styles/utils/types.ts b/src/styles/utils/types.ts index cfd11eb587cdf..abda2420b8f98 100644 --- a/src/styles/utils/types.ts +++ b/src/styles/utils/types.ts @@ -1,4 +1,5 @@ import type {ImageStyle, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {SharedValue} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; import type colors from '@styles/theme/colors'; import type variables from '@styles/variables'; @@ -44,6 +45,16 @@ type AvatarSize = {width: number}; type SVGAvatarColorStyle = {backgroundColor: ColorValue; fill: ColorValue}; type EreceiptColorStyle = {backgroundColor: ColorValue; color: ColorValue; titleColor: ColorValue}; type TextColorStyle = {color: string}; +type ReportFooterStyle = { + paddingTop?: number; + paddingBottom?: number; + headerHeight: number; + isComposerFullSize?: boolean; + isKeyboardActive: boolean; + keyboardHeight: SharedValue; + windowHeight: number; + composerHeight: number; +}; export type { AllStyles, @@ -59,4 +70,5 @@ export type { SVGAvatarColorStyle, EreceiptColorStyle, TextColorStyle, + ReportFooterStyle, }; diff --git a/tests/perf-test/ReportActionCompose.perf-test.tsx b/tests/perf-test/ReportActionCompose.perf-test.tsx index 4aa022dfa7495..90b4712f25b8e 100644 --- a/tests/perf-test/ReportActionCompose.perf-test.tsx +++ b/tests/perf-test/ReportActionCompose.perf-test.tsx @@ -87,6 +87,7 @@ function ReportActionComposeWrapper() { reportID="1" report={LHNTestUtils.getFakeReport()} isComposerFullSize + onLayout={() => {}} /> ); diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 9886df0076876..7262d7f19d427 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -10,6 +10,7 @@ import {ActionListContext, ReactionListContext} from '@pages/inbox/ReportScreenC import {AttachmentModalContextProvider} from '@pages/media/AttachmentModalScreen/AttachmentModalContext'; import ComposeProviders from '@src/components/ComposeProviders'; import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList} from '@src/types/onyx'; import createRandomReportAction from '../utils/collections/reportActions'; @@ -68,7 +69,6 @@ beforeAll(() => ); const mockOnLayout = jest.fn(); -const mockOnScroll = jest.fn(); const mockLoadChats = jest.fn(); const mockRef = {current: null, flatListRef: null, scrollPosition: null, setScrollPosition: () => {}}; @@ -106,7 +106,7 @@ function ReportActionsListWrapper() { sortedVisibleReportActions={reportActions} report={report} onLayout={mockOnLayout} - onScroll={mockOnScroll} + composerHeight={CONST.CHAT_FOOTER_MIN_HEIGHT} listID={1} loadOlderChats={mockLoadChats} loadNewerChats={mockLoadChats} diff --git a/tests/ui/ReportActionComposeTest.tsx b/tests/ui/ReportActionComposeTest.tsx index a9eecd42f6495..ee1e22f85a650 100644 --- a/tests/ui/ReportActionComposeTest.tsx +++ b/tests/ui/ReportActionComposeTest.tsx @@ -44,6 +44,7 @@ TestHelper.setupGlobalFetchMock(); const defaultReport = LHNTestUtils.getFakeReport(); const defaultProps: ReportActionComposeProps = { onSubmit: jest.fn(), + onLayout: jest.fn(), isComposerFullSize: false, reportID: defaultReport.reportID, report: defaultReport, diff --git a/tests/ui/ReportActionsViewTest.tsx b/tests/ui/ReportActionsViewTest.tsx index 86fa52332b2cf..ccc4018e6e0d0 100644 --- a/tests/ui/ReportActionsViewTest.tsx +++ b/tests/ui/ReportActionsViewTest.tsx @@ -115,6 +115,7 @@ const renderReportActionsView = ( isLoadingInitialReportActions: false, hasNewerActions: false, hasOlderActions: false, + composerHeight: 89, ...props, }; diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index 68ce4f0609aed..bb346005c9b97 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -1,16 +1,52 @@ import {act, renderHook} from '@testing-library/react-native'; -import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; +import type {SharedValue} from 'react-native-reanimated'; import type Navigation from '@libs/Navigation/Navigation'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; import {readNewestAction} from '@userActions/Report'; import CONST from '@src/CONST'; +type MockSharedValue = SharedValue & { + get(): T; + set(v: T): void; +}; + +function createMockSharedValue(initial: T): MockSharedValue { + let internalValue = initial; + + return { + get value() { + return internalValue; + }, + set value(v: T) { + internalValue = v; + }, + addListener: jest.fn(), + removeListener: jest.fn(), + modify: jest.fn(), + get: () => internalValue, + set: (v: T) => { + internalValue = v; + }, + }; +} + jest.mock('@userActions/Report', () => { return { readNewestAction: jest.fn(), }; }); +jest.mock('react-native-reanimated', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...require('react-native-reanimated/mock'), + useAnimatedReaction: (prepare: () => unknown, react: (a: unknown, b: unknown) => void) => { + const prepared = prepare(); + react(prepared, prepared); + }, + }; +}); + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -21,25 +57,22 @@ jest.mock('@react-navigation/native', () => { const reportID = '12345'; const readActionRefFalse = {current: false}; -const emptyScrollEventMock = { - nativeEvent: {layoutMeasurement: {height: 0, width: 0}, contentSize: {width: 100, height: 100}, contentOffset: {x: 0, y: 0}}, -} as NativeSyntheticEvent; describe('useReportUnreadMessageScrollTracking', () => { describe('on init and without any scrolling', () => { - const onTrackScrollingMockFn = jest.fn(); - it('returns floatingMessage visibility that was set to a new value', () => { // Given - const offsetRef = {current: 0}; + const offsetY = createMockSharedValue(0); + const keyboardHeight = createMockSharedValue(0); const {result, rerender} = renderHook(() => useReportUnreadMessageScrollTracking({ reportID, - currentVerticalScrollingOffsetRef: offsetRef, readActionSkippedRef: readActionRefFalse, unreadMarkerReportActionIndex: -1, isInverted: true, - onTrackScrolling: onTrackScrollingMockFn, + currentVerticalScrollingOffset: offsetY, + keyboardHeight, + hasUnreadMarkerReportAction: false, }), ); @@ -47,54 +80,55 @@ describe('useReportUnreadMessageScrollTracking', () => { act(() => { result.current.setIsFloatingMessageCounterVisible(true); }); + rerender({}); // Then expect(result.current.isFloatingMessageCounterVisible).toBe(true); - expect(onTrackScrollingMockFn).not.toHaveBeenCalled(); }); }); describe('when scrolling', () => { - const onTrackScrollingMockFn = jest.fn(); - it('returns floatingMessage visibility as true when scrolling outside of threshold', () => { // Given - const offsetRef = {current: 0}; + const offsetY = createMockSharedValue(0); + const keyboardHeight = createMockSharedValue(0); const {result, rerender} = renderHook(() => useReportUnreadMessageScrollTracking({ reportID, - currentVerticalScrollingOffsetRef: offsetRef, readActionSkippedRef: readActionRefFalse, isInverted: true, unreadMarkerReportActionIndex: -1, - onTrackScrolling: onTrackScrollingMockFn, + currentVerticalScrollingOffset: offsetY, + keyboardHeight, + hasUnreadMarkerReportAction: false, }), ); // When act(() => { - offsetRef.current = CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD + 100; - result.current.trackVerticalScrolling(emptyScrollEventMock); + offsetY.set(CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD + 100); }); + rerender({}); // Then expect(result.current.isFloatingMessageCounterVisible).toBe(true); - expect(onTrackScrollingMockFn).toHaveBeenCalledWith(emptyScrollEventMock); }); it('returns floatingMessage visibility as true when the unread message is not visible in the view port', () => { // Given - const offsetRef = {current: 0}; + const offsetY = createMockSharedValue(0); + const keyboardHeight = createMockSharedValue(0); const {result} = renderHook(() => useReportUnreadMessageScrollTracking({ reportID, - currentVerticalScrollingOffsetRef: offsetRef, + currentVerticalScrollingOffset: offsetY, readActionSkippedRef: readActionRefFalse, isInverted: true, unreadMarkerReportActionIndex: 1, - onTrackScrolling: onTrackScrollingMockFn, + hasUnreadMarkerReportAction: false, + keyboardHeight, }), ); @@ -112,45 +146,46 @@ describe('useReportUnreadMessageScrollTracking', () => { // Then expect(result.current.isFloatingMessageCounterVisible).toBe(true); - expect(onTrackScrollingMockFn).toHaveBeenCalledWith(emptyScrollEventMock); }); it('returns floatingMessage visibility as false when scrolling inside the threshold', () => { // Given - const offsetRef = {current: 0}; + const offsetY = createMockSharedValue(0); + const keyboardHeight = createMockSharedValue(0); const {result} = renderHook(() => useReportUnreadMessageScrollTracking({ reportID, - currentVerticalScrollingOffsetRef: offsetRef, readActionSkippedRef: readActionRefFalse, unreadMarkerReportActionIndex: -1, isInverted: true, - onTrackScrolling: onTrackScrollingMockFn, + currentVerticalScrollingOffset: offsetY, + hasUnreadMarkerReportAction: false, + keyboardHeight, }), ); // When act(() => { - offsetRef.current = CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD - 100; - result.current.trackVerticalScrolling(emptyScrollEventMock); + offsetY.set(CONST.REPORT.ACTIONS.LATEST_MESSAGES_PILL_SCROLL_OFFSET_THRESHOLD - 100); }); // Then expect(result.current.isFloatingMessageCounterVisible).toBe(false); - expect(onTrackScrollingMockFn).toHaveBeenCalledWith(emptyScrollEventMock); }); it('returns floatingMessage visibility as false when unread message is visible', () => { // Given - const offsetRef = {current: 0}; + const offsetY = createMockSharedValue(0); + const keyboardHeight = createMockSharedValue(0); const {result} = renderHook(() => useReportUnreadMessageScrollTracking({ reportID, - currentVerticalScrollingOffsetRef: offsetRef, + currentVerticalScrollingOffset: offsetY, readActionSkippedRef: readActionRefFalse, unreadMarkerReportActionIndex: 1, isInverted: true, - onTrackScrolling: onTrackScrollingMockFn, + hasUnreadMarkerReportAction: false, + keyboardHeight, }), ); @@ -166,20 +201,21 @@ describe('useReportUnreadMessageScrollTracking', () => { // Then expect(result.current.isFloatingMessageCounterVisible).toBe(false); - expect(onTrackScrollingMockFn).toHaveBeenCalledWith(emptyScrollEventMock); }); it('calls readAction when scrolling to an extent the unread message is visible and read action skipped is true', () => { // Given - const offsetRef = {current: 0}; - const {result} = renderHook(() => + const offsetY = createMockSharedValue(0); + const keyboardHeight = createMockSharedValue(0); + const {result, rerender} = renderHook(() => useReportUnreadMessageScrollTracking({ reportID, - currentVerticalScrollingOffsetRef: offsetRef, + currentVerticalScrollingOffset: offsetY, readActionSkippedRef: {current: true}, unreadMarkerReportActionIndex: 1, isInverted: true, - onTrackScrolling: onTrackScrollingMockFn, + hasUnreadMarkerReportAction: false, + keyboardHeight, }), ); @@ -189,6 +225,8 @@ describe('useReportUnreadMessageScrollTracking', () => { result.current.onViewableItemsChanged({viewableItems: [{index: 2, key: 'reportActions_2', isViewable: true, item: {}}], changed: []}); }); + rerender({}); + expect(result.current.isFloatingMessageCounterVisible).toBe(true); expect(readNewestAction).toHaveBeenCalledTimes(0); @@ -197,6 +235,8 @@ describe('useReportUnreadMessageScrollTracking', () => { result.current.onViewableItemsChanged({viewableItems: [{index: 1, key: 'reportActions_1', isViewable: true, item: {}}], changed: []}); }); + rerender({}); + // Then expect(readNewestAction).toHaveBeenCalledTimes(1); expect(readActionRefFalse.current).toBe(false);