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();
+ });
});