Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
821ecd7
Keep ready exports on reload instead of clearing them
mollfpr Aug 10, 2026
29c6b16
Show export status in one app-level modal instead of per screen
mollfpr Aug 10, 2026
7ca6857
Update export tests for the app-level status modal
mollfpr Aug 10, 2026
548a32e
Skip Concierge hand-offs in the export status manager
mollfpr Aug 13, 2026
5de1e87
Fix the import type annotation in the useExportActions test
mollfpr Aug 13, 2026
0901054
Merge remote-tracking branch 'origin/main' into mollfpr-export-status…
mollfpr Aug 13, 2026
79f147a
Merge branch 'main' of github.com:Expensify/App into mollfpr-export-s…
mollfpr Aug 13, 2026
4f021f3
keep the concierge export confirmation and remember it was dismissed
mollfpr Aug 16, 2026
e2639d9
show the concierge confirmation modal and open concierge in the side …
mollfpr Aug 16, 2026
5e3b37d
Merge branch 'main' of github.com:Expensify/App into mollfpr-export-s…
mollfpr Aug 16, 2026
6e94511
Never clear a Concierge export record on dismiss
mollfpr Aug 16, 2026
7d5a57a
Never clear a concierge export record from the frontend
mollfpr Aug 16, 2026
06a0792
Add test for the Concierge handoff
mollfpr Aug 16, 2026
4893468
Add comment back
mollfpr Aug 16, 2026
cb1aed7
Keyed the modal by exportID
mollfpr Aug 16, 2026
9664283
Merge main and route receipts export through the status manager, drop…
mollfpr Aug 17, 2026
363fb9d
Only auto-download exports on the leader tab to avoid duplicates acro…
mollfpr Aug 17, 2026
b6ac844
rewrite comments as plain sentences without jargon
mollfpr Aug 17, 2026
697be31
Merge remote-tracking branch 'origin/main' into mollfpr-export-status…
mollfpr Aug 18, 2026
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
67 changes: 67 additions & 0 deletions src/components/ExportDownloadStatusManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import useOnyx from '@hooks/useOnyx';

import {clearExportDownload, markExportDownloadSurfaced} from '@libs/actions/Export';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

import React from 'react';

import ExportDownloadStatusModal from './ExportDownloadStatusModal';

/**
* Renders the queued export status modal for the whole app. It watches the export collection and shows the modal
* for the active export (preparing, ready, or failed). Because it is the single owner of the modal and reads
* straight from Onyx, a screen only has to start an export (which writes its record); this shows the progress,
* delivers the file when it is ready, and still surfaces it after a reload or once the screen that started it is
* gone. There is no per-screen modal to coordinate with.
*/
function ExportDownloadStatusManager() {
const [exportDownloads] = useOnyx(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD);

const activeEntry = Object.entries(exportDownloads ?? {}).find(([, exportDownload]) => {
Comment thread
mollfpr marked this conversation as resolved.
Comment on lines +20 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Render export status only in the owning tab

When the same account is open in multiple browser tabs, this shared Onyx collection causes every AuthScreens instance to select the export and render the covering modal, so an export started in one tab still interrupts every other tab and a follower can dismiss or hand off the initiator's export. Fresh evidence is that the new isClientTheLeader() guard only suppresses the automatic fileDownload; it does not prevent this manager from rendering in follower tabs. Scope the manager to the owning/leader client rather than merely gating the download effect.

Useful? React with 👍 / 👎.

if (!exportDownload) {
return false;
}
if (exportDownload.shouldSendFromConcierge) {
return !exportDownload.hasBeenSurfaced;
}
return (
exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING ||
exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.READY ||
exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.FAILED
Comment thread
mollfpr marked this conversation as resolved.
);
});

if (!activeEntry) {
return null;
}

const exportID = activeEntry[0].replace(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, '');
const exportDownload = activeEntry[1];

const handleClose = () => {
if (exportDownload?.shouldSendFromConcierge) {
markExportDownloadSurfaced(exportID);
return;
}
// The modal already blocks dismissal while preparing, so this is an extra guard.
if (exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING) {
return;
}
clearExportDownload(exportID, exportDownload);
};

Comment thread
mollfpr marked this conversation as resolved.
return (
<ExportDownloadStatusModal
key={exportID}
exportID={exportID}
isVisible
onClose={handleClose}
Comment thread
mollfpr marked this conversation as resolved.
/>
);
}

ExportDownloadStatusManager.displayName = 'ExportDownloadStatusManager';

export default ExportDownloadStatusManager;
10 changes: 6 additions & 4 deletions src/components/ExportDownloadStatusModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import usePreviousDefined from '@hooks/usePreviousDefined';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';

import {isClientTheLeader} from '@libs/ActiveClientManager';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import {isMobileSafari} from '@libs/Browser';
import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment';
import fileDownload from '@libs/fileDownload';
import {buildSecureDownloadURL} from '@libs/UrlUtils';

import {sendExportFileFromConcierge} from '@userActions/Export';
import {close} from '@userActions/Modal';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -71,7 +73,7 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E

