diff --git a/patches/react-native/details.md b/patches/react-native/details.md index 4395fa022dbb..7b69f3793020 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -9,9 +9,10 @@ ### [react-native+0.86.0+002+fixMVCPAndroid.patch](react-native+0.86.0+002+fixMVCPAndroid.patch) -- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch makes two key improvements: +- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch: 1. Changes when the first visible view is calculated - now happens on scroll events instead of during Fabric's willMountItems lifecycle, which was causing incorrect updates 2. Improves first visible view selection logic to handle Fabric's z-index-based view reordering by finding the view with the smallest position that's still greater than the scroll position + 3. Preserves a positioned, zero-sized first child as a scroll anchor. LegendList moves this anchor to compensate for item measurements. Selecting its surrounding container or rejecting its empty frame prevents native scroll compensation and makes the chat jump as estimated rows shrink. - Upstream PR/issue: https://github.com/facebook/react-native/pull/46247 - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) diff --git a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch index 223cce3db9b3..90b705c6df69 100644 --- a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch +++ b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch @@ -35,9 +35,16 @@ index 2bee605..ba26e7b 100644 for (i in config.minIndexForVisible until contentView.childCount) { val child = contentView.getChildAt(i) -@@ -128,27 +135,49 @@ internal class MaintainVisibleScrollPositionHelper( +@@ -128,27 +135,57 @@ internal class MaintainVisibleScrollPositionHelper( val position = if (horizontal) child.x + child.width else child.y + child.height ++ // Virtualized lists can use a zero-sized, positioned first child as their scroll anchor. ++ // Preserve it instead of choosing the container that holds all rendered items. ++ if (i == config.minIndexForVisible && child.width == 0 && child.height == 0 && position > currentScroll) { ++ firstVisibleView = child ++ break ++ } ++ // If the child is partially visible or this is the last child, select it as the anchor. - if (position > currentScroll || i == contentView.childCount - 1) { - firstVisibleViewRef = WeakReference(child) @@ -70,7 +77,8 @@ index 2bee605..ba26e7b 100644 } + val frame = Rect() + firstVisibleView.getHitRect(frame) -+ if (frame.width() > 0 || frame.height() > 0) { ++ // Zero-sized anchors have a meaningful position even though they have no area. ++ if (frame.width() > 0 || frame.height() > 0 || frame.left != 0 || frame.top != 0) { + prevFirstVisibleFrame = frame + } else { + prevFirstVisibleFrame = null diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx deleted file mode 100644 index bc11ccf61296..000000000000 --- a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; - -import React from 'react'; -import {View} from 'react-native'; - -type CellRendererComponentProps = ViewProps & { - index: number; - style?: StyleProp; -}; - -function CellRendererComponent(props: CellRendererComponentProps) { - return ( - - ); -} - -export default CellRendererComponent; diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx deleted file mode 100644 index b343f95b8be1..000000000000 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type FlatListRefType from '@components/FlashList/types'; - -import type {FlashListProps} from '@shopify/flash-list'; - -import React from 'react'; - -import FlashList from '..'; -import CellRendererComponent from './CellRendererComponent'; - -type InvertedFlashListProps = FlashListProps & { - data: T[]; - keyExtractor: (item: T, index: number) => string; - - /** Ref to the underlying list instance. */ - ref: FlatListRefType; -}; - -function InvertedFlashList(props: InvertedFlashListProps) { - return ( - - {...props} - inverted - CellRendererComponent={CellRendererComponent} - /> - ); -} - -export default InvertedFlashList; diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx index e98507c9a3bd..f9a7b9528735 100644 --- a/src/components/FlashList/index.tsx +++ b/src/components/FlashList/index.tsx @@ -7,7 +7,7 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list'; import React from 'react'; function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) { - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const handleScroll = (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/FlashList/types.ts b/src/components/FlashList/types.ts index cf7718d3d148..8563ec9959a4 100644 --- a/src/components/FlashList/types.ts +++ b/src/components/FlashList/types.ts @@ -1,7 +1,3 @@ -import type {RefObject} from 'react'; -import type {FlatList} from 'react-native'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; -/** Ref to the underlying list instance attached via `ref={}`. */ -type FlatListRefType = RefObject | null> | null; - -export default FlatListRefType; +export default ActionListRefType; diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx index 5fc8e9b546a7..05bfe439c045 100644 --- a/src/components/FlatList/FlatList/index.ios.tsx +++ b/src/components/FlatList/FlatList/index.ios.tsx @@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import React, {useCallback, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import {FlatList} from 'react-native'; import type {CustomFlatListProps} from './types'; @@ -26,30 +26,21 @@ function CustomFlatList({ }: CustomFlatListProps) { const [isScrolling, setIsScrolling] = useState(false); const styles = useThemeStyles(); - const handleScrollBegin = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollBegin?.(event); - setIsScrolling(true); - }, - [onMomentumScrollBegin], - ); + const handleScrollBegin = (event: NativeSyntheticEvent) => { + onMomentumScrollBegin?.(event); + setIsScrolling(true); + }; - const handleScrollEnd = useCallback( - (event: NativeSyntheticEvent) => { - onMomentumScrollEnd?.(event); - setIsScrolling(false); - }, - [onMomentumScrollEnd], - ); + const handleScrollEnd = (event: NativeSyntheticEvent) => { + onMomentumScrollEnd?.(event); + setIsScrolling(false); + }; - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted}); - const handleScroll = useCallback( - (e: NativeSyntheticEvent) => { - onScrollProp?.(e); - emitComposerScrollEvents(); - }, - [emitComposerScrollEvents, onScrollProp], - ); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted}); + const handleScroll = (e: NativeSyntheticEvent) => { + onScrollProp?.(e); + emitComposerScrollEvents(); + }; const listRef = useRef | null>(null); useFlatListHandle({ diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx index fb977c60a232..9c13ff4c7068 100644 --- a/src/components/FlatList/FlatList/index.tsx +++ b/src/components/FlatList/FlatList/index.tsx @@ -245,7 +245,7 @@ function MVCPFlatList({ }; }, []); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted}); const handleScroll = useCallback( (e: NativeSyntheticEvent) => { onScrollProp?.(e); diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx index 043dba4d39db..3e8109d2d0e6 100644 --- a/src/components/KeyboardDismissibleFlatList/index.tsx +++ b/src/components/KeyboardDismissibleFlatList/index.tsx @@ -11,7 +11,7 @@ import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatLi function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) { const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions(); - const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted}); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted}); const additionalOnScroll = useAnimatedScrollHandler({ onScroll: emitComposerScrollEvents, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index c0d170d8153d..cb80a3f281d8 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -50,7 +50,7 @@ import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListCon import {useAgentZeroStatus} from '@pages/inbox/AgentZeroStatusContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; -import ReportActionIndexContext from '@pages/inbox/report/ReportActionIndexContext'; +import ReportActionIndexContext, {ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; import ReportActionsListItemRenderer from '@pages/inbox/report/ReportActionsListItemRenderer'; import {getUnreadMarkerReportAction} from '@pages/inbox/report/shouldDisplayNewMarkerOnReportAction'; import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportUnreadMessageScrollTracking'; @@ -637,25 +637,34 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline); const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + // This value cannot be memoized, because it is based on the indexWithinReportActions which changes on every render. + // eslint-disable-next-line react/jsx-no-constructed-context-values + const reportActionIndexContextValue = { + index: indexWithinReportActions, + isNewest: indexWithinReportActions === visibleReportActions.length - 1, + }; + return ( - - 1} - isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} - shouldHideThreadDividerLine - linkedReportActionID={linkedReportActionID} - isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} - shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} - /> - + + + 1} + isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} + shouldHideThreadDividerLine + linkedReportActionID={linkedReportActionID} + isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction} + shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} + /> + + ); }, [ @@ -671,6 +680,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) shouldShowHarvestCreatedAction, draftReportActionID, isDraftPendingCompletion, + scrollToBottom, ], ); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index d411a44189a6..232f71657bc4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -3,6 +3,7 @@ import MoneyReportHeader from '@components/MoneyReportHeader'; import MoneyRequestHeader from '@components/MoneyRequestHeader'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView'; +import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; @@ -44,7 +45,7 @@ import type {LayoutChangeEvent} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {PortalHost} from '@gorhom/portal'; -import React, {useCallback, useEffect, useMemo} from 'react'; +import {useEffect} from 'react'; // 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 @@ -105,7 +106,7 @@ function InitialLoadingSkeleton({styles, onLayout}: {styles: ThemeStyles; onLayo {}} /> - + ); } @@ -126,34 +127,23 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, const {reportActions: unfilteredReportActions} = usePaginatedReportActions(reportID); - const reportActions = useMemo(() => { - return getFilteredReportActionsForReportView(unfilteredReportActions); - }, [unfilteredReportActions]); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const reportTransactions = useReportTransactionsCollection(reportID); - const transactions = useMemo(() => getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true), [reportTransactions, reportActions, isOffline]); + const transactions = getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true); - const visibleTransactions = useMemo(() => { - if (isOffline) { - return transactions; - } - - // When there are no pending delete transactions, which is most of the time, we can return the same transactions keeping the same reference avoiding extra work - const hasPendingDelete = transactions.some((transaction) => transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - if (!hasPendingDelete) { - return transactions; - } - - return transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - }, [transactions, isOffline]); + // When there are no pending delete transactions, which is most of the time, return the same transactions to keep the same reference and avoid extra work. + const hasPendingDelete = transactions.some((transaction) => transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const visibleTransactions = + isOffline || !hasPendingDelete ? transactions : transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const reportErrors = visibleTransactions.length === 1 && visibleTransactions.at(0)?.errors ? undefined : allReportErrors; const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const isReportLoadPending = useIsReportLoadPending(reportID); - const dismissReportCreationError = useCallback(() => { + const dismissReportCreationError = () => { goBackFromSearchMoneyRequest({afterTransition: () => removeFailedReport(reportID)}); - }, [reportID]); + }; // Special case handling a report that is a transaction thread // If true we will use the standard `ReportActionsList` to display report data and a special header, anything else is handled via `MoneyRequestReportActionsList` @@ -171,33 +161,29 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); const shouldShowWideRHPReceipt = visibleTransactions.length === 1 && !isSmallScreenWidth && !!transactionThreadReport; - const reportHeaderView = useMemo( - () => - isTransactionThreadView ? ( - { - if (!backToRoute) { - goBackFromSearchMoneyRequest(); - return; - } - Navigation.goBack(backToRoute); - }} - /> - ) : ( - { - if (!backToRoute) { - goBackFromSearchMoneyRequest(); - return; - } - Navigation.goBack(backToRoute); - }} - /> - ), - [backToRoute, isTransactionThreadView, report?.reportID], + const reportHeaderView = isTransactionThreadView ? ( + { + if (!backToRoute) { + goBackFromSearchMoneyRequest(); + return; + } + Navigation.goBack(backToRoute); + }} + /> + ) : ( + { + if (!backToRoute) { + goBackFromSearchMoneyRequest(); + return; + } + Navigation.goBack(backToRoute); + }} + /> ); // We need to cancel telemetry span when user leaves the screen before full report data is loaded @@ -217,7 +203,11 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, } if (shouldShowEmptyActionsSkeleton) { - return ; + return ( + + + + ); } if (!report) { @@ -228,7 +218,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, return ( - + {shouldDisplayReportFooter ? : null} ); diff --git a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx index 27171c3e183d..84f9189259f4 100644 --- a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx +++ b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx @@ -5,9 +5,10 @@ import PDFThumbnailError from '@components/PDFThumbnail/PDFThumbnailError'; import useThemeStyles from '@hooks/useThemeStyles'; +import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext'; + import CONST from '@src/CONST'; -import React, {useState} from 'react'; import {View} from 'react-native'; import type LocalPDFReceiptPreviewProps from './types'; @@ -18,9 +19,9 @@ const DOCUMENT_OPTIONS = {cMapUrl: '/cmaps/', cMapPacked: true}; function LocalPDFReceiptPreview({sourceURL, shouldUseFullHeight, onLoadFailure, onLoadSuccess}: LocalPDFReceiptPreviewProps) { const styles = useThemeStyles(); - const [failedToLoad, setFailedToLoad] = useState(false); - const [containerSize, setContainerSize] = useState<{width: number; height: number} | undefined>(undefined); - const [pageAspectRatio, setPageAspectRatio] = useState(undefined); + const [failedToLoad, setFailedToLoad] = useReportActionItemState(false); + const [containerSize, setContainerSize] = useReportActionItemState<{width: number; height: number} | undefined>(undefined); + const [pageAspectRatio, setPageAspectRatio] = useReportActionItemState(undefined); const handleDocumentLoadSuccess = (pdf: PDFDocumentProxy) => { pdf.getPage(1) diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx index 6e64cd2d4184..24c0014f920c 100644 --- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx +++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx @@ -3,6 +3,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext'; + import variables from '@styles/variables'; import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} from '@userActions/CanvasSize'; @@ -10,7 +12,7 @@ import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {PDFPreviewer} from 'react-fast-pdf'; import {View} from 'react-native'; @@ -52,7 +54,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure // Track which URL failed so hasFailed resets automatically when fileURL changes (e.g. after auth token refresh), // mirroring the pattern in ThumbnailImage. No useEffect needed — the comparison runs synchronously during render. - const [failedURL, setFailedURL] = useState(null); + const [failedURL, setFailedURL] = useReportActionItemState(null); const hasFailed = failedURL !== null && failedURL === fileURL; // If the PDF can't be rendered, fall back to the thumbnail underneath by rendering nothing. diff --git a/src/components/ReportActionsSkeletonCover.tsx b/src/components/ReportActionsSkeletonCover.tsx new file mode 100644 index 000000000000..9e3a4de0986e --- /dev/null +++ b/src/components/ReportActionsSkeletonCover.tsx @@ -0,0 +1,34 @@ +import useThemeStyles from '@hooks/useThemeStyles'; + +import type {ReactNode} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +import React from 'react'; +import {View} from 'react-native'; + +import ReportActionsSkeletonView from './ReportActionsSkeletonView'; + +type ReportActionsSkeletonCoverProps = { + /** The skeleton content to place at the bottom of the report viewport */ + children?: ReactNode; + + /** Additional styles for the cover */ + style?: StyleProp; +}; + +/** Fills the report-actions viewport with a consistently positioned loading skeleton. */ +function ReportActionsSkeletonCover({children, style}: ReportActionsSkeletonCoverProps) { + const styles = useThemeStyles(); + + return ( + + {children ?? } + + ); +} + +export default ReportActionsSkeletonCover; diff --git a/src/hooks/useEmitComposerScrollEvents/index.ts b/src/hooks/useEmitComposerScrollEvents/index.ts index ba65bfcdcf5e..cfd42ee98773 100644 --- a/src/hooks/useEmitComposerScrollEvents/index.ts +++ b/src/hooks/useEmitComposerScrollEvents/index.ts @@ -5,19 +5,16 @@ import {DeviceEventEmitter} from 'react-native'; type UseEmitComposerScrollEventsOptions = { enabled?: boolean; - inverted: boolean | null | undefined; }; /** * This is used to trigger scroll behavior in the composer on web. On native, this is a no-op. - * The scroll events are only emitted when the list is inverted, since it is only used in the report screen in combination with the composer. * Since our custom FlatList implementation can either be a `KeyboardDismissibleFlatList` or a regular `FlatList`, * we need to emit the scroll events inside the scroll handler of the specific implementation. - * @param inverted - Whether the list is inverted. * @returns A function that can be used to emit the scroll events. */ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOptions) { - const {enabled = true, inverted} = options ?? {}; + const {enabled = true} = options ?? {}; const lastScrollEvent = useRef(null); const scrollEndTimeout = useRef(null); @@ -28,7 +25,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * invokes the onScroll callback function from props. */ const onScroll = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -44,7 +41,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * Emits when the scrolling has ended. */ const onScrollEnd = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } @@ -67,7 +64,7 @@ function useEmitComposerScrollEvents(options?: UseEmitComposerScrollEventsOption * */ const emitComposerScrollEvents = () => { - if (!enabled || !inverted) { + if (!enabled) { return; } diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index b3e438fd3adb..988354e83518 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -56,6 +56,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea }); const hasOnceLoadedReportActions = reportLoadingState?.hasOnceLoadedReportActions; const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; + const isInitialReportLoadPending = !hasOnceLoadedReportActions && (isReportLoadPending || isLoadingInitialReportActions !== false); const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; @@ -146,7 +147,10 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const state = { report, hasOnceLoadedReportActions, + isInitialReportLoadPending, + hasOlderActions, hasNewerActions, + oldestReportActionID: currentReportOldestActionID, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index b899c31b1312..3f5ce23335be 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -34,8 +34,6 @@ import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; -import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; -import useWindowDimensions from './useWindowDimensions'; type UseReportActionsScrollParams = { /** The Concierge chat report */ @@ -55,15 +53,13 @@ type UseReportActionsScrollParams = { /** Sorted actions that should be visible to the user */ sortedVisibleReportActions: OnyxTypes.ReportAction[]; - /** Actions actually rendered by the list (may include a synthetic draft), used for mount scroll positioning */ + /** Actions actually rendered by the list in chronological order (may include a synthetic draft), used for scroll positioning */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Extracts the list key for an action; used to locate the initial scroll target */ keyExtractor: (item: OnyxTypes.ReportAction) => string; /** Whether the user has scrolled past the "visible" threshold */ - hasScrolledOverThreshold: boolean; - /** Marks the newest action as read and clears any pending skipped mark-as-read */ markNewestActionAsRead: () => void; @@ -79,9 +75,6 @@ type UseReportActionsScrollParams = { /** Whether the report has newer actions to load */ hasNewerActions: boolean; - /** Stable key that changes when a streamed concierge draft becomes visible, used to trigger autoscroll */ - draftAutoScrollKey: string; - /** The index of the action badge target in the rendered actions list (-1 if none) */ actionBadgeTargetIndex: number; @@ -112,28 +105,16 @@ type UseReportActionsScrollResult = { scrollToActionBadgeTarget: () => void; - /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout */ - flushPendingScrollToBottom: () => void; - /** Whether the list should be pinned to the visual top (transaction thread / money request) */ shouldBeAlignedToTop: boolean; - /** Whether the list should focus to the visual top on mount */ - shouldFocusToTopOnMount: boolean; - - /** The initial scroll target key for the list */ - initialScrollKey: string | undefined; - - /** maintainVisibleContentPosition config for the inverted list */ - maintainVisibleContentPosition: {disabled: boolean; autoscrollToBottomThreshold?: number; animateAutoScrollToBottom?: boolean}; - /** The index the list should scroll to on mount (undefined to keep default position) */ initialScrollIndex: number | undefined; /** Positioning params (viewPosition/viewOffset) paired with initialScrollIndex */ initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - /** onLoad handler that disables autoscroll-to-top once the initial render settles */ + /** onLoad handler that enables pill tracking after initial positioning settles */ onLoad: () => void; }; @@ -146,13 +127,11 @@ function useReportActionsScroll({ sortedVisibleReportActions, renderedVisibleReportActions, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, unreadMarkerReportActionIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination, treatAsNoPaginationAnchor, @@ -160,7 +139,6 @@ function useReportActionsScroll({ }: UseReportActionsScrollParams): UseReportActionsScrollResult { const reportScrollManager = useReportScrollManager(); const {scrollOffsetRef} = useActionListContext(); - const {windowHeight} = useWindowDimensions(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; const backTo = route?.params?.backTo; @@ -204,18 +182,8 @@ function useReportActionsScroll({ } const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey && !shouldScrollToLatestOnOpen; - const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; - const [shouldAutoscrollToBottom, setShouldAutoscrollToBottom] = useState(shouldFocusToTopOnMount); const [shouldDisablePillTracking, setShouldDisablePillTracking] = useState(!!initialScrollKey); - const maintainVisibleContentPosition = { - disabled: !shouldMaintainVisibleContentPosition, - // Focus-to-top mode: once autoscroll is released, keep the threshold at 0 rather than - // removing it — FlashList only clears its pending-autoscroll flag while threshold >= 0, - // otherwise the next content change (e.g. mark-as-unread) scrolls back to top. - ...(shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: shouldAutoscrollToBottom ? CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD : 0, animateAutoScrollToBottom: false} : {}), - }; - const {isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible, isActionBadgeAboveViewport, trackVerticalScrolling, onViewableItemsChanged, updatePillVisibility} = useReportUnreadMessageScrollTracking({ reportID, @@ -223,7 +191,7 @@ function useReportActionsScroll({ onUnreadActionVisible: completeSkippedMarkAsRead, hasNewerActions, unreadMarkerReportActionIndex, - isInverted: true, + isInverted: false, shouldDisablePillTracking, onTrackScrolling: (event: NativeSyntheticEvent) => { scrollOffsetRef.current = event.nativeEvent.contentOffset.y; @@ -247,7 +215,7 @@ function useReportActionsScroll({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -256,57 +224,17 @@ function useReportActionsScroll({ reportLoadingState, }); - useScrollToEndOnNewMessageReceived({ - sizeChangeType: 'changed', - scrollOffsetRef, - lastActionID: lastAction?.reportActionID, - visibleActionsLength: sortedVisibleReportActions.length, - hasNewestReportAction, - setIsFloatingMessageCounterVisible, - scrollToEnd: reportScrollManager.scrollToBottom, - // Include reportID so list-length / last-id baselines reset when the same screen instance shows another report. - resetKey: `${reportID}:${linkedReportActionID}`, - }); - - const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); - + // Consume an explicit live-tail request after the data render, not on an unrelated future + // viewport layout (for example, opening the keyboard after the user has scrolled away). + // Incoming messages and streaming draft growth are followed by LegendList itself. useEffect(() => { - if (!draftAutoScrollKey || previousDraftAutoScrollKey === draftAutoScrollKey) { - return; - } - - if (scrollOffsetRef.current >= CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { + if (!isScrollToBottomEnabled) { return; } - - setIsFloatingMessageCounterVisible(false); - requestAnimationFrame(() => { - reportScrollManager.scrollToBottom(); - }); - }, [draftAutoScrollKey, hasNewestReportAction, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); - - const scheduleInitialScrollToBottom = useEffectEvent(() => { - if (initialScrollKey) { - return undefined; - } - - return TransitionTracker.runAfterTransitions({ - callback: () => { - if (shouldFocusToTopOnMount) { - return; - } - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - }, - waitForUpcomingTransition: true, - }); - }); - - // The initial scroll-to-bottom must be scheduled exactly once, on mount; re-running it as deps change would yank the user back down while they read history. - useEffect(() => { - const handle = scheduleInitialScrollToBottom(); - return () => handle?.cancel(); - }, []); + reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(false); + completeLiveTailPruneAfterScrollToBottom(); + }, [isScrollToBottomEnabled, reportScrollManager, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom]); // Clear the shouldScrollToLatest route param once the mount scroll above has consumed it, so a later remount of // this report doesn't pull the user down again. MoneyRequestReportActionsList clears it the same way for the @@ -393,64 +321,33 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } - reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; - const flushPendingScrollToBottom = () => { - if (!isScrollToBottomEnabled) { - return; - } - reportScrollManager.scrollToBottom(); - setIsScrollToBottomEnabled(false); - completeLiveTailPruneAfterScrollToBottom(); - }; - - // Data is ready at the moment FlashList finishes its first render. + // Data is ready when LegendList finishes its first render. const onLoad = () => { - if (shouldDisablePillTracking) { - // Wait one frame so the initial positioning can settle, then disable it. - requestAnimationFrame(() => { - setShouldDisablePillTracking(false); - updatePillVisibility(); - }); - } - if (!shouldFocusToTopOnMount) { + if (!shouldDisablePillTracking) { return; } - if (!reportLoadingState?.hasOnceLoadedReportActions && !isOffline) { - return; - } - // Wait one frame so the initial autoscroll-to-top can settle, then disable it. - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); - }; - const prevHasOnceLoadedReportActions = usePrevious(reportLoadingState?.hasOnceLoadedReportActions); - // Data finished initial loading after the list mounted. onLoad has already fired, so we need - // a separate trigger to turn off autoscroll-to-top. - useEffect(() => { - if (!shouldFocusToTopOnMount || !shouldAutoscrollToBottom) { - return; - } - if (prevHasOnceLoadedReportActions || !reportLoadingState?.hasOnceLoadedReportActions) { - return; - } - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); - }, [shouldFocusToTopOnMount, shouldAutoscrollToBottom, prevHasOnceLoadedReportActions, reportLoadingState?.hasOnceLoadedReportActions]); + // Wait one frame so the initial positioning can settle, then disable it. + requestAnimationFrame(() => { + setShouldDisablePillTracking(false); + updatePillVisibility(); + }); + }; // Decide where the list should be positioned on mount. - // 1. If we're opening a linked message (initialScrollKey), find that action in the list and scroll it to the top - // of the viewport (viewPosition: 1) with a small offset so the message above is partly visible. - // 2. Otherwise, if the report should be opened at top (ex: for transaction threads), scroll to the top message and offset by - // the window height so we land at top of the top message for sure. + // 1. If we're opening a linked or unread message, find that action in the chronological list. + // 2. Otherwise, aligned-to-top reports start at the first action. const targetIndex = initialScrollKey ? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey) : -1; let initialScrollIndex: number | undefined; let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - if (targetIndex > 0) { + if (targetIndex >= 0) { initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + initialScrollIndexParams = {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; } else if (shouldFocusToTopOnMount) { - initialScrollIndex = renderedVisibleReportActions.length - 1; - initialScrollIndexParams = {viewOffset: windowHeight}; + initialScrollIndex = 0; } return { @@ -460,11 +357,7 @@ function useReportActionsScroll({ isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, - initialScrollKey, - maintainVisibleContentPosition, initialScrollIndex, initialScrollIndexParams, onLoad, diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 97c9a92cf1f5..c1823e3dc0a8 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -21,17 +21,14 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); + listRef.current.scrollToEnd({animated: false}); }; /** diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index a2e615e7d6d6..09ce64a5687c 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -18,17 +18,14 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); + listRef.current.scrollToEnd({animated: false}); }; /** diff --git a/src/pages/inbox/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx index a2764b32c88b..f620b9e53724 100644 --- a/src/pages/inbox/ActionListContext.tsx +++ b/src/pages/inbox/ActionListContext.tsx @@ -1,10 +1,10 @@ -import type FlatListRefType from '@components/FlashList/types'; - import type {ReactNode, RefObject} from 'react'; -import type {FlatList} from 'react-native'; import React, {createContext, useContext, useLayoutEffect, useRef} from 'react'; +import type ActionListRefType from './ActionListTypes'; +import type {ActionListRef} from './ActionListTypes'; + type ActionListContextType = { scrollOffsetRef: RefObject; @@ -12,10 +12,10 @@ type ActionListContextType = { getScrollOffset: () => number; /** Each list publishes its locally-owned ref on mount; pass `null` to clear on unmount. */ - registerListRef: (ref: FlatListRefType) => void; + registerListRef: (ref: ActionListRefType) => void; /** Reads the currently registered list ref. Call from handlers only, never during render. */ - getListRef: () => FlatListRefType; + getListRef: () => ActionListRefType; }; const ActionListContext = createContext({ @@ -35,7 +35,7 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); - const listRef = useRef(null); + const listRef = useRef(null); useLayoutEffect(() => { registerListRef(listRef); @@ -49,7 +49,7 @@ function useActionListRef() { function ActionListContextProvider({children}: {children: ReactNode}) { // Each list owns its own ref locally and publishes it here on mount; only the register/get // callbacks live in context, so attaching `ref={}` stays local to each list. - const listRefHolder = useRef(null); + const listRefHolder = useRef(null); const scrollOffsetRef = useRef(0); const value: ActionListContextType = { diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts new file mode 100644 index 000000000000..d429168c46cb --- /dev/null +++ b/src/pages/inbox/ActionListTypes.ts @@ -0,0 +1,31 @@ +import type {RefObject} from 'react'; + +type ScrollToIndexParams = { + animated?: boolean; + index: number; + viewOffset?: number; + viewPosition?: number; +}; + +type ScrollToOffsetParams = { + animated?: boolean; + offset: number; +}; + +type ScrollToEndParams = { + animated?: boolean; +}; + +/** Common imperative API used by the report scroll manager across FlatList, FlashList, and LegendList. */ +type ActionListRef = { + scrollToIndex: (params: ScrollToIndexParams) => void; + scrollToOffset: (params: ScrollToOffsetParams) => void; + scrollToEnd: (params?: ScrollToEndParams) => void; + getNativeScrollRef?: () => unknown; +}; + +/** Ref to the underlying list instance attached via `ref={}`. */ +type ActionListRefType = RefObject | null; + +export default ActionListRefType; +export type {ActionListRef}; diff --git a/src/pages/inbox/report/MoneyReportContentCreated.tsx b/src/pages/inbox/report/MoneyReportContentCreated.tsx index 46096456685d..efd9f93c761b 100644 --- a/src/pages/inbox/report/MoneyReportContentCreated.tsx +++ b/src/pages/inbox/report/MoneyReportContentCreated.tsx @@ -75,6 +75,7 @@ function MoneyReportContentCreated({report, policy, transaction, transactionThre void; debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>; composerRef: React.RefObject; }; @@ -30,7 +31,15 @@ type UseEditMessageProps = { /** * Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content. */ -function useEditMessage({reportID, originalReportID, reportAction, shouldScrollToLastMessage = false, debouncedCommentMaxLengthValidation, composerRef}: UseEditMessageProps) { +function useEditMessage({ + reportID, + originalReportID, + reportAction, + shouldScrollToLastMessage = false, + scrollToLastMessage, + debouncedCommentMaxLengthValidation, + composerRef, +}: UseEditMessageProps) { const reportScrollManager = useReportScrollManager(); const {email} = useCurrentUserPersonalDetails(); @@ -52,7 +61,7 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. if (shouldScrollToLastMessage) { - reportScrollManager.scrollToIndex(0); + (scrollToLastMessage ?? reportScrollManager.scrollToBottom)(); } } diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 1abe9823fcf3..04a1a0d22bd0 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -1,13 +1,36 @@ -import {createContext} from 'react'; +import type {Dispatch, SetStateAction} from 'react'; + +import {useRecyclingState} from '@legendapp/list/react-native'; +import {createContext, useContext, useState} from 'react'; /** - * Carries an action item's position index from the list renderer down to the rare consumers that + * Carries an action item's position from the list renderer down to the rare consumers that * actually need it (e.g. `ReportActionItemMessageEdit` for scroll-to-index during edit mode). * * Using context keeps `index` out of the prop signatures of every intermediate component, so a * position shift caused by a new message arriving doesn't cascade re-renders through items that * never read it. Only components that `useContext(ReportActionIndexContext)` re-render on change. */ -const ReportActionIndexContext = createContext(0); +type ReportActionPosition = { + index: number; + isNewest: boolean; + isRecycling?: boolean; +}; + +const ReportActionIndexContext = createContext({index: 0, isNewest: false}); + +/** Lets shared list implementations provide their own reliable way to reach the newest action. */ +const ReportActionScrollToNewestContext = createContext<(() => void) | undefined>(undefined); + +/** + * Uses LegendList's recycling-aware state in the main report list and behaves like useState in shared, non-recycled lists. + */ +function useReportActionItemState(initialState: State | (() => State)): [State, Dispatch>] { + const {isRecycling = false} = useContext(ReportActionIndexContext); + const state = useState(initialState); + const recyclingState = useRecyclingState(initialState); + return isRecycling ? [...recyclingState] : state; +} +export {ReportActionScrollToNewestContext, useReportActionItemState}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItem.tsx b/src/pages/inbox/report/ReportActionItem.tsx index 7011ba259e63..37c041d5b16e 100644 --- a/src/pages/inbox/report/ReportActionItem.tsx +++ b/src/pages/inbox/report/ReportActionItem.tsx @@ -79,12 +79,13 @@ import {isEmptyObject, isEmptyValueObject} from '@src/types/utils/EmptyObject'; import type {GestureResponderEvent, TextInput} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {useRecyclingEffect} from '@legendapp/list/react-native'; import {useNavigation} from '@react-navigation/native'; import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import {personalDetailsDisplayNameSelector} from '@selectors/PersonalDetails'; import {deepEqual} from 'fast-equals'; import mapValues from 'lodash/mapValues'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useContext, useEffect, useRef} from 'react'; import {Keyboard, View} from 'react-native'; import type {ContextMenuAnchor} from './ContextMenu/ReportActionContextMenu'; @@ -95,6 +96,7 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe import {hideContextMenu, hideDeleteModal, isActiveReportAction, showContextMenu} from './ContextMenu/ReportActionContextMenu'; import LinkPreviewer from './LinkPreviewer'; import {useReportActionActiveEdit} from './ReportActionEditMessageContext'; +import {useReportActionItemState} from './ReportActionIndexContext'; import ReportActionItemContentCreated from './ReportActionItemContentCreated'; import ReportActionItemFrame from './ReportActionItemFrame'; import ReportActionItemThread from './ReportActionItemThread'; @@ -203,16 +205,19 @@ function ReportActionItem({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const [isContextMenuActive, setIsContextMenuActive] = useState(() => isActiveReportAction(action.reportActionID)); - const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); - const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useState(); - const [isHidden, setIsHidden] = useState(false); + const [isContextMenuActive, setIsContextMenuActive] = useReportActionItemState(() => isActiveReportAction(action.reportActionID)); + const [isEmojiPickerActive, setIsEmojiPickerActive] = useReportActionItemState(undefined); + const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useReportActionItemState(undefined); + const [isHidden, setIsHidden] = useReportActionItemState(false); const {isActiveReportAction: isActiveReactionListReportAction, hideReactionList} = useContext(ReactionListContext); const {updateHiddenAttachments} = useContext(AttachmentModalContext); const popoverAnchorRef = useRef>(null); const downloadedPreviews = useRef([]); + useRecyclingEffect(() => { + downloadedPreviews.current = []; + }); const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; - const [isReportActionActive, setIsReportActionActive] = useState(!!isReportActionLinked); + const [isReportActionActive, setIsReportActionActive] = useReportActionItemState(!!isReportActionLinked); const shouldBreakGrouping = shouldBreakAccessibilityGrouping(); const isScreenReaderActive = Accessibility.useScreenReaderStatus(); @@ -356,7 +361,7 @@ function ReportActionItem({ return; } setIsHidden(false); - }, [latestDecision, action]); + }, [latestDecision, action, setIsHidden]); const toggleContextMenuFromActiveReportAction = () => { setIsContextMenuActive(isActiveReportAction(action.reportActionID)); diff --git a/src/pages/inbox/report/ReportActionItemContentCreated.tsx b/src/pages/inbox/report/ReportActionItemContentCreated.tsx index c284acd7a82a..d8fe6333c1b3 100644 --- a/src/pages/inbox/report/ReportActionItemContentCreated.tsx +++ b/src/pages/inbox/report/ReportActionItemContentCreated.tsx @@ -96,6 +96,7 @@ function ReportActionItemContentCreated({parentReportAction, transactionID, draf { - didLayout.current = false; - }, [reportID]); + const lastRequestedOldestActionIDRef = useRef(undefined); + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute}); // Owned here rather than by the callout, which unmounts as the layout and composer change size. useRetireMerchantRuleSuggestionOnLeave(reportID); - // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. - const listID = [reportID, reportActionIDFromRoute, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); + // OpenReport can first provide a tiny cached page and then replace it with the hydrated page. Remounting + // gives the complete dataset a fresh initial layout so initialScrollAtEnd targets its actual end. + const listID = [ + reportID, + reportActionIDFromRoute, + hasOnceLoadedReportActions ? 'hydrated' : 'initial', + hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID, + ].join(':'); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); const isReportArchived = !!isArchivedReport(reportNameValuePairs); @@ -178,6 +234,24 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const {getScrollOffset} = useActionListContext(); const listRef = useActionListRef(); + const legendListRef = useRef(null); + + useImperativeHandle( + listRef, + (): ActionListRef => ({ + getNativeScrollRef: () => legendListRef.current?.getNativeScrollRef(), + scrollToEnd: (options) => { + legendListRef.current?.scrollToEnd(options); + }, + scrollToIndex: (options) => { + legendListRef.current?.scrollToIndex(options); + }, + scrollToOffset: (options) => { + legendListRef.current?.scrollToOffset(options); + }, + }), + [], + ); const {draftReportAction, isDraftPendingCompletion} = useConciergeDraft(); const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); @@ -187,7 +261,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const {unreadMarkerReportActionID, unreadMarkerReportActionIndex} = useUnreadMarker({ + const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, sortedVisibleReportActions, sortedReportActions, @@ -241,10 +315,25 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return visibleReportActionsWithDraft; })(); + const [initialReportActionsSnapshot, setInitialReportActionsSnapshot] = useState<{reportActions: OnyxTypes.ReportAction[]; reportID: string}>(); + const hasInitialReportActionsSnapshot = initialReportActionsSnapshot?.reportID === reportID; + + // OpenReport starts with a tiny cached page before replacing it with the hydrated page. Keep that + // already-visible page mounted until hydration finishes instead of exposing intermediate estimated + // layouts. The hydrated list then mounts from scratch using the full dataset. + if (!hasOnceLoadedReportActions && !hasInitialReportActionsSnapshot && renderedVisibleReportActions.length > 0) { + setInitialReportActionsSnapshot({reportActions: renderedVisibleReportActions, reportID}); + } + + const reportActionsToRender = !hasOnceLoadedReportActions && hasInitialReportActionsSnapshot ? initialReportActionsSnapshot.reportActions : renderedVisibleReportActions; + + // Report actions are stored newest-first. LegendList intentionally has no inverted mode, so + // give it chronological data and use its normal start/end and scrolling semantics. + const listData = reportActionsToRender.toReversed(); + const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; const isSyntheticDraftVisible = !!draftReportAction && renderedVisibleReportActions !== sortedVisibleReportActions; - const draftAutoScrollKey = isSyntheticDraftVisible ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; useEffect(() => { if (!draftReportAction || isSyntheticDraftVisible) { @@ -262,9 +351,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct revealDraftFromReportAction(persistedDraftReportAction); }, [draftReportAction, persistedDraftReportAction, revealDraftFromReportAction]); - // Find the index of the action badge target in the rendered actions list (which is what the FlatList uses as data) + // Find the index of the action badge target in the chronological data rendered by LegendList. const actionBadgeTargetID = reportAttributes?.actionTargetReportActionID; - const actionBadgeTargetIndex = actionBadgeTargetID ? renderedVisibleReportActions.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; + const actionBadgeTargetIndex = actionBadgeTargetID ? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID) : -1; + const unreadMarkerListIndex = unreadMarkerReportActionID ? listData.findIndex((action) => action.reportActionID === unreadMarkerReportActionID) : -1; const { trackVerticalScrolling, @@ -273,12 +363,9 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, initialScrollIndex, initialScrollIndexParams, - maintainVisibleContentPosition, onLoad, } = useReportActionsScroll({ reportID, @@ -287,33 +374,60 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct transactionThreadReport, parentReportAction, sortedVisibleReportActions, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, - unreadMarkerReportActionIndex, + unreadMarkerReportActionIndex: unreadMarkerListIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination: sortedAllReportActions ?? [], treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor, }); - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackVerticalScrolling(event); - setHasScrolledOverThreshold(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); + const shouldShowInitialViewportSkeleton = !isOffline && (isInitialReportLoadPending || loadedInitialViewportListID !== listID); + + const handleListLoad = () => { + onLoad(); + setLoadedInitialViewportListID(listID); }; - const loadOlderChatsOnEndReached = () => { - if (showHiddenHistory) { + const loadOlderChatsOnStartReached = () => { + if (showHiddenHistory || isOffline || !hasOlderActions || !oldestReportActionID || lastRequestedOldestActionIDRef.current === oldestReportActionID) { return; } + + lastRequestedOldestActionIDRef.current = oldestReportActionID; loadOlderChats(false); }; + const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + const isNearStart = contentOffset.y <= layoutMeasurement.height * PAGINATION_THRESHOLD; + + if (isNearStart) { + loadOlderChatsOnStartReached(); + } else { + lastRequestedOldestActionIDRef.current = undefined; + } + + const bottomRelativeEvent = { + ...event, + nativeEvent: { + ...event.nativeEvent, + contentOffset: {...contentOffset, y: distanceFromBottom}, + }, + }; + + trackVerticalScrolling(bottomRelativeEvent); + setHasScrolledOverThreshold(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + emitComposerScrollEvents(); + }; + const loadNewerChatsAfterTransitions = () => { if (!isSearchTopmostFullScreenRoute()) { loadNewerChats(false); @@ -334,7 +448,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct reportID, actionTargetReportActionID: reportAttributes?.actionTargetReportActionID, actionBadgeTargetIndex, - renderedVisibleReportActions, + renderedVisibleReportActions: listData, scrollToActionBadgeTarget, }); @@ -362,11 +476,12 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); })(); - const renderItem = ({item: reportAction, index}: ListRenderItemInfo) => { + const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + const reportActionIndex = reportActionsToRender.length - index - 1; return ( - + 1} + shouldDisplayReplyDivider={reportActionsToRender.length > 1} isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} shouldUseThreadDividerLine={shouldUseThreadDividerLine} isHarvestCreatedExpenseReport={isHarvestCreatedExpenseReportAction} @@ -446,7 +561,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report // is cleared mid-session while the latch keeps the content mounted. if (!report) { - return ; + return ; } return ( @@ -472,46 +587,45 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct report={report} isReportArchived={isReportArchived} > - { - recordTimeToMeasureItemLayout(event); - flushPendingScrollToBottom(); - }} + onLayout={recordTimeToMeasureItemLayout} onScroll={trackScrollPositionAndThreshold} + onStartReached={loadOlderChatsOnStartReached} + onStartReachedThreshold={PAGINATION_THRESHOLD} onViewableItemsChanged={onViewableItemsChanged} extraData={extraData} key={listID} - overrideProps={{ - isInvertedVirtualizedList: true, - contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, - }} - getItemType={(item) => item.actionName} - initialScrollIndex={initialScrollIndex} - initialScrollIndexParams={initialScrollIndexParams} - maintainVisibleContentPosition={maintainVisibleContentPosition} - onLoad={onLoad} - onContentSizeChange={() => { - trackVerticalScrolling(undefined); - }} + getItemType={getItemType} + initialScrollAtEnd={initialScrollIndex === undefined} + initialScrollIndex={initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}} + alignItemsAtEnd={!shouldBeAlignedToTop} + // Only follow the real latest page. Older/linked windows must retain their visible anchor. + maintainScrollAtEnd={!hasNewerActions && {animated: false}} + // Leave the end-follow region as soon as the user starts reading older messages. + maintainScrollAtEndThreshold={0.01} + maintainVisibleContentPosition + onLoad={handleListLoad} + onContentSizeChange={() => trackVerticalScrolling(undefined)} /> + {shouldShowInitialViewportSkeleton && } ); @@ -525,6 +639,7 @@ function ReportActionsList({reportID, conciergeChat, onLayout}: ReportActionsLis return ( ; + return ( + + + + ); } ReportActionsLoadingSkeleton.displayName = 'ReportActionsLoadingSkeleton'; diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 0e8f9d76f666..5f55dfb63898 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -204,7 +204,7 @@ function ActionContentRouter({ if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { return ( {isEditingInline ? ( = 0 && actionBadgeTargetIndex < prevActionBadgeTargetIndex; + return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex > prevActionBadgeTargetIndex; } export default shouldFollowActionBadgeTarget; diff --git a/src/pages/inbox/report/useFollowActionBadgeTarget.ts b/src/pages/inbox/report/useFollowActionBadgeTarget.ts index b15df8c3a2ff..67c952452920 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -18,10 +18,10 @@ type UseFollowActionBadgeTargetParams = { /** The report action the badge currently targets (the oldest preview still requiring action) */ actionTargetReportActionID: string | undefined; - /** Index of the current target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the current target in the chronological list, or -1 when it is not rendered */ actionBadgeTargetIndex: number; - /** The rendered (inverted) report actions the list is displaying */ + /** The chronological report actions the list is displaying */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Scrolls the list to the current action-badge target */ diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index da424fdf5e13..02b8d8665a76 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -19,7 +19,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; import {useNavigation} from '@react-navigation/native'; -import {useCallback, useEffect, useEffectEvent, useRef, useState} from 'react'; +import {useEffect, useEffectEvent, useRef, useState} from 'react'; // In the component we are subscribing to the arrival of new actions. // As there is the possibility that there are multiple instances of a ReportScreen @@ -48,7 +48,8 @@ type UseReportActionsNewActionLiveTailParams = { hasNewerActions: boolean; linkedReportActionID: string | undefined; hasNewestReportAction: boolean; - sortedVisibleReportActions: OnyxTypes.ReportAction[]; + /** Actions rendered by the list in chronological order. */ + renderedVisibleReportActions: OnyxTypes.ReportAction[]; sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; reportActionPages: OnyxTypes.Pages | undefined; setTreatAsNoPaginationAnchor: (value: boolean) => void; @@ -61,9 +62,8 @@ type LiveTailJumpStage = 'idle' | 'open_report' | 'await_scroll' | 'await_prune' /** * Owns subscribe-to-new-action scrolling, live-tail jump (openReport → scroll → prune), and the - * deferred scroll + pagination prune after layout. Uses useEffectEvent for the Pusher subscription handler so it - * always sees the latest props without mirror refs. The layout-time prune step uses useCallback so callers can invoke - * it from list `onLayout` outside this hook. + * deferred scroll + pagination prune after the data render. Uses useEffectEvent for the Pusher subscription handler so it + * always sees the latest props without mirror refs. The prune callback completes the explicit scroll request in the caller. */ function useReportActionsNewActionLiveTail({ conciergeChat, @@ -80,7 +80,7 @@ function useReportActionsNewActionLiveTail({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -132,14 +132,14 @@ function useReportActionsNewActionLiveTail({ return; } - const index = sortedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); + const index = renderedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - if (index > 0) { + setIsFloatingMessageCounterVisible(false); + if (index >= 0 && index < renderedVisibleReportActions.length - 1) { setTimeout(() => { reportScrollManager.scrollToIndex(index); }, 100); } else { - setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); } if (action?.reportActionID) { @@ -147,22 +147,20 @@ function useReportActionsNewActionLiveTail({ } } else { setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); + setIsScrollToBottomEnabled(true); } - - setIsScrollToBottomEnabled(true); }, }); }); - const completeLiveTailPruneAfterScrollToBottom = useCallback(() => { + const completeLiveTailPruneAfterScrollToBottom = () => { if (liveTailJumpRef.current.stage !== 'await_prune') { return; } pruneReportActionPagesToNewestWindow(reportID, sortedAllReportActionsForPagination, reportActionPages); setTreatAsNoPaginationAnchor(false); liveTailJumpRef.current = {stage: 'idle'}; - }, [reportID, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor]); + }; useEffect(() => { liveTailJumpRef.current = {stage: 'idle'}; diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 053d23522914..8f41679a75fd 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -157,11 +157,9 @@ export default function useReportUnreadMessageScrollTracking({ ref.current.onUnreadActionVisible(); } - // Track whether the action badge target is above the viewport (i.e., not visible and at a higher index in the inverted list) + // Track whether the action badge target is above the viewport. const badgeTargetIndex = ref.current.actionBadgeTargetIndex; if (badgeTargetIndex !== -1) { - // In an inverted list, higher indexes are "above" (older messages). The target is above the viewport - // when its index is greater than the max visible index. const isAbove = isInverted ? badgeTargetIndex > maxIndex : badgeTargetIndex < minIndex; setIsActionBadgeAboveViewport(isAbove); } else { diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index d39902dc322c..a1ab104f6a2b 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -120,7 +120,9 @@ function ReportActionsListWrapper() { ); } -test('[ReportActionsList] should render ReportActionsList with 500 reportActions stored', async () => { +// LegendList's behavior mock eagerly renders the supplied data, while the previous FlashList test used +// its virtualized implementation. Start a renderer-specific baseline instead of comparing those harnesses. +test('[ReportActionsList] should render LegendList with 500 reportActions stored', async () => { const scenario = async () => { await screen.findByTestId('report-actions-list'); }; diff --git a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx index da7168b6e9c3..717e79d489d9 100644 --- a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx +++ b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx @@ -11,6 +11,9 @@ import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; import type * as InFlightRequests from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; +import {ActionListContextProvider} from '@pages/inbox/ActionListContext'; +import type * as ReportActionIndexContexts from '@pages/inbox/report/ReportActionIndexContext'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -31,6 +34,12 @@ const FAKE_POLICY_ID = 'FAKE_POLICY_001'; const FAKE_ACCOUNT_ID = 15593135; const FAKE_TRANSACTION_ID = 'FAKE_TXN_001'; const FAKE_EMAIL = 'testuser@example.com'; +const MOCK_UNIFIED_LAST_ITEM_INDEX = 37; +const mockScrollToIndex = jest.fn(); +const mockScrollToEnd = jest.fn(); +const mockScrollToOffset = jest.fn(); +let mockReportActionPosition: {index: number; isNewest: boolean} | undefined; +let mockScrollToNewestAction: (() => void) | undefined; jest.mock('@react-navigation/native', () => ({ ...jest.requireActual('@react-navigation/native'), @@ -80,12 +89,38 @@ jest.mock('@libs/Navigation/Navigation', () => ({ jest.mock('@components/MoneyRequestReportView/MoneyRequestReportTransactionList', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View} = require('react-native'); - return ({listFooterComponent, isLoadingInitialActions}: {listFooterComponent?: React.ReactElement; isLoadingInitialActions: boolean}) => ( - - {isLoadingInitialActions ? : null} - {listFooterComponent} - - ); + const ReactActual = jest.requireActual('react'); + return ({ + listFooterComponent, + isLoadingInitialActions, + listRef, + onLastItemIndexChange, + visibleReportActions, + renderReportAction, + }: { + listFooterComponent?: React.ReactElement; + isLoadingInitialActions: boolean; + listRef: React.Ref<{ + scrollToIndex: typeof mockScrollToIndex; + scrollToEnd: typeof mockScrollToEnd; + scrollToOffset: typeof mockScrollToOffset; + }>; + onLastItemIndexChange?: (index: number) => void; + visibleReportActions: ReportAction[]; + renderReportAction: (reportAction: ReportAction, index: number) => React.ReactElement; + }) => { + ReactActual.useImperativeHandle(listRef, () => ({scrollToIndex: mockScrollToIndex, scrollToEnd: mockScrollToEnd, scrollToOffset: mockScrollToOffset})); + ReactActual.useLayoutEffect(() => onLastItemIndexChange?.(MOCK_UNIFIED_LAST_ITEM_INDEX), [onLastItemIndexChange]); + const newestAction = visibleReportActions.at(-1); + + return ( + + {isLoadingInitialActions ? : null} + {newestAction ? renderReportAction(newestAction, visibleReportActions.length - 1) : null} + {listFooterComponent} + + ); + }; }); jest.mock('@components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState', () => { @@ -159,7 +194,17 @@ jest.mock('@hooks/useMobileSelectionMode', () => jest.fn(() => true)); jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => jest.fn(() => ({shouldUseNarrowLayout: true}))); jest.mock('@hooks/useFilterSelectedTransactions', () => jest.fn()); jest.mock('@hooks/useLoadReportActions', () => jest.fn(() => ({loadOlderChats: jest.fn(), loadNewerChats: jest.fn()}))); -jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => jest.fn(() => null)); +jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => { + const ReactActual = jest.requireActual('react'); + const {default: ReportActionIndexContextActual, ReportActionScrollToNewestContext: ReportActionScrollToNewestContextActual} = + jest.requireActual('@pages/inbox/report/ReportActionIndexContext'); + + return jest.fn(() => { + mockReportActionPosition = ReactActual.useContext(ReportActionIndexContextActual); + mockScrollToNewestAction = ReactActual.useContext(ReportActionScrollToNewestContextActual); + return null; + }); +}); jest.mock('@hooks/useParentReportAction', () => jest.fn(() => undefined)); jest.mock('@navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn(() => false)); @@ -223,9 +268,23 @@ const mockReportAction = createMock { return render( - + @@ -250,6 +309,8 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { beforeEach(async () => { jest.clearAllMocks(); + mockReportActionPosition = undefined; + mockScrollToNewestAction = undefined; jest.spyOn(NativeNavigation, 'useIsFocused').mockReturnValue(true); mockUseIsReportLoadPending.mockReturnValue(false); mockUseNetwork.mockReturnValue({isOffline: false}); @@ -259,6 +320,30 @@ describe('MoneyRequestReportActionsList - Reject Educational Modal', () => { }); }); + it('should use the unified list index when the newest action requests a bottom scroll', async () => { + await act(async () => { + await Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}${FAKE_REPORT_ID}` as const]: mockReport, + [`${ONYXKEYS.COLLECTION.POLICY}${FAKE_POLICY_ID}` as const]: mockPolicy, + [`${ONYXKEYS.COLLECTION.TRANSACTION}${FAKE_TRANSACTION_ID}` as const]: mockTransaction, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${FAKE_REPORT_ID}` as const]: { + [mockReportAction.reportActionID]: mockReportAction, + [mockCommentReportAction.reportActionID]: mockCommentReportAction, + }, + [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${FAKE_REPORT_ID}` as const]: {isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}, + [ONYXKEYS.SESSION]: {accountID: FAKE_ACCOUNT_ID, email: FAKE_EMAIL} as Session, + }); + }); + + renderComponent(); + await waitForBatchedUpdatesWithAct(); + + expect(mockReportActionPosition).toEqual(expect.objectContaining({isNewest: true})); + act(() => mockScrollToNewestAction?.()); + expect(mockScrollToIndex).toHaveBeenCalledWith({index: MOCK_UNIFIED_LAST_ITEM_INDEX, animated: false, viewPosition: 1}); + expect(mockScrollToEnd).not.toHaveBeenCalled(); + }); + it('should show reject educational modal when reject option is selected and explanation has NOT been dismissed', async () => { await act(async () => { await Onyx.multiSet({ diff --git a/tests/ui/MoneyRequestReportViewTest.tsx b/tests/ui/MoneyRequestReportViewTest.tsx index f0dc787734b5..00723a42cc92 100644 --- a/tests/ui/MoneyRequestReportViewTest.tsx +++ b/tests/ui/MoneyRequestReportViewTest.tsx @@ -1,9 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ -import {render} from '@testing-library/react-native'; +import {render, screen} from '@testing-library/react-native'; import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; import MoneyRequestReportView from '@components/MoneyRequestReportView/MoneyRequestReportView'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; @@ -34,6 +36,10 @@ jest.mock('@hooks/useOnyx', () => jest.fn()); jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); jest.mock('@hooks/usePaginatedReportActions', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); +jest.mock('@hooks/useInFlightRequests', () => ({ + useIsAppLoadPending: jest.fn(), + useIsReportLoadPending: jest.fn(), +})); // useThemeStyles throws without a ; return a proxy that yields an empty style object // for any key so the (mostly-mocked) tree renders without wiring up the full provider stack. @@ -54,6 +60,8 @@ jest.mock('@components/MoneyReportHeader', () => jest.fn(() => null)); jest.mock('@components/MoneyRequestHeader', () => jest.fn(() => null)); jest.mock('@components/CollapsibleHeaderOnKeyboard', () => jest.fn(() => null)); jest.mock('@components/ReportActionItem/MoneyRequestReceiptView', () => jest.fn(() => null)); +jest.mock('@components/ReportActionsSkeletonView', () => jest.fn(() => null)); +jest.mock('@components/ReportHeaderSkeletonView', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportFooter', () => jest.fn(() => null)); jest.mock('@components/OfflineWithFeedback', () => { const reactModule = jest.requireActual('react'); @@ -61,12 +69,15 @@ jest.mock('@components/OfflineWithFeedback', () => { }); const mockUseNetwork = useNetwork as jest.MockedFunction; +const mockUseIsAppLoadPending = jest.mocked(useIsAppLoadPending); +const mockUseIsReportLoadPending = jest.mocked(useIsReportLoadPending); const mockUseOnyx = useOnyx as jest.MockedFunction; const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; const mockUsePaginatedReportActions = usePaginatedReportActions as jest.MockedFunction; const mockUseReportTransactionsCollection = useReportTransactionsCollection as jest.MockedFunction; const mockMoneyRequestReportActionsList = MoneyRequestReportActionsList as jest.MockedFunction; const mockReportActionsListBody = ReportActionsList as jest.MockedFunction; +const mockReportActionsSkeletonView = jest.mocked(ReportActionsSkeletonView); const mockUserTypingEventListener = UserTypingEventListener as jest.MockedFunction; const defaultPaginatedReportActionsResult: ReturnType = { @@ -134,6 +145,8 @@ describe('MoneyRequestReportView', () => { jest.clearAllMocks(); mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsAppLoadPending.mockReturnValue(false); + mockUseIsReportLoadPending.mockReturnValue(false); mockUsePaginatedReportActions.mockReturnValue(defaultPaginatedReportActionsResult); mockUseReportTransactionsCollection.mockReturnValue({}); mockUseResponsiveLayout.mockReturnValue({ @@ -176,6 +189,37 @@ describe('MoneyRequestReportView', () => { expect(MoneyRequestReportUtils.shouldWaitForTransactions).toHaveBeenLastCalledWith(mockReport, [], mockReportLoadingState, false, false); }); + it('uses the bottom-padded cover while the report is waiting for transactions', () => { + jest.spyOn(MoneyRequestReportUtils, 'shouldWaitForTransactions').mockReturnValue(true); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + + it('uses a static bottom-padded cover while report actions are empty', () => { + jest.spyOn(ReportActionsUtils, 'getFilteredReportActionsForReportView').mockReturnValue([]); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + + it('uses the bottom-padded cover while the app is loading', () => { + mockUseIsAppLoadPending.mockReturnValue(true); + + renderMoneyRequestReportView(jest.fn()); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockMoneyRequestReportActionsList).not.toHaveBeenCalled(); + }); + it('mounts the chat list body and the typing listener (not the table view) for a transaction-thread report', () => { const onLayout = jest.fn(); diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index fd23ffdab5bf..b569e00b5215 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -43,6 +43,12 @@ const LIST_CONTENT_SIZE = { width: 300, height: 600, }; +const LIST_END_OFFSET = LIST_CONTENT_SIZE.height - LIST_SIZE.height; +const PAGINATED_LIST_CONTENT_SIZE = { + ...LIST_CONTENT_SIZE, + height: LIST_CONTENT_SIZE.height * 2, +}; +const PAGINATED_LIST_END_OFFSET = PAGINATED_LIST_CONTENT_SIZE.height - LIST_SIZE.height; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); const REPORT_ID = '1'; @@ -58,14 +64,14 @@ function getReportScreen(reportID = REPORT_ID) { return screen.getByTestId(`report-screen-${reportID}`); } -function scrollToOffset(offset: number) { +function scrollToOffset(offset: number, contentSize = LIST_CONTENT_SIZE) { const hintText = TestHelper.translateLocal('sidebarScreen.listOfChatMessages'); fireEvent.scroll(within(getReportScreen()).getByLabelText(hintText), { nativeEvent: { contentOffset: { y: offset, }, - contentSize: LIST_CONTENT_SIZE, + contentSize, layoutMeasurement: LIST_SIZE, }, }); @@ -315,7 +321,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should not trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(LIST_END_OFFSET); await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); @@ -339,7 +345,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(0); await waitForBatchedUpdatesWithAct(); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); @@ -370,8 +376,8 @@ describe('Pagination', () => { jest.requireMock('@react-navigation/native').triggerTransitionEnd(); }); // Due to https://github.com/facebook/react-native/commit/3485e9ed871886b3e7408f90d623da5c018da493 - // we need to scroll too to trigger `onStartReached` which triggers other updates - scrollToOffset(0); + // we need to scroll too to trigger `onEndReached` which triggers other updates + scrollToOffset(LIST_END_OFFSET); // ReportScreen relies on the onLayout event to receive updates from onyx. triggerListLayout(); await waitForNetworkPromises(); @@ -393,9 +399,9 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalledWith('GetNewerActions', 0, {reportID: REPORT_ID, reportActionID: '5'}); // Simulate the maintainVisibleContentPosition scroll adjustment, so it is now possible to scroll down more. - scrollToOffset(500); + scrollToOffset(0, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); - scrollToOffset(0); + scrollToOffset(PAGINATED_LIST_END_OFFSET, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. @@ -405,9 +411,9 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - scrollToOffset(500); + scrollToOffset(0, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); - scrollToOffset(0); + scrollToOffset(PAGINATED_LIST_END_OFFSET, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); // When there are no newer actions, we don't want to trigger GetNewerActions again. diff --git a/tests/ui/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 302edd2eb643..af137b294ba1 100644 --- a/tests/ui/ReportActionItemMessageEditTest.tsx +++ b/tests/ui/ReportActionItemMessageEditTest.tsx @@ -9,6 +9,7 @@ import {editReportComment} from '@libs/actions/Report'; import * as ReportActionContextMenu from '@pages/inbox/report/ContextMenu/ReportActionContextMenu'; import {ReportActionEditMessageContextProvider} from '@pages/inbox/report/ReportActionEditMessageContext'; +import ReportActionIndexContext, {ReportActionScrollToNewestContext} from '@pages/inbox/report/ReportActionIndexContext'; import type {ReportActionItemMessageEditProps} from '@pages/inbox/report/ReportActionItemMessageEdit'; import ReportActionItemMessageEdit from '@pages/inbox/report/ReportActionItemMessageEdit'; import {draftMessageVideoAttributeCache} from '@pages/inbox/report/useDraftMessageVideoAttributeCache'; @@ -86,13 +87,17 @@ function ReportScreenProviders({children}: PropsWithChildren) { return {children}; } -const renderReportActionItemMessageEdit = (props?: Partial) => { +const renderReportActionItemMessageEdit = (props?: Partial, listContext?: {index: number; isNewest: boolean; scrollToNewestAction?: () => void}) => { return render( - + + + + + , ); }; @@ -210,5 +215,15 @@ describe('ReportActionCompose Integration Tests', () => { expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-height'); expect(videoAttributeCache?.[videoSource]).toContain('data-expensify-width'); }); + + it('should use the list-specific scroll after saving the newest message', () => { + const scrollToNewestAction = jest.fn(); + renderReportActionItemMessageEdit(undefined, {index: 9, isNewest: true, scrollToNewestAction}); + + fireEvent.changeText(screen.getByTestId('composer'), 'Edited message'); + fireEvent.press(screen.getByLabelText('common.saveChanges')); + + expect(scrollToNewestAction).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/tests/ui/ReportActionsListTest.tsx b/tests/ui/ReportActionsListTest.tsx index 0b1aac4cf61e..9aeb898162af 100644 --- a/tests/ui/ReportActionsListTest.tsx +++ b/tests/ui/ReportActionsListTest.tsx @@ -1,4 +1,4 @@ -import {render, screen} from '@testing-library/react-native'; +import {act, render, screen} from '@testing-library/react-native'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; @@ -85,8 +85,10 @@ const mockUseConciergeDraftActions = useConciergeDraftActions as jest.MockedFunc const mockUseConciergeSessionState = useConciergeSessionState as jest.MockedFunction; const mockUseConciergeSessionActions = useConciergeSessionActions as jest.MockedFunction; -function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { - return selector === reportActionsListLoadingStateSelector ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false} : undefined; +function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true, isLoadingInitialReportActions = false, isLoadingOlderReportActions = false) { + return selector === reportActionsListLoadingStateSelector + ? {hasOnceLoadedReportActions, isLoadingInitialReportActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError: false} + : undefined; } const defaultPaginatedReportActionsResult: ReturnType = { @@ -113,10 +115,12 @@ const defaultSidePanelState: ReturnType = { jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); +const mockLoadOlderChats = jest.fn(); jest.mock('@hooks/useLoadReportActions', () => - jest.fn(() => ({ - loadOlderChats: jest.fn(), + jest.fn(({reportActions}: {reportActions: OnyxTypes.ReportAction[]}) => ({ + loadOlderChats: mockLoadOlderChats, loadNewerChats: jest.fn(), + currentReportOldestActionID: reportActions.at(-1)?.reportActionID, })), ); jest.mock('@hooks/usePrevious', () => jest.fn()); @@ -124,11 +128,30 @@ jest.mock('@hooks/usePrevious', () => jest.fn()); const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest.MockedFunction; // We mount the public ReportActionsList (the skeleton guard + its content) and observe what the content -// feeds the list via InvertedFlashList's `data`. The heavy scroll/marker hooks have their own unit tests, +// feeds chronological data directly to LegendList. The heavy scroll/marker hooks have their own unit tests, // so they are stubbed here to isolate the skeleton logic. Because the guard only mounts the content when // the skeleton is not showing, these stubs double as a probe for dormancy: while a skeleton renders the // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. -jest.mock('@components/FlashList/InvertedFlashList', () => jest.fn(() => null)); +const mockLegendListMount = jest.fn(); +const mockLegendListUnmount = jest.fn(); +let mockShouldCallLegendListOnLoad = true; +jest.mock('@legendapp/list/react-native', () => { + const reactModule = jest.requireActual('react'); + return { + LegendList: jest.fn(({onLoad}: {onLoad?: () => void}) => { + reactModule.useEffect(() => { + mockLegendListMount(); + if (mockShouldCallLegendListOnLoad) { + onLoad?.(); + } + return () => { + mockLegendListUnmount(); + }; + }, []); + return null; + }), + }; +}); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); jest.mock('@hooks/useReportActionsScroll', () => @@ -140,11 +163,9 @@ jest.mock('@hooks/useReportActionsScroll', () => isActionBadgeAboveViewport: false, scrollToBottomAndMarkReportAsRead: jest.fn(), scrollToActionBadgeTarget: jest.fn(), - flushPendingScrollToBottom: jest.fn(), shouldBeAlignedToTop: false, - shouldFocusToTopOnMount: false, - initialScrollKey: undefined, - shouldAutoscrollToBottom: false, + initialScrollIndex: undefined, + initialScrollIndexParams: undefined, onLoad: jest.fn(), })), ); @@ -156,18 +177,38 @@ jest.mock('@pages/inbox/report/ReportActionsListPaddingView', () => { jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportActionItemCreated', () => jest.fn(() => null)); -type MockInvertedFlashListProps = { +type MockLegendListProps = { + alignItemsAtEnd?: boolean; data?: OnyxTypes.ReportAction[]; + drawDistance?: number; + // eslint-disable-next-line @typescript-eslint/naming-convention + experimental_hideItemsUntilMeasured?: boolean; extraData?: unknown; + getItemType?: (item: OnyxTypes.ReportAction) => string; + initialScrollAtEnd?: boolean; + maintainScrollAtEnd?: {animated: boolean} | false; + maintainScrollAtEndThreshold?: number; + maintainVisibleContentPosition?: boolean; + onLoad?: () => void; + onStartReachedThreshold?: number; + recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; + onStartReached?: () => void; + onScroll?: (event: { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; + }) => void; }; -const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/FlashList/InvertedFlashList'); +const {LegendList: mockLegendList} = jest.requireMock<{LegendList: jest.MockedFunction<(props: MockLegendListProps) => null>}>('@legendapp/list/react-native'); const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/report/ReportActionItemCreated'); -/** Returns the report actions the body fed into the (mocked) inverted list on its latest render. */ -const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0)?.data; -const getCapturedListProps = (): MockInvertedFlashListProps | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0); +/** Returns the chronological report actions the body fed into the mocked LegendList on its latest render. */ +const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockLegendList.mock.calls.at(-1)?.at(0)?.data; +const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendList.mock.calls.at(-1)?.at(0); const getRenderedReportActionsListItemProps = (reportAction: OnyxTypes.ReportAction, index = 0): {shouldDisableContextMenuForConciergeDraft?: boolean} => { const renderedItem = getCapturedListProps()?.renderItem?.({item: reportAction, index}); @@ -191,6 +232,9 @@ const getRenderedReportActionsListItemProps = (reportAction: OnyxTypes.ReportAct const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseReportActionsScroll: jest.Mock = jest.requireMock('@hooks/useReportActionsScroll'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); +let mockHasOnceLoadedReportActions = true; +let mockIsLoadingInitialReportActions = false; +let mockIsLoadingOlderReportActions = false; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -233,6 +277,19 @@ const mockReportActions: OnyxTypes.ReportAction[] = [ }, ]; +const olderMockReportAction: OnyxTypes.ReportAction = { + reportActionID: '0', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2022-12-31', + actorAccountID: 125, + message: [{type: 'COMMENT', html: 'Older message', text: 'Older message'}], + originalMessage: {}, + shouldShow: true, + person: [{type: 'TEXT', style: 'strong', text: 'Older User'}], + pendingAction: null, + errors: {}, +}; + const renderReportActionsList = (props: {reportID?: string} = {}) => { const reportID = props.reportID ?? mockReport.reportID; return render( @@ -252,6 +309,10 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); + mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = false; + mockIsLoadingOlderReportActions = false; + mockShouldCallLegendListOnLoad = true; mockUseIsReportLoadPending.mockReturnValue(false); mockUseCurrentUserPersonalDetails.mockReturnValue({ @@ -314,7 +375,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, mockIsLoadingInitialReportActions, mockIsLoadingOlderReportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -334,6 +395,311 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + it('delegates end following and size corrections to LegendList without a full-viewport threshold', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + expect(getCapturedListProps()?.maintainScrollAtEndThreshold).toBe(0.01); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + }); + + it('initially aligns the seed page to the end', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + renderReportActionsList(); + + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.alignItemsAtEnd).toBe(true); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + }); + + it('does not follow the end of a page that still has newer actions to load', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toBe(true); + }); + + it('remounts the list when the initial report actions finish hydrating', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + const view = renderReportActionsList(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + + mockHasOnceLoadedReportActions = true; + // The mocked Onyx hook does not own state, so changing its return value cannot schedule the + // rerender that the real Onyx subscription causes. Change a prop to trigger that render. + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(2); + expect(mockLegendListUnmount).toHaveBeenCalledTimes(1); + expect(getCapturedListProps()?.initialScrollAtEnd).toBe(true); + expect(getCapturedListProps()?.maintainScrollAtEnd).toEqual({animated: false}); + }); + + it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + + it('releases the initial viewport cover after a terminal OpenReport failure', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = false; + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('keeps the initial viewport covered while OpenReport is queued despite stale stored loading state', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = false; + mockUseIsReportLoadPending.mockReturnValue(true); + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + act(() => { + getCapturedListProps()?.onLoad?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + mockUseIsReportLoadPending.mockReturnValue(false); + view.rerender( + , + ); + + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('keeps the initial actions visible until the hydrated page is complete', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + const groupingSpy = jest.spyOn(ReportActionsUtils, 'isConsecutiveActionMadeByPreviousActor'); + const view = renderReportActionsList(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + }); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(false); + + const snapshotActions = getCapturedVisibleActions(); + const oldestSnapshotAction = snapshotActions?.at(0); + if (!oldestSnapshotAction || !snapshotActions) { + throw new Error('Expected the initial action snapshot to remain visible'); + } + getRenderedReportActionsListItemProps(oldestSnapshotAction); + expect(groupingSpy).toHaveBeenLastCalledWith(snapshotActions.toReversed(), snapshotActions.length - 1, false); + groupingSpy.mockRestore(); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length + 1); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(true); + }); + + it('limits the render buffer and enables item recycling', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const listProps = getCapturedListProps(); + + expect(listProps?.drawDistance).toBe(1500); + expect(listProps?.recycleItems).toBe(true); + expect(listProps?.experimental_hideItemsUntilMeasured).toBe(true); + }); + + it('groups comments by layout characteristics for measurement estimates', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const getItemType = getCapturedListProps()?.getItemType; + const comment = mockReportActions.at(1); + if (!comment) { + throw new Error('Expected comment report action fixture'); + } + + expect(getItemType?.(comment)).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-short`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'medium-comment', + message: [{type: 'COMMENT', html: 'Medium comment', text: 'a'.repeat(320)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-medium`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'long-comment', + message: [{type: 'COMMENT', html: 'Long comment', text: 'a'.repeat(1200)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'extra-long-comment', + message: [{type: 'COMMENT', html: 'Extra long comment', text: 'a'.repeat(1201)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-extra-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'attachment', + isAttachmentOnly: true, + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-attachment`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'link-preview', + linkMetadata: [{url: 'https://example.com'}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-link-preview-short`); + }); + + it('loads older pages when LegendList reaches the start and deduplicates the scroll fallback', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + }); + const view = renderReportActionsList(); + + const listProps = getCapturedListProps(); + const createScrollEvent = (offset: number) => ({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + + act(() => { + listProps?.onStartReached?.(); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + expect(listProps?.onStartReachedThreshold).toBe(0.75); + + act(() => { + listProps?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + + mockIsLoadingOlderReportActions = true; + view.rerender( + , + ); + mockIsLoadingOlderReportActions = false; + view.rerender( + , + ); + + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + hasOlderActions: true, + }); + view.rerender( + , + ); + + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + expect(mockLoadOlderChats).toHaveBeenCalledTimes(2); + }); + describe('Concierge Draft Context Menu', () => { const conciergeDraftReportAction: OnyxTypes.ReportAction = { reportID: mockReport.reportID, @@ -670,10 +1036,10 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.length).toBeGreaterThanOrEqual(1); - expect(passedActions?.at(0)?.reportActionID).toBe(CONST.CONCIERGE_GREETING_ACTION_ID); + expect(passedActions?.some((action) => action.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); }); it('should not show welcome state when not in side panel', () => { @@ -737,7 +1103,7 @@ describe('ReportActionsList (body)', () => { // Welcome should not be shown since user has sent a message expect(mockReportActionItemCreated).not.toHaveBeenCalled(); // ReportActionsList should be rendered with filtered actions - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); }); }); @@ -804,7 +1170,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions, !hasOnceLoadedReportActions), {status: 'loaded'}]; } if (key.includes('reportActions')) { return [[], {status: 'loaded'}]; @@ -830,7 +1196,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -848,7 +1214,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); @@ -881,7 +1247,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // After user sends a message, the greeting stays visible alongside session actions expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); @@ -899,7 +1265,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // With no session, old messages should not be shown expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(false); @@ -945,7 +1311,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); // New user with no prior messages — onboarding messages pass through (no filtering) expect(passedActions?.some((a) => a.reportActionID === 'onboarding-msg')).toBe(true); @@ -968,9 +1334,10 @@ describe('ReportActionsList (body)', () => { expect(mockStartSession).toHaveBeenCalled(); }); - it('should render cached actions without a skeleton on refresh when hasOnceLoadedReportActions resets but actions are cached', () => { + it('should cover cached actions until the refreshed report finishes hydrating', () => { // Simulates a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, - // but report actions persist in Onyx cache. We should render them immediately (production behavior). + // but report actions persist in Onyx cache. Keep the cached list covered until OpenReport + // finishes so the final hydrated viewport is the first report content the user sees. setupMainDMConciergeMocks(SESSION_START, false, false); mockUsePaginatedReportActions.mockReturnValue({ @@ -981,8 +1348,8 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockLegendList).toHaveBeenCalled(); }); it('should show a skeleton on a cold load when hasOnceLoadedReportActions is false and there are no cached actions', () => { diff --git a/tests/ui/ReportActionsTest.tsx b/tests/ui/ReportActionsTest.tsx index 18f7f63e2496..6099444f6a52 100644 --- a/tests/ui/ReportActionsTest.tsx +++ b/tests/ui/ReportActionsTest.tsx @@ -149,6 +149,7 @@ describe('ReportActions (orchestrator)', () => { render(); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); @@ -159,6 +160,7 @@ describe('ReportActions (orchestrator)', () => { render(); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index d8ce64ac8449..61c2508361be 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -30,38 +30,52 @@ import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatch const THRESHOLD = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; -type ScrollEvent = {nativeEvent: {contentOffset: {y: number}}}; +type ScrollEvent = { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; +}; type CapturedListProps = { - maintainVisibleContentPosition?: {disabled: boolean}; + maintainVisibleContentPosition?: boolean | {data: boolean}; onScroll?: (event: ScrollEvent) => void; }; -// Capture the props the list is rendered with so we can observe `maintainVisibleContentPosition`, whose -// `disabled` flag is `!(hasScrolledOverThreshold || shouldFocusToTopOnMount)`. With no deep-link the latter -// is false, so `!disabled` mirrors the boolean under test. +// Capture the props the list is rendered with so we can verify data anchoring remains enabled while the +// list crosses the visible-action threshold. let capturedListProps: CapturedListProps = {}; -// Every value the maintain-visible-content-position flag has held (`!disabled`), in render order. `[0]` is the -// value on the list's very first render — the property that matters, since it must be right before any effect runs. -let mockMvcpHistory: Array = []; -// `!disabled` from the captured `maintainVisibleContentPosition`, or `undefined` before the list first renders. +// Whether the captured LegendList configuration enables data-based maintain-visible-content-position. function isMvcpEnabled() { const config = capturedListProps.maintainVisibleContentPosition; - return config ? !config.disabled : undefined; + return config === true || (typeof config === 'object' && config.data); } -jest.mock('@components/FlashList/InvertedFlashList', () => { +jest.mock('@legendapp/list/react-native', () => { const {forwardRef} = jest.requireActual('react'); return { - __esModule: true, - default: forwardRef((props) => { + // The second parameter is intentionally unused; forwardRef requires it to avoid a React development warning. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + LegendList: forwardRef((props, ref) => { capturedListProps = props; - mockMvcpHistory.push(props.maintainVisibleContentPosition ? !props.maintainVisibleContentPosition.disabled : undefined); return null; }), }; }); +function createScrollEvent(distanceFromBottom: number): ScrollEvent { + const contentHeight = 1000; + const viewportHeight = 500; + return { + nativeEvent: { + contentOffset: {x: 0, y: contentHeight - viewportHeight - distanceFromBottom}, + contentSize: {height: contentHeight, width: 300}, + layoutMeasurement: {height: viewportHeight, width: 300}, + }, + }; +} + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -120,7 +134,6 @@ async function renderList(initialOffset: number) { beforeEach(async () => { capturedListProps = {}; - mockMvcpHistory = []; setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); await act(async () => { @@ -145,34 +158,31 @@ afterEach(async () => { await waitForBatchedUpdates(); }); -describe('ReportActionsList hasScrolledOverThreshold', () => { - it('enables maintainVisibleContentPosition on first render when mounted while scrolled past the threshold', async () => { +describe('ReportActionsList maintainVisibleContentPosition', () => { + it('enables data anchoring on first render when mounted while scrolled past the threshold', async () => { await renderList(THRESHOLD + 50); - // Must be true on the FIRST render, not merely after an effect settles — deferring this to an effect - // would let the mount-time mark-as-read path observe a wrong `isScrolledToEnd`. - expect(mockMvcpHistory.at(0)).toBe(true); expect(isMvcpEnabled()).toBe(true); }); - it('leaves maintainVisibleContentPosition off on first render when mounted at the bottom (offset below threshold)', async () => { + it('enables data anchoring at the bottom so initial hydration preserves the visible tail', async () => { await renderList(0); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); }); - it('flips the flag as the user scrolls across the threshold', async () => { + it('keeps data anchoring enabled as the user scrolls across the threshold', async () => { await renderList(0); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: THRESHOLD + 50}}}); + capturedListProps.onScroll?.(createScrollEvent(THRESHOLD + 50)); }); expect(isMvcpEnabled()).toBe(true); act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: 0}}}); + capturedListProps.onScroll?.(createScrollEvent(0)); }); - expect(isMvcpEnabled()).toBe(false); + expect(isMvcpEnabled()).toBe(true); }); }); diff --git a/tests/unit/ReportActionsSkeletonCoverTest.tsx b/tests/unit/ReportActionsSkeletonCoverTest.tsx new file mode 100644 index 000000000000..b33a729a9ea8 --- /dev/null +++ b/tests/unit/ReportActionsSkeletonCoverTest.tsx @@ -0,0 +1,45 @@ +import {render, screen} from '@testing-library/react-native'; + +import ReportActionsSkeletonCover from '@components/ReportActionsSkeletonCover'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; + +import React from 'react'; + +jest.mock('@components/ReportActionsSkeletonView', () => jest.fn(() => null)); + +const mockReportActionsSkeletonView = jest.mocked(ReportActionsSkeletonView); + +describe('ReportActionsSkeletonCover', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fills, clips, and bottom-aligns the report-actions skeleton', () => { + render( + + + , + ); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ + flex: 1, + overflow: 'hidden', + justifyContent: 'flex-end', + paddingBottom: 16, + }); + expect(mockReportActionsSkeletonView.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({shouldAnimate: false})); + }); + + it('accepts additional presentation styles', () => { + render(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toHaveStyle({ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 10, + }); + }); +}); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index aebd3cdc0186..721b905757a3 100644 --- a/tests/unit/hooks/useEditMessage.test.ts +++ b/tests/unit/hooks/useEditMessage.test.ts @@ -58,9 +58,10 @@ jest.mock('@hooks/useReportIsArchived', () => ({ default: () => false, })); +const mockScrollToBottom = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, - default: () => ({scrollToIndex: jest.fn()}), + default: () => ({scrollToBottom: mockScrollToBottom}), })); jest.mock('@libs/ReportUtils', () => { @@ -142,4 +143,32 @@ describe('useEditMessage', () => { const args = mockShowDeleteModal.mock.calls.at(0); expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); }); + + it('scrolls to the bottom after deleting the newest message draft', () => { + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); + }); + + it('uses the list-specific bottom scroll after deleting the newest message draft', () => { + const scrollToLastMessage = jest.fn(); + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true, scrollToLastMessage}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(scrollToLastMessage).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/shouldFollowActionBadgeTargetTest.ts b/tests/unit/shouldFollowActionBadgeTargetTest.ts index e0ab679a0ce4..2f2b9328b90c 100644 --- a/tests/unit/shouldFollowActionBadgeTargetTest.ts +++ b/tests/unit/shouldFollowActionBadgeTargetTest.ts @@ -4,17 +4,17 @@ const BASE_PARAMS = { isProduction: false, actionTargetReportActionID: '200', prevActionTargetReportActionID: '100', - actionBadgeTargetIndex: 2, + actionBadgeTargetIndex: 7, prevActionBadgeTargetIndex: 5, }; describe('shouldFollowActionBadgeTarget', () => { - it('follows the target when it advances to a newer (lower-index) preview', () => { + it('follows the target when it advances to a newer (higher-index) preview', () => { expect(shouldFollowActionBadgeTarget(BASE_PARAMS)).toBe(true); }); - it('does not follow when the target moves to an older (higher-index) preview, e.g. while paginating', () => { - expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 7})).toBe(false); + it('does not follow when the target moves to an older (lower-index) preview, e.g. while paginating', () => { + expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 2})).toBe(false); }); it('does not follow when the target index is unchanged', () => { diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 87e435b002a5..cf372cf3122c 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -101,7 +101,7 @@ function buildParams(overrides: Partial = {}): HookParams { hasNewerActions: true, linkedReportActionID: undefined, hasNewestReportAction: false, - sortedVisibleReportActions: [], + renderedVisibleReportActions: [], sortedAllReportActionsForPagination: [], reportActionPages: undefined, setTreatAsNoPaginationAnchor: jest.fn(), @@ -120,6 +120,29 @@ describe('useReportActionsNewActionLiveTail', () => { mockIsInSidePanel = false; }); + it('requests one post-render scroll for a sent comment instead of also scrolling immediately', () => { + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true}))); + + act(() => { + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + }); + + expect(result.current.isScrollToBottomEnabled).toBe(true); + expect(reportScrollManager.scrollToBottom).not.toHaveBeenCalled(); + }); + + it('does not queue a bottom scroll that would compete with a report-preview target', () => { + const preview = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW}); + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [preview]}))); + + act(() => { + newActionHandler?.(true, preview); + }); + + expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + }); + it('threads the conciergeChat report through to the catch-up openReport call', () => { const conciergeChat = {reportID: 'concierge-live-tail-1'}; renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({conciergeChat})}); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 0cf03c9ff5e6..118361255128 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -47,6 +47,7 @@ jest.mock('@hooks/useReportScrollManager', () => ({ const mockSetIsFloatingMessageCounterVisible = jest.fn(); const mockTrackVerticalScrolling = jest.fn(); const mockOnViewableItemsChanged = jest.fn(); +const mockUpdatePillVisibility = jest.fn(); let mockIsFloatingMessageCounterVisible = false; let mockIsActionBadgeAboveViewport = false; jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ @@ -57,6 +58,7 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ isActionBadgeAboveViewport: mockIsActionBadgeAboveViewport, trackVerticalScrolling: mockTrackVerticalScrolling, onViewableItemsChanged: mockOnViewableItemsChanged, + updatePillVisibility: mockUpdatePillVisibility, }), })); @@ -73,12 +75,6 @@ jest.mock('@pages/inbox/report/useReportActionsNewActionLiveTail', () => ({ }), })); -// --- useScrollToEndOnNewMessageReceived --- -jest.mock('@hooks/useScrollToEndOnNewMessageReceived', () => ({ - __esModule: true, - default: jest.fn(), -})); - // --- TransitionTracker --- const mockTransitionCallbacks: Array<() => void> = []; jest.mock('@libs/Navigation/TransitionTracker', () => ({ @@ -183,13 +179,11 @@ function buildParams(overrides: Partial = {}): ScrollParams { sortedVisibleReportActions: [makeAction('1')], renderedVisibleReportActions: [makeAction('1')], keyExtractor: (item: ReportAction) => item.reportActionID, - hasScrolledOverThreshold: false, markNewestActionAsRead: mockMarkNewestActionAsRead, completeSkippedMarkAsRead: mockCompleteSkippedMarkAsRead, unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1, hasNewerActions: false, - draftAutoScrollKey: '', actionBadgeTargetIndex: -1, sortedAllReportActionsForPagination: [], treatAsNoPaginationAnchor: false, @@ -221,10 +215,6 @@ function flushTransitions() { }); } -function setReportLoadingState(value: {isLoadingInitialReportActions?: boolean; hasOnceLoadedReportActions?: boolean}) { - return Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, value); -} - describe('useReportActionsScroll', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -240,6 +230,9 @@ describe('useReportActionsScroll', () => { mockIsFloatingMessageCounterVisible = false; mockIsActionBadgeAboveViewport = false; mockIsScrollToBottomEnabled = false; + mockSetIsScrollToBottomEnabled.mockImplementation((enabled: boolean) => { + mockIsScrollToBottomEnabled = enabled; + }); mockIsTransactionThread = false; mockIsSentMoneyReportAction = false; mockIsReportPreviewAction = false; @@ -259,9 +252,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(false); - expect(result.current.shouldFocusToTopOnMount).toBe(false); - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); }); it('is aligned to top and focuses to top on mount for a transaction thread report', async () => { @@ -270,8 +260,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(true); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); }); it('is aligned to top for a money request report', async () => { @@ -290,22 +278,25 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldBeAlignedToTop).toBe(true); }); - it('uses the linked report action as the initial scroll key', async () => { + it('positions a linked report action at chronological index zero', async () => { mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - const {result} = await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); - expect(result.current.initialScrollKey).toBe(LINKED_ACTION_ID); - expect(result.current.shouldFocusToTopOnMount).toBe(false); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); - it('falls back to the unread marker action as the initial scroll key', async () => { + it('positions an unread marker at chronological index zero', async () => { + const unreadAction = makeAction(UNREAD_ACTION_ID); const {result} = await renderScroll({ unreadMarkerReportActionID: UNREAD_ACTION_ID, - sortedVisibleReportActions: [makeAction(UNREAD_ACTION_ID)], + sortedVisibleReportActions: [unreadAction], + renderedVisibleReportActions: [unreadAction], }); - expect(result.current.initialScrollKey).toBe(UNREAD_ACTION_ID); + expect(result.current.initialScrollIndex).toBe(0); }); it('suppresses the initial scroll key for an aligned-to-top CREATED anchor action', async () => { @@ -316,9 +307,8 @@ describe('useReportActionsScroll', () => { sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID, {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED})], }); - expect(result.current.initialScrollKey).toBeUndefined(); - // No key + aligned-to-top → focus to top. - expect(result.current.shouldFocusToTopOnMount).toBe(true); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toBeUndefined(); }); it('does not focus to top for a single-expense money request report opened from the X Replies link', async () => { @@ -329,7 +319,6 @@ describe('useReportActionsScroll', () => { // Still aligned to top so short reports keep their layout, but the mount position is the latest message. expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); expect(result.current.initialScrollIndex).toBeUndefined(); }); @@ -340,7 +329,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); }); }); @@ -403,135 +391,48 @@ describe('useReportActionsScroll', () => { result.current.scrollToActionBadgeTarget(); }); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); }); - describe('flushPendingScrollToBottom', () => { + describe('pending live-tail requests', () => { it('does nothing when scroll-to-bottom is not enabled', async () => { mockIsScrollToBottomEnabled = false; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + await renderScroll(); expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(mockSetIsScrollToBottomEnabled).not.toHaveBeenCalled(); expect(mockCompleteLiveTailPrune).not.toHaveBeenCalled(); }); - it('scrolls, disables itself and prunes when scroll-to-bottom is enabled', async () => { + it('consumes the request after render without waiting for a future viewport layout', async () => { mockIsScrollToBottomEnabled = true; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + const {rerender} = await renderScroll(); expect(mockScrollToBottom).toHaveBeenCalledTimes(1); expect(mockSetIsScrollToBottomEnabled).toHaveBeenCalledWith(false); expect(mockCompleteLiveTailPrune).toHaveBeenCalledTimes(1); - }); - }); - - describe('onLoad', () => { - it('does nothing when the list is not configured to focus to top on mount', async () => { - const {result} = await renderScroll(); - act(() => { - result.current.onLoad(); - }); - - // Stays disabled with no autoscroll threshold for a regular chat. - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); - }); - - it('waits for the report actions to have loaded before disabling autoscroll-to-top', async () => { - mockIsTransactionThread = true; - // No loading state → onLoad bails. - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }); - - it('disables autoscroll-to-top after a frame once report actions have loaded', async () => { - mockIsTransactionThread = true; - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); - }); - - it('disables autoscroll-to-top when report actions finish loading after the list has mounted', async () => { - mockIsTransactionThread = true; - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - // Load completes after mount → companion effect turns autoscroll off. - await act(async () => { - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - await waitForBatchedUpdates(); - }); - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); + mockScrollOffsetRef.current = 9999; + rerender(buildParams()); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); }); describe('effects', () => { - it('schedules an initial scroll-to-bottom on mount for a regular chat report', async () => { - await renderScroll(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - flushTransitions(); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - }); - - it('does not scroll to bottom on mount when there is an initial scroll key', async () => { - mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - - await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); - flushTransitions(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - }); + it('leaves incoming-message following to LegendList', async () => { + mockScrollOffsetRef.current = 0; - it('does not scroll to bottom on mount when the list focuses to top', async () => { - mockIsTransactionThread = true; + const {rerender} = await renderScroll(); - await renderScroll(); - flushTransitions(); + const actions = [makeAction('2'), makeAction('1')]; + rerender(buildParams({sortedVisibleReportActions: actions, renderedVisibleReportActions: actions.toReversed()})); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); - it('scrolls to bottom on mount for a single-expense money request report opened from the X Replies link', async () => { - mockIsMoneyRequestReport = true; - mockRouteParams = {shouldScrollToLatest: 'true'}; - - await renderScroll(); - flushTransitions(); - - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - }); - it('clears the X Replies flag once it has been applied', async () => { mockIsMoneyRequestReport = true; mockRouteParams = {shouldScrollToLatest: 'true'}; @@ -541,32 +442,15 @@ describe('useReportActionsScroll', () => { expect(mockSetParams).toHaveBeenCalledWith({shouldScrollToLatest: undefined}); }); - it('does not clear the X Replies flag when it was never set', async () => { - mockIsMoneyRequestReport = true; - - await renderScroll(); - - expect(mockSetParams).not.toHaveBeenCalled(); - }); - - it('auto-scrolls to bottom when a new draft key arrives near the bottom and the newest action is present', async () => { + it('does not schedule a competing scroll when a streamed draft grows', async () => { mockScrollOffsetRef.current = 0; - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); - - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalled(); - }); - - it('does not auto-scroll on a new draft key when scrolled away from the bottom', async () => { - mockScrollOffsetRef.current = 9999; - - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); + const draft = makeAction('2', {message: [{type: 'COMMENT', text: 'Hello', html: '

