diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index c0d170d8153d..be319bbead6e 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 {ReportActionPositionContextProvider, 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'; @@ -636,26 +636,32 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) !isConsecutiveChronosAutomaticTimerAction(visibleReportActions, indexWithinReportActions, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline); const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; + const isNewestReportAction = 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 +677,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps) shouldShowHarvestCreatedAction, draftReportActionID, isDraftPendingCompletion, + scrollToBottom, ], ); diff --git a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts index 4b856d90e1ba..81fb3e7c3d01 100644 --- a/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts +++ b/src/pages/inbox/report/ReportActionCompose/useEditMessage.ts @@ -23,6 +23,7 @@ type UseEditMessageProps = { originalReportID: string | undefined; reportAction: OnyxTypes.ReportAction | null | undefined; shouldScrollToLastMessage?: boolean; + scrollToLastMessage?: () => 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(); @@ -51,9 +60,16 @@ function useEditMessage({reportID, originalReportID, reportAction, shouldScrollT clearAllReportActionDrafts(); // Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report. - if (shouldScrollToLastMessage) { - reportScrollManager.scrollToIndex(0); + if (!shouldScrollToLastMessage) { + return; } + + if (scrollToLastMessage) { + scrollToLastMessage(); + return; + } + + reportScrollManager.scrollToIndex(0); } /** diff --git a/src/pages/inbox/report/ReportActionIndexContext.tsx b/src/pages/inbox/report/ReportActionIndexContext.tsx index 1abe9823fcf3..3cc5eab3b727 100644 --- a/src/pages/inbox/report/ReportActionIndexContext.tsx +++ b/src/pages/inbox/report/ReportActionIndexContext.tsx @@ -1,13 +1,28 @@ +import type {PropsWithChildren} from 'react'; + import {createContext} from 'react'; +type ReportActionPosition = { + index: number; + isNewest: boolean; +}; + /** - * Carries an action item's position index from the list renderer down to the rare consumers that - * actually need it (e.g. `ReportActionItemMessageEdit` for scroll-to-index during edit mode). + * Carries an action item's position from the list renderer down to the rare consumers that + * actually need it (e.g. `ReportActionItemMessageEdit` for scrolling 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. + * Using context keeps position data out of the prop signatures of every intermediate component, so + * a 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); +const ReportActionIndexContext = createContext({index: 0, isNewest: true}); + +/** Lets shared list implementations provide their own reliable way to reach the newest action. */ +const ReportActionScrollToNewestContext = createContext<(() => void) | undefined>(undefined); + +function ReportActionPositionContextProvider({children, index, isNewest}: PropsWithChildren) { + return {children}; +} +export {ReportActionPositionContextProvider, ReportActionScrollToNewestContext}; export default ReportActionIndexContext; diff --git a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx index 90634e30cfd3..e753c2d6823c 100644 --- a/src/pages/inbox/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/inbox/report/ReportActionItemMessageEdit.tsx @@ -49,7 +49,7 @@ import useComposerSuggestions from './ReportActionCompose/useComposerSuggestions import useDebouncedCommentMaxLengthValidation from './ReportActionCompose/useDebouncedCommentMaxLengthValidation'; import useEditMessage from './ReportActionCompose/useEditMessage'; import {useReportActionActiveEdit, useReportActionActiveEditActions} from './ReportActionEditMessageContext'; -import ReportActionIndexContext from './ReportActionIndexContext'; +import ReportActionIndexContext, {ReportActionScrollToNewestContext} from './ReportActionIndexContext'; import shouldUseEmojiPickerSelection from './shouldUseEmojiPickerSelection'; import useDebouncedSaveDraft from './useDebouncedSaveDraft'; import useDraftMessageVideoAttributeCache from './useDraftMessageVideoAttributeCache'; @@ -77,7 +77,8 @@ const DEFAULT_MODAL_VALUE = { }; function ReportActionItemMessageEdit({action, reportID, originalReportID, policyID, ref}: ReportActionItemMessageEditProps) { - const index = useContext(ReportActionIndexContext); + const {index, isNewest} = useContext(ReportActionIndexContext); + const scrollToNewestAction = useContext(ReportActionScrollToNewestContext); const [preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE] = useOnyx(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`); @@ -249,7 +250,8 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy reportID, originalReportID, reportAction: action, - shouldScrollToLastMessage: index === 0, + shouldScrollToLastMessage: isNewest, + scrollToLastMessage: scrollToNewestAction, debouncedCommentMaxLengthValidation, composerRef, }); diff --git a/src/pages/inbox/report/ReportActionsList.tsx b/src/pages/inbox/report/ReportActionsList.tsx index 1f471b3fb6a3..b247b9c00e0b 100644 --- a/src/pages/inbox/report/ReportActionsList.tsx +++ b/src/pages/inbox/report/ReportActionsList.tsx @@ -66,7 +66,7 @@ import {isTrackIntentUserSelector} from '@selectors/Onboarding'; import React, {useEffect, useRef, useState} from 'react'; import FloatingMessageCounter from './FloatingMessageCounter'; -import ReportActionIndexContext from './ReportActionIndexContext'; +import {ReportActionPositionContextProvider} from './ReportActionIndexContext'; import {useReportActionsListActions, useReportActionsListState} from './ReportActionsListContext'; import ReportActionsListHeader from './ReportActionsListHeader'; import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; @@ -366,7 +366,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; return ( - + )} - + ); }; diff --git a/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx b/tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx index da7168b6e9c3..b99fb74af778 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 mockIsNewestReportAction: 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(() => { + mockIsNewestReportAction = ReactActual.useContext(ReportActionIndexContextActual).isNewest; + 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(); + mockIsNewestReportAction = 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(mockIsNewestReportAction).toBe(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/ReportActionItemMessageEditTest.tsx b/tests/ui/ReportActionItemMessageEditTest.tsx index 302edd2eb643..e7468c15412d 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 {ReportActionPositionContextProvider, 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,20 @@ 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 +218,25 @@ 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); + }); + + it('should not scroll after saving a non-newest message at index zero', () => { + const scrollToNewestAction = jest.fn(); + renderReportActionItemMessageEdit(undefined, {index: 0, isNewest: false, scrollToNewestAction}); + + fireEvent.changeText(screen.getByTestId('composer'), 'Edited message'); + fireEvent.press(screen.getByLabelText('common.saveChanges')); + + expect(scrollToNewestAction).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/hooks/useEditMessage.test.ts b/tests/unit/hooks/useEditMessage.test.ts index aebd3cdc0186..e945ebf00b19 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 mockScrollToIndex = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, - default: () => ({scrollToIndex: jest.fn()}), + default: () => ({scrollToIndex: mockScrollToIndex}), })); jest.mock('@libs/ReportUtils', () => { @@ -142,4 +143,44 @@ describe('useEditMessage', () => { const args = mockShowDeleteModal.mock.calls.at(0); expect(args?.[1]?.reportActionID).toBe(props.reportAction?.reportActionID); }); + + it('scrolls to index zero after deleting the newest message draft without a list-specific callback', () => { + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: true}); + + act(() => { + hook.result.current.publishDraft(' '); + }); + act(() => { + mockShowDeleteModal.mock.calls.at(0)?.[3]?.(); + }); + + expect(mockScrollToIndex).toHaveBeenCalledWith(0); + }); + + it('uses the list-specific 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(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('does not scroll after deleting a non-newest message draft', () => { + const scrollToLastMessage = jest.fn(); + const {hook} = renderUseEditMessage({shouldScrollToLastMessage: false, scrollToLastMessage}); + + act(() => { + hook.result.current.deleteDraft(); + }); + + expect(scrollToLastMessage).not.toHaveBeenCalled(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); });