diff --git a/.github/scripts/detectRedirectCycle.ts b/.github/scripts/detectRedirectCycle.ts new file mode 100644 index 000000000000..5aa0d1daf342 --- /dev/null +++ b/.github/scripts/detectRedirectCycle.ts @@ -0,0 +1,64 @@ +import {parse} from 'csv-parse'; +import fs from 'fs'; + +const parser = parse(); + +const adjacencyList: Record = {}; +const visited: Map = new Map(); +const backEdges: Map = new Map(); + +function addEdge(source: string, target: string) { + if (!adjacencyList[source]) { + adjacencyList[source] = []; + } + adjacencyList[source].push(target); +} + +function isCyclic(currentNode: string): boolean { + visited.set(currentNode, true); + backEdges.set(currentNode, true); + + // Do a depth first search for all the neighbours. If a node is found in backedge, a cycle is detected. + const neighbours = adjacencyList[currentNode]; + if (neighbours) { + for (const node of neighbours) { + if (!visited.has(node)) { + if (isCyclic(node)) { + return true; + } + } else if (backEdges.has(node)) { + return true; + } + } + } + + backEdges.delete(currentNode); + + return false; +} + +function detectCycle(): boolean { + for (const [node] of Object.entries(adjacencyList)) { + if (!visited.has(node)) { + if (isCyclic(node)) { + const cycle = Array.from(backEdges.keys()); + console.log(`Infinite redirect found in the cycle: ${cycle.join(' -> ')} -> ${node}`); + return true; + } + } + } + return false; +} + +fs.createReadStream(`${process.cwd()}/docs/redirects.csv`) + .pipe(parser) + .on('data', (row) => { + // Create a directed graph of sourceURL -> targetURL + addEdge(row[0], row[1]); + }) + .on('end', () => { + if (detectCycle()) { + process.exit(1); + } + process.exit(0); + }); diff --git a/.github/scripts/verifyRedirect.sh b/.github/scripts/verifyRedirect.sh old mode 100644 new mode 100755 index 737d9bffacf9..b8942cd5b23d --- a/.github/scripts/verifyRedirect.sh +++ b/.github/scripts/verifyRedirect.sh @@ -5,11 +5,22 @@ declare -r REDIRECTS_FILE="docs/redirects.csv" +declare -r RED='\033[0;31m' +declare -r GREEN='\033[0;32m' +declare -r NC='\033[0m' + duplicates=$(awk -F, 'a[$1]++{print $1}' $REDIRECTS_FILE) +if [[ -n "$duplicates" ]]; then + echo "${RED}duplicate redirects are not allowed: $duplicates ${NC}" + exit 1 +fi -if [[ -z "$duplicates" ]]; then - exit 0 +npm run detectRedirectCycle +DETECT_CYCLE_EXIT_CODE=$? +if [[ DETECT_CYCLE_EXIT_CODE -eq 1 ]]; then + echo -e "${RED}The redirects.csv has a cycle. Please remove the redirect cycle because it will cause an infinite redirect loop ${NC}" + exit 1 fi -echo "duplicate redirects are not allowed: $duplicates" -exit 1 +echo -e "${GREEN}The redirects.csv is valid!${NC}" +exit 0 diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 699bd379fb77..f7f826d66f9b 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -36,8 +36,8 @@ jobs: - name: Create docs routes file run: ./.github/scripts/createDocsRoutes.sh - - - name: Check duplicates in redirect.csv + + - name: Check for duplicates and cycles in redirects.csv run: ./.github/scripts/verifyRedirect.sh - name: Build with Jekyll diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index d3aa084b1fcf..10723d5efa04 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -107,7 +107,7 @@ jobs: if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} id: getMergeCommitShaIfUnmergedPR run: | - git merge --allow-unrelated-histories --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} + git merge --allow-unrelated-histories -X ours --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} git checkout ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} env: GITHUB_TOKEN: ${{ github.token }} diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index a2c7365f7de8..394e45f8d9ae 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -52,6 +52,10 @@ "/": "/split/*", "comment": "Split Expense" }, + { + "/": "/submit/*", + "comment": "Submit Expense" + }, { "/": "/request/*", "comment": "Submit Expense" @@ -76,6 +80,10 @@ "/": "/search/*", "comment": "Search" }, + { + "/": "/pay/*", + "comment": "Pay someone" + }, { "/": "/send/*", "comment": "Pay someone" diff --git a/android/app/build.gradle b/android/app/build.gradle index b8350a8a0111..7b6bbc2ac794 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001046307 - versionName "1.4.63-7" + versionCode 1001046321 + versionName "1.4.63-21" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 84364f2ef7ff..520602a28a02 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -62,6 +62,7 @@ + @@ -70,6 +71,7 @@ + @@ -81,6 +83,7 @@ + @@ -89,6 +92,7 @@ + diff --git a/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md b/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md new file mode 100644 index 000000000000..7c3d8077c14d --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Enable-and-set-up-expense-violations.md @@ -0,0 +1,111 @@ +--- +title: Enable and set up expense violations +description: Set up rules for expenses and enable violations +--- +
+ +Expensify automatically detects expense errors or discrepancies as violations that must be corrected. You can also set rules for a workspace that will trigger a violation if the rule is not met. These rules can be set for categories, tags, and even for specific domain groups. + +When reviewing submitted expense reports, approvers will see violations highlighted with an exclamation mark. There are two types of violations: +- **Yellow**: Automated highlights that require attention but may not require corrective action. For example, if a receipt was SmartScanned and then the amount was modified, a yellow violation will be added to call out the change for review. +- **Red**: Violations directly tied to your workspace settings. These violations must be addressed before the report can be submitted and reimbursed. + +You can hover over the icon to see a brief description, and you can find more detailed information below the list of expenses. + +{% include info.html %} +If your workspace has automations set to automatically submit reports for approval, the report that contains violations will not be submitted automatically until the violations are corrected. (However, if a comment is added to an expense, it will override the violation as the member is providing a reason for submission *unless* domain workspace rules are set to be strictly enforced, as detailed near the bottom of this article.) +{% include end-info.html %} + +# Enable or disable expense violations + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Expenses** tab on the left. +5. Click the “Enable violations” toggle. +6. If desired, enter the expense rules that will be used to create violations: + - **Max expense age (days)**: How old an expense can be + - **Max expense amount**: How much a single expense can cost + - **Receipt required amount**: How much a single expense can cost before a receipt is required + +{% include info.html %} +Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off. +{% include end-info.html %} + +# Set category rules + +Admins on a Control workspace can enable specific rules for each category, including setting expense caps for specific categories, requiring receipts, and more. These rules can allow you to have a default expense limit of $2,500 but to only allow a daily entertainment limit of $150 per person. You can also choose to not require receipts for mileage or per diem expenses. + +To set up category rules, +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Categories** tab on the left. +5. Click **Edit** to the right of the category. +6. Enter your category rules, as desired: + - **GL Code and Payroll Code**: You can add general ledger (GL) or payroll codes to the category for accounting. GL codes populate automatically if you have an accounting integration connected with Expensify. + - **Max Amount**: You can set specific expense caps for the expense category. Use the Limit Type dropdown to determine if the amount is set per individual expense or per day, then enter the maximum amount into this field. + - **Receipts**: You can determine whether receipts are required for the category. For example, many companies disable receipt requirements for toll expenses. + - **Description**: You can determine whether a description is required for expenses under this category. + - **Description Hint**: You can add a hint in the description field to prompt the expense creator on what they should enter into the description field for expenses under this category. + - **Approver**: You can set a specific approver for expenses labeled with this category. + +If users are in violation of these rules, the violations will be shown in red on the report. + +{% include info.html %} +If Scheduled Submit is enabled on a workspace, expenses with category violations will not be auto-submitted unless the expense has a comment added. +{% include end-info.html %} + +# Make categories required + +This means all expenses must be coded with a Category. + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Categories** tab on the left. +5. Enable the “People must categorize expenses” toggle. + +Each Workspace Member will now be required to select a category for their expense. If they do not select a category, the report will receive a violation, which can prevent submission if Scheduled Submit is enabled. + +# Make tags required + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Tags** tab on the left. +5. Enable the “People must tag expenses” toggle. + +Each Workspace Member will now be required to select a tag for their expense before they’re able to submit it. + +# Require strict compliance by domain group + +You can require strict compliance to require members of a specific domain group to submit reports that meet **all** workspace rules before they can submit their expense report—even if they add a note. Every rule and regulation on the workspace must be met before a report can be submitted. + +{% include info.html %} +This will prevent members from submitting any reports where a manager has granted them a manual exception for any of the workspace rules. +{% include end-info.html %} + +To enable strict domain group compliance for reports, + +1. Hover over Settings, then click **Domains**. +2. Click the **Groups** tab on the left. +3. Click **Edit** to the right of the desired workspace name. +4. Enable the “Strictly enforce expense workspace rules” toggle. + +# FAQs + +**Why can’t my employees see the categories on their expenses?** + +The employee may have their default workspace set as their personal workspace. Look under the details section on top right of the report to ensure it is being reported under the correct workspace. + +**Will the account numbers from our accounting system (QuickBooks Online, Sage Intacct, etc.) show in the category list for employees?** + +The general ledger (GL) account numbers are visible only for Workspace Admins in the workspace settings when they are part of a control workspace. This information is not visible to other members of the workspace. However, if you wish to have this information available to your employees when they are categorizing their expenses, you can edit the account name in your accounting software to include the GL number (for example, Accounts Payable - 12345). + +**What causes a category violation?** + +- An expense is categorized with a category that is not included in the workspace's categories. This may happen if the employee creates an expense under the wrong workspace, which will cause a "category out of workspace" violation. +- If the workspace categories are being imported from an accounting integration and they’ve been updated in the accounting system but not in Expensify, this can cause an old category to still be in use on an open report which would throw a violation on submission. Simply reselect a proper category to clear violation. + +
diff --git a/docs/redirects.csv b/docs/redirects.csv index af595ecc5f83..95404c2326a0 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -87,6 +87,7 @@ https://help.expensify.com/articles/new-expensify/payments/Request-Money,https:/ https://help.expensify.com/articles/expensify-classic/workspace-and-domain-settings/tax-tracking,https://help.expensify.com/articles/expensify-classic/expenses/expenses/Apply-Tax https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/User-Roles.html,https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/ https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Owner,https://help.expensify.com/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account +https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Owner.html,https://help.expensify.com/articles/expensify-classic/workspaces/Assign-billing-owner-and-payment-account https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Other-Export-Options.html,https://help.expensify.com/articles/expensify-classic/spending-insights/Other-Export-Options https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/business-bank-accounts/Business-Bank-Accounts-AUD,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Global-Reimbursements https://help.expensify.com/expensify-classic/hubs/bank-accounts-and-credit-cards,https://help.expensify.com/expensify-classic/hubs/ @@ -123,7 +124,6 @@ https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/ https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Tax-Exempt,https://help.expensify.com/articles/expensify-classic/expensify-billing/Tax-Exempt https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Approving-Reports,https://help.expensify.com/expensify-classic/hubs/reports/ https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Invite-Members,https://help.expensify.com/articles/expensify-classic/workspaces/Invite-members-and-assign-roles -https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Removing-Members,https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Removing-Members https://help.expensify.com/articles/expensify-classic/expense-and-report-features/Attendee-Tracking,https://help.expensify.com/articles/expensify-classic/expenses/Track-group-expenses https://help.expensify.com/articles/expensify-classic/expense-and-report-features/Currency,https://help.expensify.com/articles/expensify-classic/workspaces/Currency https://help.expensify.com/articles/expensify-classic/expense-and-report-features/Expense-Rules,https://help.expensify.com/articles/expensify-classic/expenses/Expense-Rules @@ -158,3 +158,5 @@ https://help.expensify.com/articles/expensify-classic/workspaces/Budgets,https:/ https://help.expensify.com/articles/expensify-classic/workspaces/Categories,https://help.expensify.com/articles/expensify-classic/workspaces/Create-categories https://help.expensify.com/articles/expensify-classic/workspaces/Tags,https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags https://help.expensify.com/expensify-classic/hubs/manage-employees-and-report-approvals,https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Approval-Workflows +https://help.expensify.com/articles/expensify-classic/reports/Report-Audit-Log-and-Comments,https://help.expensify.com/articles/expensify-classic/reports/Print-or-download-a-report +https://help.expensify.com/articles/expensify-classic/reports/The-Reports-Page,https://help.expensify.com/articles/expensify-classic/reports/Report-statuses diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 8b911fa849cd..b224296ed75a 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -6,7 +6,7 @@ layout: {% assign pages = site.html_pages | where_exp:'doc','doc.sitemap != false' | where_exp:'doc','doc.url != "/404.html"' %} {% for page in pages %} - {{ page.url | replace:'/index.html','/' | absolute_url | xml_escape }} + {{ page.url | replace:'/index.html','/' | absolute_url | xml_escape | replace:'.html','' }} {% endfor %} \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4523bf1c4418..981db10cc7e0 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.63.7 + 1.4.63.21 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index d40d0fa27486..d59aec68a621 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.63.7 + 1.4.63.21 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index d472daa53ab7..3f6869c084a9 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.63 CFBundleVersion - 1.4.63.7 + 1.4.63.21 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f564bfd931e4..ed387a8d522f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1697,25 +1697,6 @@ PODS: - React-perflogger (= 0.73.4) - RNAppleAuthentication (2.2.2): - React-Core - - RNCAsyncStorage (1.21.0): - - glog - - hermes-engine - - RCT-Folly (= 2022.05.16.00) - - RCTRequired - - RCTTypeSafety - - React-Codegen - - React-Core - - React-debug - - React-Fabric - - React-graphics - - React-ImageManager - - React-NativeModulesApple - - React-RCTFabric - - React-rendererdebug - - React-utils - - ReactCommon/turbomodule/bridging - - ReactCommon/turbomodule/core - - Yoga - RNCClipboard (1.13.2): - glog - hermes-engine @@ -2154,7 +2135,6 @@ DEPENDENCIES: - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)" - - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - "RNCClipboard (from `../node_modules/@react-native-clipboard/clipboard`)" - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) @@ -2385,8 +2365,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon" RNAppleAuthentication: :path: "../node_modules/@invertase/react-native-apple-authentication" - RNCAsyncStorage: - :path: "../node_modules/@react-native-async-storage/async-storage" RNCClipboard: :path: "../node_modules/@react-native-clipboard/clipboard" RNCPicker: @@ -2551,7 +2529,6 @@ SPEC CHECKSUMS: React-utils: 6e5ad394416482ae21831050928ae27348f83487 ReactCommon: 840a955d37b7f3358554d819446bffcf624b2522 RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6 - RNCAsyncStorage: 559f22cc4b582414e783fd7255974b29e24b451c RNCClipboard: c73bbc2e9012120161f1012578418827983bfd0c RNCPicker: c77efa39690952647b83d8085520bf50ebf94ecb RNDeviceInfo: cbf78fdb515ae73e641ee7c6b474f77a0299e7e6 @@ -2581,7 +2558,7 @@ SPEC CHECKSUMS: SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2 VisionCamera: 3033e0dd5272d46e97bcb406adea4ae0e6907abf - Yoga: 1b901a6d6eeba4e8a2e8f308f708691cdb5db312 + Yoga: 64cd2a583ead952b0315d5135bf39e053ae9be70 PODFILE CHECKSUM: a25a81f2b50270f0c0bd0aff2e2ebe4d0b4ec06d diff --git a/package-lock.json b/package-lock.json index 52a975e3a83e..ba65f7292a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.63-7", + "version": "1.4.63-21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.63-7", + "version": "1.4.63-21", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -25,7 +25,6 @@ "@kie/act-js": "^2.6.0", "@kie/mock-github": "^1.0.0", "@onfido/react-native-sdk": "10.6.0", - "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "3.2.1", @@ -201,6 +200,7 @@ "concurrently": "^8.2.2", "copy-webpack-plugin": "^10.1.0", "css-loader": "^6.7.2", + "csv-parse": "^5.5.5", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", "electron": "^29.2.0", @@ -7671,16 +7671,6 @@ } } }, - "node_modules/@react-native-async-storage/async-storage": { - "version": "1.21.0", - "license": "MIT", - "dependencies": { - "merge-options": "^3.0.4" - }, - "peerDependencies": { - "react-native": "^0.0.0-0 || >=0.60 <1.0" - } - }, "node_modules/@react-native-camera-roll/camera-roll": { "version": "7.4.0", "license": "MIT", @@ -17740,6 +17730,12 @@ "version": "3.1.1", "license": "MIT" }, + "node_modules/csv-parse": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.5.tgz", + "integrity": "sha512-erCk7tyU3yLWAhk6wvKxnyPtftuy/6Ak622gOO7BCJ05+TYffnPCJF905wmOQm+BpkX54OdAl8pveJwUdpnCXQ==", + "dev": true + }, "node_modules/dag-map": { "version": "1.0.2", "license": "MIT" @@ -23514,13 +23510,6 @@ "node": ">=6" } }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-object": { "version": "5.0.0", "license": "MIT", @@ -27688,16 +27677,6 @@ "version": "1.0.1", "license": "MIT" }, - "node_modules/merge-options": { - "version": "3.0.4", - "license": "MIT", - "dependencies": { - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/merge-refs": { "version": "1.2.1", "license": "MIT", diff --git a/package.json b/package.json index 1cef7d94fcba..414082054f65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.63-7", + "version": "1.4.63-21", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -28,6 +28,7 @@ "desktop-build": "scripts/build-desktop.sh production", "desktop-build-staging": "scripts/build-desktop.sh staging", "createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.ts", + "detectRedirectCycle": "ts-node .github/scripts/detectRedirectCycle.ts", "desktop-build-adhoc": "scripts/build-desktop.sh adhoc", "ios-build": "fastlane ios build", "android-build": "fastlane android build", @@ -76,7 +77,6 @@ "@kie/act-js": "^2.6.0", "@kie/mock-github": "^1.0.0", "@onfido/react-native-sdk": "10.6.0", - "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "3.2.1", @@ -252,6 +252,7 @@ "concurrently": "^8.2.2", "copy-webpack-plugin": "^10.1.0", "css-loader": "^6.7.2", + "csv-parse": "^5.5.5", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", "electron": "^29.2.0", diff --git a/patches/react-native+0.73.4+014+fix-inverted-flatlist.patch b/patches/react-native+0.73.4+014+fix-inverted-flatlist.patch new file mode 100644 index 000000000000..7bed06d01913 --- /dev/null +++ b/patches/react-native+0.73.4+014+fix-inverted-flatlist.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp +index a8ecce5..6ad790e 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp +@@ -66,7 +66,17 @@ void ScrollViewShadowNode::layout(LayoutContext layoutContext) { + Point ScrollViewShadowNode::getContentOriginOffset() const { + auto stateData = getStateData(); + auto contentOffset = stateData.contentOffset; +- return {-contentOffset.x, -contentOffset.y + stateData.scrollAwayPaddingTop}; ++ auto props = getConcreteProps(); ++ ++ float productX = 1.0f; ++ float productY = 1.0f; ++ ++ for (const auto& operation : props.transform.operations) { ++ productX *= operation.x; ++ productY *= operation.y; ++ } ++ ++ return {-contentOffset.x * productX, (-contentOffset.y + stateData.scrollAwayPaddingTop) * productY}; + } + + } // namespace facebook::react diff --git a/patches/react-native+0.73.4+014+iOSCoreAnimationBorderRendering.patch b/patches/react-native+0.73.4+014+iOSCoreAnimationBorderRendering.patch new file mode 100644 index 000000000000..b59729e79622 --- /dev/null +++ b/patches/react-native+0.73.4+014+iOSCoreAnimationBorderRendering.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.mm b/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.mm +index b4cfb3d..7aa00e5 100644 +--- a/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.mm ++++ b/node_modules/react-native/React/Fabric/Mounting/RCTMountingManager.mm +@@ -49,6 +49,9 @@ static void RCTPerformMountInstructions( + { + SystraceSection s("RCTPerformMountInstructions"); + ++ [CATransaction begin]; ++ [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions]; ++ + for (const auto &mutation : mutations) { + switch (mutation.type) { + case ShadowViewMutation::Create: { +@@ -147,6 +150,7 @@ static void RCTPerformMountInstructions( + } + } + } ++ [CATransaction commit]; + } + + @implementation RCTMountingManager { diff --git a/patches/react-native-screens+3.30.1+001+fix-screen-type.patch b/patches/react-native-screens+3.30.1+001+fix-screen-type.patch new file mode 100644 index 000000000000..f282ec58b07b --- /dev/null +++ b/patches/react-native-screens+3.30.1+001+fix-screen-type.patch @@ -0,0 +1,12 @@ +diff --git a/node_modules/react-native-screens/src/components/Screen.tsx b/node_modules/react-native-screens/src/components/Screen.tsx +index 3f9a1cb..45767f7 100644 +--- a/node_modules/react-native-screens/src/components/Screen.tsx ++++ b/node_modules/react-native-screens/src/components/Screen.tsx +@@ -79,6 +79,7 @@ export class InnerScreen extends React.Component { + // Due to how Yoga resolves layout, we need to have different components for modal nad non-modal screens + const AnimatedScreen = + Platform.OS === 'android' || ++ stackPresentation === undefined || + stackPresentation === 'push' || + stackPresentation === 'containedModal' || + stackPresentation === 'containedTransparentModal' diff --git a/src/CONST.ts b/src/CONST.ts index a840cb481a1a..8d523dd71a3d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -6,9 +6,10 @@ import * as KeyCommand from 'react-native-key-command'; import type {ValueOf} from 'type-fest'; import * as Url from './libs/Url'; import SCREENS from './SCREENS'; +import type {Unit} from './types/onyx/Policy'; type RateAndUnit = { - unit: string; + unit: Unit; rate: number; }; type CurrencyDefaultMileageRate = Record; @@ -66,6 +67,8 @@ const onboardingChoices = { LOOKING_AROUND: 'newDotLookingAround', }; +type OnboardingPurposeType = ValueOf; + const CONST = { MERGED_ACCOUNT_PREFIX: 'MERGED_', DEFAULT_POLICY_ROOM_CHAT_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL], @@ -1411,16 +1414,18 @@ const CONST = { ACTION: { EDIT: 'edit', CREATE: 'create', - REQUEST: 'request', + SUBMIT: 'submit', CATEGORIZE: 'categorize', SHARE: 'share', }, DEFAULT_AMOUNT: 0, TYPE: { SEND: 'send', + PAY: 'pay', SPLIT: 'split', REQUEST: 'request', - TRACK_EXPENSE: 'track-expense', + SUBMIT: 'submit', + TRACK: 'track', }, REQUEST_TYPE: { DISTANCE: 'distance', @@ -3377,9 +3382,9 @@ const CONST = { REFERRAL_PROGRAM: { CONTENT_TYPES: { - MONEY_REQUEST: 'request', + SUBMIT_EXPENSE: 'submitExpense', START_CHAT: 'startChat', - SEND_MONEY: 'sendMoney', + PAY_SOMEONE: 'paySomeone', REFER_FRIEND: 'referralFriend', SHARE_CODE: 'shareCode', }, @@ -3558,7 +3563,7 @@ const CONST = { "# Let's start tracking your expenses!\n" + '\n' + "To track your expenses, create a workspace to keep everything in one place. Here's how:\n" + - '1. From the home screen, click the green + button > New Workspace\n' + + '1. From the home screen, click the green + button > *New Workspace*\n' + '2. Give your workspace a name (e.g. "My business expenses").\n' + '\n' + 'Then, add expenses to your workspace:\n' + @@ -3571,7 +3576,7 @@ const CONST = { '# Expensify is the fastest way to get paid back!\n' + '\n' + 'To submit expenses for reimbursement:\n' + - '1. From the home screen, click the green + button > Request money.\n' + + '1. From the home screen, click the green + button > *Request money*.\n' + "2. Enter an amount or scan a receipt, then input your boss's email.\n" + '\n' + "That'll send a request to get you paid back. Let me know if you have any questions!", @@ -3579,7 +3584,7 @@ const CONST = { "# Let's start managing your team's expenses!\n" + '\n' + "To manage your team's expenses, create a workspace to keep everything in one place. Here's how:\n" + - '1. From the home screen, click the green + button > New Workspace\n' + + '1. From the home screen, click the green + button > *New Workspace*\n' + '2. Give your workspace a name (e.g. "Sales team expenses").\n' + '\n' + 'Then, invite your team to your workspace via the Members pane and [connect a business bank account](https://help.expensify.com/articles/new-expensify/bank-accounts/Connect-a-Bank-Account) to reimburse them. Let me know if you have any questions!', @@ -3587,7 +3592,7 @@ const CONST = { "# Let's start tracking your expenses! \n" + '\n' + "To track your expenses, create a workspace to keep everything in one place. Here's how:\n" + - '1. From the home screen, click the green + button > New Workspace\n' + + '1. From the home screen, click the green + button > *New Workspace*\n' + '2. Give your workspace a name (e.g. "My expenses").\n' + '\n' + 'Then, add expenses to your workspace:\n' + @@ -3600,12 +3605,278 @@ const CONST = { '# Splitting the bill is as easy as a conversation!\n' + '\n' + 'To split an expense:\n' + - '1. From the home screen, click the green + button > Request money.\n' + + '1. From the home screen, click the green + button > *Request money*.\n' + '2. Enter an amount or scan a receipt, then choose who you want to split it with.\n' + '\n' + "We'll send a request to each person so they can pay you back. Let me know if you have any questions!", }, + ONBOARDING_MESSAGES: { + [onboardingChoices.TRACK]: { + message: 'Here are some essential tasks to keep your business spend in shape for tax season.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'createWorkspace', + autoCompleted: true, + title: 'Create a workspace', + subtitle: 'Create a workspace to track expenses, scan receipts, chat, and more.', + message: + 'Here’s how to create a workspace:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Workspaces > New workspace.\n' + + '\n' + + 'Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.', + }, + { + type: 'trackExpense', + autoCompleted: false, + title: 'Track an expense', + subtitle: 'Track an expense in any currency, in just a few clicks.', + message: + 'Here’s how to track an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Track expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Click Track.\n' + + '\n' + + 'And you’re done! Yep, it’s that easy.', + }, + ], + }, + [onboardingChoices.EMPLOYER]: { + message: 'Getting paid back is as easy as sending a message. Let’s go over the basics.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'submitExpense', + autoCompleted: false, + title: 'Submit an expense', + subtitle: 'Submit an expense by entering an amount or scanning a receipt.', + message: + 'Here’s how to submit an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Submit expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Add your reimburser to the request.\n' + + '\n' + + 'Then, send your request and wait for that sweet “Cha-ching!” when it’s complete.', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + subtitle: 'You’ll need to enable your Expensify Wallet to get paid back. Don’t worry, it’s easy!', + message: + 'Here’s how to set up your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Wallet > Enable wallet.\n' + + '3. Connect your bank account.\n' + + '\n' + + 'Once that’s done, you can request money from anyone and get paid back right into your personal bank account.', + }, + ], + }, + [onboardingChoices.MANAGE_TEAM]: { + message: 'Here are some important tasks to help get your team’s expenses under control.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'createWorkspace', + autoCompleted: true, + title: 'Create a workspace', + subtitle: 'Create a workspace to track expenses, scan receipts, chat, and more.', + message: + 'Here’s how to create a workspace:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Workspaces > New workspace.\n' + + '\n' + + 'Your new workspace is ready! It’ll keep all of your spend (and chats) in one place.', + }, + { + type: 'meetGuide', + autoCompleted: false, + title: 'Meet your setup specialist', + subtitle: '', + message: ({adminsRoomLink, guideCalendarLink}: {adminsRoomLink: string; guideCalendarLink: string}) => + `Meet your setup specialist, who can answer any questions as you get started with Expensify. Yes, a real human!\n` + + '\n' + + `Chat with the specialist in your [#admins room](${adminsRoomLink}) or [schedule a call](${guideCalendarLink}) today.`, + }, + { + type: 'setupCategories', + autoCompleted: false, + title: 'Set up categories', + subtitle: 'Set up categories so your team can code expenses for easy reporting.', + message: + 'Here’s how to set up categories:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click Categories.\n' + + '4. Enable and disable default categories.\n' + + '5. Click Add categories to make your own.\n' + + '\n' + + 'For more controls like requiring a category for every expense, click Settings.', + }, + { + type: 'addExpenseApprovals', + autoCompleted: false, + title: 'Add expense approvals', + subtitle: 'Add expense approvals to review your team’s spend and keep it under control.', + message: + 'Here’s how to add expense approvals:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click More features.\n' + + '4. Enable Workflows.\n' + + '5. In Workflows, enable Add approvals.\n' + + '\n' + + 'You’ll be set as the expense approver. You can change this to any admin once you invite your team.', + }, + { + type: 'inviteTeam', + autoCompleted: false, + title: 'Invite your team', + subtitle: 'Invite your team to Expensify so they can start tracking expenses today.', + message: + 'Here’s how to invite your team:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Go to Workspaces > [your workspace].\n' + + '3. Click Members > Invite member.\n' + + '4. Enter emails or phone numbers. \n' + + '5. Add an invite message if you want.\n' + + '\n' + + 'That’s it! Happy expensing :)', + }, + ], + }, + [onboardingChoices.PERSONAL_SPEND]: { + message: 'Here’s how to track your spend in a few clicks.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'trackExpense', + autoCompleted: false, + title: 'Track an expense', + subtitle: 'Track an expense in any currency, whether you have a receipt or not.', + message: + 'Here’s how to track an expense:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Track expense.\n' + + '3. Enter an amount or scan a receipt.\n' + + '4. Click Track.\n' + + '\n' + + 'And you’re done! Yep, it’s that easy.', + }, + ], + }, + [onboardingChoices.CHAT_SPLIT]: { + message: 'Splitting bills with friends is as easy as sending a message. Here’s how.', + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [ + { + type: 'startChat', + autoCompleted: false, + title: 'Start a chat', + subtitle: 'Start a chat with a friend or group using their email or phone number.', + message: + 'Here’s how to start a chat:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Start chat.\n' + + '3. Enter emails or phone numbers.\n' + + '\n' + + 'If any of your friends aren’t using Expensify already, they’ll be invited automatically. \n' + + '\n' + + 'Every chat will also turn into an email or text that they can respond to directly.', + }, + { + type: 'splitExpense', + autoCompleted: false, + title: 'Split an expense', + subtitle: 'Split an expense right in your chat with one or more friends.', + message: + 'Here’s how to request money:\n' + + '\n' + + '1. Click the green + button.\n' + + '2. Choose Split expense.\n' + + '3. Scan a receipt or enter an amount.\n' + + '4. Add your friend(s) to the request.\n' + + '\n' + + 'Feel free to add more details if you want, or just send it off. Let’s get you paid back!', + }, + { + type: 'enableWallet', + autoCompleted: false, + title: 'Enable your wallet', + subtitle: 'You’ll need to enable your Expensify Wallet to get paid back. Don’t worry, it’s easy!', + message: + 'Here’s how to enable your wallet:\n' + + '\n' + + '1. Click your profile picture.\n' + + '2. Click Wallet > Enable wallet.\n' + + '3. Add your bank account.\n' + + '\n' + + 'Once that’s done, you can request money from anyone and get paid right into your personal bank account.', + }, + ], + }, + [onboardingChoices.LOOKING_AROUND]: { + message: + '# Welcome to Expensify!\n' + + "Hi there, I'm Concierge. Chat with me here for anything you need.\n" + + '\n' + + "Expensify is best known for expense and corporate card management, but we do a lot more than that. Let me know what you're interested in and I'll help get you started.", + video: { + url: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + thumbnailUrl: `${CLOUDFRONT_URL}/images/expensify__favicon.png`, + duration: 55, + width: 1280, + height: 960, + }, + tasks: [], + }, + }, + REPORT_FIELD_TITLE_FIELD_ID: 'text_title', MOBILE_PAGINATION_SIZE: 15, @@ -4351,6 +4622,6 @@ type Country = keyof typeof CONST.ALL_COUNTRIES; type IOUType = ValueOf; type IOUAction = ValueOf; -export type {Country, IOUAction, IOUType}; +export type {Country, IOUAction, IOUType, RateAndUnit, OnboardingPurposeType}; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 819680db0e8a..95d383345ec6 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -385,6 +385,8 @@ const ONYXKEYS = { DISPLAY_NAME_FORM_DRAFT: 'displayNameFormDraft', ONBOARDING_PERSONAL_DETAILS_FORM: 'onboardingPersonalDetailsForm', ONBOARDING_PERSONAL_DETAILS_FORM_DRAFT: 'onboardingPersonalDetailsFormDraft', + ONBOARDING_PERSONAL_WORK: 'onboardingWorkForm', + ONBOARDING_PERSONAL_WORK_DRAFT: 'onboardingWorkFormDraft', ROOM_NAME_FORM: 'roomNameForm', ROOM_NAME_FORM_DRAFT: 'roomNameFormDraft', REPORT_DESCRIPTION_FORM: 'reportDescriptionForm', @@ -475,6 +477,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_DETAILS_FORM]: FormTypes.DisplayNameForm; + [ONYXKEYS.FORMS.ONBOARDING_PERSONAL_WORK]: FormTypes.WorkForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 46f2e2fef049..d372b30d2393 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -310,10 +310,6 @@ const ROUTES = { route: 'r/:reportID/invite/:role?', getRoute: (reportID: string, role?: string) => `r/${reportID}/invite/${role}` as const, }, - MONEY_REQUEST_PARTICIPANTS: { - route: ':iouType/new/participants/:reportID?', - getRoute: (iouType: IOUType, reportID = '') => `${iouType}/new/participants/${reportID}` as const, - }, MONEY_REQUEST_HOLD_REASON: { route: ':type/edit/reason/:transactionID?', getRoute: (type: ValueOf, transactionID: string, reportID: string, backTo: string) => @@ -376,6 +372,11 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => getUrlWithBackToParam(`${action as string}/${iouType as string}/distance/${transactionID}/${reportID}`, backTo), }, + MONEY_REQUEST_STEP_DISTANCE_RATE: { + route: ':action/:iouType/distanceRate/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/distanceRate/${transactionID}/${reportID}`, backTo), + }, MONEY_REQUEST_STEP_MERCHANT: { route: ':action/:iouType/merchant/:transactionID/:reportID', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => @@ -422,20 +423,20 @@ const ROUTES = { }, MONEY_REQUEST_STATE_SELECTOR: { - route: 'request/state', + route: 'submit/state', getRoute: (state?: string, backTo?: string, label?: string) => - `${getUrlWithBackToParam(`request/state${state ? `?state=${encodeURIComponent(state)}` : ''}`, backTo)}${ + `${getUrlWithBackToParam(`submit/state${state ? `?state=${encodeURIComponent(state)}` : ''}`, backTo)}${ // the label param can be an empty string so we cannot use a nullish ?? operator // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing label ? `${backTo || state ? '&' : '?'}label=${encodeURIComponent(label)}` : '' }` as const, }, - IOU_REQUEST: 'request/new', - IOU_SEND: 'send/new', - IOU_SEND_ADD_BANK_ACCOUNT: 'send/new/add-bank-account', - IOU_SEND_ADD_DEBIT_CARD: 'send/new/add-debit-card', - IOU_SEND_ENABLE_PAYMENTS: 'send/new/enable-payments', + IOU_REQUEST: 'submit/new', + IOU_SEND: 'pay/new', + IOU_SEND_ADD_BANK_ACCOUNT: 'pay/new/add-bank-account', + IOU_SEND_ADD_DEBIT_CARD: 'pay/new/add-debit-card', + IOU_SEND_ENABLE_PAYMENTS: 'pay/new/enable-payments', NEW_TASK: 'new/task', NEW_TASK_ASSIGNEE: 'new/task/assignee', @@ -694,9 +695,10 @@ const ROUTES = { route: 'referral/:contentType', getRoute: (contentType: string, backTo?: string) => getUrlWithBackToParam(`referral/${contentType}`, backTo), }, - PROCESS_MONEY_REQUEST_HOLD: 'hold-request-educational', + PROCESS_MONEY_REQUEST_HOLD: 'hold-expense-educational', ONBOARDING_ROOT: 'onboarding', ONBOARDING_PERSONAL_DETAILS: 'onboarding/personal-details', + ONBOARDING_WORK: 'onboarding/work', ONBOARDING_PURPOSE: 'onboarding/purpose', WELCOME_VIDEO_ROOT: 'onboarding/welcome-video', @@ -737,6 +739,7 @@ const ROUTES = { */ const HYBRID_APP_ROUTES = { MONEY_REQUEST_CREATE: '/request/new/scan', + MONEY_REQUEST_SUBMIT_CREATE: '/submit/new/scan', } as const; export {HYBRID_APP_ROUTES, getUrlWithBackToParam}; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index acbb4b507b65..aed70dc1e949 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -152,6 +152,7 @@ const SCREENS = { STEP_DATE: 'Money_Request_Step_Date', STEP_DESCRIPTION: 'Money_Request_Step_Description', STEP_DISTANCE: 'Money_Request_Step_Distance', + STEP_DISTANCE_RATE: 'Money_Request_Step_Rate', STEP_MERCHANT: 'Money_Request_Step_Merchant', STEP_PARTICIPANTS: 'Money_Request_Step_Participants', STEP_SCAN: 'Money_Request_Step_Scan', @@ -159,7 +160,6 @@ const SCREENS = { STEP_WAYPOINT: 'Money_Request_Step_Waypoint', STEP_TAX_AMOUNT: 'Money_Request_Step_Tax_Amount', STEP_TAX_RATE: 'Money_Request_Step_Tax_Rate', - PARTICIPANTS: 'Money_Request_Participants', CURRENCY: 'Money_Request_Currency', WAYPOINT: 'Money_Request_Waypoint', EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', @@ -293,6 +293,7 @@ const SCREENS = { ONBOARDING: { PERSONAL_DETAILS: 'Onboarding_Personal_Details', PURPOSE: 'Onboarding_Purpose', + WORK: 'Onboarding_Work', }, ONBOARD_ENGAGEMENT: { diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 7d13524b78df..1612e97c4903 100644 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -422,7 +422,7 @@ function AttachmentModal({ Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.REQUEST, + CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report?.reportID ?? '', Navigation.getActiveRouteWithoutParams(), diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 358f5333bfba..bf48894beaab 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -90,7 +90,7 @@ function Avatar({ if (isWorkspace) { iconColors = StyleUtils.getDefaultWorkspaceAvatarColor(name); } else if (useFallBackAvatar) { - iconColors = StyleUtils.getBackgroundColorAndFill(theme.border, theme.icon); + iconColors = StyleUtils.getBackgroundColorAndFill(theme.buttonHoveredBG, theme.icon); } else { iconColors = null; } diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 902a96b1bcaf..1ba633e5c9fe 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -102,7 +102,7 @@ function FormWrapper({ {children} {isSubmitButtonVisible && ( @@ -123,26 +123,27 @@ function FormWrapper({ ), [ - children, - enabledWhenOffline, - errorMessage, - errors, - footerContent, formID, - formState?.errorFields, - formState?.isLoading, - isSubmitActionDangerous, - isSubmitButtonVisible, - onSubmit, style, - styles.flex1, + styles.pb5, styles.mh0, styles.mt5, - submitButtonStyles, - submitFlexEnabled, + styles.flex1, + children, + isSubmitButtonVisible, submitButtonText, + errors, + formState?.errorFields, + formState?.isLoading, shouldHideFixErrorsAlert, + errorMessage, + onSubmit, + footerContent, onFixTheErrorsLinkPressed, + submitFlexEnabled, + submitButtonStyles, + enabledWhenOffline, + isSubmitActionDangerous, disablePressOnEnter, ], ); diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index e90cb7584c43..421787202fdd 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -8,13 +8,14 @@ import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import type {DefaultMileageRate} from '@libs/DistanceRequestUtils'; +import type {MileageRate} from '@libs/DistanceRequestUtils'; import * as IOUUtils from '@libs/IOUUtils'; import Log from '@libs/Log'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; @@ -65,7 +66,13 @@ type MoneyRequestConfirmationListOnyxProps = { session: OnyxEntry; /** Unit and rate used for if the expense is a distance expense */ - mileageRate: OnyxEntry; + mileageRates: OnyxEntry>; + + /** Mileage rate default for the policy */ + defaultMileageRate: OnyxEntry; + + /** Last selected distance rates */ + lastSelectedDistanceRates: OnyxEntry>; }; type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { @@ -91,7 +98,7 @@ type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & iouCurrencyCode?: string; /** IOU type */ - iouType?: IOUType; + iouType?: Exclude; /** IOU date */ iouCreated?: string; @@ -176,11 +183,11 @@ function MoneyRequestConfirmationList({ onSendMoney, onConfirm, onSelectParticipant, - iouType = CONST.IOU.TYPE.REQUEST, + iouType = CONST.IOU.TYPE.SUBMIT, isScanRequest = false, iouAmount, policyCategories, - mileageRate, + mileageRates, isDistanceRequest = false, policy, isPolicyExpenseChat = false, @@ -208,28 +215,50 @@ function MoneyRequestConfirmationList({ onToggleBillable, hasSmartScanFailed, reportActionID, + defaultMileageRate, + lastSelectedDistanceRates, action = CONST.IOU.ACTION.CREATE, }: MoneyRequestConfirmationListProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate, toLocaleDigit} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); - const {canUseViolations} = usePermissions(); + const {canUseP2PDistanceRequests, canUseViolations} = usePermissions(iouType); + const {isOffline} = useNetwork(); - const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST; + const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; - const isTypeSend = iouType === CONST.IOU.TYPE.SEND; - const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK_EXPENSE; + const isTypeSend = iouType === CONST.IOU.TYPE.PAY; + const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK; - const {unit, rate, currency} = mileageRate ?? { - unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, - rate: 0, - currency: CONST.CURRENCY.USD, - }; - const distance = transaction?.routes?.route0.distance ?? 0; - const shouldCalculateDistanceAmount = isDistanceRequest && iouAmount === 0; - const taxRates = policy?.taxRates; const transactionID = transaction?.transactionID ?? ''; + const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; + + useEffect(() => { + if (customUnitRateID || !canUseP2PDistanceRequests) { + return; + } + if (!customUnitRateID) { + const rateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? defaultMileageRate?.customUnitRateID ?? ''; + IOU.setCustomUnitRateID(transactionID, rateID); + } + }, [defaultMileageRate, customUnitRateID, lastSelectedDistanceRates, policy?.id, canUseP2PDistanceRequests, transactionID]); + + const policyCurrency = policy?.outputCurrency ?? PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; + + const mileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction) + ? DistanceRequestUtils.getRateForP2P(policyCurrency) + : mileageRates?.[customUnitRateID] ?? DistanceRequestUtils.getDefaultMileageRate(policy); + + const {unit, rate} = mileageRate ?? {}; + + const prevRate = usePrevious(rate); + const shouldCalculateDistanceAmount = isDistanceRequest && (iouAmount === 0 || prevRate !== rate); + + const currency = (mileageRate as MileageRate)?.currency ?? policyCurrency; + + const distance = transaction?.routes?.route0?.distance ?? 0; + const taxRates = policy?.taxRates ?? null; // A flag for showing the categories field const shouldShowCategories = isPolicyExpenseChat && (!!iouCategory || OptionsListUtils.hasEnabledOptions(Object.values(policyCategories ?? {}))); @@ -255,12 +284,12 @@ function MoneyRequestConfirmationList({ // A flag for showing the billable field const shouldShowBillable = policy?.disabledFields?.defaultBillable === false; const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); - const hasRoute = TransactionUtils.hasRoute(transaction); + const hasRoute = TransactionUtils.hasRoute(transaction, isDistanceRequest); const isDistanceRequestWithPendingRoute = isDistanceRequest && (!hasRoute || !rate) && !isMovingTransactionFromTrackExpense; const formattedAmount = isDistanceRequestWithPendingRoute ? '' : CurrencyUtils.convertToDisplayString( - shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0) : iouAmount, + shouldCalculateDistanceAmount ? DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0) : iouAmount, isDistanceRequest ? currency : iouCurrencyCode, ); const formattedTaxAmount = CurrencyUtils.convertToDisplayString(transaction?.taxAmount, iouCurrencyCode); @@ -329,7 +358,7 @@ function MoneyRequestConfirmationList({ return; } - const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit, rate ?? 0); + const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0); IOU.setMoneyRequestAmount(transactionID, amount, currency ?? ''); }, [shouldCalculateDistanceAmount, distance, rate, unit, transactionID, currency]); @@ -557,7 +586,7 @@ function MoneyRequestConfirmationList({ return; } - if (iouType === CONST.IOU.TYPE.SEND) { + if (iouType === CONST.IOU.TYPE.PAY) { if (!paymentMethod) { return; } @@ -608,7 +637,7 @@ function MoneyRequestConfirmationList({ return; } - const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.SEND; + const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.PAY; const shouldDisableButton = selectedParticipants.length === 0; const button = shouldShowSettlementButton ? ( @@ -720,13 +749,50 @@ function MoneyRequestConfirmationList({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={didConfirm} // todo: handle edit for transaction while moving from track expense interactive={!isReadOnly && !isMovingTransactionFromTrackExpense} /> ), - shouldShow: isDistanceRequest, - isSupplementary: true, + shouldShow: isDistanceRequest && !canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams())) + } + disabled={didConfirm} + interactive={!isReadOnly} + /> + ), + shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + isSupplementary: false, + }, + { + item: ( + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + disabled={didConfirm} + interactive={Boolean(rate) && !isReadOnly && isPolicyExpenseChat} + /> + ), + shouldShow: isDistanceRequest && canUseP2PDistanceRequests, + isSupplementary: false, }, { item: ( @@ -937,14 +1003,14 @@ function MoneyRequestConfirmationList({ )} - {(!isMovingTransactionFromTrackExpense || !hasRoute) && + {!isDistanceRequest && // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (receiptImage || receiptThumbnail ? receiptThumbnailContent : // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") PolicyUtils.isPaidGroupPolicy(policy) && !isDistanceRequest && - iouType === CONST.IOU.TYPE.REQUEST && ( + iouType === CONST.IOU.TYPE.SUBMIT && ( Navigation.navigate( @@ -986,11 +1052,18 @@ export default withOnyx `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, }, - mileageRate: { + defaultMileageRate: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, selector: DistanceRequestUtils.getDefaultMileageRate, }, + mileageRates: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + selector: DistanceRequestUtils.getMileageRates, + }, policy: { key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, }, + lastSelectedDistanceRates: { + key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, + }, })(MoneyRequestConfirmationList); diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx index 0588f31a0a8c..237fc8f955a3 100644 --- a/src/components/ReferralProgramCTA.tsx +++ b/src/components/ReferralProgramCTA.tsx @@ -15,9 +15,9 @@ import Tooltip from './Tooltip'; type ReferralProgramCTAProps = { referralContentType: - | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST + | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT - | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY + | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.PAY_SOMEONE | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND; style?: ViewStyle; onDismiss?: () => void; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index c5cad0eccdeb..f337f2f40c71 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -12,6 +12,7 @@ import Switch from '@components/Switch'; import Text from '@components/Text'; import ViolationMessages from '@components/ViolationMessages'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import usePermissions from '@hooks/usePermissions'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -21,6 +22,8 @@ import type {ViolationField} from '@hooks/useViolations'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import type {MileageRate} from '@libs/DistanceRequestUtils'; +import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import {isTaxTrackingEnabled} from '@libs/PolicyUtils'; @@ -63,6 +66,9 @@ type MoneyRequestViewOnyxPropsWithoutTransaction = { /** The actions from the parent report */ parentReportActions: OnyxEntry; + + /** The rates for the policy */ + rates: Record; }; type MoneyRequestViewPropsWithoutTransaction = MoneyRequestViewOnyxPropsWithoutTransaction & { @@ -89,13 +95,15 @@ function MoneyRequestView({ policy, transactionViolations, shouldShowAnimatedBackground, + rates, }: MoneyRequestViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); - const {translate} = useLocalize(); - const {canUseViolations} = usePermissions(); + const {translate, toLocaleDigit} = useLocalize(); + const {canUseViolations, canUseP2PDistanceRequests} = usePermissions(); const parentReportAction = parentReportActions?.[report.parentReportActionID ?? ''] ?? null; const moneyRequestReport = parentReport; const { @@ -170,6 +178,18 @@ function MoneyRequestView({ let amountDescription = `${translate('iou.amount')}`; + const hasRoute = TransactionUtils.hasRoute(transaction, isDistanceRequest); + const rateID = transaction?.comment.customUnit?.customUnitRateID ?? '0'; + + const currency = policy ? policy.outputCurrency : PolicyUtils.getPersonalPolicy()?.outputCurrency ?? CONST.CURRENCY.USD; + + const mileageRate = TransactionUtils.isCustomUnitRateIDForP2P(transaction) ? DistanceRequestUtils.getRateForP2P(currency) : rates[rateID as string] ?? {}; + const {unit, rate} = mileageRate; + + const distance = DistanceRequestUtils.getDistanceFromMerchant(transactionMerchant, unit); + const rateToDisplay = DistanceRequestUtils.getRateForDisplay(unit, rate, currency, translate, toLocaleDigit, isOffline); + const distanceToDisplay = DistanceRequestUtils.getDistanceForDisplay(hasRoute, distance, unit, rate, translate); + const saveBillable = useCallback( (newBillable: boolean) => { // If the value hasn't changed, don't request to save changes on the server and just close the modal @@ -255,6 +275,48 @@ function MoneyRequestView({ [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, canUseViolations, hasViolations, translate, getViolationsForField], ); + const distanceRequestFields = canUseP2PDistanceRequests ? ( + <> + + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID)) + } + /> + + + {}} + /> + + + ) : ( + + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID)) + } + /> + + ); + return ( {shouldShowAnimatedBackground && } @@ -297,7 +359,7 @@ function MoneyRequestView({ Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( CONST.IOU.ACTION.EDIT, - CONST.IOU.TYPE.REQUEST, + CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID, Navigation.getActiveRouteWithoutParams(), @@ -317,7 +379,7 @@ function MoneyRequestView({ interactive={canEditAmount} shouldShowRightIcon={canEditAmount} onPress={() => - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID)) } brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} error={getErrorForField('amount')} @@ -333,7 +395,7 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), + ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID), ) } wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} @@ -343,20 +405,7 @@ function MoneyRequestView({ /> {isDistanceRequest ? ( - - - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), - ) - } - /> - + distanceRequestFields ) : ( Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), + ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID), ) } brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -383,7 +432,7 @@ function MoneyRequestView({ shouldShowRightIcon={canEditDate} titleStyle={styles.flex1} onPress={() => - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID)) } brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} error={getErrorForField('date')} @@ -399,7 +448,7 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID), ) } brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} @@ -421,10 +470,17 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, orderWeight, transaction?.transactionID ?? '', report.reportID), + ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, orderWeight, transaction?.transactionID ?? '', report.reportID), ) } - brickRoadIndicator={getErrorForField('tag', {tagListIndex: index, tagListName: name}) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={ + getErrorForField('tag', { + tagListIndex: index, + tagListName: name, + }) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined + } error={getErrorForField('tag', {tagListIndex: index, tagListName: name})} /> @@ -448,9 +504,11 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), + ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID), ) } + brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('tax')} /> )} @@ -465,7 +523,7 @@ function MoneyRequestView({ titleStyle={styles.flex1} onPress={() => Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), + ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.SUBMIT, transaction?.transactionID ?? '', report.reportID), ) } /> @@ -520,6 +578,10 @@ export default withOnyx `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, canEvict: false, }, + rates: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, + selector: DistanceRequestUtils.getMileageRates, + }, })( withOnyx({ transaction: { diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 219199c25bc3..d61bd5186ecc 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -44,7 +44,7 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(report, isUserPolicyAdmin); - const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs, canUseTrackExpense); + const moneyRequestOptions = ReportUtils.temporary_getMoneyRequestOptions(report, policy, participantAccountIDs, canUseTrackExpense); const additionalText = moneyRequestOptions.map((item) => translate(`reportActionsView.iouTypes.${item}`)).join(', '); const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy); const reportName = ReportUtils.getReportName(report); @@ -160,9 +160,9 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP ))} )} - {(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND) || - moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST) || - moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK_EXPENSE)) && {translate('reportActionsView.usePlusButton', {additionalText})}} + {(moneyRequestOptions.includes(CONST.IOU.TYPE.PAY) || moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT) || moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK)) && ( + {translate('reportActionsView.usePlusButton', {additionalText})} + )} ); diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 9e6fb31d0316..b1f0141663ad 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -18,6 +18,7 @@ function BaseListItem({ containerStyle, isDisabled = false, shouldPreventDefaultFocusOnSelectRow = false, + shouldPreventEnterKeySubmit = false, canSelectMultiple = false, onSelectRow, onDismissError = () => {}, @@ -39,7 +40,7 @@ function BaseListItem({ const pressableRef = useRef(null); // Sync focus on an item - useSyncFocus(pressableRef, Boolean(isFocused && shouldSyncFocus)); + useSyncFocus(pressableRef, Boolean(isFocused), shouldSyncFocus); const rightHandSideComponentRender = () => { if (canSelectMultiple || !rightHandSideComponent) { @@ -65,7 +66,12 @@ function BaseListItem({ // eslint-disable-next-line react/jsx-props-no-spreading {...bind} ref={pressableRef} - onPress={() => onSelectRow(item)} + onPress={(e) => { + if (shouldPreventEnterKeySubmit && e && 'key' in e && e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) { + return; + } + onSelectRow(item); + }} disabled={isDisabled} accessibilityLabel={item.text ?? ''} role={CONST.ROLE.BUTTON} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 9ab89aa73f86..f0d22251bc74 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -352,6 +352,8 @@ function BaseSelectionList( onCheckboxPress={onCheckboxPress ? () => onCheckboxPress?.(item) : undefined} onDismissError={() => onDismissError?.(item)} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} + // We're already handling the Enter key press in the useKeyboardShortcut hook, so we don't want the list item to submit the form + shouldPreventEnterKeySubmit rightHandSideComponent={rightHandSideComponent} keyForList={item.keyForList ?? ''} isMultilineSupported={isRowMultilineSupported} diff --git a/src/components/SelectionList/RadioListItem.tsx b/src/components/SelectionList/RadioListItem.tsx index 7ad4819b9690..b595008e4e3b 100644 --- a/src/components/SelectionList/RadioListItem.tsx +++ b/src/components/SelectionList/RadioListItem.tsx @@ -14,6 +14,7 @@ function RadioListItem({ onSelectRow, onDismissError, shouldPreventDefaultFocusOnSelectRow, + shouldPreventEnterKeySubmit, rightHandSideComponent, isMultilineSupported = false, onFocus, @@ -34,6 +35,7 @@ function RadioListItem({ onSelectRow={onSelectRow} onDismissError={onDismissError} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} + shouldPreventEnterKeySubmit={shouldPreventEnterKeySubmit} rightHandSideComponent={rightHandSideComponent} keyForList={item.keyForList} onFocus={onFocus} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index a96d6c3abb17..50929095dc91 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -137,6 +137,9 @@ type ListItemProps = CommonListItemProps & { /** Whether the default focus should be prevented on row selection */ shouldPreventDefaultFocusOnSelectRow?: boolean; + /** Prevent the submission of the list item when enter key is pressed */ + shouldPreventEnterKeySubmit?: boolean; + /** Key used internally by React */ keyForList?: string; @@ -150,6 +153,7 @@ type ListItemProps = CommonListItemProps & { type BaseListItemProps = CommonListItemProps & { item: TItem; shouldPreventDefaultFocusOnSelectRow?: boolean; + shouldPreventEnterKeySubmit?: boolean; keyForList?: string | null; errors?: Errors | ReceiptErrors | null; pendingAction?: PendingAction | null; diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 6827dee44141..165b438a1d4c 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -100,7 +100,7 @@ function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) { text="Navigate" onPress={() => { Navigation.dismissModal(); - Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS); + Navigation.navigate(ROUTES.ONBOARDING_PURPOSE); }} /> diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 31cd0aee37da..0138e2f870e1 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -2,7 +2,7 @@ import type {AVPlaybackStatus, VideoFullscreenUpdateEvent} from 'expo-av'; import {ResizeMode, Video, VideoFullscreenUpdate} from 'expo-av'; import type {MutableRefObject} from 'react'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react'; import type {GestureResponderEvent} from 'react-native'; import {View} from 'react-native'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; @@ -194,6 +194,18 @@ function BaseVideoPlayer({ }); }, [currentVideoPlayerRef, handleFullscreenUpdate, handlePlaybackStatusUpdate]); + // use `useLayoutEffect` instead of `useEffect` because ref is null when unmount in `useEffect` hook + // ref url: https://reactjs.org/blog/2020/08/10/react-v17-rc.html#effect-cleanup-timing + useLayoutEffect( + () => () => { + if (shouldUseSharedVideoElement || videoPlayerRef.current !== currentVideoPlayerRef.current) { + return; + } + currentVideoPlayerRef.current = null; + }, + [currentVideoPlayerRef, shouldUseSharedVideoElement], + ); + useEffect(() => { if (!isUploading || !videoPlayerRef.current) { return; diff --git a/src/hooks/useDismissedReferralBanners.ts b/src/hooks/useDismissedReferralBanners.ts index 94ccd0a0b567..23a3ecefbbc9 100644 --- a/src/hooks/useDismissedReferralBanners.ts +++ b/src/hooks/useDismissedReferralBanners.ts @@ -5,9 +5,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; type UseDismissedReferralBannersProps = { referralContentType: - | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST + | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT - | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY + | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.PAY_SOMEONE | typeof CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND; }; diff --git a/src/hooks/useSyncFocus/index.ts b/src/hooks/useSyncFocus/index.ts index bdc4a6a876da..2dd0fef70cf7 100644 --- a/src/hooks/useSyncFocus/index.ts +++ b/src/hooks/useSyncFocus/index.ts @@ -1,20 +1,24 @@ import {useLayoutEffect} from 'react'; import type {RefObject} from 'react'; import type {View} from 'react-native'; +import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; /** * Custom React hook created to handle sync of focus on an element when the user navigates through the app with keyboard. * When the user navigates through the app using the arrows and then the tab button, the focus on the element and the native focus of the browser differs. * To maintain consistency when an element is focused in the app, the focus() method is additionally called on the focused element to eliminate the difference between native browser focus and application focus. */ -const useSyncFocus = (ref: RefObject, isFocused: boolean) => { +const useSyncFocus = (ref: RefObject, isFocused: boolean, shouldSyncFocus = true) => { + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + useLayoutEffect(() => { - if (!isFocused) { + if (!isFocused || !shouldSyncFocus || !didScreenTransitionEnd) { return; } ref.current?.focus(); - }, [isFocused, ref]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [didScreenTransitionEnd, isFocused, ref]); }; export default useSyncFocus; diff --git a/src/languages/en.ts b/src/languages/en.ts index ed2587e5e2c6..eec4d660e44a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -51,6 +51,7 @@ import type { PayerPaidParams, PayerSettledParams, PaySomeoneParams, + ReimbursementRateParams, RemovedTheRequestParams, RenamedRoomActionParams, ReportArchiveReasonsClosedParams, @@ -315,12 +316,14 @@ export default { member: 'Member', role: 'Role', currency: 'Currency', + rate: 'Rate', emptyLHN: { title: 'Woohoo! All caught up.', subtitleText1: 'Find a chat using the', subtitleText2: 'button above, or create something using the', subtitleText3: 'button below.', }, + businessName: 'Business name', }, location: { useCurrent: 'Use current location', @@ -509,11 +512,10 @@ export default { welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Welcome to ${roomName}!`, usePlusButton: ({additionalText}: UsePlusButtonParams) => `\nYou can also use the + button to ${additionalText}, or assign a task!`, iouTypes: { - send: 'pay expenses', + pay: 'pay expenses', split: 'split an expense', - request: 'submit an expense', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'track-expense': 'track an expense', + submit: 'submit an expense', + track: 'track an expense', }, }, reportAction: { @@ -628,7 +630,8 @@ export default { canceled: 'Canceled', posted: 'Posted', deleteReceipt: 'Delete receipt', - routePending: 'Route pending...', + routePending: 'Pending...', + defaultRate: 'Default rate', receiptScanning: 'Scan in progress…', receiptMissingDetails: 'Receipt missing details', missingAmount: 'Missing amount', @@ -732,6 +735,7 @@ export default { set: 'set', changed: 'changed', removed: 'removed', + chooseARate: ({unit}: ReimbursementRateParams) => `Select a workspace reimbursement rate per ${unit}`, }, notificationPreferencesPage: { header: 'Notification preferences', @@ -1312,13 +1316,13 @@ export default { notYou: ({user}: NotYouParams) => `Not ${user}?`, }, onboarding: { - welcome: 'Welcome!', welcomeVideo: { title: 'Welcome to Expensify', description: 'Getting paid is as easy as sending a message.', button: "Let's go", }, whatsYourName: "What's your name?", + whereYouWork: 'Where do you work?', purpose: { title: 'What do you want to do today?', error: 'Please make a selection before continuing', @@ -2646,16 +2650,16 @@ export default { header: `Start a chat, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, body: `Get paid to talk to your friends! Start a chat with a new Expensify account and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer.`, }, - [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE]: { buttonText1: 'Submit expense, ', buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, header: `Submit an expense, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, body: `It pays to get paid! Submit an expense to a new Expensify account and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer.`, }, - [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: { - buttonText1: 'Pay Someone, ', + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.PAY_SOMEONE]: { + buttonText1: 'Pay someone, ', buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, - header: `Pay Someone, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Pay someone, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, body: `You gotta spend money to make money! Pay someone with Expensify and get $${CONST.REFERRAL_PROGRAM.REVENUE} when they become a customer.`, }, [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: { diff --git a/src/languages/es.ts b/src/languages/es.ts index beb654cf0bc4..57b5dde8297c 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -50,6 +50,7 @@ import type { PayerPaidParams, PayerSettledParams, PaySomeoneParams, + ReimbursementRateParams, RemovedTheRequestParams, RenamedRoomActionParams, ReportArchiveReasonsClosedParams, @@ -305,12 +306,14 @@ export default { member: 'Miembro', role: 'Role', currency: 'Divisa', + rate: 'Tarifa', emptyLHN: { title: 'Woohoo! Todo al día.', subtitleText1: 'Encuentra un chat usando el botón', subtitleText2: 'o crea algo usando el botón', subtitleText3: '.', }, + businessName: 'Nombre del Negocio', }, location: { useCurrent: 'Usar ubicación actual', @@ -502,11 +505,10 @@ export default { welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `¡Bienvenido a ${roomName}!`, usePlusButton: ({additionalText}: UsePlusButtonParams) => `\n¡También puedes usar el botón + de abajo para ${additionalText}, o asignar una tarea!`, iouTypes: { - send: 'pagar gastos', + pay: 'pagar gastos', split: 'dividir un gasto', - request: 'presentar un gasto', - // eslint-disable-next-line @typescript-eslint/naming-convention - 'track-expense': 'rastrear un gasto', + submit: 'presentar un gasto', + track: 'rastrear un gasto', }, }, reportAction: { @@ -621,7 +623,8 @@ export default { canceled: 'Canceló', posted: 'Contabilizado', deleteReceipt: 'Eliminar recibo', - routePending: 'Ruta pendiente...', + routePending: 'Pendiente...', + defaultRate: 'Tasa predeterminada', receiptScanning: 'Escaneo en curso…', receiptMissingDetails: 'Recibo con campos vacíos', missingAmount: 'Falta importe', @@ -727,6 +730,7 @@ export default { set: 'estableció', changed: 'cambió', removed: 'eliminó', + chooseARate: ({unit}: ReimbursementRateParams) => `Seleccione una tasa de reembolso del espacio de trabajo por ${unit}`, }, notificationPreferencesPage: { header: 'Preferencias de avisos', @@ -1311,13 +1315,13 @@ export default { notYou: ({user}: NotYouParams) => `¿No eres ${user}?`, }, onboarding: { - welcome: '¡Bienvenido!', welcomeVideo: { title: 'Bienvenido a Expensify', description: 'Cobrar es tan fácil como enviar un mensaje.', button: 'Vámonos', }, whatsYourName: '¿Cómo te llamas?', + whereYouWork: '¿Dónde trabajas?', purpose: { title: '¿Qué quieres hacer hoy?', error: 'Por favor, haga una selección antes de continuar.', @@ -3136,13 +3140,13 @@ export default { header: `Inicia un chat y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, body: `¡Gana dinero por hablar con tus amigos! Inicia un chat con una cuenta nueva de Expensify y recibe $${CONST.REFERRAL_PROGRAM.REVENUE} cuando se conviertan en clientes.`, }, - [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SUBMIT_EXPENSE]: { buttonText1: 'Presentar gasto, ', buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, header: `Presenta un gasto y consigue $${CONST.REFERRAL_PROGRAM.REVENUE}`, body: `¡Vale la pena cobrar! Envia un gasto a una cuenta nueva de Expensify y recibe $${CONST.REFERRAL_PROGRAM.REVENUE} cuando se conviertan en clientes.`, }, - [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.PAY_SOMEONE]: { buttonText1: 'Pagar a alguien, ', buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, header: `Paga a alguien y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, diff --git a/src/languages/types.ts b/src/languages/types.ts index 30b7f842db4c..9426e343bbf0 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -1,4 +1,5 @@ import type {ReportAction} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; import type en from './en'; type AddressLineParams = { @@ -299,6 +300,8 @@ type HeldRequestParams = {comment: string}; type DistanceRateOperationsParams = {count: number}; +type ReimbursementRateParams = {unit: Unit}; + export type { AdminCanceledRequestParams, ApprovedAmountParams, @@ -403,4 +406,5 @@ export type { LogSizeParams, HeldRequestParams, PaySomeoneParams, + ReimbursementRateParams, }; diff --git a/src/libs/API/parameters/CompleteGuidedSetupParams.ts b/src/libs/API/parameters/CompleteGuidedSetupParams.ts new file mode 100644 index 000000000000..e3a0309d5113 --- /dev/null +++ b/src/libs/API/parameters/CompleteGuidedSetupParams.ts @@ -0,0 +1,10 @@ +import type {OnboardingPurposeType} from '@src/CONST'; + +type CompleteGuidedSetupParams = { + firstName: string; + lastName: string; + guidedSetupData: string; + engagementChoice: OnboardingPurposeType; +}; + +export default CompleteGuidedSetupParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index bfa89b5d3bd3..61a0d6870cd5 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -207,6 +207,7 @@ export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrenc export type {default as UpdatePolicyConnectionConfigParams} from './UpdatePolicyConnectionConfigParams'; export type {default as RemovePolicyConnectionParams} from './RemovePolicyConnectionParams'; export type {default as RenamePolicyTaxParams} from './RenamePolicyTaxParams'; +export type {default as CompleteGuidedSetupParams} from './CompleteGuidedSetupParams'; export type {default as DismissTrackExpenseActionableWhisperParams} from './DismissTrackExpenseActionableWhisperParams'; export type {default as ConvertTrackedExpenseToRequestParams} from './ConvertTrackedExpenseToRequestParams'; export type {default as ShareTrackedExpenseParams} from './ShareTrackedExpenseParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f91b694548ba..e0a46bebc1e3 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -141,6 +141,7 @@ const WRITE_COMMANDS = { REOPEN_TASK: 'ReopenTask', COMPLETE_TASK: 'CompleteTask', COMPLETE_ENGAGEMENT_MODAL: 'CompleteEngagementModal', + COMPLETE_GUIDED_SETUP: 'CompleteGuidedSetup', SET_NAME_VALUE_PAIR: 'SetNameValuePair', SET_REPORT_FIELD: 'Report_SetFields', DELETE_REPORT_FIELD: 'RemoveReportField', @@ -343,6 +344,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.REOPEN_TASK]: Parameters.ReopenTaskParams; [WRITE_COMMANDS.COMPLETE_TASK]: Parameters.CompleteTaskParams; [WRITE_COMMANDS.COMPLETE_ENGAGEMENT_MODAL]: Parameters.CompleteEngagementModalParams; + [WRITE_COMMANDS.COMPLETE_GUIDED_SETUP]: Parameters.CompleteGuidedSetupParams; [WRITE_COMMANDS.SET_NAME_VALUE_PAIR]: Parameters.SetNameValuePairParams; [WRITE_COMMANDS.SET_REPORT_FIELD]: Parameters.SetReportFieldParams; [WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 9b96bfa009dc..5ced8b1a06e3 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -379,11 +379,11 @@ function getDBTime(timestamp: string | number = ''): string { /** * Returns the current time plus skew in milliseconds in the format expected by the database */ -function getDBTimeWithSkew(): string { +function getDBTimeWithSkew(timestamp: string | number = ''): string { if (networkTimeSkew > 0) { - return getDBTime(new Date().valueOf() + networkTimeSkew); + return getDBTime(new Date(timestamp).valueOf() + networkTimeSkew); } - return getDBTime(); + return getDBTime(timestamp); } function subtractMillisecondsFromDateTime(dateTime: string, milliseconds: number): string { diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 12a240ae9041..676c4493fe75 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -1,18 +1,55 @@ -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import type {RateAndUnit} from '@src/CONST'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {LastSelectedDistanceRates, Report} from '@src/types/onyx'; import type {Unit} from '@src/types/onyx/Policy'; import type Policy from '@src/types/onyx/Policy'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; import * as CurrencyUtils from './CurrencyUtils'; import * as PolicyUtils from './PolicyUtils'; +import * as ReportUtils from './ReportUtils'; -type DefaultMileageRate = { +type MileageRate = { customUnitRateID?: string; rate?: number; currency?: string; unit: Unit; + name?: string; }; +const policies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (policy, key) => { + if (!policy || !key || !policy.name) { + return; + } + + policies[key] = policy; + }, +}); + +let lastSelectedDistanceRates: OnyxEntry = {}; +Onyx.connect({ + key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, + callback: (value) => { + lastSelectedDistanceRates = value; + }, +}); + +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + +const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters +const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter + /** * Retrieves the default mileage rate based on a given policy. * @@ -23,7 +60,7 @@ type DefaultMileageRate = { * @returns [currency] - The currency associated with the rate. * @returns [unit] - The unit of measurement for the distance. */ -function getDefaultMileageRate(policy: OnyxEntry): DefaultMileageRate | null { +function getDefaultMileageRate(policy: OnyxEntry | EmptyObject): MileageRate | null { if (!policy?.customUnits) { return null; } @@ -33,16 +70,14 @@ function getDefaultMileageRate(policy: OnyxEntry): DefaultMileageRate | return null; } - const distanceRate = Object.values(distanceUnit.rates).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - if (!distanceRate) { - return null; - } + const distanceRate = Object.values(distanceUnit.rates).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE) ?? Object.values(distanceUnit.rates)[0]; return { customUnitRateID: distanceRate.customUnitRateID, rate: distanceRate.rate, currency: distanceRate.currency, unit: distanceUnit.attributes.unit, + name: distanceRate.name, }; } @@ -55,9 +90,6 @@ function getDefaultMileageRate(policy: OnyxEntry): DefaultMileageRate | * @returns The converted distance in the specified unit. */ function convertDistanceUnit(distanceInMeters: number, unit: Unit): number { - const METERS_TO_KM = 0.001; // 1 kilometer is 1000 meters - const METERS_TO_MILES = 0.000621371; // There are approximately 0.000621371 miles in a meter - switch (unit) { case CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS: return distanceInMeters * METERS_TO_KM; @@ -78,6 +110,37 @@ function getRoundedDistanceInUnits(distanceInMeters: number, unit: Unit): string return convertedDistance.toFixed(2); } +/** + * @param unit Unit that should be used to display the distance + * @param rate Expensable amount allowed per unit + * @param currency The currency associated with the rate + * @param translate Translate function + * @param toLocaleDigit Function to convert to localized digit + * @returns A string that describes the distance traveled and the rate used for expense calculation + */ +function getRateForDisplay( + unit: Unit | undefined, + rate: number | undefined, + currency: string | undefined, + translate: LocaleContextProps['translate'], + toLocaleDigit: LocaleContextProps['toLocaleDigit'], + isOffline?: boolean, +): string { + if (isOffline && !rate) { + return translate('iou.defaultRate'); + } + if (!rate || !currency || !unit) { + return translate('iou.routePending'); + } + + const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); + const ratePerUnit = PolicyUtils.getUnitRateValue(toLocaleDigit, {rate}); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; + + return `${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`; +} + /** * @param hasRoute Whether the route exists for the distance expense * @param distanceInMeters Distance traveled @@ -86,8 +149,8 @@ function getRoundedDistanceInUnits(distanceInMeters: number, unit: Unit): string * @param translate Translate function * @returns A string that describes the distance traveled */ -function getDistanceForDisplay(hasRoute: boolean, distanceInMeters: number, unit: Unit, rate: number, translate: LocaleContextProps['translate']): string { - if (!hasRoute || !rate) { +function getDistanceForDisplay(hasRoute: boolean, distanceInMeters: number, unit: Unit | undefined, rate: number | undefined, translate: LocaleContextProps['translate']): string { + if (!hasRoute || !rate || !unit) { return translate('iou.routePending'); } @@ -112,8 +175,8 @@ function getDistanceForDisplay(hasRoute: boolean, distanceInMeters: number, unit function getDistanceMerchant( hasRoute: boolean, distanceInMeters: number, - unit: Unit, - rate: number, + unit: Unit | undefined, + rate: number | undefined, currency: string, translate: LocaleContextProps['translate'], toLocaleDigit: LocaleContextProps['toLocaleDigit'], @@ -122,14 +185,50 @@ function getDistanceMerchant( return translate('iou.routePending'); } - const formattedDistance = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate); - const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); - const ratePerUnit = PolicyUtils.getUnitRateValue(toLocaleDigit, {rate}); + const distanceInUnits = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate); + const ratePerUnit = getRateForDisplay(unit, rate, currency, translate, toLocaleDigit); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; + return `${distanceInUnits} @ ${ratePerUnit}`; +} + +/** + * Retrieves the mileage rates for given policy. + * + * @param policy - The policy from which to extract the mileage rates. + * + * @returns An array of mileage rates or an empty array if not found. + */ +function getMileageRates(policy: OnyxEntry): Record { + const mileageRates: Record = {}; + + if (!policy) { + return mileageRates; + } + + if (!policy?.customUnits) { + return mileageRates; + } + + const distanceUnit = Object.values(policy.customUnits).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + if (!distanceUnit?.rates) { + return mileageRates; + } + + Object.entries(distanceUnit.rates).forEach(([rateID, rate]) => { + mileageRates[rateID] = { + rate: rate.rate, + currency: rate.currency, + unit: distanceUnit.attributes.unit, + name: rate.name, + customUnitRateID: rate.customUnitRateID, + }; + }); + + return mileageRates; +} - return `${formattedDistance} @ ${currencySymbol}${ratePerUnit} / ${singularDistanceUnit}`; +function getRateForP2P(currency: string): RateAndUnit { + return CONST.CURRENCY_TO_DEFAULT_MILEAGE_RATE[currency] ?? CONST.CURRENCY_TO_DEFAULT_MILEAGE_RATE.USD; } /** @@ -146,6 +245,52 @@ function getDistanceRequestAmount(distance: number, unit: Unit, rate: number): n return Math.round(roundedDistance * rate); } -export default {getDefaultMileageRate, getDistanceMerchant, getDistanceRequestAmount}; +/** + * Extracts the distance from a merchant string. + * + * @param merchant - The merchant string containing the distance. + * @returns The distance extracted from the merchant string. + */ +function getDistanceFromMerchant(merchant: string | undefined, unit: Unit): number { + if (!merchant) { + return 0; + } + + const distance = Number(merchant.split(' ')[0]); + if (!distance) { + return 0; + } + // we need to convert the distance back to meters (it's saved in kilometers or miles in merchant) to pass it to getDistanceForDisplay + return unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS ? distance / METERS_TO_KM : distance / METERS_TO_MILES; +} + +/** + * Returns custom unit rate ID for the distance transaction + */ +function getCustomUnitRateID(reportID: string) { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; + const parentReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`] ?? null; + const policy = PolicyUtils.getPolicy(report?.policyID ?? parentReport?.policyID ?? ''); + + let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; + + if (ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isPolicyExpenseChat(parentReport)) { + customUnitRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? getDefaultMileageRate(policy)?.customUnitRateID ?? ''; + } + + return customUnitRateID; +} + +export default { + getDefaultMileageRate, + getDistanceMerchant, + getDistanceRequestAmount, + getRateForDisplay, + getMileageRates, + getDistanceForDisplay, + getRateForP2P, + getDistanceFromMerchant, + getCustomUnitRateID, +}; -export type {DefaultMileageRate}; +export type {MileageRate}; diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 63930ffd7131..4a4ce6407fa2 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -9,7 +9,7 @@ import Navigation from './Navigation/Navigation'; import * as TransactionUtils from './TransactionUtils'; function navigateToStartMoneyRequestStep(requestType: IOURequestType, iouType: IOUType, transactionID: string, reportID: string, iouAction?: IOUAction): void { - if (iouAction === CONST.IOU.ACTION.CATEGORIZE || iouAction === CONST.IOU.ACTION.REQUEST) { + if (iouAction === CONST.IOU.ACTION.CATEGORIZE || iouAction === CONST.IOU.ACTION.SUBMIT) { Navigation.goBack(); return; } @@ -109,7 +109,16 @@ function isIOUReportPendingCurrencyConversion(iouReport: Report): boolean { * Checks if the iou type is one of request, send, or split. */ function isValidMoneyRequestType(iouType: string): boolean { - const moneyRequestType: string[] = [CONST.IOU.TYPE.REQUEST, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.SEND, CONST.IOU.TYPE.TRACK_EXPENSE]; + const moneyRequestType: string[] = [CONST.IOU.TYPE.REQUEST, CONST.IOU.TYPE.SUBMIT, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.SEND, CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK]; + return moneyRequestType.includes(iouType); +} + +/** + * Checks if the iou type is one of submit, pay, track, or split. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +function temporary_isValidMoneyRequestType(iouType: string): boolean { + const moneyRequestType: string[] = [CONST.IOU.TYPE.SUBMIT, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK]; return moneyRequestType.includes(iouType); } @@ -129,7 +138,7 @@ function insertTagIntoTransactionTagsString(transactionTags: string, tag: string } function isMovingTransactionFromTrackExpense(action?: IOUAction) { - if (action === CONST.IOU.ACTION.REQUEST || action === CONST.IOU.ACTION.SHARE || action === CONST.IOU.ACTION.CATEGORIZE) { + if (action === CONST.IOU.ACTION.SUBMIT || action === CONST.IOU.ACTION.SHARE || action === CONST.IOU.ACTION.CATEGORIZE) { return true; } @@ -144,4 +153,5 @@ export { isValidMoneyRequestType, navigateToStartMoneyRequestStep, updateIOUOwnerAndTotal, + temporary_isValidMoneyRequestType, }; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 4e681e016b6b..2da048ffab4f 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -81,7 +81,7 @@ function replaceAllDigits(text: string, convertFn: (char: string) => string): st * Check if distance expense or not */ function isDistanceRequest(iouType: IOUType, selectedTab: OnyxEntry): boolean { - return iouType === CONST.IOU.TYPE.REQUEST && selectedTab === CONST.TAB_REQUEST.DISTANCE; + return (iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SUBMIT) && selectedTab === CONST.TAB_REQUEST.DISTANCE; } /** diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 9157d7486c9e..096a88254eae 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -369,7 +369,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie /> require('../../../../pages/iou/request/step/IOURequestStepDate').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: () => require('../../../../pages/iou/request/step/IOURequestStepDescription').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: () => require('../../../../pages/iou/request/step/IOURequestStepDistance').default as React.ComponentType, + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE]: () => require('@pages/iou/request/step/IOURequestStepDistanceRate').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: () => require('../../../../pages/iou/request/step/IOURequestStepMerchant').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: () => require('../../../../pages/iou/request/step/IOURequestStepParticipants').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_SCAN]: () => require('../../../../pages/iou/request/step/IOURequestStepScan').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_TAG]: () => require('../../../../pages/iou/request/step/IOURequestStepTag').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: () => require('../../../../pages/iou/request/step/IOURequestStepWaypoint').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.PARTICIPANTS]: () => require('../../../../pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.HOLD]: () => require('../../../../pages/iou/HoldReasonPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_DEBIT_CARD]: () => require('../../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index 6f4fbb08403b..ef3f6340f3e4 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -8,6 +8,7 @@ import OnboardingModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator import type {OnboardingModalNavigatorParamList} from '@libs/Navigation/types'; import OnboardingPersonalDetails from '@pages/OnboardingPersonalDetails'; import OnboardingPurpose from '@pages/OnboardingPurpose'; +import OnboardingWork from '@pages/OnboardingWork'; import SCREENS from '@src/SCREENS'; import Overlay from './Overlay'; @@ -23,13 +24,17 @@ function OnboardingModalNavigator() { + diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx index 02bfda6ba51b..0be4ac5518f3 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx @@ -49,7 +49,6 @@ function BottomTabBar({isLoadingApp = false}: PurposeForUsingExpensifyModalProps return; } - // Welcome.isOnboardingFlowCompleted({onNotCompleted: () => Navigation.navigate(ROUTES.ONBOARDING_PERSONAL_DETAILS)}); Welcome.isOnboardingFlowCompleted({ onNotCompleted: () => Navigation.navigate( diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index b94c2c5fad4a..5209d8a594c1 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -94,7 +94,8 @@ function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number function parseHybridAppUrl(url: HybridAppRoute | Route): Route { switch (url) { case HYBRID_APP_ROUTES.MONEY_REQUEST_CREATE: - return ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()); + case HYBRID_APP_ROUTES.MONEY_REQUEST_SUBMIT_CREATE: + return ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.ACTION.CREATE, CONST.IOU.TYPE.SUBMIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()); default: return url; } diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 05b7190fa181..3964b7dcd074 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -96,14 +96,18 @@ const config: LinkingOptions['config'] = { }, [NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR]: { path: ROUTES.ONBOARDING_ROOT, - initialRouteName: SCREENS.ONBOARDING.PERSONAL_DETAILS, + initialRouteName: SCREENS.ONBOARDING.PURPOSE, screens: { + [SCREENS.ONBOARDING.PURPOSE]: { + path: ROUTES.ONBOARDING_PURPOSE, + exact: true, + }, [SCREENS.ONBOARDING.PERSONAL_DETAILS]: { path: ROUTES.ONBOARDING_PERSONAL_DETAILS, exact: true, }, - [SCREENS.ONBOARDING.PURPOSE]: { - path: ROUTES.ONBOARDING_PURPOSE, + [SCREENS.ONBOARDING.WORK]: { + path: ROUTES.ONBOARDING_WORK, exact: true, }, }, @@ -569,6 +573,7 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_DATE]: ROUTES.MONEY_REQUEST_STEP_DATE.route, [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.route, [SCREENS.MONEY_REQUEST.STEP_DISTANCE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE.route, + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.route, [SCREENS.MONEY_REQUEST.HOLD]: ROUTES.MONEY_REQUEST_HOLD_REASON.route, [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: ROUTES.MONEY_REQUEST_STEP_MERCHANT.route, [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.route, @@ -577,7 +582,6 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: ROUTES.MONEY_REQUEST_STEP_WAYPOINT.route, [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.route, [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route, - [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, [SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route, [SCREENS.MONEY_REQUEST.STATE_SELECTOR]: {path: ROUTES.MONEY_REQUEST_STATE_SELECTOR.route, exact: true}, [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 54eb5f4663bc..60c3aedbc906 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -369,20 +369,23 @@ type RoomInviteNavigatorParamList = { }; type MoneyRequestNavigatorParamList = { - [SCREENS.MONEY_REQUEST.PARTICIPANTS]: { - iouType: string; + [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; reportID: string; + backTo: string; }; [SCREENS.MONEY_REQUEST.STEP_DATE]: { action: IOUAction; - iouType: IOUType; + iouType: Exclude; transactionID: string; reportID: string; backTo: Routes; }; [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: { action: IOUAction; - iouType: IOUType; + iouType: Exclude; transactionID: string; reportID: string; backTo: Routes; @@ -390,7 +393,7 @@ type MoneyRequestNavigatorParamList = { }; [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: { action: IOUAction; - iouType: IOUType; + iouType: Exclude; transactionID: string; reportActionID: string; reportID: string; @@ -398,7 +401,7 @@ type MoneyRequestNavigatorParamList = { }; [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: { action: IOUAction; - iouType: IOUType; + iouType: Exclude; transactionID: string; reportID: string; backTo: Routes; @@ -406,7 +409,7 @@ type MoneyRequestNavigatorParamList = { }; [SCREENS.MONEY_REQUEST.STEP_TAG]: { action: IOUAction; - iouType: IOUType; + iouType: Exclude; transactionID: string; reportID: string; backTo: Routes; @@ -415,7 +418,7 @@ type MoneyRequestNavigatorParamList = { }; [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: { action: IOUAction; - iouType: IOUType; + iouType: Exclude; transactionID: string; reportID: string; backTo: Routes; @@ -430,7 +433,7 @@ type MoneyRequestNavigatorParamList = { }; [SCREENS.MONEY_REQUEST.STEP_MERCHANT]: { action: IOUAction; - iouType: IOUType; + iouType: Exclude; transactionID: string; reportID: string; backTo: Routes; @@ -480,15 +483,15 @@ type MoneyRequestNavigatorParamList = { action: IOUAction; currency?: string; }; - [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: { - action: IOUAction; - iouType: IOUType; + [SCREENS.MONEY_REQUEST.STEP_DISTANCE_RATE]: { + iouType: ValueOf; transactionID: string; + backTo: Routes; reportID: string; }; [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: { action: IOUAction; - iouType: IOUType; + iouType: Exclude; transactionID: string; reportID: string; pageIndex?: string; @@ -719,6 +722,7 @@ type OnboardingModalNavigatorParamList = { [SCREENS.ONBOARDING_MODAL.ONBOARDING]: undefined; [SCREENS.ONBOARDING.PERSONAL_DETAILS]: undefined; [SCREENS.ONBOARDING.PURPOSE]: undefined; + [SCREENS.ONBOARDING.WORK]: undefined; }; type WelcomeVideoModalNavigatorParamList = { diff --git a/src/libs/Notification/PushNotification/index.native.ts b/src/libs/Notification/PushNotification/index.native.ts index d45d076a8adb..43ddff2508ee 100644 --- a/src/libs/Notification/PushNotification/index.native.ts +++ b/src/libs/Notification/PushNotification/index.native.ts @@ -10,7 +10,7 @@ import NotificationType from './NotificationType'; import type {ClearNotifications, Deregister, Init, OnReceived, OnSelected, Register} from './types'; import type PushNotificationType from './types'; -type NotificationEventActionCallback = (data: NotificationData) => void; +type NotificationEventActionCallback = (data: NotificationData) => Promise; type NotificationEventActionMap = Partial>>; @@ -56,7 +56,13 @@ function pushNotificationEventCallback(eventType: EventType, notification: PushP }); return; } - action(data); + + /** + * The action callback should return a promise. It's very important we return that promise so that + * when these callbacks are run in Android's background process (via Headless JS), the process waits + * for the promise to resolve before quitting + */ + return action(data); } /** @@ -83,15 +89,11 @@ function refreshNotificationOptInStatus() { */ const init: Init = () => { // Setup event listeners - Airship.addListener(EventType.PushReceived, (notification) => { - pushNotificationEventCallback(EventType.PushReceived, notification.pushPayload); - }); + Airship.addListener(EventType.PushReceived, (notification) => pushNotificationEventCallback(EventType.PushReceived, notification.pushPayload)); // Note: the NotificationResponse event has a nested PushReceived event, // so event.notification refers to the same thing as notification above ^ - Airship.addListener(EventType.NotificationResponse, (event) => { - pushNotificationEventCallback(EventType.NotificationResponse, event.pushPayload); - }); + Airship.addListener(EventType.NotificationResponse, (event) => pushNotificationEventCallback(EventType.NotificationResponse, event.pushPayload)); // Keep track of which users have enabled push notifications via an NVP. Airship.addListener(EventType.PushNotificationStatusChangedStatus, refreshNotificationOptInStatus); diff --git a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts index 4b17adf86841..a9b8fc0b64f4 100644 --- a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts +++ b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.ts @@ -1,5 +1,5 @@ import Onyx from 'react-native-onyx'; -import * as OnyxUpdates from '@libs/actions/OnyxUpdates'; +import applyOnyxUpdatesReliably from '@libs/actions/applyOnyxUpdatesReliably'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; @@ -26,35 +26,51 @@ Onyx.connect({ }, }); +function getLastUpdateIDAppliedToClient(): Promise { + return new Promise((resolve) => { + Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (value) => resolve(value ?? 0), + }); + }); +} + /** * Setup reportComment push notification callbacks. */ export default function subscribeToReportCommentPushNotifications() { PushNotification.onReceived(PushNotification.TYPE.REPORT_COMMENT, ({reportID, reportActionID, onyxData, lastUpdateID, previousUpdateID}) => { + Log.info(`[PushNotification] received report comment notification in the ${Visibility.isVisible() ? 'foreground' : 'background'}`, false, {reportID, reportActionID}); + if (!ActiveClientManager.isClientTheLeader()) { Log.info('[PushNotification] received report comment notification, but ignoring it since this is not the active client'); - return; + return Promise.resolve(); } - Log.info(`[PushNotification] received report comment notification in the ${Visibility.isVisible() ? 'foreground' : 'background'}`, false, {reportID, reportActionID}); - - if (onyxData && lastUpdateID && previousUpdateID) { - Log.info('[PushNotification] reliable onyx update received', false, {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); - const updates: OnyxUpdatesFromServer = { - type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, - lastUpdateID, - previousUpdateID, - updates: [ - { - eventType: 'eventType', - data: onyxData, - }, - ], - }; - OnyxUpdates.applyOnyxUpdatesReliably(updates); - } else { - Log.hmmm("[PushNotification] Didn't apply onyx updates because some data is missing", {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); + if (!onyxData || !lastUpdateID || !previousUpdateID) { + Log.hmmm("[PushNotification] didn't apply onyx updates because some data is missing", {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); + return Promise.resolve(); } + + Log.info('[PushNotification] reliable onyx update received', false, {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0}); + const updates: OnyxUpdatesFromServer = { + type: CONST.ONYX_UPDATE_TYPES.AIRSHIP, + lastUpdateID, + previousUpdateID, + updates: [ + { + eventType: 'eventType', + data: onyxData, + }, + ], + }; + + /** + * When this callback runs in the background on Android (via Headless JS), no other Onyx.connect callbacks will run. This means that + * lastUpdateIDAppliedToClient will NOT be populated in other libs. To workaround this, we manually read the value here + * and pass it as a param + */ + return getLastUpdateIDAppliedToClient().then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, true, lastUpdateIDAppliedToClient)); }); // Open correct report when push notification is clicked @@ -96,5 +112,7 @@ export default function subscribeToReportCommentPushNotifications() { } }); }); + + return Promise.resolve(); }); } diff --git a/src/libs/Notification/PushNotification/types.ts b/src/libs/Notification/PushNotification/types.ts index 4399c10b4a95..cf7e54abd094 100644 --- a/src/libs/Notification/PushNotification/types.ts +++ b/src/libs/Notification/PushNotification/types.ts @@ -5,8 +5,8 @@ import type NotificationType from './NotificationType'; type Init = () => void; type Register = (notificationID: string | number) => void; type Deregister = () => void; -type OnReceived = >(notificationType: T, callback: (data: NotificationDataMap[T]) => void) => void; -type OnSelected = >(notificationType: T, callback: (data: NotificationDataMap[T]) => void) => void; +type OnReceived = >(notificationType: T, callback: (data: NotificationDataMap[T]) => Promise) => void; +type OnSelected = >(notificationType: T, callback: (data: NotificationDataMap[T]) => Promise) => void; type ClearNotifications = () => void; type PushNotification = { diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 105736faeba0..c79e9011386f 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -29,7 +29,7 @@ function canUseTrackExpense(betas: OnyxEntry): boolean { function canUseP2PDistanceRequests(betas: OnyxEntry, iouType: IOUType | undefined): boolean { // Allow using P2P distance request for TrackExpense outside of the beta, because that project doesn't want to be limited by the more cautious P2P distance beta - return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas) || iouType === CONST.IOU.TYPE.TRACK_EXPENSE; + return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas) || iouType === CONST.IOU.TYPE.TRACK; } function canUseWorkflowsDelayedSubmission(betas: OnyxEntry): boolean { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index b4cf4b164a19..b9774949e2d9 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -1,5 +1,6 @@ import Str from 'expensify-common/lib/str'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -15,6 +16,14 @@ import {getPersonalDetailByEmail} from './PersonalDetailsUtils'; type MemberEmailsToAccountIDs = Record; +let allPolicies: OnyxCollection; + +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + /** * Filter out the active policies, which will exclude policies with pending deletion * These are policies that we can use to create reports with in NewDot. @@ -311,6 +320,10 @@ function isPolicyFeatureEnabled(policy: OnyxEntry | EmptyObject, feature return Boolean(policy?.[featureName]); } +function getPersonalPolicy() { + return Object.values(allPolicies ?? {}).find((policy) => policy?.type === CONST.POLICY.TYPE.PERSONAL); +} + /** * Get the currently selected policy ID stored in the navigation state. */ @@ -318,6 +331,16 @@ function getPolicyIDFromNavigationState() { return getPolicyIDFromState(navigationRef.getRootState() as State); } +/** + * Returns the policy of the report + */ +function getPolicy(policyID: string | undefined): Policy | EmptyObject { + if (!allPolicies || !policyID) { + return {}; + } + return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}; +} + export { getActivePolicies, hasAccountingConnections, @@ -352,11 +375,13 @@ export { getPathWithoutPolicyID, getPolicyEmployeeListByIdWithoutCurrentUser, goBackFromInvalidPolicy, + getPersonalPolicy, isPolicyFeatureEnabled, hasTaxRateError, getTaxByID, hasPolicyCategoriesError, getPolicyIDFromNavigationState, + getPolicy, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a15e1937dbe2..f2f7bab41fae 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -75,7 +75,6 @@ import * as PhoneNumber from './PhoneNumber'; import * as PolicyUtils from './PolicyUtils'; import type {LastVisibleMessage} from './ReportActionsUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; -import shouldAllowRawHTMLMessages from './shouldAllowRawHTMLMessages'; import * as TransactionUtils from './TransactionUtils'; import * as Url from './Url'; import * as UserUtils from './UserUtils'; @@ -140,6 +139,10 @@ type OptimisticAddCommentReportAction = Pick< | 'childStatusNum' | 'childStateNum' | 'errors' + | 'childVisibleActionCount' + | 'childCommenterCount' + | 'childLastVisibleActionCreated' + | 'childOldestFourAccountIDs' > & {isOptimisticAction: boolean}; type OptimisticReportAction = { @@ -3158,7 +3161,7 @@ function addDomainToShortMention(mention: string): string | undefined { * For comments shorter than or equal to 10k chars, convert the comment from MD into HTML because that's how it is stored in the database * For longer comments, skip parsing, but still escape the text, and display plaintext for performance reasons. It takes over 40s to parse a 100k long string!! */ -function getParsedComment(text: string): string { +function getParsedComment(text: string, shouldEscapeText?: boolean): string { const parser = new ExpensiMark(); const textWithMention = text.replace(CONST.REGEX.SHORT_MENTION, (match) => { const mention = match.substring(1); @@ -3166,7 +3169,7 @@ function getParsedComment(text: string): string { return mentionWithDomain ? `@${mentionWithDomain}` : match; }); - return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(textWithMention, {shouldEscapeText: !shouldAllowRawHTMLMessages()}) : lodashEscape(text); + return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(textWithMention, {shouldEscapeText}) : lodashEscape(text); } function getReportDescriptionText(report: Report): string { @@ -3187,9 +3190,9 @@ function getPolicyDescriptionText(policy: OnyxEntry): string { return parser.htmlToText(policy.description); } -function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, actorAccountID?: number): OptimisticReportAction { +function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, actorAccountID?: number, createdOffset = 0, shouldEscapeText?: boolean): OptimisticReportAction { const parser = new ExpensiMark(); - const commentText = getParsedComment(text ?? ''); + const commentText = getParsedComment(text ?? '', shouldEscapeText); const isAttachmentOnly = file && !text; const isTextOnly = text && !file; @@ -3226,7 +3229,7 @@ function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, ], automatic: false, avatar: allPersonalDetails?.[accountID ?? -1]?.avatar ?? UserUtils.getDefaultAvatarURL(accountID), - created: DateUtils.getDBTimeWithSkew(), + created: DateUtils.getDBTimeWithSkew(Date.now() + createdOffset), message: [ { translationKey: isAttachmentOnly ? CONST.TRANSLATION_KEYS.ATTACHMENT : '', @@ -3294,9 +3297,24 @@ function updateOptimisticParentReportAction(parentReportAction: OnyxEntry) { + const policy = getPolicy(report?.policyID); + + if (report?.ownerAccountID !== currentUserAccountID && policy.role === CONST.POLICY.ROLE.ADMIN) { + const ownerPersonalDetail = getPersonalDetailsForAccountID(report?.ownerAccountID ?? 0); + const ownerDisplayName = `${ownerPersonalDetail.displayName ?? ''}${ownerPersonalDetail.displayName !== ownerPersonalDetail.login ? ` (${ownerPersonalDetail.login})` : ''}`; + + return [ + { + style: 'normal', + text: 'You (on behalf of ', + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + { + style: 'strong', + text: ownerDisplayName, + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + { + style: 'normal', + text: ' via admin-submit)', + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + { + style: 'normal', + text: ' submitted this report', + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + { + style: 'normal', + text: ' to ', + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + { + style: 'strong', + text: 'you', + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + ]; + } + + const submittedToPersonalDetail = getPersonalDetailsForAccountID(policy?.submitsTo ?? 0); + let submittedToDisplayName = `${submittedToPersonalDetail.displayName ?? ''}${ + submittedToPersonalDetail.displayName !== submittedToPersonalDetail.login ? ` (${submittedToPersonalDetail.login})` : '' + }`; + if (submittedToPersonalDetail?.accountID === currentUserAccountID) { + submittedToDisplayName = 'yourself'; + } + + return [ + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: 'You', + }, + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: ' submitted this report', + }, + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'normal', + text: ' to ', + }, + { + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + style: 'strong', + text: submittedToDisplayName, + }, + ]; +} + /** * @param iouReportID - the report ID of the IOU report the action belongs to * @param type - IOUReportAction type. Can be oneOf(create, decline, cancel, pay, split) @@ -3452,8 +3563,13 @@ function buildOptimisticExpenseReport(chatReportID: string, policyID: string, pa * @param paymentType - IOU paymentMethodType. Can be oneOf(Elsewhere, Expensify) * @param isSettlingUp - Whether we are settling up an IOU */ -function getIOUReportActionMessage(iouReportID: string, type: string, total: number, comment: string, currency: string, paymentType = '', isSettlingUp = false): [Message] { +function getIOUReportActionMessage(iouReportID: string, type: string, total: number, comment: string, currency: string, paymentType = '', isSettlingUp = false): Message[] { const report = getReport(iouReportID); + + if (type === CONST.REPORT.ACTIONS.TYPE.SUBMITTED) { + return getIOUSubmittedMessage(!isEmptyObject(report) ? report : null); + } + const amount = type === CONST.IOU.REPORT_ACTION_TYPE.PAY ? CurrencyUtils.convertToDisplayString(getMoneyRequestSpendBreakdown(!isEmptyObject(report) ? report : null).totalDisplaySpend, currency) @@ -3475,9 +3591,6 @@ function getIOUReportActionMessage(iouReportID: string, type: string, total: num case CONST.REPORT.ACTIONS.TYPE.APPROVED: iouMessage = `approved ${amount}`; break; - case CONST.REPORT.ACTIONS.TYPE.SUBMITTED: - iouMessage = `submitted ${amount}`; - break; case CONST.IOU.REPORT_ACTION_TYPE.CREATE: iouMessage = `submitted ${amount}${comment && ` for ${comment}`}`; break; @@ -3880,7 +3993,13 @@ function updateReportPreview(iouReport: OnyxEntry, reportPreviewAction: }; } -function buildOptimisticTaskReportAction(taskReportID: string, actionName: OriginalMessageActionName, message = ''): OptimisticTaskReportAction { +function buildOptimisticTaskReportAction( + taskReportID: string, + actionName: OriginalMessageActionName, + message = '', + actorAccountID = currentUserAccountID, + createdOffset = 0, +): OptimisticTaskReportAction { const originalMessage = { taskReportID, type: actionName, @@ -3888,7 +4007,7 @@ function buildOptimisticTaskReportAction(taskReportID: string, actionName: Origi }; return { actionName, - actorAccountID: currentUserAccountID, + actorAccountID, automatic: false, avatar: getCurrentUserAvatarOrDefault(), isAttachment: false, @@ -3909,7 +4028,7 @@ function buildOptimisticTaskReportAction(taskReportID: string, actionName: Origi ], reportActionID: NumberUtils.rand64(), shouldShow: true, - created: DateUtils.getDBTime(), + created: DateUtils.getDBTimeWithSkew(Date.now() + createdOffset), isFirstItem: false, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }; @@ -4364,6 +4483,7 @@ function buildOptimisticTaskReport( title?: string, description?: string, policyID: string = CONST.POLICY.OWNER_EMAIL_FAKE, + notificationPreference: NotificationPreference = CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, ): OptimisticTaskReport { // When creating a report the participantsAccountIDs and visibleChatMemberAccountIDs are the same const participantsAccountIDs = assigneeAccountID && assigneeAccountID !== ownerAccountID ? [assigneeAccountID] : []; @@ -4381,7 +4501,7 @@ function buildOptimisticTaskReport( policyID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN, - notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + notificationPreference, lastVisibleActionCreated: DateUtils.getDBTime(), }; } @@ -5040,7 +5160,7 @@ function isGroupChatAdmin(report: OnyxEntry, accountID: number) { * None of the options should show in chat threads or if there is some special Expensify account * as a participant of the report. */ -function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, reportParticipants: number[], canUseTrackExpense = true): IOUType[] { +function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, reportParticipants: number[], canUseTrackExpense = true, filterDeprecatedTypes = false): IOUType[] { // In any thread or task report, we do not allow any new expenses yet if (isChatThread(report) || isTaskReport(report) || (!canUseTrackExpense && isSelfDM(report))) { return []; @@ -5058,7 +5178,7 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, policy: OnyxEntry, + policy: OnyxEntry, + reportParticipants: number[], + canUseTrackExpense = true, +): Array> { + return getMoneyRequestOptions(report, policy, reportParticipants, canUseTrackExpense, true) as Array>; +} + /** * Allows a user to leave a policy room according to the following conditions of the visibility or chatType rNVP: * `public` - Anyone can leave (because anybody can join) @@ -5957,7 +6097,7 @@ function createDraftTransactionAndNavigateToParticipantSelector(transactionID: s created: transactionCreated, } as Transaction); - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.REQUEST, transactionID, reportID, undefined, actionName)); + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID, undefined, actionName)); } /** @@ -6235,6 +6375,7 @@ export { sortReportsByLastRead, updateOptimisticParentReportAction, updateReportPreview, + temporary_getMoneyRequestOptions, }; export type { diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index a5b85b87e37e..eb794d2199f0 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -3,7 +3,6 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {RecentWaypoint, Report, TaxRate, TaxRates, TaxRatesWithDefault, Transaction, TransactionViolation} from '@src/types/onyx'; import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; @@ -450,19 +449,6 @@ function getCreated(transaction: OnyxEntry, dateFormat: string = CO return DateUtils.formatWithUTCTimeZone(created, dateFormat); } -/** - * Returns the translation key to use for the header title - */ -function getHeaderTitleTranslationKey(transaction: OnyxEntry): TranslationPaths { - const headerTitles: Record = { - [CONST.IOU.REQUEST_TYPE.DISTANCE]: 'tabSelector.distance', - [CONST.IOU.REQUEST_TYPE.MANUAL]: 'tabSelector.manual', - [CONST.IOU.REQUEST_TYPE.SCAN]: 'tabSelector.scan', - }; - - return headerTitles[getRequestType(transaction)]; -} - /** * Determine whether a transaction is made with an Expensify card. */ @@ -515,8 +501,8 @@ function hasMissingSmartscanFields(transaction: OnyxEntry): boolean /** * Check if the transaction has a defined route */ -function hasRoute(transaction: OnyxEntry): boolean { - return !!transaction?.routes?.route0?.geometry?.coordinates; +function hasRoute(transaction: OnyxEntry, isDistanceRequestType: boolean): boolean { + return !!transaction?.routes?.route0?.geometry?.coordinates || (isDistanceRequestType && !!transaction?.comment?.customUnit?.quantity); } function getAllReportTransactions(reportID?: string): Transaction[] { @@ -628,6 +614,20 @@ function getEnabledTaxRateCount(options: TaxRates) { return Object.values(options).filter((option: TaxRate) => !option.isDisabled).length; } +/** + * Check if the customUnitRateID has a value default for P2P distance requests + */ +function isCustomUnitRateIDForP2P(transaction: OnyxEntry): boolean { + return transaction?.comment?.customUnit?.customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID; +} + +/** + * Get rate ID from the transaction object + */ +function getRateID(transaction: OnyxEntry): string | undefined { + return transaction?.comment?.customUnit?.customUnitRateID?.toString(); +} + /** * Gets the default tax name */ @@ -655,7 +655,6 @@ export { getEnabledTaxRateCount, getUpdatedTransaction, getDescription, - getHeaderTitleTranslationKey, getRequestType, isManualRequest, isScanRequest, @@ -700,6 +699,8 @@ export { waypointHasValidAddress, getRecentTransactions, hasViolation, + isCustomUnitRateIDForP2P, + getRateID, }; export type {TransactionChanges}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 7fed15335e2a..3d4e6c088609 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1,4 +1,3 @@ -import type {ParamListBase, StackNavigationState} from '@react-navigation/native'; import {format} from 'date-fns'; import fastMerge from 'expensify-common/lib/fastMerge'; import Str from 'expensify-common/lib/str'; @@ -45,7 +44,6 @@ import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as UserUtils from '@libs/UserUtils'; import ViolationsUtils from '@libs/Violations/ViolationsUtils'; -import type {NavigationPartialRoute} from '@navigation/types'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -236,14 +234,6 @@ Onyx.connect({ }, }); -let lastSelectedDistanceRates: OnyxEntry = {}; -Onyx.connect({ - key: ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, - callback: (value) => { - lastSelectedDistanceRates = value; - }, -}); - let quickAction: OnyxEntry = {}; Onyx.connect({ key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, @@ -252,13 +242,6 @@ Onyx.connect({ }, }); -let allPolicies: OnyxCollection; -Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY, - waitForCollectionCallback: true, - callback: (value) => (allPolicies = value), -}); - let allReportActions: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -271,16 +254,6 @@ Onyx.connect({ }, }); -/** - * Returns the policy of the report - */ -function getPolicy(policyID: string | undefined): OnyxTypes.Policy | EmptyObject { - if (!allPolicies || !policyID) { - return {}; - } - return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}; -} - /** * Find the report preview action from given chat report and iou report */ @@ -317,12 +290,10 @@ function initMoneyRequest(reportID: string, policy: OnyxEntry, waypoint0: {}, waypoint1: {}, }; - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? null; - let customUnitRateID: string = CONST.CUSTOM_UNITS.FAKE_P2P_ID; - if (ReportUtils.isPolicyExpenseChat(report)) { - customUnitRateID = lastSelectedDistanceRates?.[policy?.id ?? ''] ?? DistanceRequestUtils.getDefaultMileageRate(policy)?.customUnitRateID ?? ''; + if (!isFromGlobalCreate) { + const customUnitRateID = DistanceRequestUtils.getCustomUnitRateID(reportID); + comment.customUnit = {customUnitRateID}; } - comment.customUnit = {customUnitRateID}; } // Store the transaction in Onyx and mark it as not saved so it can be cleaned up later @@ -357,28 +328,6 @@ function clearMoneyRequest(transactionID: string, skipConfirmation = false) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } -/** - * Update money expense-related pages IOU type params - */ -function updateMoneyRequestTypeParams(routes: StackNavigationState['routes'] | NavigationPartialRoute[], newIouType: string, tab?: string) { - routes.forEach((route) => { - const tabList = [CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN] as string[]; - if (!route.name.startsWith('Money_Request_') && !tabList.includes(route.name)) { - return; - } - const newParams: Record = {iouType: newIouType}; - if (route.name === 'Money_Request_Create') { - // Both screen and nested params are needed to properly update the nested tab navigator - newParams.params = {...newParams}; - newParams.screen = tab; - } - Navigation.setParams(newParams, route.key ?? ''); - - // Recursively update nested expense tab params - updateMoneyRequestTypeParams(route.state?.routes ?? [], newIouType, tab); - }); -} - // eslint-disable-next-line @typescript-eslint/naming-convention function startMoneyRequest(iouType: ValueOf, reportID: string, requestType?: IOURequestType, skipConfirmation = false) { clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, skipConfirmation); @@ -450,6 +399,19 @@ function setMoneyRequestReceipt(transactionID: string, source: string, filename: }); } +/** + * Set custom unit rateID for the transaction draft + */ +function setCustomUnitRateID(transactionID: string, customUnitRateID: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {customUnitRateID}}}); +} + +/** Update transaction distance rate */ +function updateDistanceRequestRate(transactionID: string, rateID: string, policyID: string) { + Onyx.merge(ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES, {[policyID]: rateID}); + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: {customUnit: {customUnitRateID: rateID}}}); +} + /** Reset expense info from the store with its initial value */ function resetMoneyRequestInfo(id = '') { // Disabling this line since currentDate can be an empty string @@ -582,7 +544,10 @@ function buildOnyxDataForMoneyRequest( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, - value: transactionThreadReport, + value: { + ...transactionThreadReport, + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, }, // Remove the temporary transaction used during the creation flow { @@ -770,6 +735,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, value: { + pendingFields: null, errorFields: existingTransactionThreadReport ? null : { @@ -935,7 +901,10 @@ function buildOnyxDataForTrackExpense( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, - value: transactionThreadReport, + value: { + ...transactionThreadReport, + pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, + }, }, // Remove the temporary transaction used during the creation flow { @@ -1106,6 +1075,7 @@ function buildOnyxDataForTrackExpense( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, value: { + pendingFields: null, errorFields: existingTransactionThreadReport ? null : { @@ -1725,6 +1695,7 @@ function createDistanceRequest( policy?: OnyxEntry, policyTagList?: OnyxEntry, policyCategories?: OnyxEntry, + customUnitRateID?: string, ) { // If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); @@ -1787,6 +1758,7 @@ function createDistanceRequest( transactionThreadReportID, createdReportActionIDForThread, payerEmail, + customUnitRateID, }; API.write(WRITE_COMMANDS.CREATE_DISTANCE_REQUEST, parameters, onyxData); @@ -2597,7 +2569,7 @@ function convertTrackedExpenseToRequest( linkedTrackedExpenseReportAction, linkedTrackedExpenseReportID, transactionThreadReportID, - CONST.IOU.ACTION.REQUEST, + CONST.IOU.ACTION.SUBMIT, ); optimisticData?.push(...moveTransactionOptimisticData); @@ -2838,7 +2810,7 @@ function requestMoney( const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport.reportID; switch (action) { - case CONST.IOU.ACTION.REQUEST: { + case CONST.IOU.ACTION.SUBMIT: { if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) { return; } @@ -5355,7 +5327,7 @@ function hasIOUToApproveOrPay(chatReport: OnyxEntry | EmptyObj return Object.values(chatReportActions).some((action) => { const iouReport = ReportUtils.getReport(action.childReportID ?? ''); - const policy = getPolicy(iouReport?.policyID); + const policy = PolicyUtils.getPolicy(iouReport?.policyID); const shouldShowSettlementButton = canIOUBePaid(iouReport, chatReport, policy) || canApproveIOU(iouReport, chatReport, policy); return action.childReportID?.toString() !== excludedIOUReportID && action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && shouldShowSettlementButton; }); @@ -5497,7 +5469,7 @@ function approveMoneyRequest(expenseReport: OnyxTypes.Report | EmptyObject, full function submitReport(expenseReport: OnyxTypes.Report) { const currentNextStep = allNextSteps[`${ONYXKEYS.COLLECTION.NEXT_STEP}${expenseReport.reportID}`] ?? null; const parentReport = ReportUtils.getReport(expenseReport.parentReportID); - const policy = getPolicy(expenseReport.policyID); + const policy = PolicyUtils.getPolicy(expenseReport.policyID); const isCurrentUserManager = currentUserPersonalDetails.accountID === expenseReport.managerID; const isSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); const adminAccountID = policy.role === CONST.POLICY.ROLE.ADMIN ? currentUserPersonalDetails.accountID : undefined; @@ -5621,7 +5593,7 @@ function submitReport(expenseReport: OnyxTypes.Report) { function cancelPayment(expenseReport: OnyxTypes.Report, chatReport: OnyxTypes.Report) { const optimisticReportAction = ReportUtils.buildOptimisticCancelPaymentReportAction(expenseReport.reportID, -(expenseReport.total ?? 0), expenseReport.currency ?? ''); - const policy = getPolicy(chatReport.policyID); + const policy = PolicyUtils.getPolicy(chatReport.policyID); const isFree = policy && policy.type === CONST.POLICY.TYPE.FREE; const approvalMode = policy.approvalMode ?? CONST.POLICY.APPROVAL_MODE.BASIC; let stateNum: ValueOf = CONST.REPORT.STATE_NUM.SUBMITTED; @@ -6024,6 +5996,7 @@ export { savePreferredPaymentMethod, sendMoneyElsewhere, sendMoneyWithWallet, + setCustomUnitRateID, setDraftSplitTransaction, setMoneyRequestAmount, setMoneyRequestBillable, @@ -6051,6 +6024,7 @@ export { submitReport, trackExpense, unholdRequest, + updateDistanceRequestRate, updateMoneyRequestAmountAndCurrency, updateMoneyRequestBillable, updateMoneyRequestCategory, @@ -6061,6 +6035,5 @@ export { updateMoneyRequestTag, updateMoneyRequestTaxAmount, updateMoneyRequestTaxRate, - updateMoneyRequestTypeParams, }; export type {GPSPoint as GpsPoint, IOURequestType}; diff --git a/src/libs/actions/OnyxUpdateManager.ts b/src/libs/actions/OnyxUpdateManager.ts index f1f26e259ab1..8d7f299160be 100644 --- a/src/libs/actions/OnyxUpdateManager.ts +++ b/src/libs/actions/OnyxUpdateManager.ts @@ -1,9 +1,11 @@ +import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import * as ActiveClientManager from '@libs/ActiveClientManager'; import Log from '@libs/Log'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import * as App from './App'; import * as OnyxUpdates from './OnyxUpdates'; @@ -36,83 +38,94 @@ Onyx.connect({ }, }); -export default () => { - console.debug('[OnyxUpdateManager] Listening for updates from the server'); - Onyx.connect({ - key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER, - callback: (value) => { - // When there's no value, there's nothing to process, so let's return early. - if (!value) { - return; - } - // If isLoadingApp is positive it means that OpenApp command hasn't finished yet, and in that case - // we don't have base state of the app (reports, policies, etc) setup. If we apply this update, - // we'll only have them overriten by the openApp response. So let's skip it and return. - if (isLoadingApp) { - // When ONYX_UPDATES_FROM_SERVER is set, we pause the queue. Let's unpause - // it so the app is not stuck forever without processing requests. - SequentialQueue.unpause(); - console.debug(`[OnyxUpdateManager] Ignoring Onyx updates while OpenApp hans't finished yet.`); - return; - } - // This key is shared across clients, thus every client/tab will have a copy and try to execute this method. - // It is very important to only process the missing onyx updates from leader client otherwise requests we'll execute - // several duplicated requests that are not controlled by the SequentialQueue. - if (!ActiveClientManager.isClientTheLeader()) { - return; - } +/** + * + * @param onyxUpdatesFromServer + * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient + * @returns + */ +function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry, clientLastUpdateID = 0) { + // When there's no value, there's nothing to process, so let's return early. + if (!onyxUpdatesFromServer) { + return; + } + // If isLoadingApp is positive it means that OpenApp command hasn't finished yet, and in that case + // we don't have base state of the app (reports, policies, etc) setup. If we apply this update, + // we'll only have them overriten by the openApp response. So let's skip it and return. + if (isLoadingApp) { + // When ONYX_UPDATES_FROM_SERVER is set, we pause the queue. Let's unpause + // it so the app is not stuck forever without processing requests. + SequentialQueue.unpause(); + console.debug(`[OnyxUpdateManager] Ignoring Onyx updates while OpenApp hans't finished yet.`); + return; + } + // This key is shared across clients, thus every client/tab will have a copy and try to execute this method. + // It is very important to only process the missing onyx updates from leader client otherwise requests we'll execute + // several duplicated requests that are not controlled by the SequentialQueue. + if (!ActiveClientManager.isClientTheLeader()) { + return; + } - // Since we used the same key that used to store another object, let's confirm that the current object is - // following the new format before we proceed. If it isn't, then let's clear the object in Onyx. - if ( - !(typeof value === 'object' && !!value) || - !('type' in value) || - (!(value.type === CONST.ONYX_UPDATE_TYPES.HTTPS && value.request && value.response) && - !((value.type === CONST.ONYX_UPDATE_TYPES.PUSHER || value.type === CONST.ONYX_UPDATE_TYPES.AIRSHIP) && value.updates)) - ) { - console.debug('[OnyxUpdateManager] Invalid format found for updates, cleaning and unpausing the queue'); - Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); - SequentialQueue.unpause(); - return; - } + // Since we used the same key that used to store another object, let's confirm that the current object is + // following the new format before we proceed. If it isn't, then let's clear the object in Onyx. + if ( + !(typeof onyxUpdatesFromServer === 'object' && !!onyxUpdatesFromServer) || + !('type' in onyxUpdatesFromServer) || + (!(onyxUpdatesFromServer.type === CONST.ONYX_UPDATE_TYPES.HTTPS && onyxUpdatesFromServer.request && onyxUpdatesFromServer.response) && + !((onyxUpdatesFromServer.type === CONST.ONYX_UPDATE_TYPES.PUSHER || onyxUpdatesFromServer.type === CONST.ONYX_UPDATE_TYPES.AIRSHIP) && onyxUpdatesFromServer.updates)) + ) { + console.debug('[OnyxUpdateManager] Invalid format found for updates, cleaning and unpausing the queue'); + Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); + SequentialQueue.unpause(); + return; + } - const updateParams = value; - const lastUpdateIDFromServer = value.lastUpdateID; - const previousUpdateIDFromServer = value.previousUpdateID; + const updateParams = onyxUpdatesFromServer; + const lastUpdateIDFromServer = onyxUpdatesFromServer.lastUpdateID; + const previousUpdateIDFromServer = onyxUpdatesFromServer.previousUpdateID; + const lastUpdateIDFromClient = clientLastUpdateID || lastUpdateIDAppliedToClient; - // In cases where we received a previousUpdateID and it doesn't match our lastUpdateIDAppliedToClient - // we need to perform one of the 2 possible cases: - // - // 1. This is the first time we're receiving an lastUpdateID, so we need to do a final reconnectApp before - // fully migrating to the reliable updates mode. - // 2. This client already has the reliable updates mode enabled, but it's missing some updates and it - // needs to fetch those. - let canUnpauseQueuePromise; + // In cases where we received a previousUpdateID and it doesn't match our lastUpdateIDAppliedToClient + // we need to perform one of the 2 possible cases: + // + // 1. This is the first time we're receiving an lastUpdateID, so we need to do a final reconnectApp before + // fully migrating to the reliable updates mode. + // 2. This client already has the reliable updates mode enabled, but it's missing some updates and it + // needs to fetch those. + let canUnpauseQueuePromise; - // The flow below is setting the promise to a reconnect app to address flow (1) explained above. - if (!lastUpdateIDAppliedToClient) { - Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process'); + // The flow below is setting the promise to a reconnect app to address flow (1) explained above. + if (!lastUpdateIDFromClient) { + Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process'); - // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request. - canUnpauseQueuePromise = App.finalReconnectAppAfterActivatingReliableUpdates(); - } else { - // The flow below is setting the promise to a getMissingOnyxUpdates to address flow (2) explained above. - console.debug(`[OnyxUpdateManager] Client is behind the server by ${Number(previousUpdateIDFromServer) - lastUpdateIDAppliedToClient} so fetching incremental updates`); - Log.info('Gap detected in update IDs from server so fetching incremental updates', true, { - lastUpdateIDFromServer, - previousUpdateIDFromServer, - lastUpdateIDAppliedToClient, - }); - canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDAppliedToClient, previousUpdateIDFromServer); - } + // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request. + canUnpauseQueuePromise = App.finalReconnectAppAfterActivatingReliableUpdates(); + } else { + // The flow below is setting the promise to a getMissingOnyxUpdates to address flow (2) explained above. + console.debug(`[OnyxUpdateManager] Client is behind the server by ${Number(previousUpdateIDFromServer) - lastUpdateIDFromClient} so fetching incremental updates`); + Log.info('Gap detected in update IDs from server so fetching incremental updates', true, { + lastUpdateIDFromServer, + previousUpdateIDFromServer, + lastUpdateIDFromClient, + }); + canUnpauseQueuePromise = App.getMissingOnyxUpdates(lastUpdateIDFromClient, previousUpdateIDFromServer); + } - canUnpauseQueuePromise.finally(() => { - OnyxUpdates.apply(updateParams).finally(() => { - console.debug('[OnyxUpdateManager] Done applying all updates'); - Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); - SequentialQueue.unpause(); - }); - }); - }, + canUnpauseQueuePromise.finally(() => { + OnyxUpdates.apply(updateParams).finally(() => { + console.debug('[OnyxUpdateManager] Done applying all updates'); + Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null); + SequentialQueue.unpause(); + }); + }); +} + +export default () => { + console.debug('[OnyxUpdateManager] Listening for updates from the server'); + Onyx.connect({ + key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER, + callback: (value) => handleOnyxUpdateGap(value), }); }; + +export {handleOnyxUpdateGap}; diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index bb486d97b33b..04656f1adfec 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -132,29 +132,23 @@ function saveUpdateInformation(updateParams: OnyxUpdatesFromServer) { * This function will receive the previousUpdateID from any request/pusher update that has it, compare to our current app state * and return if an update is needed * @param previousUpdateID The previousUpdateID contained in the response object + * @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient */ -function doesClientNeedToBeUpdated(previousUpdateID = 0): boolean { +function doesClientNeedToBeUpdated(previousUpdateID = 0, clientLastUpdateID = 0): boolean { // If no previousUpdateID is sent, this is not a WRITE request so we don't need to update our current state if (!previousUpdateID) { return false; } - // If we don't have any value in lastUpdateIDAppliedToClient, this is the first time we're receiving anything, so we need to do a last reconnectApp - if (!lastUpdateIDAppliedToClient) { + const lastUpdateIDFromClient = clientLastUpdateID || lastUpdateIDAppliedToClient; + + // If we don't have any value in lastUpdateIDFromClient, this is the first time we're receiving anything, so we need to do a last reconnectApp + if (!lastUpdateIDFromClient) { return true; } - return lastUpdateIDAppliedToClient < previousUpdateID; -} - -function applyOnyxUpdatesReliably(updates: OnyxUpdatesFromServer) { - const previousUpdateID = Number(updates.previousUpdateID) || 0; - if (!doesClientNeedToBeUpdated(previousUpdateID)) { - apply(updates); - return; - } - saveUpdateInformation(updates); + return lastUpdateIDFromClient < previousUpdateID; } // eslint-disable-next-line import/prefer-default-export -export {saveUpdateInformation, doesClientNeedToBeUpdated, apply, applyOnyxUpdatesReliably}; +export {saveUpdateInformation, doesClientNeedToBeUpdated, apply}; diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 8248b721c416..b9cea5c9447c 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -65,6 +65,23 @@ function updatePronouns(pronouns: string) { }); } +function setDisplayName(firstName: string, lastName: string) { + if (!currentUserAccountID) { + return; + } + + Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, { + [currentUserAccountID]: { + firstName, + lastName, + displayName: PersonalDetailsUtils.createDisplayName(currentUserEmail ?? '', { + firstName, + lastName, + }), + }, + }); +} + function updateDisplayName(firstName: string, lastName: string) { if (!currentUserAccountID) { return; @@ -411,6 +428,7 @@ export { updateAutomaticTimezone, updateAvatar, updateDateOfBirth, + setDisplayName, updateDisplayName, updateLegalName, updatePronouns, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index a4afff17d972..8b0dbf8a37a9 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -15,6 +15,7 @@ import type { AddEmojiReactionParams, AddWorkspaceRoomParams, CompleteEngagementModalParams, + CompleteGuidedSetupParams, DeleteCommentParams, ExpandURLPreviewParams, FlagCommentParams, @@ -55,6 +56,7 @@ import * as EmojiUtils from '@libs/EmojiUtils'; import * as Environment from '@libs/Environment/Environment'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; +import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import LocalNotification from '@libs/Notification/LocalNotification'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; @@ -71,6 +73,7 @@ import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import * as UserUtils from '@libs/UserUtils'; import Visibility from '@libs/Visibility'; import CONFIG from '@src/CONFIG'; +import type {OnboardingPurposeType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; @@ -82,7 +85,9 @@ import type { PersonalDetails, PersonalDetailsList, PolicyReportField, + QuickAction, RecentlyUsedReportFields, + ReportAction, ReportActionReactions, ReportMetadata, ReportUserIsTyping, @@ -91,7 +96,6 @@ import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage import type {NotificationPreference, Participants, Participant as ReportParticipant, RoomVisibility, WriteCapability} from '@src/types/onyx/Report'; import type Report from '@src/types/onyx/Report'; import type {Message, ReportActionBase, ReportActions} from '@src/types/onyx/ReportAction'; -import type ReportAction from '@src/types/onyx/ReportAction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as CachedPDFPaths from './CachedPDFPaths'; @@ -106,6 +110,42 @@ type ActionSubscriber = { callback: SubscriberCallback; }; +type Video = { + url: string; + thumbnailUrl: string; + duration: number; + width: number; + height: number; +}; + +type TaskMessage = Required>; + +type TaskForParameters = + | { + type: 'task'; + task: string; + taskReportID: string; + parentReportID: string; + parentReportActionID: string; + assigneeChatReportID: string; + createdTaskReportActionID: string; + completedTaskReportActionID?: string; + title: string; + description: string; + } + | ({ + type: 'message'; + } & TaskMessage); + +type GuidedSetupData = Array< + | ({type: 'message'} & AddCommentOrAttachementParams) + | TaskForParameters + | ({ + type: 'video'; + } & Video & + AddCommentOrAttachementParams) +>; + let conciergeChatReportID: string | undefined; let currentUserAccountID = -1; let currentUserEmail: string | undefined; @@ -122,6 +162,14 @@ Onyx.connect({ }, }); +let guideCalendarLink: string | undefined; +Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + guideCalendarLink = value?.guideCalendarLink ?? undefined; + }, +}); + let preferredSkinTone: number = CONST.EMOJI_DEFAULT_SKIN_TONE; Onyx.connect({ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, @@ -219,6 +267,12 @@ Onyx.connect({ callback: (val) => (allRecentlyUsedReportFields = val), }); +let quickAction: OnyxEntry = {}; +Onyx.connect({ + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + callback: (val) => (quickAction = val), +}); + function clearGroupChat() { Onyx.set(ONYXKEYS.NEW_GROUP_CHAT_DRAFT, null); } @@ -784,6 +838,11 @@ function openReport( key: ONYXKEYS.PERSONAL_DETAILS_LIST, value: settledPersonalDetails, }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: settledPersonalDetails, + }); // Add the createdReportActionID parameter to the API call parameters.createdReportActionID = optimisticCreatedAction.reportActionID; @@ -1976,16 +2035,6 @@ function deleteReport(reportID: string) { Onyx.multiSet(onyxData); - // Clear the optimistic personal detail - const participantPersonalDetails: OnyxCollection = {}; - report?.participantAccountIDs?.forEach((accountID) => { - if (!allPersonalDetails?.[accountID]?.isOptimisticPersonalDetail) { - return; - } - participantPersonalDetails[accountID] = null; - }); - Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, participantPersonalDetails); - // Delete linked IOU report if (report?.iouReportID) { deleteReport(report.iouReportID); @@ -2421,7 +2470,7 @@ function navigateToMostRecentReport(currentReport: OnyxEntry) { if (!isChatThread) { Navigation.goBack(); } - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID ?? '')); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(lastAccessedReportID ?? ''), CONST.NAVIGATION.TYPE.FORCED_UP); } else { const participantAccountIDs = PersonalDetailsUtils.getAccountIDsByLogins([CONST.EMAIL.CONCIERGE]); const chat = ReportUtils.getChatByParticipants(participantAccountIDs); @@ -2430,7 +2479,7 @@ function navigateToMostRecentReport(currentReport: OnyxEntry) { if (!isChatThread) { Navigation.goBack(); } - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chat?.reportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(chat?.reportID), CONST.NAVIGATION.TYPE.FORCED_UP); } } } @@ -2449,6 +2498,15 @@ function leaveGroupChat(reportID: string) { value: null, }, ]; + // Clean up any quick actions for the report we're leaving from + if (quickAction?.chatReportID?.toString() === reportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE, + value: null, + }); + } + navigateToMostRecentReport(report); API.write(WRITE_COMMANDS.LEAVE_GROUP_CHAT, {reportID}, {optimisticData}); } @@ -2941,13 +2999,286 @@ function getReportPrivateNote(reportID: string | undefined) { API.read(READ_COMMANDS.GET_REPORT_PRIVATE_NOTE, parameters, {optimisticData, successData, failureData}); } +function completeOnboarding( + engagementChoice: OnboardingPurposeType, + data: ValueOf, + { + login, + firstName, + lastName, + }: { + login: string; + firstName: string; + lastName: string; + }, + adminsChatReportID?: string, +) { + const targetEmail = CONST.EMAIL.CONCIERGE; + const actorAccountID = PersonalDetailsUtils.getAccountIDsByLogins([targetEmail])[0]; + const targetChatReport = ReportUtils.getChatByParticipants([actorAccountID]); + const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {}; + + // Mention message + const mentionHandle = LoginUtils.isEmailPublicDomain(login) ? login : login.split('@')[0]; + const mentionComment = ReportUtils.buildOptimisticAddCommentReportAction(`Hey @${mentionHandle} 👋`, undefined, actorAccountID); + const mentionCommentAction: OptimisticAddCommentReportAction = mentionComment.reportAction; + const mentionMessage: AddCommentOrAttachementParams = { + reportID: targetChatReportID, + reportActionID: mentionCommentAction.reportActionID, + reportComment: mentionComment.commentText, + }; + + // Text message + const textComment = ReportUtils.buildOptimisticAddCommentReportAction(data.message, undefined, actorAccountID, 1); + const textCommentAction: OptimisticAddCommentReportAction = textComment.reportAction; + const textMessage: AddCommentOrAttachementParams = { + reportID: targetChatReportID, + reportActionID: textCommentAction.reportActionID, + reportComment: textComment.commentText, + }; + + // Video message + const videoComment = ReportUtils.buildOptimisticAddCommentReportAction(CONST.ATTACHMENT_MESSAGE_TEXT, undefined, actorAccountID, 2); + const videoCommentAction: OptimisticAddCommentReportAction = videoComment.reportAction; + const videoMessage: AddCommentOrAttachementParams = { + reportID: targetChatReportID, + reportActionID: videoCommentAction.reportActionID, + reportComment: videoComment.commentText, + }; + + const tasksData = data.tasks.map((task, index) => { + const currentTask = ReportUtils.buildOptimisticTaskReport( + actorAccountID, + undefined, + targetChatReportID, + task.title, + undefined, + targetChatPolicyID, + CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + ); + const taskCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(targetEmail); + const taskReportAction = ReportUtils.buildOptimisticTaskCommentReportAction( + currentTask.reportID, + task.title, + 0, + `task for ${task.title}`, + targetChatReportID, + actorAccountID, + index + 3, + { + childVisibleActionCount: 2, + childCommenterCount: 1, + childLastVisibleActionCreated: DateUtils.getDBTime(), + childOldestFourAccountIDs: `${actorAccountID}`, + }, + ); + const subtitleComment = task.subtitle ? ReportUtils.buildOptimisticAddCommentReportAction(task.subtitle, undefined, actorAccountID) : null; + const isTaskMessageFunction = typeof task.message === 'function'; + const taskMessage = isTaskMessageFunction + ? task.message({ + adminsRoomLink: `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}${ROUTES.REPORT_WITH_ID.getRoute(adminsChatReportID ?? '')}`, + guideCalendarLink: guideCalendarLink ?? CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL, + }) + : task.message; + const instructionComment = ReportUtils.buildOptimisticAddCommentReportAction(taskMessage, undefined, actorAccountID, 1, isTaskMessageFunction ? undefined : false); + const completedTaskReportAction = task.autoCompleted + ? ReportUtils.buildOptimisticTaskReportAction(currentTask.reportID, CONST.REPORT.ACTIONS.TYPE.TASK_COMPLETED, 'marked as complete', actorAccountID, 2) + : null; + + return { + task, + currentTask, + taskCreatedAction, + taskReportAction, + subtitleComment, + instructionComment, + completedTaskReportAction, + }; + }); + + const tasksForParameters = tasksData.reduce( + (acc, {task, currentTask, taskCreatedAction, taskReportAction, subtitleComment, instructionComment, completedTaskReportAction}) => { + const instructionCommentAction: OptimisticAddCommentReportAction = instructionComment.reportAction; + const instructionCommentText = instructionComment.commentText; + const instructionMessage: TaskMessage = { + reportID: currentTask.reportID, + reportActionID: instructionCommentAction.reportActionID, + reportComment: instructionCommentText, + }; + + const tasksForParametersAcc: TaskForParameters[] = [ + ...acc, + { + type: 'task', + task: task.type, + taskReportID: currentTask.reportID, + parentReportID: currentTask.parentReportID ?? '', + parentReportActionID: taskReportAction.reportAction.reportActionID, + assigneeChatReportID: '', + createdTaskReportActionID: taskCreatedAction.reportActionID, + completedTaskReportActionID: completedTaskReportAction?.reportActionID ?? undefined, + title: currentTask.reportName ?? '', + description: currentTask.description ?? '', + }, + { + type: 'message', + ...instructionMessage, + }, + ]; + + if (subtitleComment) { + const subtitleCommentAction: OptimisticAddCommentReportAction = subtitleComment.reportAction; + const subtitleCommentText = subtitleComment.commentText; + const subtitleMessage: TaskMessage = { + reportID: currentTask.reportID, + reportActionID: subtitleCommentAction.reportActionID, + reportComment: subtitleCommentText, + }; + + tasksForParametersAcc.push({ + type: 'message', + ...subtitleMessage, + }); + } + + return tasksForParametersAcc; + }, + [], + ); + + const tasksForOptimisticData = tasksData.reduce( + (acc, {currentTask, taskCreatedAction, taskReportAction, subtitleComment, instructionComment, completedTaskReportAction}) => { + const instructionCommentAction: OptimisticAddCommentReportAction = instructionComment.reportAction; + + const tasksForOptimisticDataAcc: OnyxUpdate[] = [ + ...acc, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [taskReportAction.reportAction.reportActionID]: taskReportAction.reportAction as ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${currentTask.reportID}`, + value: { + ...currentTask, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + isOptimisticReport: true, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [taskCreatedAction.reportActionID]: taskCreatedAction as ReportAction, + [instructionCommentAction.reportActionID]: instructionCommentAction as ReportAction, + }, + }, + ]; + + if (subtitleComment) { + const subtitleCommentAction: OptimisticAddCommentReportAction = subtitleComment.reportAction; + + tasksForOptimisticDataAcc.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [subtitleCommentAction.reportActionID]: subtitleCommentAction as ReportAction, + }, + }); + } + + if (completedTaskReportAction) { + tasksForOptimisticDataAcc.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentTask.reportID}`, + value: { + [completedTaskReportAction.reportActionID]: completedTaskReportAction as ReportAction, + }, + }); + + tasksForOptimisticDataAcc.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${currentTask.reportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.APPROVED, + statusNum: CONST.REPORT.STATUS_NUM.APPROVED, + }, + }); + } + + return tasksForOptimisticDataAcc; + }, + [], + ); + + const optimisticData: OnyxUpdate[] = [ + ...tasksForOptimisticData, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${targetChatReportID}`, + value: { + lastMentionedTime: DateUtils.getDBTime(), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [mentionCommentAction.reportActionID]: mentionCommentAction as ReportAction, + [textCommentAction.reportActionID]: textCommentAction as ReportAction, + [videoCommentAction.reportActionID]: videoCommentAction as ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_INTRO_SELECTED, + value: {choice: engagementChoice}, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetChatReportID}`, + value: { + [mentionCommentAction.reportActionID]: {pendingAction: null}, + [textCommentAction.reportActionID]: {pendingAction: null}, + [videoCommentAction.reportActionID]: {pendingAction: null}, + }, + }, + ]; + + const guidedSetupData: GuidedSetupData = [ + {type: 'message', ...mentionMessage}, + {type: 'message', ...textMessage}, + {type: 'video', ...data.video, ...videoMessage}, + ...tasksForParameters, + ]; + + const parameters: CompleteGuidedSetupParams = { + engagementChoice, + firstName, + lastName, + guidedSetupData: JSON.stringify(guidedSetupData), + }; + + API.write(WRITE_COMMANDS.COMPLETE_GUIDED_SETUP, parameters, {optimisticData, successData}); +} + /** * Completes the engagement modal that new NewDot users see when they first sign up/log in by doing the following: * * - Sets the introSelected NVP to the choice the user made * - Creates an optimistic report comment from concierge */ -function completeEngagementModal(choice: ValueOf, text?: string) { +function completeEngagementModal(choice: OnboardingPurposeType, text?: string) { const conciergeAccountID = PersonalDetailsUtils.getAccountIDsByLogins([CONST.EMAIL.CONCIERGE])[0]; // We do not need to send any message for some choices @@ -3333,6 +3664,7 @@ export { setGroupDraft, clearGroupChat, startNewChat, + completeOnboarding, updateGroupChatName, updateGroupChatAvatar, leaveGroupChat, diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 26219d72920e..970b34591103 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -39,4 +39,21 @@ function restoreOriginalTransactionFromBackup(transactionID: string, isDraft: bo }); } -export {createBackupTransaction, removeBackupTransaction, restoreOriginalTransactionFromBackup}; +function createDraftTransaction(transaction: OnyxEntry) { + if (!transaction) { + return; + } + + const newTransaction = { + ...transaction, + }; + + // Use set so that it will always fully overwrite any backup transaction that could have existed before + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); +} + +function removeDraftTransaction(transactionID: string) { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); +} + +export {createBackupTransaction, removeBackupTransaction, restoreOriginalTransactionFromBackup, createDraftTransaction, removeDraftTransaction}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index f347655b6a4d..5dab277f07fa 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -44,8 +44,8 @@ import type ReportAction from '@src/types/onyx/ReportAction'; import type {OriginalMessage} from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; +import applyOnyxUpdatesReliably from './applyOnyxUpdatesReliably'; import * as Link from './Link'; -import * as OnyxUpdates from './OnyxUpdates'; import * as Report from './Report'; import * as Session from './Session'; @@ -597,7 +597,7 @@ function subscribeToUserEvents() { updates: pushJSON.updates ?? [], previousUpdateID: Number(pushJSON.previousUpdateID || 0), }; - OnyxUpdates.applyOnyxUpdatesReliably(updates); + applyOnyxUpdatesReliably(updates); }); // Handles Onyx updates coming from Pusher through the mega multipleEvents. diff --git a/src/libs/actions/Welcome.ts b/src/libs/actions/Welcome.ts index 87762bc856ca..84066c05e80e 100644 --- a/src/libs/actions/Welcome.ts +++ b/src/libs/actions/Welcome.ts @@ -1,6 +1,6 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import type {SelectedPurposeType} from '@pages/OnboardingPurpose/BaseOnboardingPurpose'; +import type {OnboardingPurposeType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type OnyxPolicy from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; @@ -112,7 +112,7 @@ function getPersonalDetails(accountID: number | undefined) { }); } -function setOnboardingPurposeSelected(value: SelectedPurposeType) { +function setOnboardingPurposeSelected(value: OnboardingPurposeType) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } diff --git a/src/libs/actions/applyOnyxUpdatesReliably.ts b/src/libs/actions/applyOnyxUpdatesReliably.ts new file mode 100644 index 000000000000..17754712cdc8 --- /dev/null +++ b/src/libs/actions/applyOnyxUpdatesReliably.ts @@ -0,0 +1,26 @@ +import type {OnyxUpdatesFromServer} from '@src/types/onyx'; +import {handleOnyxUpdateGap} from './OnyxUpdateManager'; +import * as OnyxUpdates from './OnyxUpdates'; + +/** + * Checks for and handles gaps of onyx updates between the client and the given server updates before applying them + * + * This is in it's own lib to fix a dependency cycle from OnyxUpdateManager + * + * @param updates + * @param shouldRunSync + * @returns + */ +export default function applyOnyxUpdatesReliably(updates: OnyxUpdatesFromServer, shouldRunSync = false, clientLastUpdateID = 0) { + const previousUpdateID = Number(updates.previousUpdateID) || 0; + if (!OnyxUpdates.doesClientNeedToBeUpdated(previousUpdateID, clientLastUpdateID)) { + OnyxUpdates.apply(updates); + return; + } + + if (shouldRunSync) { + handleOnyxUpdateGap(updates, clientLastUpdateID); + } else { + OnyxUpdates.saveUpdateInformation(updates); + } +} diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts index d9893d93d2f6..6517a7a28642 100644 --- a/src/libs/fileDownload/FileUtils.ts +++ b/src/libs/fileDownload/FileUtils.ts @@ -242,7 +242,7 @@ function base64ToFile(base64: string, filename: string): File { } function validateImageForCorruption(file: FileObject): Promise { - if (!Str.isImage(file.name ?? '')) { + if (!Str.isImage(file.name ?? '') || !file.uri) { return Promise.resolve(); } return new Promise((resolve, reject) => { diff --git a/src/libs/shouldAllowRawHTMLMessages/index.native.ts b/src/libs/shouldAllowRawHTMLMessages/index.native.ts deleted file mode 100644 index db886f7f6fe8..000000000000 --- a/src/libs/shouldAllowRawHTMLMessages/index.native.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default function () { - return false; -} diff --git a/src/libs/shouldAllowRawHTMLMessages/index.ts b/src/libs/shouldAllowRawHTMLMessages/index.ts deleted file mode 100644 index 577dc3055441..000000000000 --- a/src/libs/shouldAllowRawHTMLMessages/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -window.shouldAllowRawHTMLMessages = false; - -export default function () { - return window.shouldAllowRawHTMLMessages; -} diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 5f14f8576cb5..470afc28d76e 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -1,7 +1,6 @@ import isEmpty from 'lodash/isEmpty'; import reject from 'lodash/reject'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; @@ -9,6 +8,7 @@ import OfflineIndicator from '@components/OfflineIndicator'; import {useOptionsList} from '@components/OptionListContextProvider'; import {PressableWithFeedback} from '@components/Pressable'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; +import ScreenWrapper from '@components/ScreenWrapper'; import SelectCircle from '@components/SelectCircle'; import SelectionList from '@components/SelectionList'; import type {ListItem} from '@components/SelectionList/types'; @@ -265,6 +265,7 @@ function NewChatPage({isGroupChat}: NewChatPageProps) { {!!selectedOptions.length && (