// Build the secure download URL the same way downloadReportPDF does, so the host always follows
// the app's current environment (instead of the env baked into a backend-built URL) and authenticates
// via the encryptedAuthToken no separate OldDot sign-in needed.
// via the encryptedAuthToken, so no separate OldDot sign-in is needed.
const downloadFile = () => {
if (!fileName || !currentUserLogin) {
return;
Expand All @@ -84,7 +86,8 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E
};

useEffect(() => {
if (!isReady || !fileName || shouldSendFromConcierge || isEmptyReceipts) {
// Only the leader tab auto-downloads, so a ready export isn't downloaded once per open tab.
if (!isReady || !fileName || shouldSendFromConcierge || isEmptyReceipts || !isClientTheLeader()) {
return;
}
downloadFile();
Expand All @@ -97,8 +100,7 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E
const {openConciergeAnywhere} = useOpenConciergeAnywhere();

const handleGoToConcierge = () => {
onClose();
openConciergeAnywhere({forceConcierge: true});
close(() => openConciergeAnywhere({forceConcierge: true}));
};

const handleDownloadFile = () => {
Expand Down
17 changes: 7 additions & 10 deletions src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {View} from 'react-native';
import HeaderLoadingBar from './HeaderLoadingBar';
import HeaderWithBackButton from './HeaderWithBackButton';
import MoneyReportHeaderActions from './MoneyReportHeaderActions';
import {ExportDownloadStatusProvider} from './MoneyReportHeaderActions/ExportDownloadStatusProvider';
import MoneyReportHeaderModals from './MoneyReportHeaderModals';
import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent';
import {PaymentAnimationsProvider} from './PaymentAnimationsContext';
Expand All @@ -44,15 +43,13 @@ type MoneyReportHeaderProps = {
function MoneyReportHeader({reportID, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) {
return (
<MoneyReportHeaderModals reportID={reportID}>
<ExportDownloadStatusProvider>
<PaymentAnimationsProvider>
<MoneyReportHeaderContent
reportID={reportID}
shouldDisplayBackButton={shouldDisplayBackButton}
onBackButtonPress={onBackButtonPress}
/>
</PaymentAnimationsProvider>
</ExportDownloadStatusProvider>
<PaymentAnimationsProvider>
<MoneyReportHeaderContent
reportID={reportID}
shouldDisplayBackButton={shouldDisplayBackButton}
onBackButtonPress={onBackButtonPress}
/>
</PaymentAnimationsProvider>
</MoneyReportHeaderModals>
);
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler';
import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext';

import useConfirmModal from '@hooks/useConfirmModal';
import useExportDownloadStatusModal from '@hooks/useExportDownloadStatusModal';
import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions';
import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
Expand Down Expand Up @@ -82,7 +81,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool

const isMobileSelectionModeEnabled = useMobileSelectionMode();
const {showConfirmModal} = useConfirmModal();
const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal(() => clearSelectedTransactions(true));

const [offlineModalVisible, setOfflineModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
Expand All @@ -100,7 +98,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
return;
}

const exportID = queueExportSearchWithTemplate(
queueExportSearchWithTemplate(
{
templateName,
templateType,
Expand All @@ -112,7 +110,8 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
},
true,
);
trackExport(exportID);
// Clear the selection now that the export has started; the app-level ExportDownloadStatusManager shows the modal.
Comment thread
mollfpr marked this conversation as resolved.
clearSelectedTransactions(true);
};

const onDeleteSelected = (handleDeleteTransactions: () => void, handleDeleteTransactionsWithNavigation: (backToRoute?: Route) => void) => {
Expand Down Expand Up @@ -238,7 +237,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool

return (
<>
{exportDownloadStatusModal}
{isDuplicateOptionVisible && (
<BulkDuplicateHandler
selectedTransactionsKeys={selectedTransactionIDs}
Expand Down
2 changes: 0 additions & 2 deletions src/components/Search/SearchBulkActionsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
handleExpensifyCardStatementPDFModalHide,
isExpensifyCardStatementMultiFeedAlertVisible,
handleExpensifyCardStatementMultiFeedAlertClose,
exportDownloadStatusModal,
dismissModalAndUpdateUseHold,
dismissRejectModalBasedOnAction,
isDuplicateOptionVisible,
Expand Down Expand Up @@ -339,7 +338,6 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
isDM={areAllTransactionsFromDMReports}
/>
)}
{exportDownloadStatusModal}
</>
);
}
Expand Down
13 changes: 7 additions & 6 deletions src/hooks/useExportActions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import {useExportDownloadStatus} from '@components/MoneyReportHeaderActions/ExportDownloadStatusProvider';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import {useSearchSelectionActions} from '@components/Search/SearchContext';

import {exportReceiptsToZip} from '@libs/actions/Export';
import {openOldDotLink} from '@libs/actions/Link';
Expand Down Expand Up @@ -83,7 +83,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa

const {showDecisionModal} = useDecisionModal();
const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID);
const {trackExport} = useExportDownloadStatus();
const {clearSelectedTransactions} = useSearchSelectionActions();

const expensifyIcons = useMemoizedLazyExpensifyIcons([
'Table',
Expand Down Expand Up @@ -129,7 +129,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa
return;
}

const exportID = queueExportSearchWithTemplate(
queueExportSearchWithTemplate(
{
templateName,
templateType,
Expand All @@ -141,7 +141,8 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa
},
true,
);
trackExport(exportID);
// Clear the selection now that the export has started. The app-level ExportDownloadStatusManager shows the modal.
clearSelectedTransactions(true);
};

const exportSubmenuOptions: Record<string, DropdownOption<string>> = {
Expand Down Expand Up @@ -291,8 +292,8 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa
if (!moneyRequestReport?.reportID) {
return;
}
const exportID = exportReceiptsToZip({reportIDs: [moneyRequestReport.reportID]});
trackExport(exportID);
exportReceiptsToZip({reportIDs: [moneyRequestReport.reportID]});
clearSelectedTransactions(true);
},
},
[CONST.REPORT.SECONDARY_ACTIONS.PRINT]: {
Expand Down
56 changes: 0 additions & 56 deletions src/hooks/useExportDownloadStatusModal.tsx

This file was deleted.

Loading
Loading