Skip to content
Open
35 changes: 32 additions & 3 deletions src/hooks/usePaginatedReportActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,27 @@ type UsePaginatedReportActionsOptions = {
* anchor from ever resolving. Scoped to Concierge so regular inbox chat pagination keeps the first-render ref behavior.
*/
shouldSnapshotInitialLastReadTime?: boolean;

/**
* When true, the linked `reportActionID` is known to live in the one-transaction thread that gets merged into this
* report, so we drop the pagination anchor and render the newest window (the merged view surfaces the linked action).
* This must NOT be set merely because the action is absent from this report's cache — an action that belongs to this
* report but hasn't been fetched yet still needs the anchor so the list scrolls to it once `OpenReport` hydrates it
* (https://github.com/Expensify/App/issues/86919).
*/
isLinkedActionInMergedTransactionThread?: boolean;
};

/**
* Get the longest continuous chunk of reportActions including the linked reportAction. If not linking to a specific action, returns the continuous chunk of newest reportActions.
*/
function usePaginatedReportActions(reportID: string | undefined, reportActionID?: string, options?: UsePaginatedReportActionsOptions) {
const {shouldLinkToOldestUnreadReportAction = false, treatAsNoPaginationAnchor = false, shouldSnapshotInitialLastReadTime = false} = options ?? {};
const {
shouldLinkToOldestUnreadReportAction = false,
treatAsNoPaginationAnchor = false,
shouldSnapshotInitialLastReadTime = false,
isLinkedActionInMergedTransactionThread = false,
} = options ?? {};

const nonEmptyStringReportID = getNonEmptyStringOnyxID(reportID);
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${nonEmptyStringReportID}`);
Expand Down Expand Up @@ -67,7 +81,14 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID?
}

if (reportActionID) {
return reportActionID;
// Only drop the anchor when the linked action is known to live in the one-transaction thread merged into this
// report. There getContinuousChain would otherwise return an empty page (the action isn't in this report's own
// actions), hiding the parent-level "Submitted" system message; rendering the newest window instead lets the
// merged view render and ReportActionsList's initialScrollKey anchors to the linked message. We must NOT drop the
// anchor just because the action is absent from this report's cache — an action that belongs to this report but
// hasn't loaded yet still needs the anchor so the list scrolls to it once OpenReport hydrates it
// (https://github.com/Expensify/App/issues/86919).
return isLinkedActionInMergedTransactionThread ? undefined : reportActionID;
}

if (!shouldLinkToOldestUnreadReportAction) {
Expand All @@ -81,7 +102,15 @@ function usePaginatedReportActions(reportID: string | undefined, reportActionID?

return sortedAllReportActions.findLast((reportAction) => reportAction.created > initialLastReadTime)?.reportActionID;
/* eslint-enable react-hooks/refs */
}, [treatAsNoPaginationAnchor, reportActionID, shouldLinkToOldestUnreadReportAction, sortedAllReportActions, shouldSnapshotInitialLastReadTime, firstDefinedLastReadTime]);
}, [
treatAsNoPaginationAnchor,
reportActionID,
isLinkedActionInMergedTransactionThread,
shouldLinkToOldestUnreadReportAction,
sortedAllReportActions,
shouldSnapshotInitialLastReadTime,
firstDefinedLastReadTime,
]);

const {
data: reportActions,
Expand Down
13 changes: 12 additions & 1 deletion src/hooks/useReportActionsPagination.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {getReportPreviewAction} from '@libs/actions/IOU/MoneyRequestBuilder';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getCombinedReportActions, getFilteredReportActionsForReportView, isCreatedAction} from '@libs/ReportActionsUtils';
import {getCombinedReportActions, getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isCreatedAction} from '@libs/ReportActionsUtils';
import {isConciergeChatReport, isInvoiceReport, isMoneyRequestReport, isReportTransactionThread as isReportTransactionThreadUtil, shouldReportAlignToTop} from '@libs/ReportUtils';

import getReportActionsToDisplay from '@pages/inbox/report/getReportActionsToDisplay';
Expand Down Expand Up @@ -46,6 +46,16 @@ function useReportActionsPagination(reportID: string | undefined, reportActionID

const shouldBeAlignedToTop = shouldReportAlignToTop(report, parentReportAction);

// Resolve whether the linked action lives in the one-transaction thread that gets merged into this report. Only in that
// case should usePaginatedReportActions drop the pagination anchor — dropping it merely because the action is absent from
// this report's cache would break the initial scroll-to for a valid linked action that simply hasn't been fetched into
// this report yet (e.g. an older message). A non-one-transaction report resolves to undefined here, so its anchor is kept.
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`);
const [reportActionsForThreadCheck] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`);
const linkedActionTransactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActionsForThreadCheck ?? {}, isOffline);
const [linkedActionTransactionThreadActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(linkedActionTransactionThreadReportID)}`);
const isLinkedActionInMergedTransactionThread = !!reportActionIDFromRoute && !!linkedActionTransactionThreadActions?.[reportActionIDFromRoute];