Hello

'}]}); + const {rerender} = await renderScroll({renderedVisibleReportActions: [makeAction('1'), draft]}); mockScrollToBottom.mockClear(); - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); + const updatedDraft: ReportAction = {...draft, message: [{type: 'COMMENT', text: 'Hello, here is the rest of the reply.', html: '

Hello, here is the rest of the reply.

'}]}; + rerender(buildParams({renderedVisibleReportActions: [makeAction('1'), updatedDraft]})); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index ecc1f04d688f..09c0d77e37d8 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -227,6 +227,32 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(onUnreadActionVisibleLocalMockFn).toHaveBeenCalledTimes(1); expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); + + it('tracks an unread marker in a chronological list', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + unreadMarkerReportActionIndex: 5, + isInverted: false, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + }), + ); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'reportActions_5', isViewable: true, item: {}}], changed: []}); + }); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onUnreadActionVisibleMockFn).toHaveBeenCalled(); + }); }); describe('action badge above viewport tracking', () => { @@ -281,6 +307,28 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(result.current.isActionBadgeAboveViewport).toBe(true); }); + it('returns isActionBadgeAboveViewport as true for a lower index above a chronological viewport', () => { + const offsetRef = {current: 0}; + const {result} = renderHook(() => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: offsetRef, + onUnreadActionVisible: onUnreadActionVisibleMockFn, + onTrackScrolling: onTrackScrollingMockFn, + hasNewerActions: false, + unreadMarkerReportActionIndex: -1, + isInverted: false, + actionBadgeTargetIndex: 1, + }), + ); + + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 3, key: 'reportActions_3', isViewable: true, item: {}}], changed: []}); + }); + + expect(result.current.isActionBadgeAboveViewport).toBe(true); + }); + it('returns isActionBadgeAboveViewport as false when action badge target is visible in viewport', () => { const offsetRef = {current: 0}; const {result} = renderHook(() =>