Skip to content

Commit a23ae1b

Browse files
authored
Merge pull request #88095 from TaduJR/fix-Add-updated-automatic/scan-flow-to-native-share-sheet-creation-flow
[CP Staging] fix: share sheet upload edited receipt and keep Category after upgrade
2 parents a611c16 + cbd3dc0 commit a23ae1b

4 files changed

Lines changed: 142 additions & 61 deletions

File tree

src/components/MoneyRequestConfirmationList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ function MoneyRequestConfirmationList({
323323

324324
// A flag for showing the categories field
325325
const shouldShowCategories = isTrackExpense
326-
? !policy || shouldSelectPolicy || hasEnabledOptions(Object.values(policyCategories ?? {}))
326+
? !policy || shouldSelectPolicy || !!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {}))
327327
: (isPolicyExpenseChat || isTypeInvoice) && (!!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {})));
328328

329329
const shouldShowMerchant = (shouldShowSmartScanFields || isTypeSend) && !isDistanceRequest && !isPerDiemRequest && (!isTimeRequest || action !== CONST.IOU.ACTION.CREATE);

src/pages/Share/ShareDetailsPage.tsx

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ import type SCREENS from '@src/SCREENS';
3737
import type {Report as ReportType} from '@src/types/onyx';
3838
import {isEmptyObject} from '@src/types/utils/EmptyObject';
3939
import KeyboardUtils from '@src/utils/keyboard';
40-
import getFileSize from './getFileSize';
4140
import ShareButton from './ShareButton';
4241
import {showErrorAlert} from './ShareRootPage';
42+
import useShareFileSizeValidation from './useShareFileSizeValidation';
4343

4444
type ShareDetailsPageProps = StackScreenProps<ShareNavigatorParamList, typeof SCREENS.SHARE.SHARE_DETAILS>;
4545

@@ -97,22 +97,7 @@ function ShareDetailsPage({route}: ShareDetailsPageProps) {
9797
Navigation.navigate(ROUTES.SHARE_DETAILS_ATTACHMENT);
9898
}, [reportAttachmentsContext, fileSource, validateFileName, icons.FallbackAvatar]);
9999

100-
useEffect(() => {
101-
if (!currentAttachment?.content || errorTitle || !shouldShowAttachment) {
102-
return;
103-
}
104-
getFileSize(currentAttachment?.content).then((size) => {
105-
if (size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
106-
setErrorTitle(translate('attachmentPicker.attachmentTooLarge'));
107-
setErrorMessage(translate('attachmentPicker.sizeExceeded'));
108-
}
109-
110-
if (size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
111-
setErrorTitle(translate('attachmentPicker.attachmentTooSmall'));
112-
setErrorMessage(translate('attachmentPicker.sizeNotMet'));
113-
}
114-
});
115-
}, [currentAttachment?.content, errorTitle, translate, shouldShowAttachment]);
100+
useShareFileSizeValidation(currentAttachment?.content, setErrorTitle, setErrorMessage, !errorTitle && shouldShowAttachment);
116101