const {
reportActions: unfilteredReportActions,
hasOlderActions,
Expand All @@ -58,6 +68,7 @@ function useReportActionsPagination(reportID: string | undefined, reportActionID
// Scope the first-defined lastReadTime snapshot to Concierge so the cold-open unread anchor resolves
// (https://github.com/Expensify/App/issues/93196) without changing regular inbox chat pagination.
shouldSnapshotInitialLastReadTime: isConciergeChat,
isLinkedActionInMergedTransactionThread,
});
const allReportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]);

Expand Down
15 changes: 13 additions & 2 deletions src/pages/inbox/LinkedActionNotFoundGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import useReportIsArchived from '@hooks/useReportIsArchived';

import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import Navigation from '@libs/Navigation/Navigation';
import {isReportActionVisible, isWhisperAction} from '@libs/ReportActionsUtils';
import {getOneTransactionThreadReportID, isReportActionVisible, isWhisperAction} from '@libs/ReportActionsUtils';
import {canUserPerformWriteAction} from '@libs/ReportUtils';

import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -57,14 +58,24 @@ function LinkedActionNotFoundGate({reportActionIDFromRoute, children}: LinkedAct
const reportIDFromRoute = getNonEmptyStringOnyxID(routeParams?.reportID);

const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
const {isOffline} = useNetwork();

const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`);
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.chatReportID)}`);
const [isLoadingInitialReportActions = true] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`, {
selector: isLoadingInitialReportActionsSelector,
});
const [linkedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDFromRoute}`, {
const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDFromRoute}`);
const [linkedActionInRoute] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportIDFromRoute}`, {
selector: (actions: OnyxEntry<ReportActions>) => getReportActionByIDSelector(actions, reportActionIDFromRoute),
});

