diff --git a/src/hooks/useDefaultExpensePolicy.tsx b/src/hooks/useDefaultExpensePolicy.tsx index 60854211b2e43..32630fafff885 100644 --- a/src/hooks/useDefaultExpensePolicy.tsx +++ b/src/hooks/useDefaultExpensePolicy.tsx @@ -1,17 +1,50 @@ +import type {OnyxCollection} from 'react-native-onyx'; import {isPaidGroupPolicy, isPolicyAccessible} from '@libs/PolicyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy} from '@src/types/onyx'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useOnyx from './useOnyx'; import usePreferredPolicy from './usePreferredPolicy'; +/** + * Selector that finds the single qualifying group policy ID from the collection. + * Returns only an ID (stable) — prevents re-renders when unrelated policies change. + */ +function getSingleGroupPolicyID(policies: OnyxCollection, login: string): string | undefined { + if (!policies) { + return undefined; + } + + let singlePolicyID: string | undefined; + for (const policy of Object.values(policies)) { + if (!policy || !isPaidGroupPolicy(policy) || !isPolicyAccessible(policy, login)) { + continue; + } + if (!singlePolicyID) { + singlePolicyID = policy.id; + } else { + return undefined; // More than one — no single default + } + } + + return singlePolicyID; +} + export default function useDefaultExpensePolicy() { const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); const {login = ''} = useCurrentUserPersonalDetails(); const [preferredPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${preferredPolicyID}`); + // Selector returns only the qualifying policy ID — stable value, prevents re-renders + const [singleGroupPolicyID] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: (policies) => getSingleGroupPolicyID(policies, login), + }); + + // Per-key lookup for the single group policy (only fires when that specific policy changes) + const [singleGroupPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${singleGroupPolicyID}`); + if (isRestrictedToPreferredPolicy && isPaidGroupPolicy(preferredPolicy) && isPolicyAccessible(preferredPolicy, login)) { return preferredPolicy; } @@ -20,20 +53,5 @@ export default function useDefaultExpensePolicy() { return activePolicy; } - // If there is exactly one group policy, use that as the default expense policy - let singlePolicy; - for (const policy of Object.values(allPolicies ?? {})) { - if (!policy || !isPaidGroupPolicy(policy) || !isPolicyAccessible(policy, login)) { - continue; - } - - if (!singlePolicy) { - singlePolicy = policy; - } else { - singlePolicy = undefined; - break; - } - } - - return singlePolicy; + return singleGroupPolicy; } diff --git a/src/hooks/usePolicyForMovingExpenses.ts b/src/hooks/usePolicyForMovingExpenses.ts index 712e689f56b98..b3f88e88a2acb 100644 --- a/src/hooks/usePolicyForMovingExpenses.ts +++ b/src/hooks/usePolicyForMovingExpenses.ts @@ -1,5 +1,5 @@ import {activePolicySelector} from '@selectors/Policy'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useSession} from '@components/OnyxListItemProvider'; import {canSubmitPerDiemExpenseFromWorkspace, isPaidGroupPolicy, isPolicyMemberWithoutPendingDelete, isTimeTrackingEnabled} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -31,48 +31,85 @@ function isPolicyValidForMovingExpenses(policy: OnyxEntry, login: string ); } -function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string) { - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { - selector: activePolicySelector, - }); +type PolicyQualificationResult = { + singlePolicyID: string | undefined; + isMemberOfMoreThanOnePolicy: boolean; + validExpensePolicyID: string | undefined; +}; - const session = useSession(); - const login = session?.email ?? ''; +/** + * Selector that computes which policies qualify for moving expenses. + * Returns only IDs and flags — stable output that prevents re-renders when unrelated policies change. + */ +function getPolicyQualificationResult( + policies: OnyxCollection, + login: string, + isPerDiemRequest?: boolean, + isTimeRequest?: boolean, + expensePolicyID?: string, +): PolicyQualificationResult { + if (!policies) { + return {singlePolicyID: undefined, isMemberOfMoreThanOnePolicy: false, validExpensePolicyID: undefined}; + } - // Early exit optimization: only need to check if we have 0, 1, or >1 policies - let singleUserPolicy; + let singlePolicyID: string | undefined; let isMemberOfMoreThanOnePolicy = false; - for (const policy of Object.values(allPolicies ?? {})) { + for (const policy of Object.values(policies)) { if (!isPolicyValidForMovingExpenses(policy, login, isPerDiemRequest, isTimeRequest)) { continue; } - - if (!singleUserPolicy) { - singleUserPolicy = policy; + if (!singlePolicyID) { + singlePolicyID = policy?.id; } else { isMemberOfMoreThanOnePolicy = true; - break; // Found 2, no need to continue + break; } } - // If an expense policy ID is provided and valid, prefer it over the active policy - // This ensures that when viewing/editing an expense from workspace B, we show workspace B - // even if the user's default workspace is A + let validExpensePolicyID: string | undefined; if (expensePolicyID) { - const expensePolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${expensePolicyID}`]; + const expensePolicy = policies[`${ONYXKEYS.COLLECTION.POLICY}${expensePolicyID}`]; if (expensePolicy && isPolicyValidForMovingExpenses(expensePolicy, login, isPerDiemRequest, isTimeRequest)) { - return {policyForMovingExpensesID: expensePolicyID, policyForMovingExpenses: expensePolicy, shouldSelectPolicy: false}; + validExpensePolicyID = expensePolicyID; } } + return {singlePolicyID, isMemberOfMoreThanOnePolicy, validExpensePolicyID}; +} + +function usePolicyForMovingExpenses(isPerDiemRequest?: boolean, isTimeRequest?: boolean, expensePolicyID?: string) { + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, { + selector: activePolicySelector, + }); + + const session = useSession(); + const login = session?.email ?? ''; + + // Contextual selector — captures login/flags from closure. + // Returns only IDs + flags (stable output) to prevent re-renders when unrelated policies change. + const policyQualificationSelector = (policies: OnyxCollection) => getPolicyQualificationResult(policies, login, isPerDiemRequest, isTimeRequest, expensePolicyID); + const [qualificationResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { + selector: policyQualificationSelector, + }); + + const {singlePolicyID, isMemberOfMoreThanOnePolicy, validExpensePolicyID} = qualificationResult ?? {}; + + // Per-key lookup for the resolved policy (only fires when that specific policy changes) + const resolvedPolicyID = validExpensePolicyID ?? singlePolicyID; + const [resolvedPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${resolvedPolicyID}`); + + // If an expense policy ID is provided and valid, prefer it over the active policy + if (validExpensePolicyID) { + return {policyForMovingExpensesID: validExpensePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false}; + } + if (activePolicy && (!isPerDiemRequest || canSubmitPerDiemExpenseFromWorkspace(activePolicy)) && (!isTimeRequest || isTimeTrackingEnabled(activePolicy))) { return {policyForMovingExpensesID: activePolicyID, policyForMovingExpenses: activePolicy, shouldSelectPolicy: false}; } - if (singleUserPolicy && !isMemberOfMoreThanOnePolicy) { - return {policyForMovingExpensesID: singleUserPolicy.id, policyForMovingExpenses: singleUserPolicy, shouldSelectPolicy: false}; + if (singlePolicyID && !isMemberOfMoreThanOnePolicy) { + return {policyForMovingExpensesID: singlePolicyID, policyForMovingExpenses: resolvedPolicy, shouldSelectPolicy: false}; } if (isMemberOfMoreThanOnePolicy) { diff --git a/src/hooks/useReportAttributes.ts b/src/hooks/useReportAttributes.ts index d5d308541590c..316cd5cd8c104 100644 --- a/src/hooks/useReportAttributes.ts +++ b/src/hooks/useReportAttributes.ts @@ -14,4 +14,18 @@ function useReportAttributes() { return reportAttributes?.reports; } +/** + * Returns a single report's attributes using a selector. + * Deep comparison is cheap (single small object), so re-renders only occur + * when that specific report's attributes change — not on every global report change. + */ +function useReportAttributesByID(reportID: string | undefined) { + const reportAttributesByIDSelector = (value: {reports?: Record} | undefined) => (reportID ? value?.reports?.[reportID] : undefined); + const [reportAttributes] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, { + selector: reportAttributesByIDSelector, + }); + return reportAttributes; +} + export default useReportAttributes; +export {useReportAttributesByID}; diff --git a/src/hooks/useReportIsArchived.ts b/src/hooks/useReportIsArchived.ts index a52812a4bfd11..ee42cb04f7dd1 100644 --- a/src/hooks/useReportIsArchived.ts +++ b/src/hooks/useReportIsArchived.ts @@ -2,10 +2,13 @@ import {isArchivedReport} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import useOnyx from './useOnyx'; +const isArchivedSelector = isArchivedReport; + function useReportIsArchived(reportID?: string): boolean { - const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`); - const isReportArchived = isArchivedReport(reportNameValuePairs); - return isReportArchived; + const [isArchived] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, { + selector: isArchivedSelector, + }); + return !!isArchived; } export default useReportIsArchived; diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 3ee72fe80b56f..13dccfa591362 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -1,14 +1,12 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import getCurrentPosition from '@libs/getCurrentPosition'; import {calculateDefaultReimbursable, getExistingTransactionID, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; import {toLocaleDigit} from '@libs/LocaleDigitUtils'; -import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getManagerMcTestParticipant, getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {generateReportID, getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; +import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; +import {getPolicyExpenseChat, isSelfDM} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicy from '@libs/shouldUseDefaultExpensePolicy'; import {cancelSpan} from '@libs/telemetry/activeSpans'; @@ -21,19 +19,7 @@ import IntlStore from '@src/languages/IntlStore'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; -import type { - Beta, - IntroSelected, - LastSelectedDistanceRates, - PersonalDetailsList, - Policy, - PolicyTagLists, - QuickAction, - RecentWaypoint, - Report, - Transaction, - TransactionViolation, -} from '@src/types/onyx'; +import type {Beta, IntroSelected, LastSelectedDistanceRates, PersonalDetailsList, Policy, QuickAction, RecentWaypoint, Report, Transaction, TransactionViolation} from '@src/types/onyx'; import type {ReportAttributes, ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {Participant} from '@src/types/onyx/IOU'; import type {Unit} from '@src/types/onyx/Policy'; @@ -46,13 +32,11 @@ import { setCustomUnitRateID, setMoneyRequestDistance, setMoneyRequestMerchant, - setMoneyRequestParticipants, setMoneyRequestParticipantsFromReport, setMoneyRequestPendingFields, - setMultipleMoneyRequestParticipantsFromReport, trackExpense, } from './index'; -import {resetSplitShares, startSplitBill} from './Split'; +import {resetSplitShares} from './Split'; type CreateTransactionParams = { transactions: Transaction[]; @@ -81,53 +65,6 @@ type CreateTransactionParams = { recentWaypoints: OnyxEntry; }; -type InitialTransactionParams = { - transactionID: string; - reportID?: string; - taxCode: string; - taxAmount: number; - isFromGlobalCreate?: boolean; - currency?: string; - participants?: Participant[]; -}; - -type MoneyRequestStepScanParticipantsFlowParams = { - iouType: IOUType; - policy: OnyxEntry; - report: OnyxEntry; - reportID: string; - transactions: Transaction[]; - initialTransaction: InitialTransactionParams; - policyForMovingExpenses?: OnyxEntry; - personalDetails: OnyxEntry; - currentUserLogin?: string; - currentUserAccountID: number; - backTo?: Route; - backToReport?: string; - shouldSkipConfirmation: boolean; - defaultExpensePolicy?: OnyxEntry | null; - isArchivedExpenseReport: boolean; - isAutoReporting: boolean; - isASAPSubmitBetaEnabled: boolean; - transactionViolations?: OnyxCollection; - quickAction: OnyxEntry; - policyRecentlyUsedCurrencies?: string[]; - introSelected?: IntroSelected; - activePolicyID?: string; - files: ReceiptFile[]; - isTestTransaction?: boolean; - locationPermissionGranted?: boolean; - shouldGenerateTransactionThreadReport: boolean; - selfDMReport: OnyxEntry; - isSelfTourViewed: boolean; - allTransactionDrafts: OnyxCollection; - betas: OnyxEntry; - recentWaypoints: OnyxEntry; - participants: Participant[]; - participantsPolicyTags: Record; - amountOwed: OnyxEntry; -}; - type MoneyRequestStepDistanceNavigationParams = { iouType: IOUType; policy: OnyxEntry; @@ -297,252 +234,6 @@ function getMoneyRequestParticipantOptions( }); } -function handleMoneyRequestStepScanParticipants({ - iouType, - policy, - report, - reportID, - transactions, - initialTransaction, - policyForMovingExpenses, - personalDetails, - currentUserLogin, - currentUserAccountID, - backTo, - backToReport, - shouldSkipConfirmation, - defaultExpensePolicy, - shouldGenerateTransactionThreadReport, - isArchivedExpenseReport, - isAutoReporting, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - introSelected, - activePolicyID, - files, - isTestTransaction = false, - locationPermissionGranted = false, - selfDMReport, - isSelfTourViewed, - allTransactionDrafts, - betas, - recentWaypoints, - participants, - participantsPolicyTags, - amountOwed, -}: MoneyRequestStepScanParticipantsFlowParams) { - if (backTo) { - Navigation.goBack(backTo); - return; - } - - if (isTestTransaction) { - const managerMcTestParticipant = getManagerMcTestParticipant(currentUserAccountID, personalDetails) ?? {}; - let reportIDParam = managerMcTestParticipant.reportID; - if (!managerMcTestParticipant.reportID && report?.reportID) { - reportIDParam = generateReportID(); - } - setMoneyRequestParticipants( - initialTransaction.transactionID, - [ - { - ...managerMcTestParticipant, - reportID: reportIDParam, - selected: true, - }, - ], - true, - ).then(() => { - navigateToConfirmationPage(iouType, initialTransaction.transactionID, reportID, backToReport, true, reportIDParam); - }); - return; - } - - // If the user started this flow from using the + button in the composer inside a report - // the participants can be automatically assigned from the report and the user can skip the participants step and go straight - // to the confirmation step. - // If the user is started this flow using the Create expense option (combined submit/track flow), they should be redirected to the participants page. - if (!initialTransaction?.isFromGlobalCreate && !isArchivedExpenseReport && iouType !== CONST.IOU.TYPE.CREATE) { - if (shouldSkipConfirmation) { - cancelSpan(CONST.TELEMETRY.SPAN_SCAN_PROCESS_AND_NAVIGATE); - cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT); - cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); - cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY); - cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD); - const firstReceiptFile = files.at(0); - if (iouType === CONST.IOU.TYPE.SPLIT && firstReceiptFile) { - const splitReceipt: Receipt = firstReceiptFile.file ?? {}; - splitReceipt.source = firstReceiptFile.source; - splitReceipt.state = CONST.IOU.RECEIPT_STATE.SCAN_READY; - - startSplitBill({ - participants, - currentUserLogin: currentUserLogin ?? '', - currentUserAccountID, - comment: '', - receipt: splitReceipt, - existingSplitChatReportID: reportID, - billable: false, - category: '', - tag: '', - currency: initialTransaction?.currency ?? 'USD', - taxCode: initialTransaction.taxCode, - taxAmount: initialTransaction.taxAmount, - quickAction, - policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], - // No need to update recently used tags because no tags are used when the confirmation step is skipped - policyRecentlyUsedTags: undefined, - participantsPolicyTags, - }); - return; - } - const participant = participants.at(0); - if (!participant) { - return; - } - const defaultReimbursable = calculateDefaultReimbursable({ - iouType, - policy, - policyForMovingExpenses, - participant, - transactionReportID: initialTransaction?.reportID, - }); - if (locationPermissionGranted) { - getCurrentPosition( - (successData) => { - const policyParams = {policy}; - const gpsPoint = { - lat: successData.coords.latitude, - long: successData.coords.longitude, - }; - createTransaction({ - transactions, - iouType, - report, - currentUserAccountID, - currentUserEmail: currentUserLogin, - backToReport, - shouldGenerateTransactionThreadReport, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - introSelected, - activePolicyID, - files, - participant, - gpsPoint, - policyParams, - billable: false, - reimbursable: defaultReimbursable, - isSelfTourViewed, - allTransactionDrafts, - betas, - personalDetails, - recentWaypoints, - }); - }, - (errorData) => { - Log.info('[IOURequestStepScan] getCurrentPosition failed', false, errorData); - // When there is an error, the money can still be requested, it just won't include the GPS coordinates - createTransaction({ - transactions, - iouType, - report, - currentUserAccountID, - currentUserEmail: currentUserLogin, - backToReport, - shouldGenerateTransactionThreadReport, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - introSelected, - activePolicyID, - files, - participant, - reimbursable: defaultReimbursable, - isSelfTourViewed, - allTransactionDrafts, - betas, - personalDetails, - recentWaypoints, - }); - }, - ); - return; - } - createTransaction({ - transactions, - iouType, - report, - currentUserAccountID, - currentUserEmail: currentUserLogin, - backToReport, - shouldGenerateTransactionThreadReport, - isASAPSubmitBetaEnabled, - transactionViolations, - quickAction, - policyRecentlyUsedCurrencies, - introSelected, - activePolicyID, - files, - participant, - reimbursable: defaultReimbursable, - isSelfTourViewed, - allTransactionDrafts, - betas, - personalDetails, - recentWaypoints, - }); - return; - } - const transactionIDs = files.map((receiptFile) => receiptFile.transactionID); - setMultipleMoneyRequestParticipantsFromReport(transactionIDs, report, currentUserAccountID).then(() => - navigateToConfirmationPage(iouType, initialTransaction.transactionID, reportID, backToReport), - ); - return; - } - - // If there was no reportID, then that means the user started this flow from the global + menu - // and an optimistic reportID was generated. In that case, the next step is to select the participants for this expense. - if (shouldUseDefaultExpensePolicy(iouType, defaultExpensePolicy, amountOwed)) { - const shouldAutoReport = !!defaultExpensePolicy?.autoReporting || isAutoReporting; - const targetReport = shouldAutoReport ? getPolicyExpenseChat(currentUserAccountID, defaultExpensePolicy?.id) : selfDMReport; - const transactionReportID = isSelfDM(targetReport) ? CONST.REPORT.UNREPORTED_REPORT_ID : targetReport?.reportID; - const iouTypeTrackOrSubmit = transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID ? CONST.IOU.TYPE.TRACK : CONST.IOU.TYPE.SUBMIT; - - // If the initial transaction has different participants selected that means that the user has changed the participant in the confirmation step - if (initialTransaction?.participants && initialTransaction?.participants?.at(0)?.reportID !== targetReport?.reportID) { - const isTrackExpense = initialTransaction?.participants?.at(0)?.reportID === selfDMReport?.reportID; - - const setParticipantsPromises = files.map((receiptFile) => setMoneyRequestParticipants(receiptFile.transactionID, initialTransaction?.participants)); - Promise.all(setParticipantsPromises).then(() => { - if (isTrackExpense) { - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.TRACK, initialTransaction.transactionID, selfDMReport?.reportID), - ); - } else { - navigateToConfirmationPage(iouType, initialTransaction.transactionID, reportID, backToReport, iouType === CONST.IOU.TYPE.CREATE, initialTransaction?.reportID); - } - }); - return; - } - - const setParticipantsPromises = files.map((receiptFile) => { - setTransactionReport(receiptFile.transactionID, {reportID: transactionReportID}, true); - return setMoneyRequestParticipantsFromReport(receiptFile.transactionID, targetReport, currentUserAccountID); - }); - Promise.all(setParticipantsPromises).then(() => - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, initialTransaction.transactionID, targetReport?.reportID)), - ); - } else { - navigateToParticipantPage(iouType, initialTransaction.transactionID, reportID); - } -} - function handleMoneyRequestStepDistanceNavigation({ iouType, report, @@ -774,5 +465,5 @@ function handleMoneyRequestStepDistanceNavigation({ } } -export {createTransaction, handleMoneyRequestStepScanParticipants, handleMoneyRequestStepDistanceNavigation, getMoneyRequestParticipantOptions}; -export type {MoneyRequestStepScanParticipantsFlowParams, MoneyRequestStepDistanceNavigationParams}; +export {createTransaction, handleMoneyRequestStepDistanceNavigation, getMoneyRequestParticipantOptions}; +export type {MoneyRequestStepDistanceNavigationParams}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 47d72fd6e80e0..a3a82c9fd99a7 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -1038,6 +1038,14 @@ function getUserAccountID(): number { return userAccountID; } +function getAllTransactionDrafts(): NonNullable> { + return allTransactionDrafts; +} + +function getAllReportNameValuePairs(): OnyxCollection { + return allReportNameValuePairs; +} + /** * This function uses Onyx.connect and should be replaced with useOnyx for reactive data access. * TODO: remove `getPolicyTagsData` from this file (https://github.com/Expensify/App/issues/72721) @@ -13230,6 +13238,8 @@ export { getAllTransactionViolations, getAllReports, getAllReportActionsFromIOU, + getAllTransactionDrafts, + getAllReportNameValuePairs, getCurrentUserEmail, getUserAccountID, getReceiptError, diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index 21b5278b84d83..921fa003459cf 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -3,6 +3,7 @@ import {iouRequestPolicyCollectionSelector} from '@selectors/Policy'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import TestReceipt from '@assets/images/fake-receipt.png'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -20,13 +21,16 @@ import usePersonalPolicy from '@hooks/usePersonalPolicy'; import usePolicy from '@hooks/usePolicy'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; +import setTestReceipt from '@libs/actions/setTestReceipt'; import {dismissProductTraining} from '@libs/actions/Welcome'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getPlatform from '@libs/getPlatform'; import type Platform from '@libs/getPlatform/types'; +import {navigateToConfirmationPage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import {getManagerMcTestParticipant} from '@libs/OptionsListUtils'; import { getActivePoliciesWithExpenseChatAndPerDiemEnabledAndHasRates, getActivePoliciesWithExpenseChatAndTimeEnabled, @@ -35,11 +39,12 @@ import { isControlPolicy, isTimeTrackingEnabled, } from '@libs/PolicyUtils'; -import {getPayeeName} from '@libs/ReportUtils'; +import {generateReportID, getPayeeName} from '@libs/ReportUtils'; import {endSpan} from '@libs/telemetry/activeSpans'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {IOURequestType} from '@userActions/IOU'; -import {initMoneyRequest} from '@userActions/IOU'; +import {getAllPersonalDetails, getUserAccountID, initMoneyRequest, setMoneyRequestParticipants, setMoneyRequestReceipt} from '@userActions/IOU'; +import {removeDraftTransactions} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -254,14 +259,22 @@ function IOURequestStartPage({ }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); const {isBetaEnabled} = usePermissions(); - const setTestReceiptAndNavigateRef = useRef<() => void>(() => {}); + const transactionID = route?.params.transactionID ?? ''; const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip} = useProductTrainingContext( CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SCAN_TEST_TOOLTIP, // The test receipt image is served via our server on web so it requires internet connection !hasUserSubmittedExpenseOrScannedReceipt && isBetaEnabled(CONST.BETAS.NEWDOT_MANAGER_MCTEST) && selectedTab === CONST.TAB_REQUEST.SCAN && !(isOffline && isWeb), { onConfirm: () => { - setTestReceiptAndNavigateRef?.current?.(); + setTestReceipt(TestReceipt, 'png', (source, file, filename) => { + setMoneyRequestReceipt(transactionID, source, filename, true, CONST.TEST_RECEIPT.FILE_TYPE, true); + removeDraftTransactions(true); + const mcTestParticipant = getManagerMcTestParticipant(getUserAccountID(), getAllPersonalDetails()) ?? {}; + const reportIDParam = mcTestParticipant.reportID ?? (report?.reportID ? generateReportID() : undefined); + setMoneyRequestParticipants(transactionID, [{...mcTestParticipant, reportID: reportIDParam, selected: true}], true).then(() => + navigateToConfirmationPage(iouType, transactionID, reportID, undefined, true, reportIDParam), + ); + }); }, onDismiss: () => { dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.SCAN_TEST_TOOLTIP, true); @@ -338,9 +351,6 @@ function IOURequestStartPage({ { - setTestReceiptAndNavigateRef.current = setTestReceiptAndNavigate; - }} isMultiScanEnabled={isMultiScanEnabled} setIsMultiScanEnabled={setIsMultiScanEnabled} isStartingScan diff --git a/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx new file mode 100644 index 0000000000000..9c10d0f392b3c --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepScan/components/Camera/CameraCapture.tsx @@ -0,0 +1,423 @@ +import {useIsFocused} from '@react-navigation/native'; +import React, {useEffect, useReducer, useRef, useState} from 'react'; +import type {LayoutRectangle} from 'react-native'; +import {StyleSheet, View} from 'react-native'; +import Animated, {useAnimatedStyle, useSharedValue, withSequence, withTiming} from 'react-native-reanimated'; +import type Webcam from 'react-webcam'; +import ActivityIndicator from '@components/ActivityIndicator'; +import AttachmentPicker from '@components/AttachmentPicker'; +import Button from '@components/Button'; +import Icon from '@components/Icon'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {isMobileWebKit} from '@libs/Browser'; +import {base64ToFile} from '@libs/fileDownload/FileUtils'; +import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/WebCamera'; +import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; +import type {ImageObject} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; +import CONST from '@src/CONST'; +import type {FileObject} from '@src/types/utils/Attachment'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {CameraProps} from './types'; + +/** + * Preload camera permission state at module load so first render can use a cached value. + */ +let cachedPermissionState: PermissionState | undefined; + +if (typeof navigator !== 'undefined' && navigator.permissions) { + navigator.permissions + .query({name: 'camera'}) + .then((status) => { + cachedPermissionState = status.state; + if ('addEventListener' in status) { + status.addEventListener('change', () => { + cachedPermissionState = status.state; + }); + } + }) + .catch(() => { + cachedPermissionState = 'denied'; + }); +} + +function queryCameraPermission(): Promise { + if (cachedPermissionState !== undefined) { + return Promise.resolve(cachedPermissionState); + } + + if (typeof navigator === 'undefined' || !navigator.permissions) { + return Promise.resolve('denied'); + } + + return navigator.permissions + .query({name: 'camera'}) + .then((status) => status.state) + .catch(() => 'denied'); +} + +const BLINK_DURATION_MS = 80; + +/** + * CameraCapture — mobile web capture variant. + * Renders a camera viewfinder, shutter button, flash toggle and gallery picker. + * Calls `onCapture(file, source)` for each photo taken or file picked from the gallery. + */ +function CameraCapture({onCapture, shouldAcceptMultipleFiles = false, onLayout}: CameraProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const lazyIllustrations = useMemoizedLazyIllustrations(['Hand', 'Shutter']); + const lazyIcons = useMemoizedLazyExpensifyIcons(['Bolt', 'Gallery', 'boltSlash']); + const isTabActive = useIsFocused(); + const [cameraPermissionState, setCameraPermissionState] = useState(() => cachedPermissionState ?? 'prompt'); + const [isFlashLightOn, toggleFlashlight] = useReducer((state: boolean) => !state, false); + const [isTorchAvailable, setIsTorchAvailable] = useState(false); + const [isQueriedPermissionState, setIsQueriedPermissionState] = useState(() => cachedPermissionState !== undefined); + const [deviceConstraints, setDeviceConstraints] = useState(); + const videoConstraints = isTabActive ? deviceConstraints : undefined; + const cameraRef = useRef(null); + const trackRef = useRef(null); + const viewfinderLayout = useRef(null); + const getScreenshotTimeoutRef = useRef(null); + + // Blink animation for shutter feedback + const blinkOpacity = useSharedValue(0); + const blinkStyle = useAnimatedStyle(() => ({ + opacity: blinkOpacity.get(), + })); + + const showBlink = () => { + blinkOpacity.set(withSequence(withTiming(1, {duration: BLINK_DURATION_MS}), withTiming(0, {duration: BLINK_DURATION_MS}))); + }; + + /** + * On phones that have ultra-wide lens, react-webcam uses ultra-wide by default. + * The last deviceId is of regular lens camera. + */ + const requestCameraPermission = () => { + const defaultConstraints = {facingMode: {exact: 'environment'}}; + navigator.mediaDevices + .getUserMedia({video: {facingMode: {exact: 'environment'}, zoom: {ideal: 1}}}) + .then((stream) => { + setCameraPermissionState('granted'); + for (const track of stream.getTracks()) { + track.stop(); + } + // Only Safari 17+ supports zoom constraint + if (isMobileWebKit() && stream.getTracks().length > 0) { + let deviceId; + for (const track of stream.getTracks()) { + const setting = track.getSettings(); + if (setting.zoom === 1) { + deviceId = setting.deviceId; + break; + } + } + if (deviceId) { + setDeviceConstraints({deviceId}); + return; + } + } + if (!navigator.mediaDevices.enumerateDevices) { + setDeviceConstraints(defaultConstraints); + return; + } + navigator.mediaDevices.enumerateDevices().then((devices) => { + let lastBackDeviceId = ''; + for (let i = devices.length - 1; i >= 0; i--) { + const device = devices.at(i); + if (device?.kind === 'videoinput') { + lastBackDeviceId = device.deviceId; + break; + } + } + if (!lastBackDeviceId) { + setDeviceConstraints(defaultConstraints); + return; + } + setDeviceConstraints({deviceId: lastBackDeviceId}); + }); + }) + .catch(() => { + setDeviceConstraints(defaultConstraints); + setCameraPermissionState('denied'); + }); + }; + + useEffect(() => { + if (!isTabActive) { + return; + } + queryCameraPermission() + .then((state) => { + setCameraPermissionState(state); + if (state === 'granted') { + requestCameraPermission(); + } + }) + .catch(() => { + setCameraPermissionState('denied'); + }) + .finally(() => { + setIsQueriedPermissionState(true); + }); + // Refresh permission state whenever this tab regains focus. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTabActive]); + + useEffect( + () => () => { + cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + if (!getScreenshotTimeoutRef.current) { + return; + } + clearTimeout(getScreenshotTimeoutRef.current); + }, + [], + ); + + const setupCameraPermissionsAndCapabilities = (stream: MediaStream) => { + setCameraPermissionState('granted'); + + const [track] = stream.getVideoTracks(); + const capabilities = track.getCapabilities(); + + if ('torch' in capabilities && capabilities.torch) { + trackRef.current = track; + } + setIsTorchAvailable('torch' in capabilities && !!capabilities.torch); + }; + + const clearTorchConstraints = () => { + if (!trackRef.current) { + return; + } + trackRef.current.applyConstraints({ + advanced: [{torch: false}], + }); + }; + + const getScreenshot = () => { + if (!cameraRef.current) { + requestCameraPermission(); + return; + } + + startSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, { + name: CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, + op: CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION, + attributes: {[CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: 'web'}, + }); + startSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, { + name: CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, + op: CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE, + parentSpan: getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION), + attributes: {[CONST.TELEMETRY.ATTRIBUTE_PLATFORM]: 'web'}, + }); + + const imageBase64 = cameraRef.current.getScreenshot(); + + if (imageBase64 === null) { + cancelSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + cancelSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + return; + } + + showBlink(); + + const originalFileName = `receipt_${Date.now()}.png`; + const originalFile = base64ToFile(imageBase64 ?? '', originalFileName); + const imageObject: ImageObject = {file: originalFile, filename: originalFile.name, source: URL.createObjectURL(originalFile)}; + // Some browsers center-crop the viewfinder inside the video element (due to object-position: center), + // while other browsers let the video element overflow and the container crops it from the top. + // We crop and align the result image the same way. + const videoHeight = cameraRef.current.video?.getBoundingClientRect?.()?.height ?? NaN; + const viewFinderHeight = viewfinderLayout.current?.height ?? NaN; + const shouldAlignTop = videoHeight > viewFinderHeight; + cropImageToAspectRatio(imageObject, viewfinderLayout.current?.width, viewfinderLayout.current?.height, shouldAlignTop).then(({file, source}) => { + endSpan(CONST.TELEMETRY.SPAN_RECEIPT_CAPTURE); + onCapture(file, source); + }); + }; + + const capturePhoto = () => { + if (trackRef.current && isFlashLightOn) { + trackRef.current + .applyConstraints({ + advanced: [{torch: true}], + }) + .then(() => { + getScreenshotTimeoutRef.current = setTimeout(() => { + getScreenshot(); + clearTorchConstraints(); + }, CONST.RECEIPT.FLASH_DELAY_MS); + }); + return; + } + + getScreenshot(); + }; + + const emitPickedFiles = (files: FileObject[]) => { + for (const file of files) { + const source = file.uri ?? URL.createObjectURL(file as Blob); + onCapture(file, source); + } + }; + + return ( + onLayout?.()} + style={[styles.flex1]} + > + + + {((cameraPermissionState === 'prompt' && !isQueriedPermissionState) || (cameraPermissionState === 'granted' && isEmptyObject(videoConstraints))) && ( + + )} + {cameraPermissionState !== 'granted' && isQueriedPermissionState && ( + + + {translate('receipt.takePhoto')} + {cameraPermissionState === 'denied' ? ( + + + + ) : ( + {translate('receipt.cameraAccess')} + )} +