From 7fc2c0c166d766307353fc610452ac7919a4d48a Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Sun, 28 Sep 2025 13:12:15 +0100 Subject: [PATCH 01/28] feat: migrate lists to interactive keyboard dismissal --- src/CONST/index.ts | 2 + .../BaseInvertedFlatList/types.ts | 14 ++ .../MoneyRequestReportActionsList.tsx | 75 ++++-- .../MoneyRequestReportView.tsx | 31 ++- .../ScreenWrapperOfflineIndicators.tsx | 40 ++-- .../index.ios.ts | 15 ++ .../index.ts | 7 + src/components/SwipeableView/index.native.tsx | 32 --- src/components/SwipeableView/index.tsx | 4 - src/components/SwipeableView/types.ts | 11 - .../handlers/scrollToBottom/index.ios.ts | 25 ++ .../handlers/scrollToBottom/index.ts | 13 ++ .../handlers/scrollToOffset/index.ios.ts | 22 ++ .../handlers/scrollToOffset/index.ts | 11 + .../useReportScrollManager/index.native.ts | 28 +-- src/hooks/useReportScrollManager/types.ts | 15 ++ .../Search/SearchMoneyRequestReportPage.tsx | 6 + .../index.ios.ts | 1 + .../shouldEnableKeyboardAvoidingView/index.ts | 1 + src/pages/home/ReportScreen.tsx | 216 ++++++++++-------- .../ComposerWithSuggestions.tsx | 5 + .../ReportActionCompose.tsx | 17 +- src/pages/home/report/ReportActionsList.tsx | 77 ++++--- src/pages/home/report/ReportActionsView.tsx | 10 + src/pages/home/report/ReportFooter.tsx | 64 ++++-- .../index.ios.ts | 1 + .../index.ts | 1 + .../index.ios.ts | 5 + .../index.ts | 7 + .../types.ts | 6 + .../report/useReportFooterStyles/index.ios.ts | 26 +++ .../report/useReportFooterStyles/index.ts | 10 + .../report/useReportFooterStyles/types.ts | 7 + .../index.ios.ts | 34 +++ .../index.ts | 31 +++ .../types.ts | 12 + .../index.ts} | 74 +++--- .../index.ios.ts | 25 ++ .../useScrollingVerticalOffsetRef/index.ts | 22 ++ .../useScrollingVerticalOffsetRef/types.ts | 8 + src/styles/index.ts | 10 +- .../utils/getReportPaddingBottom/index.ios.ts | 8 + .../utils/getReportPaddingBottom/index.ts | 6 + .../utils/getReportPaddingBottom/types.ts | 8 + src/styles/utils/index.ts | 55 +++++ src/styles/utils/types.ts | 12 + .../ReportActionCompose.perf-test.tsx | 1 + .../perf-test/ReportActionsList.perf-test.tsx | 4 +- tests/ui/ReportActionsViewTest.tsx | 1 + ...seReportUnreadMessageScrollTrackingTest.ts | 112 ++++++--- 50 files changed, 896 insertions(+), 332 deletions(-) create mode 100644 src/components/InvertedFlatList/BaseInvertedFlatList/types.ts create mode 100644 src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ios.ts create mode 100644 src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ts delete mode 100644 src/components/SwipeableView/index.native.tsx delete mode 100644 src/components/SwipeableView/index.tsx delete mode 100644 src/components/SwipeableView/types.ts create mode 100644 src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ios.ts create mode 100644 src/hooks/useReportScrollManager/handlers/scrollToBottom/index.ts create mode 100644 src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ios.ts create mode 100644 src/hooks/useReportScrollManager/handlers/scrollToOffset/index.ts create mode 100644 src/pages/Search/shouldEnableKeyboardAvoidingView/index.ios.ts create mode 100644 src/pages/Search/shouldEnableKeyboardAvoidingView/index.ts create mode 100644 src/pages/home/report/includeSafeAreaPaddingBottomInReportScreen/index.ios.ts create mode 100644 src/pages/home/report/includeSafeAreaPaddingBottomInReportScreen/index.ts create mode 100644 src/pages/home/report/shouldEnableKeyboardAvoidingViewInReportScreen/index.ios.ts create mode 100644 src/pages/home/report/shouldEnableKeyboardAvoidingViewInReportScreen/index.ts create mode 100644 src/pages/home/report/shouldEnableKeyboardAvoidingViewInReportScreen/types.ts create mode 100644 src/pages/home/report/useReportFooterStyles/index.ios.ts create mode 100644 src/pages/home/report/useReportFooterStyles/index.ts create mode 100644 src/pages/home/report/useReportFooterStyles/types.ts create mode 100644 src/pages/home/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/index.ios.ts create mode 100644 src/pages/home/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/index.ts create mode 100644 src/pages/home/report/useReportUnreadMessageScrollTracking/floatingMessageCounterVisibilityHandler/types.ts rename src/pages/home/report/{useReportUnreadMessageScrollTracking.ts => useReportUnreadMessageScrollTracking/index.ts} (73%) create mode 100644 src/pages/home/report/useScrollingVerticalOffsetRef/index.ios.ts create mode 100644 src/pages/home/report/useScrollingVerticalOffsetRef/index.ts create mode 100644 src/pages/home/report/useScrollingVerticalOffsetRef/types.ts create mode 100644 src/styles/utils/getReportPaddingBottom/index.ios.ts create mode 100644 src/styles/utils/getReportPaddingBottom/index.ts create mode 100644 src/styles/utils/getReportPaddingBottom/types.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0eb13e95ef83f..701600cc3e951 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2045,6 +2045,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/InvertedFlatList/BaseInvertedFlatList/types.ts b/src/components/InvertedFlatList/BaseInvertedFlatList/types.ts new file mode 100644 index 0000000000000..4b801a2540345 --- /dev/null +++ b/src/components/InvertedFlatList/BaseInvertedFlatList/types.ts @@ -0,0 +1,14 @@ +import type {ForwardedRef} from 'react'; +import type {ListRenderItem, FlatList as RNFlatList} from 'react-native'; +import type {CustomFlatListProps} from '@components/FlatList/types'; + +type BaseInvertedFlatListProps = Omit, 'data' | 'renderItem' | 'initialScrollIndex'> & { + shouldEnableAutoScrollToTopThreshold?: boolean; + data: T[]; + renderItem: ListRenderItem; + initialScrollKey?: string | null; + ref?: ForwardedRef; +}; + +// eslint-disable-next-line import/prefer-default-export +export type {BaseInvertedFlatListProps}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index a9ca147df1b2a..67399b714e6a5 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -5,19 +5,21 @@ import {isUserValidatedSelector} from '@selectors/Account'; import {accountIDSelector} from '@selectors/Session'; 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 FlatList from '@components/FlatList'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; +import useKeyboardDismissibleFlatListValues from '@components/KeyboardDismissibleFlatList/useKeyboardDismissibleFlatListValues'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; +import useKeyboardState from '@hooks/useKeyboardState'; import useLoadReportActions from '@hooks/useLoadReportActions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; @@ -28,7 +30,9 @@ import usePrevious from '@hooks/usePrevious'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportScrollManager from '@hooks/useReportScrollManager'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {queueExportSearchWithTemplate} from '@libs/actions/Search'; import DateUtils from '@libs/DateUtils'; @@ -105,8 +109,16 @@ type MoneyRequestReportListProps = { /** Whether report actions are still loading and we load the report for the first time, since the last sign in */ showReportActionsLoadingState?: boolean; + + /** 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, @@ -117,6 +129,8 @@ function MoneyRequestReportActionsList({ hasNewerActions, hasOlderActions, showReportActionsLoadingState, + composerHeight, + isComposerFullSize, }: MoneyRequestReportListProps) { const styles = useThemeStyles(); const {translate, getLocalDateFromDatetime} = useLocalize(); @@ -126,6 +140,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]); @@ -261,6 +279,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(() => { @@ -399,25 +439,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(() => { @@ -633,6 +662,9 @@ function MoneyRequestReportActionsList({ const isSelectAllChecked = selectedTransactionIDs.length > 0 && selectedTransactionIDs.length === transactionsWithoutPendingDelete.length; // 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}); + return ( )} - + ) : ( } + 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 23d40bb69786a..fc102b4d8a343 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -1,6 +1,8 @@ import {PortalHost} from '@gorhom/portal'; -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent} from 'react-native'; import {InteractionManager, View} from 'react-native'; +import {KeyboardGestureArea} from 'react-native-keyboard-controller'; import type {OnyxEntry} from 'react-native-onyx'; import HeaderGap from '@components/HeaderGap'; import MoneyReportHeader from '@components/MoneyReportHeader'; @@ -87,6 +89,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); + const reportID = report?.reportID; const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const [isComposerFullSize = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${reportID}`, {canBeMissing: true}); @@ -130,6 +136,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 reportHeaderView = useMemo( () => isTransactionThreadView ? ( @@ -191,6 +202,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} /> @@ -200,7 +213,13 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe } return ( - + - {reportHeaderView} + {reportHeaderView} {shouldDisplayMoneyRequestActionsList ? ( ) : ( )} {shouldDisplayReportFooter ? ( @@ -246,6 +267,8 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe isComposerFullSize={!!isComposerFullSize} lastReportAction={lastReportAction} reportTransactions={transactions} + 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} /> @@ -254,7 +277,7 @@ function MoneyRequestReportView({report, policy, reportMetadata, shouldDisplayRe ) : null} - + ); } diff --git a/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx b/src/components/ScreenWrapper/ScreenWrapperOfflineIndicators.tsx index 61f901f9c00ca..8830ddfde8039 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 useOfflineIndicatoKeyboardHandlingStyles from './useOfflineIndicatoKeyboardHandlingStyles'; 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 = useOfflineIndicatoKeyboardHandlingStyles(); + 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/useOfflineIndicatoKeyboardHandlingStyles/index.ios.ts b/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ios.ts new file mode 100644 index 0000000000000..b052c5e266095 --- /dev/null +++ b/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/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 useOfflineIndicatoKeyboardHandlingStyles(): ViewStyle { + const StyleUtils = useStyleUtils(); + const {keyboardHeight} = useKeyboardDismissibleFlatListValues(); + const {paddingBottom} = useSafeAreaPaddings(true); + + return useAnimatedStyle(() => StyleUtils.getOfflineIndicatorKeyboardHandlingStyles(keyboardHeight, paddingBottom)); +} + +export default useOfflineIndicatoKeyboardHandlingStyles; diff --git a/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ts b/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ts new file mode 100644 index 0000000000000..7c8386eab4b4f --- /dev/null +++ b/src/components/ScreenWrapper/useOfflineIndicatoKeyboardHandlingStyles/index.ts @@ -0,0 +1,7 @@ +import type {ViewStyle} from 'react-native'; + +function useOfflineIndicatoKeyboardHandlingStyles(): ViewStyle { + return {}; +} + +export default useOfflineIndicatoKeyboardHandlingStyles; diff --git a/src/components/SwipeableView/index.native.tsx b/src/components/SwipeableView/index.native.tsx deleted file mode 100644 index 4376585c6f0ac..0000000000000 --- a/src/components/SwipeableView/index.native.tsx +++ /dev/null @@ -1,32 +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( - // eslint-disable-next-line react-compiler/react-compiler - 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, react-compiler/react-compiler - return {children}; -} - -SwipeableView.displayName = 'SwipeableView'; - -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 fa0042a3bf262..1eae25bc6bf24 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/home/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 6706f00e17448..907275201edae 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 17ffd1d7e9aa2..6958c078738a9 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -16,6 +16,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import includeSafeAreaBottom from '@libs/includeSafeAreaBottom'; import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; @@ -31,6 +32,7 @@ import {ActionListContext} from '@src/pages/home/ReportScreenContext'; import type SCREENS from '@src/SCREENS'; import type {Policy} from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; +import shouldEnableKeyboardAvoidingView from './shouldEnableKeyboardAvoidingView'; type SearchMoneyRequestPageProps = PlatformStackScreenProps; @@ -119,6 +121,8 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { shouldEnableMaxHeight offlineIndicatorStyle={styles.mtAuto} headerGapStyles={styles.searchHeaderGap} + shouldEnableKeyboardAvoidingView={shouldEnableKeyboardAvoidingView} + includeSafeAreaPaddingBottom={includeSafeAreaBottom} > { + return () => { + KeyboardController.dismiss(); + }; + }, []), + ); + useEffect(() => { // Don't update if there is a reportID in the params already if (route.params.reportID) { @@ -284,6 +293,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { 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 wasReportAccessibleRef = useRef(false); @@ -377,6 +388,10 @@ function ReportScreen({route, navigation}: ReportScreenProps) { [isInNarrowPaneModal, backTo], ); + const onHeaderLayout = useCallback((e: LayoutChangeEvent) => { + setHeaderHeight(e.nativeEvent.layout.height); + }, []); + let headerView = ( setComposerHeight(height), []); + + const shouldEnableKeyboardAvoidingViewResult = shouldEnableKeyboardAvoidingViewInReportScreen({isInNarrowPaneModal, isTopMostReportId}); + // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. // If we have cached reportActions, they will be shown immediately. // We aim to display a loader first, then fetch relevant reportActions, and finally show them. @@ -822,98 +841,113 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const shouldDisplayMoneyRequestActionsList = isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, visibleTransactions ?? []); return ( - - - - + + + - - {headerView} - - {!!accountManagerReportID && isConciergeChatReport(report) && isBannerVisible && ( - - )} - - - {(!report || shouldWaitForTransactions) && } - {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( - - ) : null} - {isCurrentReportLoadedFromOnyx ? ( - - ) : null} - + {headerView} + + {!!accountManagerReportID && isConciergeChatReport(report) && isBannerVisible && ( + + )} + + + {(!report || shouldWaitForTransactions) && } + {!!report && !shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {!!report && shouldDisplayMoneyRequestActionsList && !shouldWaitForTransactions ? ( + + ) : null} + {isCurrentReportLoadedFromOnyx ? ( + + ) : null} + + - - - - - + + + + + ); } -ReportScreen.displayName = 'ReportScreen'; export default memo(ReportScreen, (prevProps, nextProps) => deepEqual(prevProps.route, nextProps.route)); diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx index e07482bd3b39a..a78a495cfedeb 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.tsx @@ -145,6 +145,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; }; @@ -225,6 +228,7 @@ function ComposerWithSuggestions({ raiseIsScrollLikelyLayoutTriggered, onCleared = () => {}, onLayout: onLayoutProps, + nativeID, // Refs suggestionsRef, @@ -826,6 +830,7 @@ function ComposerWithSuggestions({ onScroll={hideSuggestionMenu} shouldContainScroll={isMobileSafari()} isGroupPolicyReport={isGroupPolicyReport} + nativeID={nativeID} /> diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 4074ba0413fff..8dad40841908e 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -111,6 +111,12 @@ type ReportActionComposeProps = Pick