117102
useEffect(() => {
118103
if (!errorTitle || !errorMessage) {

src/pages/Share/SubmitDetailsPage.tsx

Lines changed: 101 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type {StackScreenProps} from '@react-navigation/stack';
22
import {hasSeenTourSelector} from '@selectors/Onboarding';
33
import {validTransactionDraftsSelector} from '@selectors/TransactionDraft';
4-
import React, {useCallback, useEffect, useMemo, useState} from 'react';
4+
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
55
import {View} from 'react-native';
66
import type {OnyxEntry} from 'react-native-onyx';
77
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
@@ -15,6 +15,7 @@ import useNetwork from '@hooks/useNetwork';
1515
import useOnyx from '@hooks/useOnyx';
1616
import usePermissions from '@hooks/usePermissions';
1717
import usePersonalPolicy from '@hooks/usePersonalPolicy';
18+
import usePolicyForTransaction from '@hooks/usePolicyForTransaction';
1819
import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap';
1920
import useReportAttributes from '@hooks/useReportAttributes';
2021
import useReportIsArchived from '@hooks/useReportIsArchived';
@@ -51,6 +52,7 @@ import type {Report as ReportType} from '@src/types/onyx';
5152
import type {Participant} from '@src/types/onyx/IOU';
5253
import type {Receipt} from '@src/types/onyx/Transaction';
5354
import {showErrorAlert} from './ShareRootPage';
55+
import useShareFileSizeValidation from './useShareFileSizeValidation';
5456

5557
type ShareDetailsPageProps = StackScreenProps<ShareNavigatorParamList, typeof SCREENS.SHARE.SUBMIT_DETAILS>;
5658
function SubmitDetailsPage({
@@ -64,8 +66,16 @@ function SubmitDetailsPage({
6466
const [personalDetails] = useOnyx(`${ONYXKEYS.PERSONAL_DETAILS_LIST}`);
6567
const report: OnyxEntry<ReportType> = getReportOrDraftReport(reportOrAccountID);
6668
const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`);
67-
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`);
6869
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`);
70+
const iouType = isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT;
71+
// Self-DM has a FAKE report policyID — usePolicyForTransaction (same hook MoneyRequestConfirmationList uses) returns the active workspace for self-DM track expense, covering the upgrade-from-free flow.
72+
const {policy} = usePolicyForTransaction({
73+
transaction,
74+
reportPolicyID: getIOURequestPolicyID(transaction, report),
75+
action: CONST.IOU.ACTION.CREATE,
76+
iouType,
77+
isPerDiemRequest: false,
78+
});
6979
const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${getIOURequestPolicyID(transaction, report)}`);
7080
const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getIOURequestPolicyID(transaction, report)}`);
7181
const [lastLocationPermissionPrompt] = useOnyx(ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT);
@@ -92,6 +102,10 @@ function SubmitDetailsPage({
92102
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
93103
const personalPolicy = usePersonalPolicy();
94104
const [startLocationPermissionFlow, setStartLocationPermissionFlow] = useState(false);
105+
const [selectedParticipantList, setSelectedParticipantList] = useState<Participant[]>([]);
106+
const [isConfirming, setIsConfirming] = useState(false);
107+
const formHasBeenSubmitted = useRef(false);
108+
const [userLocation] = useOnyx(ONYXKEYS.USER_LOCATION);
95109

96110
const [errorTitle, setErrorTitle] = useState<string | undefined>(undefined);
97111
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
@@ -128,18 +142,26 @@ function SubmitDetailsPage({
128142
// eslint-disable-next-line react-hooks/exhaustive-deps
129143
}, [reportOrAccountID, policy, personalPolicy, report, parentReport, currentDate, currentUserPersonalDetails, hasOnlyPersonalPolicies]);
130144

131-
// Set receipt on the transaction draft so isScanRequest() returns true and
132-
// compact mode, "Automatic" labels, and receipt image rendering all work correctly
133-
const receiptSource = currentAttachment?.content ?? fileUri;
134-
const receiptFileName = getFileName(currentAttachment?.content ?? '') || fileName;
135-
const receiptFileType = currentAttachment?.mimeType ?? fileType;
145+
const sharedFileSource = currentAttachment?.content ?? fileUri;
146+
const sharedFileName = getFileName(currentAttachment?.content ?? '') || fileName;
147+
const sharedFileType = currentAttachment?.mimeType ?? fileType;
136148

149+
// Seed the transaction draft so isScanRequest() returns true and compact mode / "Automatic" labels / receipt rendering work.
137150
useEffect(() => {
138-
if (!receiptSource) {
151+
if (!sharedFileSource) {
139152
return;
140153
}
141-
setMoneyRequestReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, receiptSource, receiptFileName, true, receiptFileType);
142-
}, [receiptSource, receiptFileName, receiptFileType]);
154+
setMoneyRequestReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, sharedFileSource, sharedFileName, true, sharedFileType);
155+
}, [sharedFileSource, sharedFileName, sharedFileType]);
156+
157+
// The current receipt — prefers the transaction draft (reflects Replace/Crop), falls back to the shared file; used for both display and upload so they stay in sync.
158+
const currentReceiptSource = typeof transaction?.receipt?.source === 'string' ? transaction.receipt.source : sharedFileSource;
159+
// Strip filesystem path segments without URL-decoding — getFileName() decodes via decodeURIComponent and would throw on raw filenames containing a literal '%' (e.g., "Receipt 100%.jpg").
160+
const currentReceiptName = (transaction?.receipt?.filename?.split('/').pop() ?? '') || sharedFileName;
161+
const currentReceiptType = transaction?.receipt?.type ?? sharedFileType;
162+
163+
// Validate the same source that performUpload reads — so Replace/Crop to an oversized file is still caught before submit.
164+
useShareFileSizeValidation(currentReceiptSource, setErrorTitle, setErrorMessage, !errorTitle);
143165

144166
const selectedParticipants = unknownUserDetails ? [unknownUserDetails] : getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID);
145167
const participants = selectedParticipants.map((participant) => {
@@ -152,7 +174,6 @@ function SubmitDetailsPage({
152174
const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]);
153175
const policyExpenseChatPolicyID = participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID;
154176
const senderPolicyID = participants?.find((participant) => !!participant && 'isSender' in participant && participant.isSender)?.policyID;
155-
const iouType = isSelfDM(report) ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT;
156177
const {isOffline} = useNetwork();
157178
const isCreatingTrackExpense = iouType === CONST.IOU.TYPE.TRACK;
158179

@@ -275,51 +296,72 @@ function SubmitDetailsPage({
275296
const onSuccess = (participant: Participant, file: File, locationPermissionGranted?: boolean) => {
276297
const receipt: Receipt = file;
277298
receipt.state = file && CONST.IOU.RECEIPT_STATE.SCAN_READY;
278-
if (locationPermissionGranted) {
279-
getCurrentPosition(
280-
(successData) => {
281-
finishRequestAndNavigate(participant, receipt, {
282-
lat: successData.coords.latitude,
283-
long: successData.coords.longitude,
284-
});
285-
},
286-
(errorData) => {
287-
Log.info('[SubmitDetailsPage] getCurrentPosition failed', false, errorData);
288-
finishRequestAndNavigate(participant, receipt);
289-
},
290-
);
299+
if (!locationPermissionGranted) {
300+
finishRequestAndNavigate(participant, receipt);
301+
return;
302+
}
303+
// Use cached userLocation when available — avoids an extra getCurrentPosition round-trip.
304+
if (userLocation) {
305+
finishRequestAndNavigate(participant, receipt, {
306+
lat: userLocation.latitude,
307+
long: userLocation.longitude,
308+
});
309+
return;
310+
}
311+
getCurrentPosition(
312+
(successData) => {
313+
finishRequestAndNavigate(participant, receipt, {
314+
lat: successData.coords.latitude,
315+
long: successData.coords.longitude,
316+
});
317+
},
318+
(errorData) => {
319+
Log.info('[SubmitDetailsPage] getCurrentPosition failed', false, errorData);
320+
finishRequestAndNavigate(participant, receipt);
321+
},
322+
);
323+
};
324+
325+
// Extracted from onConfirm — re-entering onConfirm from the permission modal deadlocked when OS permission was pre-granted.
326+
const performUpload = (participant: Participant, locationPermissionGranted: boolean) => {
327+
if (formHasBeenSubmitted.current || !currentAttachment) {
328+
setIsConfirming(false);
291329
return;
292330
}
293-
finishRequestAndNavigate(participant, receipt);
331+
formHasBeenSubmitted.current = true;
332+
readFileAsync(
333+
currentReceiptSource,
334+
currentReceiptName,
335+
(file) => onSuccess(participant, file, locationPermissionGranted),
336+
() => {
337+
// Allow retry after a file-read failure.
338+
formHasBeenSubmitted.current = false;
339+
setIsConfirming(false);
340+
},
341+
currentReceiptType,
342+
);
294343
};
295344

296345
const onConfirm = (listOfParticipants?: Participant[], gpsRequired?: boolean) => {
346+
setIsConfirming(true);
297347
const shouldStartLocationPermissionFlow =
298348
gpsRequired &&
299349
(!lastLocationPermissionPrompt ||
300350
(DateUtils.isValidDateString(lastLocationPermissionPrompt ?? '') &&
301351
DateUtils.getDifferenceInDaysFromNow(new Date(lastLocationPermissionPrompt ?? '')) > CONST.IOU.LOCATION_PERMISSION_PROMPT_THRESHOLD_DAYS));
302352

303353
if (shouldStartLocationPermissionFlow) {
354+
setSelectedParticipantList(listOfParticipants ?? selectedParticipants);
304355
setStartLocationPermissionFlow(true);
305356
return;
306357
}
307-
if (!currentAttachment) {
308-
return;
309-
}
310358

311359
const participant = listOfParticipants?.at(0) ?? selectedParticipants.at(0);
312360
if (!participant) {
361+
setIsConfirming(false);
313362
return;
314363
}
315-
316-
readFileAsync(
317-
receiptSource,
318-
receiptFileName,
319-
(file) => onSuccess(participant, file, shouldStartLocationPermissionFlow),
320-
() => {},
321-
receiptFileType,
322-
);
364+
performUpload(participant, false);
323365
};
324366

325367
return (
@@ -339,15 +381,30 @@ function SubmitDetailsPage({
339381
/>
340382
<LocationPermissionModal
341383
startPermissionFlow={startLocationPermissionFlow}
342-
resetPermissionFlow={() => setStartLocationPermissionFlow(false)}
343-
onGrant={() => onConfirm(undefined, true)}
384+
resetPermissionFlow={() => {
385+
setStartLocationPermissionFlow(false);
386+
setIsConfirming(false);
387+
}}
388+
onGrant={() => {
389+
setStartLocationPermissionFlow(false);
390+
const participant = selectedParticipantList.at(0) ?? selectedParticipants.at(0);
391+
if (!participant) {
392+
setIsConfirming(false);
393+
return;
394+
}
395+
navigateAfterInteraction(() => performUpload(participant, true));
396+
}}
344397
onDeny={() => {
345398
updateLastLocationPermissionPrompt();
346399
setStartLocationPermissionFlow(false);
347-
navigateAfterInteraction(() => {
348-
onConfirm(undefined, false);
349-
});
400+
const participant = selectedParticipantList.at(0) ?? selectedParticipants.at(0);
401+
if (!participant) {
402+
setIsConfirming(false);
403+
return;
404+
}
405+
navigateAfterInteraction(() => performUpload(participant, false));
350406
}}
407+
onInitialGetLocationCompleted={() => setIsConfirming(false)}
351408
/>
352409
<View style={[styles.containerWithSpaceBetween, styles.pointerEventsBoxNone]}>
353410
<MoneyRequestConfirmationList
@@ -358,9 +415,10 @@ function SubmitDetailsPage({
358415
onToggleReimbursable={setReimbursable}
359416
isPolicyExpenseChat={isPolicyExpenseChat}
360417
policyID={policy?.id}
418+
isConfirming={isConfirming}
361419
onConfirm={(updatedParticipants) => onConfirm(updatedParticipants, true)}
362-
receiptPath={receiptSource}
363-
receiptFilename={receiptFileName}
420+
receiptPath={currentReceiptSource}
421+
receiptFilename={currentReceiptName}
364422
reportID={reportOrAccountID}
365423
shouldShowSmartScanFields={false}
366424
shouldDisplayReceipt
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {useEffect} from 'react';
2+
import type {Dispatch, SetStateAction} from 'react';
3+
import useLocalize from '@hooks/useLocalize';
4+
import CONST from '@src/CONST';
5+
import getFileSize from './getFileSize';
6+
7+
type SetState = Dispatch<SetStateAction<string | undefined>>;
8+
9+
/** Validate the shared file against API min/max size limits. Pass `enabled: false` to skip (e.g., when an earlier error already took precedence). */
10+
function useShareFileSizeValidation(content: string | undefined, setErrorTitle: SetState, setErrorMessage: SetState, enabled = true) {
11+
const {translate} = useLocalize();
12+
13+
useEffect(() => {
14+
if (!content || !enabled) {
15+
return;
16+
}
17+
let ignore = false;
18+
getFileSize(content).then((size) => {
19+
if (ignore) {
20+
return;
21+
}
22+
if (size > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
23+
setErrorTitle(translate('attachmentPicker.attachmentTooLarge'));
24+
setErrorMessage(translate('attachmentPicker.sizeExceeded'));
25+
}
26+
27+
if (size < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
28+
setErrorTitle(translate('attachmentPicker.attachmentTooSmall'));
29+
setErrorMessage(translate('attachmentPicker.sizeNotMet'));
30+
}
31+
});
32+
return () => {
33+
ignore = true;
34+
};
35+
}, [content, enabled, setErrorTitle, setErrorMessage, translate]);
36+
}
37+
38+
export default useShareFileSizeValidation;

0 commit comments

Comments
 (0)