const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? {}, isOffline);
const [linkedActionInTransactionThread] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(transactionThreadReportID)}`, {
selector: (actions: OnyxEntry<ReportActions>) => getReportActionByIDSelector(actions, reportActionIDFromRoute),
});

const linkedAction = linkedActionInRoute ?? linkedActionInTransactionThread;
const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS);

const isReportArchived = useReportIsArchived(reportIDFromRoute);
Expand Down
6 changes: 4 additions & 2 deletions src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ import {
changeMoneyRequestHoldStatus,
getChildReportNotificationPreference as getChildReportNotificationPreferenceReportUtils,
getDeletedTransactionMessage,
getDisplayedReportID,
getIOUReportActionDisplayMessage,
getMovedActionMessage,
getMovedTransactionMessage,
Expand Down Expand Up @@ -1383,10 +1384,11 @@ const ContextMenuActions: ContextMenuAction[] = [
const isDynamicWorkflowRoutedAction = isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.DYNAMIC_EXTERNAL_WORKFLOW_ROUTED);
return type === CONST.CONTEXT_MENU_TYPES.REPORT_ACTION && !isAttachmentTarget && !isMessageDeleted(reportAction) && !isDynamicWorkflowRoutedAction;
},
onPress: (closePopover, {reportAction, originalReportID}) => {
onPress: (closePopover, {reportAction, originalReportID, isOffline}) => {
getEnvironmentURL().then((environmentURL) => {
const reportActionID = reportAction?.reportActionID;
Clipboard.setString(`${environmentURL}/r/${originalReportID}/${reportActionID}`);
const reportID = originalReportID ? getDisplayedReportID(originalReportID, isOffline) : originalReportID;
Comment thread
abbasifaizan70 marked this conversation as resolved.
Outdated
Clipboard.setString(`${environmentURL}/r/${reportID}/${reportActionID}`);
});
hideContextMenu(true, ReportActionComposeFocusManager.focus);
},
Expand Down
108 changes: 108 additions & 0 deletions tests/unit/ContextMenuActionsCopyLinkTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import Clipboard from '@libs/Clipboard';
import type * as EnvironmentModule from '@libs/Environment/Environment';
import {getDisplayedReportID} from '@libs/ReportUtils';
// eslint-disable-next-line no-restricted-imports -- type-only namespace (erased at runtime) used solely for the requireActual generic; no restricted functions are actually imported
import type * as ReportUtilsModule from '@libs/ReportUtils';

import ContextMenuActions from '@pages/inbox/report/ContextMenu/ContextMenuActions';
import type {ContextMenuActionPayload} from '@pages/inbox/report/ContextMenu/ContextMenuActions';

import CONST from '@src/CONST';

import createRandomReportAction from '../utils/collections/reportActions';

// Verifies the "Copy link" context menu action (change A for issue #86919): for one-transaction
// expense flows the copied link must use the DISPLAYED (parent expense) report ID rather than the
// transaction thread's `originalReportID`, so the link opens the combined view where the parent
// "Submitted" system message is present and the linked message can be scrolled to.

jest.mock(
'expo-web-browser',
() => ({
openAuthSessionAsync: jest.fn(),
}),
{virtual: true},
);

jest.mock('@components/Reactions/MiniQuickEmojiReactions', () => 'MiniQuickEmojiReactions');
jest.mock('@components/Reactions/QuickEmojiReactions', () => 'QuickEmojiReactions');

jest.mock('@libs/Clipboard', () => ({
__esModule: true,
default: {
canSetHtml: jest.fn(),
setString: jest.fn(),
setHtml: jest.fn(),
},
}));

jest.mock('@libs/Environment/Environment', () => ({
__esModule: true,
...jest.requireActual<typeof EnvironmentModule>('@libs/Environment/Environment'),
getEnvironmentURL: jest.fn(() => Promise.resolve('https://new.expensify.com')),
}));

jest.mock('@libs/ReportUtils', () => ({
__esModule: true,
...jest.requireActual<typeof ReportUtilsModule>('@libs/ReportUtils'),
getDisplayedReportID: jest.fn(),
}));

const mockClipboard = jest.mocked(Clipboard);
const mockGetDisplayedReportID = jest.mocked(getDisplayedReportID);

// ContextMenuAction is a union; sentryLabel/onPress only exist on the icon variant, so narrow with `in`.
const copyLinkAction = ContextMenuActions.find((action) => 'sentryLabel' in action && action.sentryLabel === CONST.SENTRY_LABEL.CONTEXT_MENU.COPY_LINK);

// Flush the microtasks queued by getEnvironmentURL().then(...) inside the onPress handler.
const flushPromises = () =>
new Promise((resolve) => {
process.nextTick(resolve);
});

function createPayload(overrides: Partial<ContextMenuActionPayload>): ContextMenuActionPayload {
// The copy-link handler only reads reportAction, originalReportID, and isOffline; the rest of the
// (large) payload type is irrelevant to this action, so we assert the minimal shape it needs.
const payload = {
reportAction: {...createRandomReportAction(1), reportActionID: 'action-1'},
originalReportID: 'transaction-thread-1',
isOffline: false,
...overrides,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test payload only needs the fields the copy-link handler reads
return payload as ContextMenuActionPayload;
}

describe('ContextMenuActions copy link', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('copies a link using the displayed (parent) report ID for one-transaction expense flows', async () => {
mockGetDisplayedReportID.mockReturnValue('parent-expense-1');

if (!copyLinkAction || !('onPress' in copyLinkAction)) {
throw new Error('Copy link context menu action was not found');
}

copyLinkAction.onPress(true, createPayload({originalReportID: 'transaction-thread-1', isOffline: false}));
await flushPromises();

// The displayed report ID is resolved from the original (transaction-thread) report ID and the offline flag.
expect(mockGetDisplayedReportID).toHaveBeenCalledWith('transaction-thread-1', false);
// The copied link points at the parent expense report, not the transaction thread.
expect(mockClipboard.setString).toHaveBeenCalledWith('https://new.expensify.com/r/parent-expense-1/action-1');
});

it('does not resolve a displayed report ID when there is no original report ID', async () => {
if (!copyLinkAction || !('onPress' in copyLinkAction)) {
throw new Error('Copy link context menu action was not found');
}

copyLinkAction.onPress(true, createPayload({originalReportID: undefined}));
await flushPromises();

expect(mockGetDisplayedReportID).not.toHaveBeenCalled();
expect(mockClipboard.setString).toHaveBeenCalledTimes(1);
});
});
Loading
Loading