Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<ReportActionIndexContext.Provider value={indexWithinReportActions}>
<ReportActionsListItemRenderer
reportAction={reportAction}
parentReportAction={parentReportAction}
parentReportActionForTransactionThread={EmptyParentReportActionForTransactionThread}
report={reportStable}
transactionThreadReport={transactionThreadReport}
chatReport={chatReport}
displayAsGroup={displayAsGroup}
shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID}
shouldDisplayReplyDivider={visibleReportActions.length > 1}
isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID}
shouldHideThreadDividerLine
linkedReportActionID={linkedReportActionID}
isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction}
shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft}
/>
</ReportActionIndexContext.Provider>
<ReportActionScrollToNewestContext.Provider value={scrollToBottom}>
<ReportActionPositionContextProvider
index={indexWithinReportActions}
isNewest={isNewestReportAction}
>
<ReportActionsListItemRenderer
reportAction={reportAction}
parentReportAction={parentReportAction}
parentReportActionForTransactionThread={EmptyParentReportActionForTransactionThread}
report={reportStable}
transactionThreadReport={transactionThreadReport}
chatReport={chatReport}
displayAsGroup={displayAsGroup}
shouldDisplayNewMarker={reportAction.reportActionID === unreadMarkerReportActionID}
shouldDisplayReplyDivider={visibleReportActions.length > 1}
isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID}
shouldHideThreadDividerLine
linkedReportActionID={linkedReportActionID}
isHarvestCreatedExpenseReport={shouldShowHarvestCreatedAction}
shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft}
/>
</ReportActionPositionContextProvider>
</ReportActionScrollToNewestContext.Provider>
);
},
[
Expand All @@ -671,6 +677,7 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
shouldShowHarvestCreatedAction,
draftReportActionID,
isDraftPendingCompletion,
scrollToBottom,
],
);

Expand Down
22 changes: 19 additions & 3 deletions src/pages/inbox/report/ReportActionCompose/useEditMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,23 @@ type UseEditMessageProps = {
originalReportID: string | undefined;
reportAction: OnyxTypes.ReportAction | null | undefined;
shouldScrollToLastMessage?: boolean;
scrollToLastMessage?: () => void;
debouncedCommentMaxLengthValidation: DebouncedFuncLeading<(value: string) => boolean>;
composerRef: React.RefObject<ComposerRef | null>;
};

/**
* 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();
Expand All @@ -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);
}

/**
Expand Down
27 changes: 21 additions & 6 deletions src/pages/inbox/report/ReportActionIndexContext.tsx
Original file line number Diff line number Diff line change
@@ -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<number>(0);
const ReportActionIndexContext = createContext<ReportActionPosition>({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<ReportActionPosition>) {
return <ReportActionIndexContext.Provider value={{index, isNewest}}>{children}</ReportActionIndexContext.Provider>;
}

export {ReportActionPositionContextProvider, ReportActionScrollToNewestContext};
export default ReportActionIndexContext;
8 changes: 5 additions & 3 deletions src/pages/inbox/report/ReportActionItemMessageEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)}`);
Expand Down Expand Up @@ -249,7 +250,8 @@ function ReportActionItemMessageEdit({action, reportID, originalReportID, policy
reportID,
originalReportID,
reportAction: action,
shouldScrollToLastMessage: index === 0,
shouldScrollToLastMessage: isNewest,
scrollToLastMessage: scrollToNewestAction,
debouncedCommentMaxLengthValidation,
composerRef,
});
Expand Down
9 changes: 6 additions & 3 deletions src/pages/inbox/report/ReportActionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -366,7 +366,10 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct
const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID;

return (
<ReportActionIndexContext.Provider value={index}>
<ReportActionPositionContextProvider
index={index}
isNewest={index === 0}
>
<ReportActionsListItemRenderer
reportAction={reportAction}
parentReportAction={parentReportAction}
Expand Down Expand Up @@ -396,7 +399,7 @@ function ReportActionsListContent({reportID, conciergeChat, onLayout}: ReportAct
onPress={onShowPreviousMessages}
/>
)}
</ReportActionIndexContext.Provider>
</ReportActionPositionContextProvider>
);
};

Expand Down
101 changes: 93 additions & 8 deletions tests/ui/MoneyRequestReportActionsListRejectModalTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<typeof NativeNavigation>('@react-navigation/native'),
Expand Down Expand Up @@ -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}) => (
<View testID="MockMoneyRequestReportTransactionList">
{isLoadingInitialActions ? <View testID="MockInitialReportActionsSkeleton" /> : null}
{listFooterComponent}
</View>
);
const ReactActual = jest.requireActual<typeof React>('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 (
<View testID="MockMoneyRequestReportTransactionList">
{isLoadingInitialActions ? <View testID="MockInitialReportActionsSkeleton" /> : null}
{newestAction ? renderReportAction(newestAction, visibleReportActions.length - 1) : null}
{listFooterComponent}
</View>
);
};
});

jest.mock('@components/MoneyRequestReportView/SearchMoneyRequestReportEmptyState', () => {
Expand Down Expand Up @@ -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<typeof React>('react');
const {default: ReportActionIndexContextActual, ReportActionScrollToNewestContext: ReportActionScrollToNewestContextActual} =
jest.requireActual<typeof ReportActionIndexContexts>('@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));

Expand Down Expand Up @@ -223,9 +268,23 @@ const mockReportAction = createMock<ReportAction<typeof CONST.REPORT.ACTIONS.TYP
childReportID: 'CHILD_001',
});

const mockCommentReportAction: ReportAction = {
reportActionID: 'ACTION_002',
reportID: FAKE_REPORT_ID,
actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
created: '2025-01-02 00:00:00',
actorAccountID: FAKE_ACCOUNT_ID,
message: [{type: 'COMMENT', html: 'comment', text: 'comment'}],
originalMessage: {},
shouldShow: true,
person: [{type: 'TEXT', style: 'strong', text: 'Test User'}],
pendingAction: null,
errors: {},
};

const renderComponent = () => {
return render(
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
<ComposeProviders components={[ActionListContextProvider, OnyxListItemProvider, LocaleContextProvider]}>
<SearchContextProvider>
<ScreenWrapper testID="test">
<MoneyRequestReportActionsList />
Expand All @@ -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});
Expand All @@ -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({
Expand Down
Loading
Loading