diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml index d2e0ec4f38e5..9eb5bc6eb409 100644 --- a/.github/workflows/failureNotifier.yml +++ b/.github/workflows/failureNotifier.yml @@ -88,7 +88,7 @@ jobs: repo: context.repo.repo, title: issueTitle, body: issueBody, - labels: [failureLabel, 'Daily'], + labels: [failureLabel, 'Hourly'], assignees: [prMerger] }); } diff --git a/__mocks__/@react-native-community/netinfo.ts b/__mocks__/@react-native-community/netinfo.ts index 0b7bdc9010a3..db0d34e2276d 100644 --- a/__mocks__/@react-native-community/netinfo.ts +++ b/__mocks__/@react-native-community/netinfo.ts @@ -2,12 +2,12 @@ import {NetInfoCellularGeneration, NetInfoStateType} from '@react-native-communi import type {addEventListener, configure, fetch, NetInfoState, refresh, useNetInfo} from '@react-native-community/netinfo'; const defaultState: NetInfoState = { - type: NetInfoStateType.cellular, + type: NetInfoStateType?.cellular, isConnected: true, isInternetReachable: true, details: { isConnectionExpensive: true, - cellularGeneration: NetInfoCellularGeneration['3g'], + cellularGeneration: NetInfoCellularGeneration?.['3g'], carrier: 'T-Mobile', }, }; diff --git a/__mocks__/react-native-onyx.js b/__mocks__/react-native-onyx.js deleted file mode 100644 index d44c73e824d3..000000000000 --- a/__mocks__/react-native-onyx.js +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable rulesdir/prefer-onyx-connect-in-libs */ -import Onyx, {withOnyx} from 'react-native-onyx'; - -let connectCallbackDelay = 0; -function addDelayToConnectCallback(delay) { - connectCallbackDelay = delay; -} - -export default { - ...Onyx, - connect: (mapping) => - Onyx.connect({ - ...mapping, - callback: (...params) => { - if (connectCallbackDelay > 0) { - setTimeout(() => { - mapping.callback(...params); - }, connectCallbackDelay); - } else { - mapping.callback(...params); - } - }, - }), - addDelayToConnectCallback, -}; -export {withOnyx}; -/* eslint-enable rulesdir/prefer-onyx-connect-in-libs */ diff --git a/__mocks__/react-native-onyx.ts b/__mocks__/react-native-onyx.ts new file mode 100644 index 000000000000..253e3db47a96 --- /dev/null +++ b/__mocks__/react-native-onyx.ts @@ -0,0 +1,43 @@ +/** + * We are disabling the lint rule that doesn't allow the usage of Onyx.connect outside libs + * because the intent of this file is to mock the usage of react-native-onyx so we will have to mock the connect function + */ + +/* eslint-disable rulesdir/prefer-onyx-connect-in-libs */ +import type {ConnectOptions, OnyxKey} from 'react-native-onyx'; +import Onyx, {withOnyx} from 'react-native-onyx'; + +let connectCallbackDelay = 0; +function addDelayToConnectCallback(delay: number) { + connectCallbackDelay = delay; +} + +type ReactNativeOnyxMock = { + addDelayToConnectCallback: (delay: number) => void; +} & typeof Onyx; + +type ConnectionCallback = NonNullable['callback']>; +type ConnectionCallbackParams = Parameters>; + +const reactNativeOnyxMock: ReactNativeOnyxMock = { + ...Onyx, + connect: (mapping: ConnectOptions) => { + const callback = (...params: ConnectionCallbackParams) => { + if (connectCallbackDelay > 0) { + setTimeout(() => { + (mapping.callback as (...args: ConnectionCallbackParams) => void)?.(...params); + }, connectCallbackDelay); + } else { + (mapping.callback as (...args: ConnectionCallbackParams) => void)?.(...params); + } + }; + return Onyx.connect({ + ...mapping, + callback, + }); + }, + addDelayToConnectCallback, +}; + +export default reactNativeOnyxMock; +export {withOnyx}; diff --git a/android/app/build.gradle b/android/app/build.gradle index 13a12883d6e2..ea7ea85c1729 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 1001044303 - versionName "1.4.43-3" + versionCode 1001044313 + versionName "1.4.43-13" } flavorDimensions "default" diff --git a/android/app/src/main/java/com/expensify/chat/MainApplication.kt b/android/app/src/main/java/com/expensify/chat/MainApplication.kt index 193333368991..2362af009979 100644 --- a/android/app/src/main/java/com/expensify/chat/MainApplication.kt +++ b/android/app/src/main/java/com/expensify/chat/MainApplication.kt @@ -1,7 +1,9 @@ package com.expensify.chat +import android.app.ActivityManager import android.content.res.Configuration import android.database.CursorWindow +import android.os.Process import androidx.multidex.MultiDexApplication import com.expensify.chat.bootsplash.BootSplashPackage import com.facebook.react.PackageList @@ -40,6 +42,10 @@ class MainApplication : MultiDexApplication(), ReactApplication { override fun onCreate() { super.onCreate() + if (isOnfidoProcess()) { + return + } + SoLoader.init(this, /* native exopackage */false) if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { // If you opted-in for the New Architecture, we load the native entry point for this app. @@ -73,4 +79,13 @@ class MainApplication : MultiDexApplication(), ReactApplication { super.onConfigurationChanged(newConfig) ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) } + + private fun isOnfidoProcess(): Boolean { + val pid = Process.myPid() + val manager = this.getSystemService(ACTIVITY_SERVICE) as ActivityManager + + return manager.runningAppProcesses.any { + it.pid == pid && it.processName.endsWith(":onfido_process") + } + } } diff --git a/android/build.gradle b/android/build.gradle index c4e25dde9e2b..7b5dd81e5bf1 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -43,6 +43,13 @@ allprojects { def REACT_NATIVE_VERSION = new File(['node', '--print',"JSON.parse(require('fs').readFileSync(require.resolve('react-native/package.json'), 'utf-8')).version"].execute(null, rootDir).text.trim()) force "com.facebook.react:react-native:" + REACT_NATIVE_VERSION force "com.facebook.react:hermes-engine:" + REACT_NATIVE_VERSION + + eachDependency { dependency -> + if (dependency.requested.group == 'org.bouncycastle') { + println dependency.requested.module + dependency.useTarget 'org.bouncycastle:bcprov-jdk15to18:1.71' + } + } } } repositories { diff --git a/assets/images/avatars/notifications-avatar.svg b/assets/images/avatars/notifications-avatar.svg new file mode 100644 index 000000000000..224baad22cf6 --- /dev/null +++ b/assets/images/avatars/notifications-avatar.svg @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/docs/articles/expensify-classic/getting-started/Invite-Members.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members.md similarity index 100% rename from docs/articles/expensify-classic/getting-started/Invite-Members.md rename to docs/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members.md diff --git a/docs/articles/expensify-classic/settings/account-settings/Manage-devices.md b/docs/articles/expensify-classic/settings/account-settings/Manage-devices.md new file mode 100644 index 000000000000..864c59a7472a --- /dev/null +++ b/docs/articles/expensify-classic/settings/account-settings/Manage-devices.md @@ -0,0 +1,18 @@ +--- +title: Manage devices +description: Control which devices can access your Expensify account +--- +
+ +You can see which devices have been used to access your Expensify account and even remove devices that you no longer want to have access to your account. + +{% include info.html %} +This process is currently not available from the mobile app and must be completed from the Expensify website. +{% include end-info.html %} + +1. Hover over Settings and click **Account**. +2. Under Account Details, scroll down to the Device Management section. +3. Click **Device Management** to expand the section. +4. Review the devices that have access to your account. To remove access for a specific device, click **Revoke** next to it. + +
diff --git a/docs/articles/expensify-classic/settings/account-settings/Set-notifications.md b/docs/articles/expensify-classic/settings/account-settings/Set-notifications.md new file mode 100644 index 000000000000..2d561ea598d9 --- /dev/null +++ b/docs/articles/expensify-classic/settings/account-settings/Set-notifications.md @@ -0,0 +1,15 @@ +--- +title: Set notifications +description: Select your Expensify notification preferences +--- +
+ +{% include info.html %} +This process is currently not available from the mobile app and must be completed from the Expensify website. +{% include end-info.html %} + +1. Hover over Settings and click **Account**. +2. Click the **Preferences** tab on the left. +3. Scroll down to the Contact Preferences section. +4. Select the checkbox for the types of notifications you wish to receive. +
diff --git a/docs/redirects.csv b/docs/redirects.csv index 76b7bac3fc99..4ed309467f13 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -25,16 +25,16 @@ https://community.expensify.com/discussion/5194/how-to-assign-a-vacation-delegat https://community.expensify.com/discussion/5190/how-to-individually-assign-a-vacation-delegate-from-account-settings,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate https://community.expensify.com/discussion/5274/how-to-set-up-an-adp-indirect-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/integrations/HR-integrations/ADP https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/get-paid-back/Distance-Tracking -https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details -https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details -https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details -https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details -https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details +https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://help.expensify.com/articles/expensify-classic/expensify-card/Expensify-Card-Perks.html,https://use.expensify.com/company-credit-card https://help.expensify.com/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.html,https://use.expensify.com/accountants-program https://help.expensify.com/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners, https://use.expensify.com/blog/maximizing-rewards-expensifyapproved-accounting-partners-now-earn-0-5-revenue-share https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements -https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts +https://community.expensify.com/discussion/4452/how-to-merge-accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://community.expensify.com/discussion/4783/how-to-add-or-remove-a-copilot,https://help.expensify.com/articles/expensify-classic/account-settings/Copilot https://community.expensify.com/discussion/4343/expensify-anz-partnership-announcement,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Connect-ANZ https://community.expensify.com/discussion/7318/deep-dive-company-credit-card-import-options,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards @@ -54,3 +54,9 @@ https://help.expensify.com/articles/expensify-classic/getting-started/Employees, https://help.expensify.com/articles/expensify-classic/getting-started/Using-The-App,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace https://help.expensify.com/articles/expensify-classic/getting-started/support/Expensify-Support,https://use.expensify.com/support https://help.expensify.com/articles/expensify-classic/getting-started/Plan-Types,https://use.expensify.com/ +https://help.expensify.com/articles/new-expensify/payments/Referral-Program,https://help.expensify.com/articles/expensify-classic/get-paid-back/Referral-Program +https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://help.expensify.com/articles/expensify-classic/account-settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings +https://help.expensify.com/articles/expensify-classic/account-settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts +https://help.expensify.com/articles/expensify-classic/getting-started/Individual-Users,https://help.expensify.com/articles/expensify-classic/getting-started/Create-a-workspace-for-yourself +https://help.expensify.com/articles/expensify-classic/getting-started/Invite-Members,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Invite-Members diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index c534e29e4bc8..637805356a70 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.43.3 + 1.4.43.13 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 6c2ba1269fc4..78372f479d35 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.43.3 + 1.4.43.13 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index b902b2fc37b9..06d36d7ec496 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -13,7 +13,7 @@ CFBundleShortVersionString 1.4.43 CFBundleVersion - 1.4.43.3 + 1.4.43.13 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 80933065c450..52c817c739b3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -280,9 +280,9 @@ PODS: - nanopb/encode (= 2.30908.0) - nanopb/decode (2.30908.0) - nanopb/encode (2.30908.0) - - Onfido (28.3.1) - - onfido-react-native-sdk (8.3.0): - - Onfido (~> 28.3.0) + - Onfido (29.6.0) + - onfido-react-native-sdk (10.6.0): + - Onfido (~> 29.6.0) - React - OpenSSL-Universal (1.1.1100) - Plaid (4.7.0) @@ -1903,8 +1903,8 @@ SPEC CHECKSUMS: MapboxMaps: cbb38845a9bf49b124f0e937975d560a4e01894e MapboxMobileEvents: de50b3a4de180dd129c326e09cd12c8adaaa46d6 nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 - Onfido: 564f60c39819635ec5b549285a1eec278cc9ba67 - onfido-react-native-sdk: b346a620af5669f9fecb6dc3052314a35a94ad9f + Onfido: c52e797b10cc9e6d29ba91996cb62e501000bfdd + onfido-react-native-sdk: 4e7f0a7a986ed93cb906d2e0b67a6aab9202de0b OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c Plaid: 431ef9be5314a1345efb451bc5e6b067bfb3b4c6 PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 diff --git a/jest.config.js b/jest.config.js index 95ecc350ed9f..441507af4228 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ module.exports = { `/?(*.)+(spec|test).${testFileExtension}`, ], transform: { - '^.+\\.jsx?$': 'babel-jest', + '^.+\\.[jt]sx?$': 'babel-jest', '^.+\\.svg?$': 'jest-transformer-svg', }, transformIgnorePatterns: ['/node_modules/(?!react-native)/'], diff --git a/package-lock.json b/package-lock.json index 3dffbe08ec57..3afc10d1846d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.43-3", + "version": "1.4.43-13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.43-3", + "version": "1.4.43-13", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -24,7 +24,7 @@ "@kie/act-js": "^2.6.0", "@kie/mock-github": "^1.0.0", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", - "@onfido/react-native-sdk": "8.3.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", @@ -38,6 +38,7 @@ "@react-native-picker/picker": "2.5.1", "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.8", + "@react-navigation/native-stack": "^6.9.17", "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "^10.1.11", @@ -97,7 +98,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.2", + "react-native-onyx": "2.0.6", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -8113,12 +8114,18 @@ } }, "node_modules/@onfido/react-native-sdk": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@onfido/react-native-sdk/-/react-native-sdk-8.3.0.tgz", - "integrity": "sha512-nnhuvezd35v08WXUTQlX+gr4pbnNnwNV5KscC/jJrfjGikNUJnhnAHYxfnfJccTn44qUC6vRaKWq2GfpMUnqNA==", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/@onfido/react-native-sdk/-/react-native-sdk-10.6.0.tgz", + "integrity": "sha512-mNXfEjWWWgf3o/3F8orPmp24cShHsINJ1e15EeGNYOtm1XBPzq1FbEiiDp0pyuxgwpNFybGZGPjJcYpX0wwa4g==", + "dependencies": { + "js-base64": "3.7.5" + }, + "engines": { + "node": ">=16" + }, "peerDependencies": { "react": ">=17.0.0", - "react-native": ">=0.68.2 <1.0.x" + "react-native": ">=0.70.0 <1.0.x" } }, "node_modules/@pkgjs/parseargs": { @@ -10252,6 +10259,17 @@ "react": "*" } }, + "node_modules/@react-navigation/elements": { + "version": "1.3.21", + "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.21.tgz", + "integrity": "sha512-eyS2C6McNR8ihUoYfc166O1D8VYVh9KIl0UQPI8/ZJVsStlfSTgeEEh+WXge6+7SFPnZ4ewzEJdSAHH+jzcEfg==", + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-safe-area-context": ">= 3.0.0" + } + }, "node_modules/@react-navigation/material-top-tabs": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/@react-navigation/material-top-tabs/-/material-top-tabs-6.6.3.tgz", @@ -10283,6 +10301,22 @@ "react-native": "*" } }, + "node_modules/@react-navigation/native-stack": { + "version": "6.9.17", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-6.9.17.tgz", + "integrity": "sha512-X8p8aS7JptQq7uZZNFEvfEcPf6tlK4PyVwYDdryRbG98B4bh2wFQYMThxvqa+FGEN7USEuHdv2mF0GhFKfX0ew==", + "dependencies": { + "@react-navigation/elements": "^1.3.21", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "@react-navigation/native": "^6.0.0", + "react": "*", + "react-native": "*", + "react-native-safe-area-context": ">= 3.0.0", + "react-native-screens": ">= 3.0.0" + } + }, "node_modules/@react-navigation/routers": { "version": "6.1.9", "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz", @@ -10309,17 +10343,6 @@ "react-native-screens": ">= 3.0.0" } }, - "node_modules/@react-navigation/stack/node_modules/@react-navigation/elements": { - "version": "1.3.17", - "resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.17.tgz", - "integrity": "sha512-sui8AzHm6TxeEvWT/NEXlz3egYvCUog4tlXA4Xlb2Vxvy3purVXDq/XsM56lJl344U5Aj/jDzkVanOTMWyk4UA==", - "peerDependencies": { - "@react-navigation/native": "^6.0.0", - "react": "*", - "react-native": "*", - "react-native-safe-area-context": ">= 3.0.0" - } - }, "node_modules/@react-ng/bounds-observer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@react-ng/bounds-observer/-/bounds-observer-0.2.1.tgz", @@ -38888,6 +38911,11 @@ "integrity": "sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==", "license": "MIT" }, + "node_modules/js-base64": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", + "integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==" + }, "node_modules/js-cookie": { "version": "3.0.1", "license": "MIT", @@ -45130,9 +45158,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.2.tgz", - "integrity": "sha512-24kcG3ChBXp+uSSCXudFvZTdCnKLRHQRgvTcnh2eA7COtKvbL8ggEJNkglSYmcf5WoDzLgYyWiKvcjcXQnmBvw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.6.tgz", + "integrity": "sha512-qsCxvNKc+mq/Y74v6Twe7VZxqgfpjBm0997R8OEtCUJEtgAp0riCQ3GvuVVIWYALz3S+ADokEAEPzeFW2frtpw==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index 39f19bf9e61c..1f02b3727ac6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.43-3", + "version": "1.4.43-13", "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.", @@ -50,8 +50,8 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e": "ts-node tests/e2e/testRunner.js --development --skipCheckout --skipInstallDeps --buildMode none", - "test:e2e:dev": "ts-node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", + "test:e2e": "ts-node tests/e2e/testRunner.js --config ./config.local.ts", + "test:e2e:dev": "ts-node tests/e2e/testRunner.js --config ./config.dev.js", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js", @@ -72,7 +72,7 @@ "@kie/act-js": "^2.6.0", "@kie/mock-github": "^1.0.0", "@oguzhnatly/react-native-image-manipulator": "github:Expensify/react-native-image-manipulator#5cdae3d4455b03a04c57f50be3863e2fe6c92c52", - "@onfido/react-native-sdk": "8.3.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", @@ -86,6 +86,7 @@ "@react-native-picker/picker": "2.5.1", "@react-navigation/material-top-tabs": "^6.6.3", "@react-navigation/native": "6.1.8", + "@react-navigation/native-stack": "^6.9.17", "@react-navigation/stack": "6.3.16", "@react-ng/bounds-observer": "^0.2.1", "@rnmapbox/maps": "^10.1.11", @@ -145,7 +146,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.2", + "react-native-onyx": "2.0.6", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", diff --git a/patches/@onfido+react-native-sdk+10.6.0.patch b/patches/@onfido+react-native-sdk+10.6.0.patch new file mode 100644 index 000000000000..d61f4ab454c9 --- /dev/null +++ b/patches/@onfido+react-native-sdk+10.6.0.patch @@ -0,0 +1,18 @@ +diff --git a/node_modules/@onfido/react-native-sdk/android/build.gradle b/node_modules/@onfido/react-native-sdk/android/build.gradle +index 33a4229..1720bef 100644 +--- a/node_modules/@onfido/react-native-sdk/android/build.gradle ++++ b/node_modules/@onfido/react-native-sdk/android/build.gradle +@@ -84,6 +84,13 @@ android { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } ++ ++ publishing { ++ singleVariant('release') { ++ withSourcesJar() ++ withJavadocJar() ++ } ++ } + } + + repositories { diff --git a/patches/@onfido+react-native-sdk+8.3.0.patch b/patches/@onfido+react-native-sdk+8.3.0.patch deleted file mode 100644 index 5d3fd51bf826..000000000000 --- a/patches/@onfido+react-native-sdk+8.3.0.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/node_modules/@onfido/react-native-sdk/android/build.gradle b/node_modules/@onfido/react-native-sdk/android/build.gradle -index b4c7106..c6efb0f 100644 ---- a/node_modules/@onfido/react-native-sdk/android/build.gradle -+++ b/node_modules/@onfido/react-native-sdk/android/build.gradle -@@ -84,6 +84,13 @@ android { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } -+ -+ publishing { -+ singleVariant('release') { -+ withSourcesJar() -+ withJavadocJar() -+ } -+ } - } - - repositories { -@@ -135,9 +142,9 @@ afterEvaluate { project -> - group = "Reporting" - description = "Generate Jacoco coverage reports after running tests." - reports { -- xml.enabled = true -- html.enabled = true -- csv.enabled = true -+ xml.required = true -+ html.required = true -+ csv.required = true - } - classDirectories.setFrom(fileTree( - dir: 'build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/com/onfido/reactnative/sdk', diff --git a/patches/@react-navigation+native-stack+6.9.17.patch b/patches/@react-navigation+native-stack+6.9.17.patch new file mode 100644 index 000000000000..933ca6ce792e --- /dev/null +++ b/patches/@react-navigation+native-stack+6.9.17.patch @@ -0,0 +1,39 @@ +diff --git a/node_modules/@react-navigation/native-stack/src/types.tsx b/node_modules/@react-navigation/native-stack/src/types.tsx +index 206fb0b..7a34a8e 100644 +--- a/node_modules/@react-navigation/native-stack/src/types.tsx ++++ b/node_modules/@react-navigation/native-stack/src/types.tsx +@@ -490,6 +490,14 @@ export type NativeStackNavigationOptions = { + * Only supported on iOS and Android. + */ + freezeOnBlur?: boolean; ++ // partial changes from https://github.com/react-navigation/react-navigation/commit/90cfbf23bcc5259f3262691a9eec6c5b906e5262 ++ // patch can be removed when new version of `native-stack` will be released ++ /** ++ * Whether the keyboard should hide when swiping to the previous screen. Defaults to `false`. ++ * ++ * Only supported on iOS ++ */ ++ keyboardHandlingEnabled?: boolean; + }; + + export type NativeStackNavigatorProps = DefaultNavigatorOptions< +diff --git a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx +index a005c43..03d8b50 100644 +--- a/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx ++++ b/node_modules/@react-navigation/native-stack/src/views/NativeStackView.native.tsx +@@ -161,6 +161,7 @@ const SceneView = ({ + statusBarTranslucent, + statusBarColor, + freezeOnBlur, ++ keyboardHandlingEnabled, + } = options; + + let { +@@ -289,6 +290,7 @@ const SceneView = ({ + onNativeDismissCancelled={onNativeDismissCancelled} + // this prop is available since rn-screens 3.16 + freezeOnBlur={freezeOnBlur} ++ hideKeyboardOnSwipe={keyboardHandlingEnabled} + > + + diff --git a/patches/expo-av+13.10.4.patch b/patches/expo-av+13.10.4.patch new file mode 100644 index 000000000000..c7b1626e233a --- /dev/null +++ b/patches/expo-av+13.10.4.patch @@ -0,0 +1,17 @@ +diff --git a/node_modules/expo-av/android/build.gradle b/node_modules/expo-av/android/build.gradle +index 2d68ca6..c3fa3c5 100644 +--- a/node_modules/expo-av/android/build.gradle ++++ b/node_modules/expo-av/android/build.gradle +@@ -7,10 +7,11 @@ apply plugin: 'maven-publish' + group = 'host.exp.exponent' + version = '13.10.4' + ++def REACT_NATIVE_PATH = this.hasProperty('reactNativeProject') ? this.reactNativeProject + '/node_modules/react-native/package.json' : 'react-native/package.json' + def REACT_NATIVE_BUILD_FROM_SOURCE = findProject(":ReactAndroid") != null + def REACT_NATIVE_DIR = REACT_NATIVE_BUILD_FROM_SOURCE + ? findProject(":ReactAndroid").getProjectDir().parent +- : new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).parent ++ : new File(["node", "--print", "require.resolve('${REACT_NATIVE_PATH}')"].execute(null, rootDir).text.trim()).parent + + def reactNativeArchitectures() { + def value = project.getProperties().get("reactNativeArchitectures") diff --git a/src/CONST.ts b/src/CONST.ts index ac8533c620b9..008002a71078 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -126,7 +126,7 @@ const CONST = { }, DATE_BIRTH: { - MIN_AGE: 5, + MIN_AGE: 0, MIN_AGE_FOR_PAYMENT: 18, MAX_AGE: 150, }, @@ -148,23 +148,23 @@ const CONST = { SMALL_SCREEN: { IMAGE_HEIGHT: 300, CONTAINER_MINHEIGHT: 200, - VIEW_HEIGHT: 185, + VIEW_HEIGHT: 240, }, WIDE_SCREEN: { IMAGE_HEIGHT: 450, CONTAINER_MINHEIGHT: 500, - VIEW_HEIGHT: 275, + VIEW_HEIGHT: 390, }, MONEY_OR_TASK_REPORT: { SMALL_SCREEN: { IMAGE_HEIGHT: 300, CONTAINER_MINHEIGHT: 280, - VIEW_HEIGHT: 220, + VIEW_HEIGHT: 240, }, WIDE_SCREEN: { IMAGE_HEIGHT: 450, CONTAINER_MINHEIGHT: 280, - VIEW_HEIGHT: 275, + VIEW_HEIGHT: 390, }, }, }, @@ -895,6 +895,7 @@ const CONST = { DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, DEFAULT_CLOSE_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, + DEFAULT_NETWORK_DATA: {isOffline: false}, FORMS: { LOGIN_FORM: 'LoginForm', VALIDATE_CODE_FORM: 'ValidateCodeForm', @@ -1555,6 +1556,7 @@ const CONST = { WORKSPACE_TRAVEL: 'WorkspaceBookTravel', WORKSPACE_MEMBERS: 'WorkspaceManageMembers', WORKSPACE_BANK_ACCOUNT: 'WorkspaceBankAccount', + WORKSPACE_SETTINGS: 'WorkspaceSettings', }, get EXPENSIFY_EMAILS() { return [ diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 9d35994875e1..afbcd768b465 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -396,36 +396,35 @@ type AllOnyxKeys = DeepValueOf; type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM]: FormTypes.AddDebitCardForm; [ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM]: FormTypes.WorkspaceSettingsForm; - [ONYXKEYS.FORMS.WORKSPACE_DESCRIPTION_FORM]: FormTypes.WorkspaceProfileDescriptionForm; [ONYXKEYS.FORMS.WORKSPACE_RATE_AND_UNIT_FORM]: FormTypes.WorkspaceRateAndUnitForm; [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM]: FormTypes.CloseAccountForm; - [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: FormTypes.ProfileSettingsForm; [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: FormTypes.DisplayNameForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: FormTypes.RoomNameForm; - [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.REPORT_DESCRIPTION_FORM]: FormTypes.ReportDescriptionForm; + [ONYXKEYS.FORMS.LEGAL_NAME_FORM]: FormTypes.LegalNameForm; + [ONYXKEYS.FORMS.WORKSPACE_INVITE_MESSAGE_FORM]: FormTypes.WorkspaceInviteMessageForm; [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM]: FormTypes.DateOfBirthForm; - [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: FormTypes.HomeAddressForm; [ONYXKEYS.FORMS.NEW_ROOM_FORM]: FormTypes.NewRoomForm; - [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: FormTypes.RoomSettingsForm; + [ONYXKEYS.FORMS.NEW_TASK_FORM]: FormTypes.NewTaskForm; + [ONYXKEYS.FORMS.EDIT_TASK_FORM]: FormTypes.EditTaskForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_DESCRIPTION_FORM]: FormTypes.MoneyRequestDescriptionForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.MoneyRequestMerchantForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.MoneyRequestAmountForm; + [ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.MoneyRequestDateForm; [ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm; - [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.Form; - [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.NewContactMethodForm; + [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.WaypointForm; + [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.SettingsStatusSetForm; + [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.SettingsStatusClearDateForm; + [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: FormTypes.SettingsStatusSetClearAfterForm; [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: FormTypes.PrivateNotesForm; [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: FormTypes.IKnowTeacherForm; [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: FormTypes.IntroSchoolPrincipalForm; - [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.Form; - [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.Form; + [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: FormTypes.ReportVirtualCardFraudForm; + [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: FormTypes.ReportPhysicalCardForm; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: FormTypes.GetPhysicalCardForm; [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: FormTypes.ReportFieldEditForm; [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: FormTypes.ReimbursementAccountForm; @@ -491,7 +490,7 @@ type OnyxValuesMapping = { [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.TASK]: OnyxTypes.Task; [ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit; - [ONYXKEYS.CURRENCY_LIST]: Record; + [ONYXKEYS.CURRENCY_LIST]: OnyxTypes.CurrencyList; [ONYXKEYS.UPDATE_AVAILABLE]: boolean; [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest; [ONYXKEYS.COUNTRY_CODE]: number; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c5480d363019..4d27c5f5e8cb 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -84,28 +84,28 @@ const ROUTES = { SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', SETTINGS_WALLET_DOMAINCARD: { - route: '/settings/wallet/card/:domain', - getRoute: (domain: string) => `/settings/wallet/card/${domain}` as const, + route: 'settings/wallet/card/:domain', + getRoute: (domain: string) => `settings/wallet/card/${domain}` as const, }, SETTINGS_REPORT_FRAUD: { - route: '/settings/wallet/card/:domain/report-virtual-fraud', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud` as const, + route: 'settings/wallet/card/:domain/report-virtual-fraud', + getRoute: (domain: string) => `settings/wallet/card/${domain}/report-virtual-fraud` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { - route: '/settings/wallet/card/:domain/get-physical/name', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name` as const, + route: 'settings/wallet/card/:domain/get-physical/name', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/name` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { - route: '/settings/wallet/card/:domain/get-physical/phone', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone` as const, + route: 'settings/wallet/card/:domain/get-physical/phone', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/phone` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { - route: '/settings/wallet/card/:domain/get-physical/address', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address` as const, + route: 'settings/wallet/card/:domain/get-physical/address', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/address` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { - route: '/settings/wallet/card/:domain/get-physical/confirm', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm` as const, + route: 'settings/wallet/card/:domain/get-physical/confirm', + getRoute: (domain: string) => `settings/wallet/card/${domain}/get-physical/confirm` as const, }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', @@ -117,8 +117,8 @@ const ROUTES = { SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: { - route: '/settings/wallet/card/:domain/report-card-lost-or-damaged', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged` as const, + route: 'settings/wallet/card/:domain/report-card-lost-or-damaged', + getRoute: (domain: string) => `settings/wallet/card/${domain}/report-card-lost-or-damaged` as const, }, SETTINGS_WALLET_CARD_ACTIVATE: { route: 'settings/wallet/card/:domain/activate', @@ -219,6 +219,10 @@ const ROUTES = { route: 'r/:reportID/settings/who-can-post', getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post` as const, }, + REPORT_SETTINGS_VISIBILITY: { + route: 'r/:reportID/settings/visibility', + getRoute: (reportID: string) => `r/${reportID}/settings/visibility` as const, + }, SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}` as const, @@ -283,10 +287,6 @@ const ROUTES = { route: ':iouType/new/currency/:reportID?', getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, }, - MONEY_REQUEST_CATEGORY: { - route: ':iouType/new/category/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const, - }, MONEY_REQUEST_HOLD_REASON: { route: ':iouType/edit/reason/:transactionID?', getRoute: (iouType: string, transactionID: string, reportID: string, backTo: string) => `${iouType}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const, @@ -334,9 +334,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/taxAmount/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_CATEGORY: { - route: 'create/:iouType/category/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/category/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/category/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/category/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_CURRENCY: { route: 'create/:iouType/currency/:transactionID/:reportID/:pageIndex?', @@ -459,6 +459,10 @@ const ROUTES = { route: 'workspace/:policyID/profile/description', getRoute: (policyID: string) => `workspace/${policyID}/profile/description` as const, }, + WORKSPACE_PROFILE_SHARE: { + route: 'workspace/:policyID/profile/share', + getRoute: (policyID: string) => `workspace/${policyID}/profile/share` as const, + }, WORKSPACE_AVATAR: { route: 'workspace/:policyID/avatar', getRoute: (policyID: string) => `workspace/${policyID}/avatar` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index ee3c64e8d804..da7ea8db5ee6 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -149,7 +149,6 @@ const SCREENS = { PARTICIPANTS: 'Money_Request_Participants', CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', - CATEGORY: 'Money_Request_Category', WAYPOINT: 'Money_Request_Waypoint', EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', DISTANCE: 'Money_Request_Distance', @@ -167,6 +166,7 @@ const SCREENS = { ROOM_NAME: 'Report_Settings_Room_Name', NOTIFICATION_PREFERENCES: 'Report_Settings_Notification_Preferences', WRITE_CAPABILITY: 'Report_Settings_Write_Capability', + VISIBILITY: 'Report_Settings_Visibility', }, NEW_TASK: { @@ -209,6 +209,7 @@ const SCREENS = { INVITE_MESSAGE: 'Workspace_Invite_Message', CURRENCY: 'Workspace_Profile_Currency', DESCRIPTION: 'Workspace_Profile_Description', + SHARE: 'Workspace_Profile_Share', NAME: 'Workspace_Profile_Name', }, diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index 0e57bcf4db03..2374fc9e5d0c 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -3,6 +3,7 @@ import React, {useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -67,6 +68,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC textInputLabel={shouldShowTextInput && translate('common.search')} onChangeText={setSearchValue} onSelectRow={onSubmit} + ListItem={RadioListItem} initiallyFocusedOptionKey={selectedOptionKey} /> ); diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 25dc99459064..5b5e99ac0621 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -1,4 +1,5 @@ -import React, {forwardRef, useEffect} from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import React, {forwardRef, useEffect, useRef} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; @@ -23,15 +24,28 @@ type CountrySelectorProps = { /** inputID used by the Form component */ // eslint-disable-next-line react/no-unused-prop-types inputID: string; + + /** Callback to call when the picker modal is dismissed */ + onBlur?: () => void; }; -function CountrySelector({errorText = '', value: countryCode, onInputChange}: CountrySelectorProps, ref: ForwardedRef) { +function CountrySelector({errorText = '', value: countryCode, onInputChange, onBlur}: CountrySelectorProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); const title = countryCode ? translate(`allCountries.${countryCode}`) : ''; const countryTitleDescStyle = title.length === 0 ? styles.textNormal : null; + const didOpenContrySelector = useRef(false); + const isFocused = useIsFocused(); + useEffect(() => { + if (!isFocused || !didOpenContrySelector.current) { + return; + } + didOpenContrySelector.current = false; + onBlur?.(); + }, [isFocused, onBlur]); + useEffect(() => { // This will cause the form to revalidate and remove any error related to country name onInputChange(countryCode); @@ -48,6 +62,7 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange}: Co description={translate('common.country')} onPress={() => { const activeRoute = Navigation.getActiveRouteWithoutParams(); + didOpenContrySelector.current = true; Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); }} /> diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index 8f2acc6fbd3c..f8c4a12ec188 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -33,7 +34,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear const yearsList = searchText === '' ? years : years.filter((year) => year.text.includes(searchText)); return { headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', - sections: [{data: yearsList, indexOffset: 0}], + sections: [{data: yearsList.sort((a, b) => b.value - a.value), indexOffset: 0}], }; }, [years, searchText, translate]); @@ -78,6 +79,8 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear initiallyFocusedOptionKey={currentYear.toString()} showScrollIndicator shouldStopPropagation + shouldUseDynamicMaxToRenderPerBatch + ListItem={RadioListItem} /> diff --git a/src/components/DistanceRequest/index.tsx b/src/components/DistanceRequest/index.tsx index 29987f716565..3f74c148de70 100644 --- a/src/components/DistanceRequest/index.tsx +++ b/src/components/DistanceRequest/index.tsx @@ -28,6 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Report, Transaction} from '@src/types/onyx'; import type {WaypointCollection} from '@src/types/onyx/Transaction'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import DistanceRequestFooter from './DistanceRequestFooter'; import DistanceRequestRenderItem from './DistanceRequestRenderItem'; @@ -176,7 +177,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit ); }; - const getError = () => { + const getError = useCallback(() => { // Get route error if available else show the invalid number of waypoints error. if (hasRouteError) { return ErrorUtils.getLatestErrorField((transaction ?? {}) as Transaction, 'route'); @@ -186,8 +187,12 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit // eslint-disable-next-line @typescript-eslint/naming-convention return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; } - return {}; - }; + + if (Object.keys(validatedWaypoints).length < Object.keys(waypoints).length) { + // eslint-disable-next-line @typescript-eslint/naming-convention + return {0: translate('iou.error.duplicateWaypointsErrorMessage')}; + } + }, [translate, transaction, hasRouteError, validatedWaypoints, waypoints]); const updateWaypoints = useCallback( ({data}: DraggableListData) => { @@ -211,7 +216,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit const submitWaypoints = useCallback(() => { // If there is any error or loading state, don't let user go to next page. - if (Object.keys(validatedWaypoints).length < 2 || hasRouteError || isLoadingRoute || (isLoading && !isOffline)) { + if (!isEmptyObject(getError()) || isLoadingRoute || (isLoading && !isOffline)) { setHasError(true); return; } @@ -221,7 +226,7 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit } onSubmit(waypoints); - }, [onSubmit, setHasError, hasRouteError, isLoadingRoute, isLoading, validatedWaypoints, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); + }, [onSubmit, setHasError, getError, isLoadingRoute, isLoading, waypoints, isEditingNewRequest, isEditingRequest, isOffline]); const content = ( <> @@ -254,10 +259,10 @@ function DistanceRequest({transactionID = '', report, transaction, route, isEdit {/* Show error message if there is route error or there are less than 2 routes and user has tried submitting, */} - {((hasError && Object.keys(validatedWaypoints).length < 2) || hasRouteError) && ( + {((hasError && !isEmptyObject(getError())) || hasRouteError) && ( )} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 863fe6fbabb1..465a4f747bcb 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -65,7 +65,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { // eslint-disable-next-line react/jsx-props-no-multi-spaces target={htmlAttribs.target || '_blank'} rel={htmlAttribs.rel || 'noopener noreferrer'} - style={[parentStyle, styles.textUnderlinePositionUnder, styles.textDecorationSkipInkNone, style]} + style={[style, parentStyle, styles.textUnderlinePositionUnder, styles.textDecorationSkipInkNone]} key={key} // Only pass the press handler for internal links. For public links or whitelisted internal links fallback to default link handling onPress={internalNewExpensifyPath || internalExpensifyPath ? () => Link.openLink(attrHref, environmentURL, isAttachment) : undefined} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx index 8e0ce759d021..f2e38ccb74af 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -10,7 +10,6 @@ import Text from '@components/Text'; import UserDetailsTooltip from '@components/UserDetailsTooltip'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; @@ -27,7 +26,6 @@ type MentionUserRendererProps = WithCurrentUserPersonalDetailsProps & CustomRend function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersonalDetails, ...defaultRendererProps}: MentionUserRendererProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); const htmlAttribAccountID = tnode.attributes.accountid; const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; @@ -39,7 +37,7 @@ function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersona const user = personalDetails[htmlAttribAccountID]; accountID = parseInt(htmlAttribAccountID, 10); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || user?.displayName || translate('common.hidden'); + displayNameOrLogin = PersonalDetailsUtils.getDisplayNameOrDefault(user, LocalePhoneNumber.formatPhoneNumber(user?.login ?? '')); navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID); } else if ('data' in tnode && !isEmptyObject(tnode.data)) { // We need to remove the LTR unicode and leading @ from data as it is not part of the login diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 6cb40e843af4..553a60e568ec 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -12,6 +12,7 @@ import DeletedRoomAvatar from '@assets/images/avatars/deleted-room.svg'; import DomainRoomAvatar from '@assets/images/avatars/domain-room.svg'; import FallbackAvatar from '@assets/images/avatars/fallback-avatar.svg'; import FallbackWorkspaceAvatar from '@assets/images/avatars/fallback-workspace-avatar.svg'; +import NotificationsAvatar from '@assets/images/avatars/notifications-avatar.svg'; import ActiveRoomAvatar from '@assets/images/avatars/room.svg'; import BackArrow from '@assets/images/back-left.svg'; import Bank from '@assets/images/bank.svg'; @@ -247,6 +248,7 @@ export { ExpensifyLogoNew, NewWindow, NewWorkspace, + NotificationsAvatar, Offline, OfflineCloud, OldDotWireframe, diff --git a/src/components/Image/index.js b/src/components/Image/index.js index ef1a69e19c12..59fcde8273fd 100644 --- a/src/components/Image/index.js +++ b/src/components/Image/index.js @@ -3,12 +3,15 @@ import React, {useEffect, useMemo} from 'react'; import {Image as RNImage} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import useNetwork from '@hooks/useNetwork'; import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, imagePropTypes} from './imagePropTypes'; import RESIZE_MODES from './resizeModes'; function Image(props) { const {source: propsSource, isAuthTokenRequired, onLoad, session} = props; + const {isOffline} = useNetwork(); + /** * Check if the image source is a URL - if so the `encryptedAuthToken` is appended * to the source. @@ -39,7 +42,7 @@ function Image(props) { RNImage.getSize(source.uri, (width, height) => { onLoad({nativeEvent: {width, height}}); }); - }, [onLoad, source]); + }, [onLoad, source, isOffline]); // Omit the props which the underlying RNImage won't use const forwardedProps = _.omit(props, ['source', 'onLoad', 'session', 'isAuthTokenRequired']); diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx index b3fc1dc91c16..0ca4a0456e33 100644 --- a/src/components/ImageWithSizeCalculation.tsx +++ b/src/components/ImageWithSizeCalculation.tsx @@ -2,6 +2,7 @@ import delay from 'lodash/delay'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import type {ImageSourcePropType, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import FullscreenLoadingIndicator from './FullscreenLoadingIndicator'; @@ -44,16 +45,27 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT const isLoadedRef = useRef(null); const [isImageCached, setIsImageCached] = useState(true); const [isLoading, setIsLoading] = useState(false); + const {isOffline} = useNetwork(); const source = useMemo(() => ({uri: url}), [url]); const onError = () => { Log.hmmm('Unable to fetch image to calculate size', {url}); onLoadFailure?.(); + if (isLoadedRef.current) { + isLoadedRef.current = false; + setIsImageCached(false); + } + if (isOffline) { + return; + } + setIsLoading(false); }; const imageLoadedSuccessfully = (event: OnLoadNativeEvent) => { isLoadedRef.current = true; + setIsLoading(false); + setIsImageCached(true); onMeasure({ width: event.nativeEvent.width, height: event.nativeEvent.height, @@ -87,10 +99,6 @@ function ImageWithSizeCalculation({url, style, onMeasure, onLoadFailure, isAuthT } setIsLoading(true); }} - onLoadEnd={() => { - setIsLoading(false); - setIsImageCached(true); - }} onError={onError} onLoad={imageLoadedSuccessfully} /> diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index 48e9aa49d0de..7313bb4aa7bb 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -45,7 +45,7 @@ type LocaleContextProps = { /** Returns a locally converted phone number for numbers from the same region * and an internationally converted phone number with the country code for numbers from other regions */ - formatPhoneNumber: (phoneNumber: string | undefined) => string; + formatPhoneNumber: (phoneNumber: string) => string; /** Gets the locale digit corresponding to a standard digit */ toLocaleDigit: (digit: string) => string; diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 46c96fd706a9..93febc4fd3c0 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -430,3 +430,4 @@ function MagicCodeInput( MagicCodeInput.displayName = 'MagicCodeInput'; export default forwardRef(MagicCodeInput); +export type {AutoCompleteVariant, MagicCodeInputHandle}; diff --git a/src/components/MapView/MapView.tsx b/src/components/MapView/MapView.tsx index a3178f642852..77447f13644c 100644 --- a/src/components/MapView/MapView.tsx +++ b/src/components/MapView/MapView.tsx @@ -30,7 +30,21 @@ const MapView = forwardRef( const [isIdle, setIsIdle] = useState(false); const [currentPosition, setCurrentPosition] = useState(cachedUserLocation); const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); - const hasAskedForLocationPermission = useRef(false); + const shouldInitializeCurrentPosition = useRef(true); + + // Determines if map can be panned to user's detected + // location without bothering the user. It will return + // false if user has already started dragging the map or + // if there are one or more waypoints present. + const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + + const setCurrentPositionToInitialState = useCallback(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (cachedUserLocation || !initialState) { + return; + } + setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); + }, [initialState, cachedUserLocation]); useFocusEffect( useCallback(() => { @@ -38,34 +52,24 @@ const MapView = forwardRef( return; } - if (hasAskedForLocationPermission.current) { + if (!shouldInitializeCurrentPosition.current) { return; } - hasAskedForLocationPermission.current = true; - getCurrentPosition( - (params) => { - const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; - setCurrentPosition(currentCoords); - setUserLocation(currentCoords); - }, - () => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (cachedUserLocation || !initialState) { - return; - } - - setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); - }, - ); - }, [cachedUserLocation, initialState, isOffline]), - ); + shouldInitializeCurrentPosition.current = false; - // Determines if map can be panned to user's detected - // location without bothering the user. It will return - // false if user has already started dragging the map or - // if there are one or more waypoints present. - const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + if (!shouldPanMapToCurrentPosition()) { + setCurrentPositionToInitialState(); + return; + } + + getCurrentPosition((params) => { + const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; + setCurrentPosition(currentCoords); + setUserLocation(currentCoords); + }, setCurrentPositionToInitialState); + }, [isOffline, shouldPanMapToCurrentPosition, setCurrentPositionToInitialState]), + ); useEffect(() => { if (!currentPosition || !cameraRef.current) { diff --git a/src/components/MapView/MapView.website.tsx b/src/components/MapView/MapView.website.tsx index 289f7d0d62a8..05be6d6409e8 100644 --- a/src/components/MapView/MapView.website.tsx +++ b/src/components/MapView/MapView.website.tsx @@ -53,7 +53,21 @@ const MapView = forwardRef( const [userInteractedWithMap, setUserInteractedWithMap] = useState(false); const [shouldResetBoundaries, setShouldResetBoundaries] = useState(false); const setRef = useCallback((newRef: MapRef | null) => setMapRef(newRef), []); - const hasAskedForLocationPermission = useRef(false); + const shouldInitializeCurrentPosition = useRef(true); + + // Determines if map can be panned to user's detected + // location without bothering the user. It will return + // false if user has already started dragging the map or + // if there are one or more waypoints present. + const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + + const setCurrentPositionToInitialState = useCallback(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (cachedUserLocation || !initialState) { + return; + } + setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); + }, [initialState, cachedUserLocation]); useFocusEffect( useCallback(() => { @@ -61,34 +75,24 @@ const MapView = forwardRef( return; } - if (hasAskedForLocationPermission.current) { + if (!shouldInitializeCurrentPosition.current) { return; } - hasAskedForLocationPermission.current = true; - getCurrentPosition( - (params) => { - const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; - setCurrentPosition(currentCoords); - setUserLocation(currentCoords); - }, - () => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (cachedUserLocation || !initialState) { - return; - } - - setCurrentPosition({longitude: initialState.location[0], latitude: initialState.location[1]}); - }, - ); - }, [cachedUserLocation, initialState, isOffline]), - ); + shouldInitializeCurrentPosition.current = false; - // Determines if map can be panned to user's detected - // location without bothering the user. It will return - // false if user has already started dragging the map or - // if there are one or more waypoints present. - const shouldPanMapToCurrentPosition = useCallback(() => !userInteractedWithMap && (!waypoints || waypoints.length === 0), [userInteractedWithMap, waypoints]); + if (!shouldPanMapToCurrentPosition()) { + setCurrentPositionToInitialState(); + return; + } + + getCurrentPosition((params) => { + const currentCoords = {longitude: params.coords.longitude, latitude: params.coords.latitude}; + setCurrentPosition(currentCoords); + setUserLocation(currentCoords); + }, setCurrentPositionToInitialState); + }, [isOffline, shouldPanMapToCurrentPosition, setCurrentPositionToInitialState]), + ); useEffect(() => { if (!currentPosition || !mapRef) { diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 8fc9c62bfb38..1c2a8a3197fe 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -573,10 +573,12 @@ function MenuItem( {badgeText && ( diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 0de601bc9f61..2b18ab9bc003 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -766,11 +766,15 @@ function MoneyRequestConfirmationList(props) { description={translate('common.category')} numberOfLinesTitle={2} onPress={() => { - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.CATEGORY)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_CATEGORY.getRoute(props.iouType, props.reportID)); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute( + CONST.IOU.ACTION.EDIT, + props.iouType, + props.transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 3939e847707d..8609b1b05e4f 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -747,7 +747,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ title={iouCategory} description={translate('common.category')} numberOfLinesTitle={2} - onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} + onPress={() => + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ) + } style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} disabled={didConfirm} diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index 69728d7be126..b8d4efbd916d 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -21,14 +21,15 @@ function PopoverContextProvider(props: PopoverContextProps) { const [isOpen, setIsOpen] = useState(false); const activePopoverRef = useRef(null); - const closePopover = useCallback((anchorRef?: RefObject) => { + const closePopover = useCallback((anchorRef?: RefObject): boolean => { if (!activePopoverRef.current || (anchorRef && anchorRef !== activePopoverRef.current.anchorRef)) { - return; + return false; } activePopoverRef.current.close(); activePopoverRef.current = null; setIsOpen(false); + return true; }, []); useEffect(() => { @@ -63,11 +64,13 @@ function PopoverContextProvider(props: PopoverContextProps) { if (e.key !== 'Escape') { return; } - closePopover(); + if (closePopover()) { + e.stopImmediatePropagation(); + } }; - document.addEventListener('keydown', listener, true); + document.addEventListener('keyup', listener, true); return () => { - document.removeEventListener('keydown', listener, true); + document.removeEventListener('keyup', listener, true); }; }, [closePopover]); diff --git a/src/components/Pressable/PressableWithFeedback.tsx b/src/components/Pressable/PressableWithFeedback.tsx index b717c4890a2d..74ea4596046e 100644 --- a/src/components/Pressable/PressableWithFeedback.tsx +++ b/src/components/Pressable/PressableWithFeedback.tsx @@ -2,6 +2,7 @@ import React, {forwardRef, useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import OpacityView from '@components/OpacityView'; +import type {Color} from '@styles/theme/types'; import variables from '@styles/variables'; import GenericPressable from './GenericPressable'; import type {PressableRef} from './GenericPressable/types'; @@ -27,6 +28,9 @@ type PressableWithFeedbackProps = PressableProps & { /** Whether the view needs to be rendered offscreen (for Android only) */ needsOffscreenAlphaCompositing?: boolean; + + /** The color of the underlay that will show through when the Pressable is active. */ + underlayColor?: Color; }; function PressableWithFeedback( diff --git a/src/components/QRShare/index.tsx b/src/components/QRShare/index.tsx index 45a4a4fd4964..c7e9e7637a6c 100644 --- a/src/components/QRShare/index.tsx +++ b/src/components/QRShare/index.tsx @@ -9,15 +9,12 @@ import QRCode from '@components/QRCode'; import Text from '@components/Text'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import variables from '@styles/variables'; -import CONST from '@src/CONST'; import type {QRShareHandle, QRShareProps} from './types'; function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRShareProps, ref: ForwardedRef) { const styles = useThemeStyles(); const theme = useTheme(); - const {isSmallScreenWidth} = useWindowDimensions(); const [qrCodeSize, setQrCodeSize] = useState(1); const svgRef = useRef(); @@ -32,11 +29,7 @@ function QRShare({url, title, subtitle, logo, logoRatio, logoMarginRatio}: QRSha const onLayout = (event: LayoutChangeEvent) => { const containerWidth = event.nativeEvent.layout.width - variables.qrShareHorizontalPadding * 2 || 0; - if (isSmallScreenWidth) { - setQrCodeSize(Math.max(1, containerWidth)); - return; - } - setQrCodeSize(Math.max(1, Math.min(containerWidth, CONST.CENTRAL_PANE_ANIMATION_HEIGHT))); + setQrCodeSize(Math.max(1, containerWidth)); }; return ( diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index e2f7314afd73..4137b259f362 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -4,6 +4,7 @@ import lodashSortBy from 'lodash/sortBy'; import React from 'react'; import {View} from 'react-native'; import type {GestureResponderEvent} from 'react-native'; +import ConfirmedRoute from '@components/ConfirmedRoute'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestSkeletonView from '@components/MoneyRequestSkeletonView'; @@ -114,6 +115,9 @@ function MoneyRequestPreviewContent({ const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(transaction)] : []; + const hasPendingWaypoints = transaction?.pendingFields?.waypoints; + const showMapAsImage = isDistanceRequest && hasPendingWaypoints; + const getSettledMessage = (): string => { if (isCardTransaction) { return translate('common.done'); @@ -206,7 +210,12 @@ function MoneyRequestPreviewContent({ !onPreviewPressed ? [styles.moneyRequestPreviewBox, containerStyles] : {}, ]} > - {hasReceipt && ( + {showMapAsImage && ( + + + + )} + {!showMapAsImage && hasReceipt && ( - {hasReceipt && ( + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + {(showMapAsImage || hasReceipt) && ( - + {showMapAsImage ? ( + + ) : ( + + )} )} @@ -365,7 +373,11 @@ function MoneyRequestView({ interactive={canEdit} shouldShowRightIcon={canEdit} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))} + onPress={() => + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID), + ) + } brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} error={getErrorForField('category')} /> diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 8b6a894cdd51..198b47cb4259 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -25,7 +25,7 @@ import SafeAreaConsumer from './SafeAreaConsumer'; import TestToolsModal from './TestToolsModal'; type ChildrenProps = { - insets?: EdgeInsets; + insets: EdgeInsets; safeAreaPaddingBottomStyle?: { paddingBottom?: DimensionValue; }; @@ -201,7 +201,17 @@ function ScreenWrapper( return ( - {({insets, paddingTop, paddingBottom, safeAreaPaddingBottomStyle}) => { + {({ + insets = { + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + paddingTop, + paddingBottom, + safeAreaPaddingBottomStyle, + }) => { const paddingStyle: StyleProp = {}; if (includePaddingTop) { diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 9bfeacbc0ac2..eb9450f6ad98 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -4,34 +4,32 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import RadioListItem from './RadioListItem'; import type {BaseListItemProps, ListItem} from './types'; -import UserListItem from './UserListItem'; function BaseListItem({ item, - isFocused = false, + pressableStyle, + wrapperStyle, + selectMultipleStyle, isDisabled = false, - showTooltip, shouldPreventDefaultFocusOnSelectRow = false, canSelectMultiple = false, onSelectRow, onDismissError = () => {}, rightHandSideComponent, keyForList, + errors, + pendingAction, + FooterComponent, + children, }: BaseListItemProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const isUserItem = 'icons' in item && item?.icons?.length && item.icons.length > 0; - const ListItem = isUserItem ? UserListItem : RadioListItem; const rightHandSideComponentRender = () => { if (canSelectMultiple || !rightHandSideComponent) { @@ -48,8 +46,8 @@ function BaseListItem({ return ( onDismissError(item)} - pendingAction={isUserItem ? item.pendingAction : undefined} - errors={isUserItem ? item.errors : undefined} + pendingAction={pendingAction} + errors={errors} errorRowStyles={styles.ph5} > ({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined} nativeID={keyForList} + style={pressableStyle} > {({hovered}) => ( <> - + {canSelectMultiple && ( - + {item.isSelected && ( ({ )} - onSelectRow(item)} - showTooltip={showTooltip} - isFocused={isFocused} - isHovered={hovered} - /> + {typeof children === 'function' ? children(hovered) : children} {!canSelectMultiple && item.isSelected && !rightHandSideComponent && ( ({ )} {rightHandSideComponentRender()} - {isUserItem && item.invitedSecondaryLogin && ( - - {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} - - )} + {FooterComponent} )} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 850874b7abc0..1c69d00b3910 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -22,12 +22,12 @@ import Log from '@libs/Log'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import BaseListItem from './BaseListItem'; import type {BaseSelectionListProps, ButtonOrCheckBoxRoles, FlattenedSectionsReturn, ListItem, Section, SectionListDataType} from './types'; function BaseSelectionList( { sections, + ListItem, canSelectMultiple = false, onSelectRow, onSelectAll, @@ -61,6 +61,8 @@ function BaseSelectionList( rightHandSideComponent, isLoadingNewOptions = false, onLayout, + customListHeader, + listHeaderWrapperStyle, }: BaseSelectionListProps, inputRef: ForwardedRef, ) { @@ -280,14 +282,14 @@ function BaseSelectionList( const showTooltip = shouldShowTooltips && normalizedIndex < 10; return ( - selectRow(item)} - onDismissError={onDismissError} + onDismissError={() => onDismissError?.(item)} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} rightHandSideComponent={rightHandSideComponent} keyForList={item.keyForList} @@ -428,7 +430,7 @@ function BaseSelectionList( <> {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( ( onPress={selectAllRow} disabled={flattenedSections.allOptions.length === flattenedSections.disabledOptionsIndexes.length} /> - - {translate('workspace.people.selectAll')} - + {customListHeader ?? ( + + {translate('workspace.people.selectAll')} + + )} )} + {!headerMessage && !canSelectMultiple && customListHeader} - - - {!!item.alternateText && ( + + - )} - + + {!!item.alternateText && ( + + )} + + ); } diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx new file mode 100644 index 000000000000..922937c72219 --- /dev/null +++ b/src/components/SelectionList/TableListItem.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import {View} from 'react-native'; +import MultipleAvatars from '@components/MultipleAvatars'; +import TextWithTooltip from '@components/TextWithTooltip'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import BaseListItem from './BaseListItem'; +import type {TableListItemProps} from './types'; + +function TableListItem({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onDismissError, + shouldPreventDefaultFocusOnSelectRow, + rightHandSideComponent, +}: TableListItemProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + + const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; + const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; + + return ( + + {(hovered) => ( + <> + {!!item.icons && ( + + )} + + + {!!item.alternateText && ( + + )} + + {!!item.rightElement && item.rightElement} + + )} + + ); +} + +TableListItem.displayName = 'TableListItem'; + +export default TableListItem; diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 60e97d887b4d..759c29013b5d 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -2,61 +2,107 @@ import React from 'react'; import {View} from 'react-native'; import MultipleAvatars from '@components/MultipleAvatars'; import SubscriptAvatar from '@components/SubscriptAvatar'; +import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type {ListItemProps} from './types'; +import BaseListItem from './BaseListItem'; +import type {UserListItemProps} from './types'; -function UserListItem({item, textStyles, alternateTextStyles, showTooltip, style, isFocused, isHovered}: ListItemProps) { +function UserListItem({ + item, + isFocused, + showTooltip, + isDisabled, + canSelectMultiple, + onSelectRow, + onDismissError, + shouldPreventDefaultFocusOnSelectRow, + rightHandSideComponent, +}: UserListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; const hoveredBackgroundColor = !!styles.sidebarLinkHover && 'backgroundColor' in styles.sidebarLinkHover ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; return ( - <> - {!!item.icons && ( + + {translate('workspace.people.invitedBySecondaryLogin', {secondaryLogin: item.invitedSecondaryLogin})} + + ) : undefined + } + keyForList={item.keyForList} + > + {(hovered) => ( <> - {item.shouldShowSubscript ? ( - - ) : ( - + {item.shouldShowSubscript ? ( + + ) : ( + + )} + + )} + + - )} + {!!item.alternateText && ( + + )} + + {!!item.rightElement && item.rightElement} )} - - - {!!item.alternateText && ( - - )} - - {!!item.rightElement && item.rightElement} - + ); } diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 6ef0fb742dc3..59f6b14cfb1f 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -1,18 +1,16 @@ import type {ReactElement, ReactNode} from 'react'; import type {GestureResponderEvent, InputModeOptions, LayoutChangeEvent, SectionListData, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {ReceiptErrors} from '@src/types/onyx/Transaction'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import type RadioListItem from './RadioListItem'; +import type TableListItem from './TableListItem'; +import type UserListItem from './UserListItem'; type CommonListItemProps = { /** Whether this item is focused (for arrow key controls) */ isFocused?: boolean; - /** Style to be applied to Text */ - textStyles?: StyleProp; - - /** Style to be applied on the alternate text */ - alternateTextStyles?: StyleProp; - /** Whether this item is disabled */ isDisabled?: boolean; @@ -30,6 +28,15 @@ type CommonListItemProps = { /** Component to display on the right side */ rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; + + /** Styles for the pressable component */ + pressableStyle?: StyleProp; + + /** Styles for the wrapper view */ + wrapperStyle?: StyleProp; + + /** Styles for the checkbox wrapper view if select multiple option is on */ + selectMultipleStyle?: StyleProp; }; type ListItem = { @@ -87,14 +94,39 @@ type ListItemProps = CommonListItemProps & { /** Is item hovered */ isHovered?: boolean; + + /** Whether the default focus should be prevented on row selection */ + shouldPreventDefaultFocusOnSelectRow?: boolean; + + /** Key used internally by React */ + keyForList?: string; }; type BaseListItemProps = CommonListItemProps & { item: TItem; shouldPreventDefaultFocusOnSelectRow?: boolean; keyForList?: string; + errors?: Errors | ReceiptErrors | null; + pendingAction?: PendingAction | null; + FooterComponent?: ReactElement; + children?: ReactElement | ((hovered: boolean) => ReactElement); }; +type UserListItemProps = ListItemProps & { + /** Errors that this user may contain */ + errors?: Errors | ReceiptErrors | null; + + /** The type of action that's pending */ + pendingAction?: PendingAction | null; + + /** The React element that will be shown as a footer */ + FooterComponent?: ReactElement; +}; + +type RadioListItemProps = ListItemProps; + +type TableListItemProps = ListItemProps; + type Section = { /** Title of the section */ title?: string; @@ -116,6 +148,9 @@ type BaseSelectionListProps = Partial & { /** Sections for the section list */ sections: Array>>; + /** Default renderer for every item in the list */ + ListItem: typeof RadioListItem | typeof UserListItem | typeof TableListItem; + /** Whether this is a multi-select list */ canSelectMultiple?: boolean; @@ -126,7 +161,7 @@ type BaseSelectionListProps = Partial & { onSelectAll?: () => void; /** Callback to fire when an error is dismissed */ - onDismissError?: () => void; + onDismissError?: (item: TItem) => void; /** Label for the text input */ textInputLabel?: string; @@ -210,13 +245,19 @@ type BaseSelectionListProps = Partial & { shouldDelayFocus?: boolean; /** Component to display on the right side of each child */ - rightHandSideComponent?: ((item: TItem) => ReactElement) | ReactElement | null; + rightHandSideComponent?: ((item: ListItem) => ReactElement) | ReactElement | null; /** Whether to show the loading indicator for new options */ isLoadingNewOptions?: boolean; /** Fired when the list is displayed with the items */ onLayout?: (event: LayoutChangeEvent) => void; + + /** Custom header to show right above list */ + customListHeader?: ReactNode; + + /** Styles for the list header wrapper */ + listHeaderWrapperStyle?: StyleProp; }; type ItemLayout = { @@ -241,6 +282,9 @@ export type { CommonListItemProps, Section, BaseListItemProps, + UserListItemProps, + RadioListItemProps, + TableListItemProps, ListItem, ListItemProps, FlattenedSectionsReturn, diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index 798d3be7a698..c09c7a25e375 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -4,6 +4,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchCountryOptions from '@libs/searchCountryOptions'; @@ -100,6 +101,7 @@ function StateSelectorModal({currentState, isVisible, onClose = () => {}, onStat initiallyFocusedOptionKey={currentState} shouldStopPropagation shouldUseDynamicMaxToRenderPerBatch + ListItem={RadioListItem} /> diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index 94b91c66f154..341ea9cddae9 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -20,7 +20,7 @@ function TagPicker({selectedTag, tag, tagIndex, policyTags, policyRecentlyUsedTa const policyRecentlyUsedTagsList = lodashGet(policyRecentlyUsedTags, tag, []); const policyTagList = PolicyUtils.getTagList(policyTags, tagIndex); - const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList); + const policyTagsCount = PolicyUtils.getCountOfEnabledTagsOfList(policyTagList.tags); const isTagsCountBelowThreshold = policyTagsCount < CONST.TAG_LIST_THRESHOLD; const shouldShowTextInput = !isTagsCountBelowThreshold; diff --git a/src/components/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx index 1e7c6088241d..fad59d4e48e4 100644 --- a/src/components/ValuePicker/ValueSelectorModal.tsx +++ b/src/components/ValuePicker/ValueSelectorModal.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import type {ValueSelectorModalProps} from './types'; @@ -40,6 +41,7 @@ function ValueSelectorModal({items = [], selectedItem, label = '', isVisible, on initiallyFocusedOptionKey={selectedItem?.value} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} + ListItem={RadioListItem} /> diff --git a/src/components/VideoPlayer/BaseVideoPlayer.js b/src/components/VideoPlayer/BaseVideoPlayer.js index 73dbf8407c0c..df79c7ef18da 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.js +++ b/src/components/VideoPlayer/BaseVideoPlayer.js @@ -13,6 +13,7 @@ import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import * as Browser from '@libs/Browser'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import {videoPlayerDefaultProps, videoPlayerPropTypes} from './propTypes'; +import shouldReplayVideo from './shouldReplayVideo'; import VideoPlayerControls from './VideoPlayerControls'; const isMobileSafari = Browser.isMobileSafari(); @@ -95,6 +96,9 @@ function BaseVideoPlayer({ const handlePlaybackStatusUpdate = useCallback( (e) => { + if (shouldReplayVideo(e, isPlaying, duration, position)) { + videoPlayerRef.current.setStatusAsync({positionMillis: 0, shouldPlay: true}); + } const isVideoPlaying = e.isPlaying || false; preventPausingWhenExitingFullscreen(isVideoPlaying); setIsPlaying(isVideoPlaying); @@ -105,7 +109,7 @@ function BaseVideoPlayer({ onPlaybackStatusUpdate(e); }, - [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration], + [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration, isPlaying, duration, position], ); const handleFullscreenUpdate = useCallback( diff --git a/src/components/VideoPlayer/shouldReplayVideo.android.ts b/src/components/VideoPlayer/shouldReplayVideo.android.ts new file mode 100644 index 000000000000..c1c3de034aac --- /dev/null +++ b/src/components/VideoPlayer/shouldReplayVideo.android.ts @@ -0,0 +1,11 @@ +import type {AVPlaybackStatusSuccess} from 'expo-av'; + +/** + * Whether to replay the video when users press play button + */ +export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean { + if (!isPlaying && !e.didJustFinish && duration === position) { + return true; + } + return false; +} diff --git a/src/components/VideoPlayer/shouldReplayVideo.ios.ts b/src/components/VideoPlayer/shouldReplayVideo.ios.ts new file mode 100644 index 000000000000..0a923d430699 --- /dev/null +++ b/src/components/VideoPlayer/shouldReplayVideo.ios.ts @@ -0,0 +1,11 @@ +import type {AVPlaybackStatusSuccess} from 'expo-av'; + +/** + * Whether to replay the video when users press play button + */ +export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean { + if (!isPlaying && e.isPlaying && duration === position) { + return true; + } + return false; +} diff --git a/src/components/VideoPlayer/shouldReplayVideo.ts b/src/components/VideoPlayer/shouldReplayVideo.ts new file mode 100644 index 000000000000..3a55562d4bd2 --- /dev/null +++ b/src/components/VideoPlayer/shouldReplayVideo.ts @@ -0,0 +1,9 @@ +import type {AVPlaybackStatusSuccess} from 'expo-av'; + +/** + * Whether to replay the video when users press play button + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function shouldReplayVideo(e: AVPlaybackStatusSuccess, isPlaying: boolean, duration: number, position: number): boolean { + return false; +} diff --git a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js index 9e6069e4d979..595442c317d5 100644 --- a/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js +++ b/src/components/VideoPlayerPreview/VideoPlayerThumbnail.js @@ -5,7 +5,11 @@ import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import Image from '@components/Image'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; import useThemeStyles from '@hooks/useThemeStyles'; +import ControlSelection from '@libs/ControlSelection'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as ReportUtils from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -35,22 +39,31 @@ function VideoPlayerThumbnail({thumbnailUrl, onPress, accessibilityLabel}) { /> )} - - - - - + + {({anchor, report, action, checkIfContextMenuActive}) => ( + DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onLongPress={(event) => + showContextMenuForReport(event, anchor, (report && report.reportID) || '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report)) + } + > + + + + + )} + ); } diff --git a/src/components/__mocks__/ConfirmedRoute.tsx b/src/components/__mocks__/ConfirmedRoute.tsx new file mode 100644 index 000000000000..3c78e764ebea --- /dev/null +++ b/src/components/__mocks__/ConfirmedRoute.tsx @@ -0,0 +1,8 @@ +import {View} from 'react-native'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any +function ConfirmedRoute(props: any) { + return ; +} + +export default ConfirmedRoute; diff --git a/src/components/withToggleVisibilityView.tsx b/src/components/withToggleVisibilityView.tsx index 17fda7fd5e30..9da862ecdebe 100644 --- a/src/components/withToggleVisibilityView.tsx +++ b/src/components/withToggleVisibilityView.tsx @@ -7,7 +7,7 @@ import getComponentDisplayName from '@libs/getComponentDisplayName'; type WithToggleVisibilityViewProps = { /** Whether the content is visible. */ - isVisible?: boolean; + isVisible: boolean; }; export default function withToggleVisibilityView( diff --git a/src/hooks/useNetwork.ts b/src/hooks/useNetwork.ts index f9e1a627c5f5..1e4a6d4cf2ca 100644 --- a/src/hooks/useNetwork.ts +++ b/src/hooks/useNetwork.ts @@ -1,17 +1,18 @@ import {useContext, useEffect, useRef} from 'react'; import {NetworkContext} from '@components/OnyxProvider'; +import CONST from '@src/CONST'; type UseNetworkProps = { onReconnect?: () => void; }; -type UseNetwork = {isOffline?: boolean}; +type UseNetwork = {isOffline: boolean}; export default function useNetwork({onReconnect = () => {}}: UseNetworkProps = {}): UseNetwork { const callback = useRef(onReconnect); callback.current = onReconnect; - const {isOffline} = useContext(NetworkContext) ?? {}; + const {isOffline} = useContext(NetworkContext) ?? CONST.DEFAULT_NETWORK_DATA; const prevOfflineStatusRef = useRef(isOffline); useEffect(() => { // If we were offline before and now we are not offline then we just reconnected diff --git a/src/languages/en.ts b/src/languages/en.ts index da7a1d0b7586..b6a24f33035c 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -26,6 +26,7 @@ import type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, + HeldRequestParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, @@ -204,6 +205,7 @@ export default { iAcceptThe: 'I accept the ', remove: 'Remove', admin: 'Admin', + owner: 'Owner', dateFormat: 'YYYY-MM-DD', send: 'Send', notifications: 'Notifications', @@ -307,6 +309,8 @@ export default { of: 'of', default: 'Default', update: 'Update', + member: 'Member', + role: 'Role', }, location: { useCurrent: 'Use current location', @@ -666,8 +670,10 @@ export default { waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`, enableWallet: 'Enable Wallet', hold: 'Hold', - holdRequest: 'Hold Request', - unholdRequest: 'Unhold Request', + holdRequest: 'Hold request', + unholdRequest: 'Unhold request', + heldRequest: ({comment}: HeldRequestParams) => `held this request with the comment: ${comment}`, + unheldRequest: 'unheld this request', explainHold: "Explain why you're holding this request.", reason: 'Reason', holdReasonRequired: 'A reason is required when holding.', @@ -1742,6 +1748,7 @@ export default { }, addedWithPrimary: 'Some users were added with their primary logins.', invitedBySecondaryLogin: ({secondaryLogin}) => `Added by secondary login ${secondaryLogin}.`, + membersListTitle: 'Directory of all workspace members.', }, card: { header: 'Unlock free Expensify Cards', diff --git a/src/languages/es.ts b/src/languages/es.ts index 23cf8be8c30c..fc6755519d6f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -25,6 +25,7 @@ import type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, + HeldRequestParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, @@ -194,6 +195,7 @@ export default { iAcceptThe: 'Acepto los ', remove: 'Eliminar', admin: 'Administrador', + owner: 'Poseedor', dateFormat: 'AAAA-MM-DD', send: 'Enviar', notifications: 'Notificaciones', @@ -297,6 +299,8 @@ export default { of: 'de', default: 'Predeterminado', update: 'Actualizar', + member: 'Miembro', + role: 'Role', }, location: { useCurrent: 'Usar ubicaciΓ³n actual', @@ -660,8 +664,10 @@ export default { }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `IniciΓ³ el pago, pero no se procesarΓ‘ hasta que ${submitterDisplayName} active su Billetera`, enableWallet: 'Habilitar Billetera', - holdRequest: 'Bloquear solicitud de dinero', - unholdRequest: 'Desbloquear solicitud de dinero', + holdRequest: 'Bloquear solicitud', + unholdRequest: 'Desbloquear solicitud', + heldRequest: ({comment}: HeldRequestParams) => `bloqueΓ³ esta solicitud con el comentario: ${comment}`, + unheldRequest: 'desbloqueΓ³ esta solicitud', explainHold: 'Explica la razΓ³n para bloquear esta solicitud.', reason: 'RazΓ³n', holdReasonRequired: 'Se requiere una razΓ³n para bloquear.', @@ -1766,6 +1772,7 @@ export default { }, addedWithPrimary: 'Se agregaron algunos usuarios con sus nombres de usuario principales.', invitedBySecondaryLogin: ({secondaryLogin}) => `Agregado por nombre de usuario secundario ${secondaryLogin}.`, + membersListTitle: 'Directorio de todos los miembros del espacio de trabajo.', }, card: { header: 'Desbloquea Tarjetas Expensify gratis', diff --git a/src/languages/types.ts b/src/languages/types.ts index f7e580819fdf..410c8e1c2085 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -105,7 +105,7 @@ type SettleExpensifyCardParams = { formattedAmount: string; }; -type RequestAmountParams = {amount: number}; +type RequestAmountParams = {amount: string}; type RequestedAmountMessageParams = {formattedAmount: string; comment?: string}; @@ -293,6 +293,8 @@ type ElectronicFundsParams = {percentage: string; amount: string}; type LogSizeParams = {size: number}; +type HeldRequestParams = {comment: string}; + export type { AdminCanceledRequestParams, ApprovedAmountParams, @@ -395,4 +397,5 @@ export type { WelcomeToRoomParams, ZipCodeExampleFormatParams, LogSizeParams, + HeldRequestParams, }; diff --git a/src/libs/API/parameters/AcceptACHContractForBankAccount.ts b/src/libs/API/parameters/AcceptACHContractForBankAccount.ts index 6cf2735beabc..de4ce4e86857 100644 --- a/src/libs/API/parameters/AcceptACHContractForBankAccount.ts +++ b/src/libs/API/parameters/AcceptACHContractForBankAccount.ts @@ -1,5 +1,5 @@ import type {ACHContractStepProps} from '@src/types/form/ReimbursementAccountForm'; -type AcceptACHContractForBankAccount = ACHContractStepProps & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; +type AcceptACHContractForBankAccount = ACHContractStepProps & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean}; export default AcceptACHContractForBankAccount; diff --git a/src/libs/API/parameters/AddPersonalBankAccountParams.ts b/src/libs/API/parameters/AddPersonalBankAccountParams.ts index 1fa8fc0eb48d..d3caf65517df 100644 --- a/src/libs/API/parameters/AddPersonalBankAccountParams.ts +++ b/src/libs/API/parameters/AddPersonalBankAccountParams.ts @@ -1,8 +1,8 @@ type AddPersonalBankAccountParams = { - addressName: string; + addressName?: string; routingNumber: string; accountNumber: string; - isSavings: boolean; + isSavings?: boolean; setupType: string; bank?: string; plaidAccountID: string; diff --git a/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts b/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts deleted file mode 100644 index 17a72588a1e2..000000000000 --- a/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts +++ /dev/null @@ -1,9 +0,0 @@ -type ConnectBankAccountManuallyParams = { - bankAccountID: number; - accountNumber?: string; - routingNumber?: string; - plaidMask?: string; - canUseNewVbbaFlow?: boolean; - policyID?: string; -}; -export default ConnectBankAccountManuallyParams; diff --git a/src/libs/API/parameters/ConnectBankAccountParams.ts b/src/libs/API/parameters/ConnectBankAccountParams.ts new file mode 100644 index 000000000000..fb0e3422d08c --- /dev/null +++ b/src/libs/API/parameters/ConnectBankAccountParams.ts @@ -0,0 +1,14 @@ +type ConnectBankAccountParams = { + bankAccountID: number; + routingNumber: string; + accountNumber: string; + bank?: string; + plaidAccountID?: string; + plaidAccessToken?: string; + plaidMask?: string; + isSavings?: boolean; + policyID?: string; + canUseNewVbbaFlow?: boolean; +}; + +export default ConnectBankAccountParams; diff --git a/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts b/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts deleted file mode 100644 index e41a3192420e..000000000000 --- a/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts +++ /dev/null @@ -1,12 +0,0 @@ -type ConnectBankAccountWithPlaidParams = { - bankAccountID: number; - routingNumber: string; - accountNumber: string; - bank?: string; - plaidAccountID: string; - plaidAccessToken: string; - canUseNewVbbaFlow?: boolean; - policyID?: string; -}; - -export default ConnectBankAccountWithPlaidParams; diff --git a/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts b/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts index 5cd4bff2b94b..31eb443ce80e 100644 --- a/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts +++ b/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts @@ -7,8 +7,8 @@ type OpenReimbursementAccountPageParams = { stepToOpen: ReimbursementAccountStep; subStep: ReimbursementAccountSubStep; localCurrentStep: ReimbursementAccountStep; - policyID?: string; canUseNewVbbaFlow?: boolean; + policyID: string; }; export default OpenReimbursementAccountPageParams; diff --git a/src/libs/API/parameters/OpenWorkspaceViewParams.ts b/src/libs/API/parameters/OpenWorkspaceViewParams.ts new file mode 100644 index 000000000000..3dc9da6e1947 --- /dev/null +++ b/src/libs/API/parameters/OpenWorkspaceViewParams.ts @@ -0,0 +1,5 @@ +type OpenWorkspaceViewParams = { + policyID: string; +}; + +export default OpenWorkspaceViewParams; diff --git a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts index 4d4e1af87e3b..dedc45d0365f 100644 --- a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts @@ -1,5 +1,5 @@ import type {ACHContractStepProps} from '@src/types/form/ReimbursementAccountForm'; -type UpdateBeneficialOwnersForBankAccountParams = ACHContractStepProps & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; +type UpdateBeneficialOwnersForBankAccountParams = Partial & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean}; export default UpdateBeneficialOwnersForBankAccountParams; diff --git a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts index 324c7070bbe2..6421fe02f571 100644 --- a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts @@ -2,7 +2,6 @@ import type {BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps} type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps; -type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; +type UpdateCompanyInformationForBankAccountParams = Partial & {bankAccountID: number; policyID: string; canUseNewVbbaFlow?: boolean}; export default UpdateCompanyInformationForBankAccountParams; -export type {BankAccountCompanyInformation}; diff --git a/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts index b9c2ce65405b..c1a29ddd9cec 100644 --- a/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts @@ -1,5 +1,5 @@ import type {RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; -type UpdatePersonalInformationForBankAccountParams = RequestorStepProps & {bankAccountID: number; canUseNewVbbaFlow: boolean}; +type UpdatePersonalInformationForBankAccountParams = RequestorStepProps & {bankAccountID: number; policyID: string; canUseNewVbbaFlow: boolean}; export default UpdatePersonalInformationForBankAccountParams; diff --git a/src/libs/API/parameters/UpdateRoomVisibilityParams.ts b/src/libs/API/parameters/UpdateRoomVisibilityParams.ts new file mode 100644 index 000000000000..a69559f0ce47 --- /dev/null +++ b/src/libs/API/parameters/UpdateRoomVisibilityParams.ts @@ -0,0 +1,8 @@ +import type {RoomVisibility} from '@src/types/onyx/Report'; + +type UpdateRoomVisibilityParams = { + reportID: string; + visibility: RoomVisibility; +}; + +export default UpdateRoomVisibilityParams; diff --git a/src/libs/API/parameters/ValidateBankAccountWithTransactionsParams.ts b/src/libs/API/parameters/ValidateBankAccountWithTransactionsParams.ts index 546889b7a68e..dde8e6141a9d 100644 --- a/src/libs/API/parameters/ValidateBankAccountWithTransactionsParams.ts +++ b/src/libs/API/parameters/ValidateBankAccountWithTransactionsParams.ts @@ -1,6 +1,7 @@ type ValidateBankAccountWithTransactionsParams = { bankAccountID: number; validateCode: string; + policyID: string; }; export default ValidateBankAccountWithTransactionsParams; diff --git a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts index 2104977e04d5..c11aec9be239 100644 --- a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts +++ b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts @@ -1,6 +1,7 @@ type VerifyIdentityForBankAccountParams = { bankAccountID: number; onfidoData: string; + policyID: string; canUseNewVbbaFlow?: boolean; }; export default VerifyIdentityForBankAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index b9042dcc1215..2633d795b561 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -9,8 +9,7 @@ export type {default as BeginAppleSignInParams} from './BeginAppleSignInParams'; export type {default as BeginGoogleSignInParams} from './BeginGoogleSignInParams'; export type {default as BeginSignInParams} from './BeginSignInParams'; export type {default as CloseAccountParams} from './CloseAccountParams'; -export type {default as ConnectBankAccountManuallyParams} from './ConnectBankAccountManuallyParams'; -export type {default as ConnectBankAccountWithPlaidParams} from './ConnectBankAccountWithPlaidParams'; +export type {default as ConnectBankAccountParams} from './ConnectBankAccountParams'; export type {default as DeleteContactMethodParams} from './DeleteContactMethodParams'; export type {default as DeletePaymentBankAccountParams} from './DeletePaymentBankAccountParams'; export type {default as DeletePaymentCardParams} from './DeletePaymentCardParams'; @@ -85,6 +84,7 @@ export type {default as DeleteCommentParams} from './DeleteCommentParams'; export type {default as UpdateCommentParams} from './UpdateCommentParams'; export type {default as UpdateReportNotificationPreferenceParams} from './UpdateReportNotificationPreferenceParams'; export type {default as UpdateRoomDescriptionParams} from './UpdateRoomDescriptionParams'; +export type {default as UpdateRoomVisibilityParams} from './UpdateRoomVisibilityParams'; export type {default as UpdateReportWriteCapabilityParams} from './UpdateReportWriteCapabilityParams'; export type {default as AddWorkspaceRoomParams} from './AddWorkspaceRoomParams'; export type {default as UpdatePolicyRoomNameParams} from './UpdatePolicyRoomNameParams'; @@ -109,6 +109,7 @@ export type {default as UpdateWorkspaceAvatarParams} from './UpdateWorkspaceAvat export type {default as AddMembersToWorkspaceParams} from './AddMembersToWorkspaceParams'; export type {default as DeleteMembersFromWorkspaceParams} from './DeleteMembersFromWorkspaceParams'; export type {default as OpenWorkspaceParams} from './OpenWorkspaceParams'; +export type {default as OpenWorkspaceViewParams} from './OpenWorkspaceViewParams'; export type {default as OpenWorkspaceReimburseViewParams} from './OpenWorkspaceReimburseViewParams'; export type {default as OpenWorkspaceInvitePageParams} from './OpenWorkspaceInvitePageParams'; export type {default as OpenWorkspaceMembersPageParams} from './OpenWorkspaceMembersPageParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 7c7695ebef57..35b03f21c841 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -85,6 +85,7 @@ const WRITE_COMMANDS = { DELETE_COMMENT: 'DeleteComment', UPDATE_COMMENT: 'UpdateComment', UPDATE_REPORT_NOTIFICATION_PREFERENCE: 'UpdateReportNotificationPreference', + UPDATE_ROOM_VISIBILITY: 'UpdateRoomVisibility', UPDATE_ROOM_DESCRIPTION: 'UpdateRoomDescription', UPDATE_REPORT_WRITE_CAPABILITY: 'UpdateReportWriteCapability', ADD_WORKSPACE_ROOM: 'AddWorkspaceRoom', @@ -162,7 +163,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.VALIDATE_BANK_ACCOUNT_WITH_TRANSACTIONS]: Parameters.ValidateBankAccountWithTransactionsParams; [WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT]: Parameters.UpdateCompanyInformationForBankAccountParams; [WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT]: UpdateBeneficialOwnersForBankAccountParams; - [WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_MANUALLY]: Parameters.ConnectBankAccountManuallyParams; + [WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_MANUALLY]: Parameters.ConnectBankAccountParams; [WRITE_COMMANDS.VERIFY_IDENTITY_FOR_BANK_ACCOUNT]: Parameters.VerifyIdentityForBankAccountParams; [WRITE_COMMANDS.BANK_ACCOUNT_HANDLE_PLAID_ERROR]: Parameters.BankAccountHandlePlaidErrorParams; [WRITE_COMMANDS.REPORT_VIRTUAL_EXPENSIFY_CARD_FRAUD]: Parameters.ReportVirtualExpensifyCardFraudParams; @@ -215,7 +216,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.TWO_FACTOR_AUTH_VALIDATE]: Parameters.ValidateTwoFactorAuthParams; [WRITE_COMMANDS.ADD_COMMENT]: Parameters.AddCommentOrAttachementParams; [WRITE_COMMANDS.ADD_ATTACHMENT]: Parameters.AddCommentOrAttachementParams; - [WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_WITH_PLAID]: Parameters.ConnectBankAccountWithPlaidParams; + [WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_WITH_PLAID]: Parameters.ConnectBankAccountParams; [WRITE_COMMANDS.ADD_PERSONAL_BANK_ACCOUNT]: Parameters.AddPersonalBankAccountParams; [WRITE_COMMANDS.OPT_IN_TO_PUSH_NOTIFICATIONS]: Parameters.OptInOutToPushNotificationsParams; [WRITE_COMMANDS.OPT_OUT_OF_PUSH_NOTIFICATIONS]: Parameters.OptInOutToPushNotificationsParams; @@ -226,6 +227,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DELETE_COMMENT]: Parameters.DeleteCommentParams; [WRITE_COMMANDS.UPDATE_COMMENT]: Parameters.UpdateCommentParams; [WRITE_COMMANDS.UPDATE_REPORT_NOTIFICATION_PREFERENCE]: Parameters.UpdateReportNotificationPreferenceParams; + [WRITE_COMMANDS.UPDATE_ROOM_VISIBILITY]: Parameters.UpdateRoomVisibilityParams; [WRITE_COMMANDS.UPDATE_ROOM_DESCRIPTION]: Parameters.UpdateRoomDescriptionParams; [WRITE_COMMANDS.UPDATE_REPORT_WRITE_CAPABILITY]: Parameters.UpdateReportWriteCapabilityParams; [WRITE_COMMANDS.ADD_WORKSPACE_ROOM]: Parameters.AddWorkspaceRoomParams; @@ -329,7 +331,7 @@ type ReadCommand = ValueOf; type ReadCommandParameters = { [READ_COMMANDS.OPEN_APP]: Parameters.OpenAppParams; [READ_COMMANDS.OPEN_REIMBURSEMENT_ACCOUNT_PAGE]: Parameters.OpenReimbursementAccountPageParams; - [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: EmptyObject; + [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: Parameters.OpenWorkspaceViewParams; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: EmptyObject; [READ_COMMANDS.OPEN_PAYMENTS_PAGE]: EmptyObject; [READ_COMMANDS.OPEN_PERSONAL_DETAILS]: EmptyObject; diff --git a/src/libs/FormUtils.ts b/src/libs/FormUtils.ts deleted file mode 100644 index 4d0571ada6f2..000000000000 --- a/src/libs/FormUtils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {OnyxFormDraftKey, OnyxFormKey} from '@src/ONYXKEYS'; - -function getDraftKey(formID: OnyxFormKey): OnyxFormDraftKey { - return `${formID}Draft`; -} - -export default {getDraftKey}; diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts index cf49ba03f287..3f8a7d191f4b 100644 --- a/src/libs/GetPhysicalCardUtils.ts +++ b/src/libs/GetPhysicalCardUtils.ts @@ -3,17 +3,18 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {GetPhysicalCardForm} from '@src/types/form'; import type {LoginList, PrivatePersonalDetails} from '@src/types/onyx'; +import * as LoginUtils from './LoginUtils'; import Navigation from './Navigation/Navigation'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as UserUtils from './UserUtils'; -function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry): Route { +function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry): Route { const {address, legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; if (!legalFirstName && !legalLastName) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); } - if (!phoneNumber && !UserUtils.getSecondaryPhoneLogin(loginList)) { + if (!phoneNumber || !LoginUtils.validateNumber(phoneNumber)) { return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); } if (!(address?.street && address?.city && address?.state && address?.country && address?.zip)) { @@ -23,8 +24,8 @@ function getCurrentRoute(domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry) { - Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails, loginList)); +function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxEntry) { + Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails)); } /** @@ -35,8 +36,8 @@ function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: OnyxE * @param loginList * @returns */ -function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry) { - const expectedRoute = getCurrentRoute(domain, privatePersonalDetails, loginList); +function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: OnyxEntry) { + const expectedRoute = getCurrentRoute(domain, privatePersonalDetails); // If the user is on the current route or the current route is confirmation, then he's allowed to stay on the current step if ([currentRoute, ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain)].includes(expectedRoute)) { @@ -53,7 +54,11 @@ function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDe * @param privatePersonalDetails * @returns */ -function getUpdatedDraftValues(draftValues: OnyxEntry, privatePersonalDetails: OnyxEntry, loginList: OnyxEntry): GetPhysicalCardForm { +function getUpdatedDraftValues( + draftValues: OnyxEntry, + privatePersonalDetails: OnyxEntry, + loginList: OnyxEntry, +): Partial { const {address, legalFirstName, legalLastName, phoneNumber} = privatePersonalDetails ?? {}; return { diff --git a/src/libs/GooglePlacesUtils.ts b/src/libs/GooglePlacesUtils.ts index 43fac2b9a785..312a0dc61c1f 100644 --- a/src/libs/GooglePlacesUtils.ts +++ b/src/libs/GooglePlacesUtils.ts @@ -51,3 +51,4 @@ function getPlaceAutocompleteTerms(addressTerms: AddressTerm[]): GetPlaceAutocom } export {getAddressComponents, getPlaceAutocompleteTerms}; +export type {AddressComponent, FieldsToExtract, AddressTerm}; diff --git a/src/libs/LocalePhoneNumber.ts b/src/libs/LocalePhoneNumber.ts index 9aacc6968e1e..933aa7937560 100644 --- a/src/libs/LocalePhoneNumber.ts +++ b/src/libs/LocalePhoneNumber.ts @@ -13,7 +13,7 @@ Onyx.connect({ * Returns a locally converted phone number for numbers from the same region * and an internationally converted phone number with the country code for numbers from other regions */ -function formatPhoneNumber(number: string | undefined): string { +function formatPhoneNumber(number: string): string { if (!number) { return ''; } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index d9835b01ceff..cd75a6d31fdb 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -1,8 +1,8 @@ import type {ParamListBase} from '@react-navigation/routers'; import type {StackNavigationOptions} from '@react-navigation/stack'; -import {CardStyleInterpolators, createStackNavigator} from '@react-navigation/stack'; import React, {useMemo} from 'react'; import useThemeStyles from '@hooks/useThemeStyles'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type { AddPersonalBankAccountNavigatorParamList, DetailsNavigatorParamList, @@ -35,6 +35,7 @@ import type { import type {ThemeStyles} from '@styles/index'; import type {Screen} from '@src/SCREENS'; import SCREENS from '@src/SCREENS'; +import subRouteOptions from './modalStackNavigatorOptions'; type Screens = Partial React.ComponentType>>; @@ -45,16 +46,15 @@ type Screens = Partial React.ComponentType>>; * @param getScreenOptions optional function that returns the screen options, override the default options */ function createModalStackNavigator(screens: Screens, getScreenOptions?: (styles: ThemeStyles) => StackNavigationOptions): React.ComponentType { - const ModalStackNavigator = createStackNavigator(); + const ModalStackNavigator = createPlatformStackNavigator(); function ModalStack() { const styles = useThemeStyles(); const defaultSubRouteOptions = useMemo( (): StackNavigationOptions => ({ + ...subRouteOptions, cardStyle: styles.navigationScreenCardStyle, - headerShown: false, - cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, }), [styles], ); @@ -100,7 +100,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, [SCREENS.MONEY_REQUEST.HOLD]: () => require('../../../pages/iou/HoldReasonPage').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').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, [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default as React.ComponentType, @@ -133,6 +132,7 @@ const ReportSettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Report/RoomNamePage').default as React.ComponentType, [SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: () => require('../../../pages/settings/Report/NotificationPreferencePage').default as React.ComponentType, [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: () => require('../../../pages/settings/Report/WriteCapabilityPage').default as React.ComponentType, + [SCREENS.REPORT_SETTINGS.VISIBILITY]: () => require('../../../pages/settings/Report/VisibilityPage').default as React.ComponentType, }); const TaskModalStackNavigator = createModalStackNavigator({ @@ -191,7 +191,6 @@ const AccountSettingsModalStackNavigator = createModalStackNavigator( [SCREENS.SETTINGS.PREFERENCES.ROOT]: () => require('../../../pages/settings/Preferences/PreferencesPage').default as React.ComponentType, [SCREENS.SETTINGS.SECURITY]: () => require('../../../pages/settings/Security/SecuritySettingsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.ROOT]: () => require('../../../pages/settings/Profile/ProfilePage').default as React.ComponentType, - [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, [SCREENS.SETTINGS.WALLET.ROOT]: () => require('../../../pages/settings/Wallet/WalletPage').default as React.ComponentType, [SCREENS.SETTINGS.ABOUT]: () => require('../../../pages/settings/AboutPage/AboutPage').default as React.ComponentType, }, @@ -203,6 +202,7 @@ const WorkspaceSwitcherModalStackNavigator = createModalStackNavigator({ + [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../pages/ShareCodePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require('../../../pages/settings/Profile/PronounsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE]: () => require('../../../pages/settings/Profile/TimezoneInitialPage').default as React.ComponentType, @@ -246,6 +246,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, [SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType, + [SCREENS.WORKSPACE.SHARE]: () => require('../../../pages/workspace/WorkspaceProfileSharePage').default as React.ComponentType, [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx index 087e963b3892..14aa6de27116 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/CentralPaneNavigator/BaseCentralPaneNavigator.tsx @@ -1,12 +1,12 @@ -import {createStackNavigator} from '@react-navigation/stack'; import React from 'react'; import useThemeStyles from '@hooks/useThemeStyles'; import ReportScreenWrapper from '@libs/Navigation/AppNavigator/ReportScreenWrapper'; import getCurrentUrl from '@libs/Navigation/currentUrl'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {CentralPaneNavigatorParamList} from '@navigation/types'; import SCREENS from '@src/SCREENS'; -const Stack = createStackNavigator(); +const Stack = createPlatformStackNavigator(); const url = getCurrentUrl(); const openOnAdminRoom = url ? new URL(url).searchParams.get('openOnAdminRoom') : undefined; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx new file mode 100644 index 000000000000..30651e32cbd6 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.native.tsx @@ -0,0 +1,7 @@ +function Overlay() { + return null; +} + +Overlay.displayName = 'Overlay'; + +export default Overlay; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx b/src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx similarity index 100% rename from src/libs/Navigation/AppNavigator/Navigators/Overlay.tsx rename to src/libs/Navigation/AppNavigator/Navigators/Overlay/index.tsx diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index c421bdc82028..550fb947a4e6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -1,5 +1,4 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import {createStackNavigator} from '@react-navigation/stack'; import React, {useMemo, useRef} from 'react'; import {View} from 'react-native'; import NoDropZone from '@components/DragAndDrop/NoDropZone'; @@ -7,6 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ModalNavigatorScreenOptions from '@libs/Navigation/AppNavigator/ModalNavigatorScreenOptions'; import * as ModalStackNavigators from '@libs/Navigation/AppNavigator/ModalStackNavigators'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {AuthScreensParamList, RightModalNavigatorParamList} from '@navigation/types'; import type NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; @@ -14,7 +14,7 @@ import Overlay from './Overlay'; type RightModalNavigatorProps = StackScreenProps; -const Stack = createStackNavigator(); +const Stack = createPlatformStackNavigator(); function RightModalNavigator({navigation}: RightModalNavigatorProps) { const styles = useThemeStyles(); diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.tsx b/src/libs/Navigation/AppNavigator/PublicScreens.tsx index 6b1557994627..792a538cfd39 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.tsx +++ b/src/libs/Navigation/AppNavigator/PublicScreens.tsx @@ -1,5 +1,5 @@ -import {createStackNavigator} from '@react-navigation/stack'; import React from 'react'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import type {PublicScreensParamList} from '@navigation/types'; import LogInWithShortLivedAuthTokenPage from '@pages/LogInWithShortLivedAuthTokenPage'; import AppleSignInDesktopPage from '@pages/signin/AppleSignInDesktopPage'; @@ -12,7 +12,7 @@ import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import defaultScreenOptions from './defaultScreenOptions'; -const RootStack = createStackNavigator(); +const RootStack = createPlatformStackNavigator(); function PublicScreens() { return ( diff --git a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts index b4bb56262860..529f0f3d31a7 100644 --- a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts +++ b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.ts @@ -5,7 +5,6 @@ import useActiveWorkspace from '@hooks/useActiveWorkspace'; import usePermissions from '@hooks/usePermissions'; import {getPolicyMembersByIdWithoutCurrentUser} from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; -import * as App from '@userActions/App'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyMembers, Report, ReportMetadata} from '@src/types/onyx'; import type {ReportScreenWrapperProps} from './ReportScreenWrapper'; @@ -71,7 +70,6 @@ function ReportScreenIDSetter({route, reports, policies, policyMembers = {}, nav if (reportActionID && !regexValidReportActionID.test(reportActionID)) { navigation.setParams({reportActionID: ''}); } - App.confirmReadyToOpenApp(); return; } @@ -93,8 +91,6 @@ function ReportScreenIDSetter({route, reports, policies, policyMembers = {}, nav // in that case the reportID is undefined if (reportID) { navigation.setParams({reportID: String(reportID)}); - } else { - App.confirmReadyToOpenApp(); } }, [route, navigation, reports, canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, reportMetadata, activeWorkspaceID, policyMembers, accountID]); diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts new file mode 100644 index 000000000000..17100bc71bda --- /dev/null +++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.native.ts @@ -0,0 +1,11 @@ +const defaultScreenOptions = { + contentStyle: { + overflow: 'visible', + flex: 1, + }, + headerShown: false, + animationTypeForReplace: 'push', + animation: 'slide_from_right', +}; + +export default defaultScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts new file mode 100644 index 000000000000..4015c43c679e --- /dev/null +++ b/src/libs/Navigation/AppNavigator/defaultScreenOptions/index.ts @@ -0,0 +1,12 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; + +const defaultScreenOptions: StackNavigationOptions = { + cardStyle: { + overflow: 'visible', + flex: 1, + }, + headerShown: false, + animationTypeForReplace: 'push', +}; + +export default defaultScreenOptions; diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts new file mode 100644 index 000000000000..2b062fd2f2be --- /dev/null +++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.native.ts @@ -0,0 +1,8 @@ +import type {NativeStackNavigationOptions} from '@react-navigation/native-stack'; + +const rightModalNavigatorOptions = (): NativeStackNavigationOptions => ({ + presentation: 'card', + animation: 'slide_from_right', +}); + +export default rightModalNavigatorOptions; diff --git a/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts new file mode 100644 index 000000000000..935c0041b794 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/getRightModalNavigatorOptions/index.ts @@ -0,0 +1,20 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; +// eslint-disable-next-line no-restricted-imports +import getNavigationModalCardStyle from '@styles/utils/getNavigationModalCardStyles'; + +const rightModalNavigatorOptions = (isSmallScreenWidth: boolean): StackNavigationOptions => ({ + presentation: 'transparentModal', + + // We want pop in RHP since there are some flows that would work weird otherwise + animationTypeForReplace: 'pop', + cardStyle: { + ...getNavigationModalCardStyle(), + + // This is necessary to cover translated sidebar with overlay. + width: isSmallScreenWidth ? '100%' : '200%', + // Excess space should be on the left so we need to position from right. + right: 0, + }, +}); + +export default rightModalNavigatorOptions; diff --git a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts index c3a69bbd7ccf..5685afec5459 100644 --- a/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts +++ b/src/libs/Navigation/AppNavigator/getRootNavigatorScreenOptions.ts @@ -4,6 +4,7 @@ import type {StyleUtilsType} from '@styles/utils'; import variables from '@styles/variables'; import CONFIG from '@src/CONFIG'; import createModalCardStyleInterpolator from './createModalCardStyleInterpolator'; +import getRightModalNavigatorOptions from './getRightModalNavigatorOptions'; type ScreenOptions = Record; @@ -25,23 +26,12 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr return { rightModalNavigator: { ...commonScreenOptions, + ...getRightModalNavigatorOptions(isSmallScreenWidth), cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), - presentation: 'transparentModal', - - // We want pop in RHP since there are some flows that would work weird otherwise - animationTypeForReplace: 'pop', - cardStyle: { - ...StyleUtils.getNavigationModalCardStyle(), - - // This is necessary to cover translated sidebar with overlay. - width: isSmallScreenWidth ? '100%' : '200%', - // Excess space should be on the left so we need to position from right. - right: 0, - }, }, leftModalNavigator: { ...commonScreenOptions, - cardStyleInterpolator: (props) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props, SLIDE_LEFT_OUTPUT_RANGE_MULTIPLIER), presentation: 'transparentModal', // We want pop in LHP since there are some flows that would work weird otherwise @@ -59,8 +49,8 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr homeScreen: { title: CONFIG.SITE_TITLE, ...commonScreenOptions, + // Note: The card* properties won't be applied on mobile platforms, as they use the native defaults. cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, false, props), - cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), width: isSmallScreenWidth ? '100%' : variables.sideBarWidth, @@ -73,6 +63,7 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr fullScreen: { ...commonScreenOptions, + cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), @@ -87,7 +78,9 @@ const getRootNavigatorScreenOptions: GetRootNavigatorScreenOptions = (isSmallScr ...commonScreenOptions, animationEnabled: isSmallScreenWidth, cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator(isSmallScreenWidth, true, props), - + // temporary solution - better to hide a keyboard than see keyboard flickering + // see https://github.com/software-mansion/react-native-screens/issues/2021 for more details + keyboardHandlingEnabled: true, cardStyle: { ...StyleUtils.getNavigationModalCardStyle(), paddingRight: isSmallScreenWidth ? 0 : variables.sideBarWidth, diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts new file mode 100644 index 000000000000..ca9769fa9972 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.native.ts @@ -0,0 +1,8 @@ +import type {NativeStackNavigationOptions} from '@react-navigation/native-stack'; + +const defaultSubRouteOptions: NativeStackNavigationOptions = { + headerShown: false, + animation: 'slide_from_right', +}; + +export default defaultSubRouteOptions; diff --git a/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts new file mode 100644 index 000000000000..280a38b263b7 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/modalStackNavigatorOptions/index.ts @@ -0,0 +1,9 @@ +import type {StackNavigationOptions} from '@react-navigation/stack'; +import {CardStyleInterpolators} from '@react-navigation/stack'; + +const defaultSubRouteOptions: StackNavigationOptions = { + headerShown: false, + cardStyleInterpolator: CardStyleInterpolators.forHorizontalIOS, +}; + +export default defaultSubRouteOptions; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts new file mode 100644 index 000000000000..ef44cefc13c9 --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.native.ts @@ -0,0 +1,7 @@ +import {createNativeStackNavigator} from '@react-navigation/native-stack'; + +function createPlatformStackNavigator() { + return createNativeStackNavigator(); +} + +export default createPlatformStackNavigator; diff --git a/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts new file mode 100644 index 000000000000..51228295572f --- /dev/null +++ b/src/libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator.ts @@ -0,0 +1,5 @@ +import {createStackNavigator} from '@react-navigation/stack'; + +const createPlatformStackNavigator: typeof createStackNavigator = () => createStackNavigator(); + +export default createPlatformStackNavigator; diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index 3a4abe225120..371ea89df2e2 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -215,7 +215,7 @@ export default function linkTo(navigation: NavigationContainerRef> = { - [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION], + [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE], [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], }; diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 427ca2251e88..f79e275007d7 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -34,7 +34,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS, ], [SCREENS.SETTINGS.SECURITY]: [SCREENS.SETTINGS.TWO_FACTOR_AUTH, SCREENS.SETTINGS.CLOSE], - [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS, SCREENS.KEYBOARD_SHORTCUTS, SCREENS.SETTINGS.TROUBLESHOOT], + [SCREENS.SETTINGS.ABOUT]: [SCREENS.SETTINGS.APP_DOWNLOAD_LINKS, SCREENS.SETTINGS.TROUBLESHOOT], }; export default FULL_SCREEN_TO_RHP_MAPPING; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 74a00dec0a1f..ad3dc305f619 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -235,6 +235,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DESCRIPTION]: { path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route, }, + [SCREENS.WORKSPACE.SHARE]: { + path: ROUTES.WORKSPACE_PROFILE_SHARE.route, + }, [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, }, @@ -261,6 +264,9 @@ const config: LinkingOptions['config'] = { path: ROUTES.KEYBOARD_SHORTCUTS, }, [SCREENS.WORKSPACE.NAME]: ROUTES.WORKSPACE_PROFILE_NAME.route, + [SCREENS.SETTINGS.SHARE_CODE]: { + path: ROUTES.SETTINGS_SHARE_CODE, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { @@ -289,6 +295,9 @@ const config: LinkingOptions['config'] = { [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: { path: ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY.route, }, + [SCREENS.REPORT_SETTINGS.VISIBILITY]: { + path: ROUTES.REPORT_SETTINGS_VISIBILITY.route, + }, }, }, [SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: { @@ -422,7 +431,6 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, - [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route, [SCREENS.MONEY_REQUEST.RECEIPT]: ROUTES.MONEY_REQUEST_RECEIPT.route, [SCREENS.MONEY_REQUEST.DISTANCE]: ROUTES.MONEY_REQUEST_DISTANCE.route, [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS, @@ -495,10 +503,6 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS_CENTRAL_PANE]: { screens: { - [SCREENS.SETTINGS.SHARE_CODE]: { - path: ROUTES.SETTINGS_SHARE_CODE, - exact: true, - }, [SCREENS.SETTINGS.PROFILE.ROOT]: { path: ROUTES.SETTINGS_PROFILE, exact: true, diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 8e246d82ff72..e7c5466852cf 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -70,14 +70,16 @@ function createCentralPaneNavigator(route: NavigationPartialRoute): NavigationPartialRoute { +function createFullScreenNavigator(route?: NavigationPartialRoute): NavigationPartialRoute { const routes = []; routes.push({name: SCREENS.SETTINGS.ROOT}); - routes.push({ - name: SCREENS.SETTINGS_CENTRAL_PANE, - state: getRoutesWithIndex([route]), - }); + if (route) { + routes.push({ + name: SCREENS.SETTINGS_CENTRAL_PANE, + state: getRoutesWithIndex([route]), + }); + } return { name: NAVIGATORS.FULL_SCREEN_NAVIGATOR, @@ -129,6 +131,11 @@ function getMatchingRootRouteForRHPRoute( return createFullScreenNavigator({name: fullScreenName as FullScreenName, params: route.params}); } } + + // This screen is opened from the LHN of the FullStackNavigator, so in this case we shouldn't push any CentralPane screen + if (route.name === SCREENS.SETTINGS.SHARE_CODE) { + return createFullScreenNavigator(); + } } function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType { diff --git a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts index 55ccca73a389..02ad78a4c044 100644 --- a/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts +++ b/src/libs/Navigation/linkingConfig/getMatchingCentralPaneRouteForState.ts @@ -1,9 +1,11 @@ import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; -import type {CentralPaneName, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; +import type {CentralPaneName, CentralPaneNavigatorParamList, NavigationPartialRoute, RootStackParamList, State} from '@libs/Navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import TAB_TO_CENTRAL_PANE_MAPPING from './TAB_TO_CENTRAL_PANE_MAPPING'; +const WORKSPACES_SCREENS = TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.WORKSPACE.INITIAL].concat(TAB_TO_CENTRAL_PANE_MAPPING[SCREENS.ALL_SETTINGS]); + /** * @param state - react-navigation state */ @@ -31,8 +33,47 @@ const getTopMostReportIDFromRHP = (state: State): string => { return ''; }; +// Check if the given route has a policyID equal to the id provided in the function params +function hasRouteMatchingPolicyID(route: NavigationPartialRoute, policyID?: string) { + if (!route.params) { + return false; + } + + const params = `params` in route?.params ? (route.params.params as Record) : undefined; + + // If params are not defined, then we need to check if the policyID exists + if (!params) { + return !policyID; + } + + return 'policyID' in params && params.policyID === policyID; +} + +// Get already opened settings screen within the policy +function getAlreadyOpenedSettingsScreen(rootState?: State, policyID?: string): keyof CentralPaneNavigatorParamList | undefined { + if (!rootState) { + return undefined; + } + + // If one of the screen from WORKSPACES_SCREENS is now in the navigation state, we can decide which screen we should display. + // A screen from the navigation state can be pushed to the navigation state again only if it has a matching policyID with the currently selected workspace. + // Otherwise, when we switch the workspace, we want to display the initial screen in the settings tab. + const alreadyOpenedSettingsTab = rootState.routes + .filter((item) => item.params && 'screen' in item.params && WORKSPACES_SCREENS.includes(item.params.screen as keyof CentralPaneNavigatorParamList)) + .at(-1); + + if (!hasRouteMatchingPolicyID(alreadyOpenedSettingsTab as NavigationPartialRoute, policyID)) { + return undefined; + } + + const settingsScreen = + alreadyOpenedSettingsTab?.params && 'screen' in alreadyOpenedSettingsTab?.params ? (alreadyOpenedSettingsTab?.params?.screen as keyof CentralPaneNavigatorParamList) : undefined; + + return settingsScreen; +} + // Get matching central pane route for bottom tab navigator. e.g HOME -> REPORT -function getMatchingCentralPaneRouteForState(state: State): NavigationPartialRoute | undefined { +function getMatchingCentralPaneRouteForState(state: State, rootState?: State): NavigationPartialRoute | undefined { const topmostBottomTabRoute = getTopmostBottomTabRoute(state); if (!topmostBottomTabRoute) { @@ -42,7 +83,10 @@ function getMatchingCentralPaneRouteForState(state: State): const centralPaneName = TAB_TO_CENTRAL_PANE_MAPPING[topmostBottomTabRoute.name][0]; if (topmostBottomTabRoute.name === SCREENS.WORKSPACE.INITIAL) { - return {name: centralPaneName, params: topmostBottomTabRoute.params}; + // When we go back to the settings tab without switching the workspace id, we want to return to the previously opened screen + const policyID = topmostBottomTabRoute?.params && 'policyID' in topmostBottomTabRoute?.params ? (topmostBottomTabRoute.params.policyID as string) : undefined; + const screen = getAlreadyOpenedSettingsScreen(rootState, policyID) ?? centralPaneName; + return {name: screen, params: topmostBottomTabRoute.params}; } if (topmostBottomTabRoute.name === SCREENS.HOME) { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 04bc25736887..1438dfdfaf67 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -92,9 +92,15 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS]: undefined; [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: undefined; - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: undefined; - [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: undefined; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { + backTo: Routes; + }; + [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: { + contactMethod: string; + }; + [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: { + backTo: Routes; + }; [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: undefined; [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: undefined; @@ -146,6 +152,7 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.CURRENCY]: undefined; [SCREENS.WORKSPACE.NAME]: undefined; [SCREENS.WORKSPACE.DESCRIPTION]: undefined; + [SCREENS.WORKSPACE.SHARE]: undefined; [SCREENS.WORKSPACE.RATE_AND_UNIT]: { policyID: string; }; @@ -203,6 +210,9 @@ type ReportSettingsNavigatorParamList = { [SCREENS.REPORT_SETTINGS.ROOM_NAME]: undefined; [SCREENS.REPORT_SETTINGS.NOTIFICATION_PREFERENCES]: undefined; [SCREENS.REPORT_SETTINGS.WRITE_CAPABILITY]: undefined; + [SCREENS.REPORT_SETTINGS.VISIBILITY]: { + reportID: string; + }; }; type ReportDescriptionNavigatorParamList = { @@ -254,9 +264,12 @@ type MoneyRequestNavigatorParamList = { reportID: string; backTo: string; }; - [SCREENS.MONEY_REQUEST.CATEGORY]: { - iouType: string; + [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: { + action: ValueOf; + iouType: ValueOf; + transactionID: string; reportID: string; + backTo: string; }; [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: { iouType: string; @@ -416,6 +429,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.NEW_CHAT]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.PROFILE]: NavigatorScreenParams; + [SCREENS.SETTINGS.SHARE_CODE]: undefined; [SCREENS.RIGHT_MODAL.REPORT_DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_SETTINGS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.REPORT_DESCRIPTION]: NavigatorScreenParams; @@ -440,7 +454,6 @@ type RightModalNavigatorParamList = { }; type SettingsCentralPaneNavigatorParamList = { - [SCREENS.SETTINGS.SHARE_CODE]: undefined; [SCREENS.SETTINGS.PROFILE.ROOT]: undefined; [SCREENS.SETTINGS.PREFERENCES.ROOT]: undefined; [SCREENS.SETTINGS.SECURITY]: undefined; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 80081061f340..97b4fc0144c8 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1494,6 +1494,10 @@ function getOptions( return; } + if (!accountIDs || accountIDs.length === 0) { + return; + } + // Save the report in the map if this is a single participant so we can associate the reportID with the // personal detail option later. Individuals should not be associated with single participant // policyExpenseChats or chatRooms since those are not people. diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index a8c0508e30b6..974ce88a03ec 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -93,7 +93,7 @@ function shouldShowPolicy(policy: OnyxEntry, isOffline: boolean): boolea ); } -function isExpensifyTeam(email: string): boolean { +function isExpensifyTeam(email: string | undefined): boolean { const emailDomain = Str.extractEmailDomain(email ?? ''); return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN; } @@ -108,6 +108,11 @@ function isExpensifyGuideTeam(email: string): boolean { */ const isPolicyAdmin = (policy: OnyxEntry | EmptyObject): boolean => policy?.role === CONST.POLICY.ROLE.ADMIN; +/** + * Checks if the policy is a free group policy. + */ +const isFreeGroupPolicy = (policy: OnyxEntry | EmptyObject): boolean => policy?.type === CONST.POLICY.TYPE.FREE; + const isPolicyMember = (policyID: string, policies: OnyxCollection): boolean => Object.values(policies ?? {}).some((policy) => policy?.id === policyID); /** @@ -258,6 +263,7 @@ export { isExpensifyTeam, isExpensifyGuideTeam, isInstantSubmitEnabled, + isFreeGroupPolicy, isPolicyAdmin, isSubmitAndClose, getMemberAccountIDsForWorkspace, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d0c3bf3e8c03..ae6e02e70d29 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -62,6 +62,7 @@ import * as PersonalDetailsUtils from './PersonalDetailsUtils'; 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'; @@ -1475,9 +1476,11 @@ function getIconsForParticipants(participants: number[], personalDetails: OnyxCo */ function getWorkspaceIcon(report: OnyxEntry, policy: OnyxEntry = null): Icon { const workspaceName = getPolicyName(report, false, policy); - const policyExpenseChatAvatarSource = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatar - ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatar - : getDefaultWorkspaceAvatar(workspaceName); + const rootParentReport = getRootParentReport(report); + const hasCustomAvatar = + !(isEmptyObject(rootParentReport) || isDefaultRoom(rootParentReport) || isChatRoom(rootParentReport) || isArchivedRoom(rootParentReport)) && + allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatar; + const policyExpenseChatAvatarSource = hasCustomAvatar ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]?.avatar : getDefaultWorkspaceAvatar(workspaceName); const workspaceIcon: Icon = { source: policyExpenseChatAvatarSource ?? '', @@ -1623,6 +1626,14 @@ function getPersonalDetailsForAccountID(accountID: number): Partial = null, isForListPreview = false, - shouldHidePayer = false, ): string { const reportActionMessage = reportAction?.message?.[0].html ?? ''; @@ -2370,7 +2380,7 @@ function getReportPreviewMessage( translatePhraseKey = 'iou.paidWithExpensifyWithAmount'; } - let actualPayerName = report.managerID === currentUserAccountID || shouldHidePayer ? '' : getDisplayNameForParticipant(report.managerID, true); + let actualPayerName = report.managerID === currentUserAccountID ? '' : getDisplayNameForParticipant(report.managerID, true); actualPayerName = actualPayerName && isForListPreview && !isPreviewMessageForParentChatReport ? `${actualPayerName}:` : actualPayerName; const payerDisplayName = isPreviewMessageForParentChatReport ? payerName : actualPayerName; @@ -2687,7 +2697,7 @@ function hasReportNameError(report: OnyxEntry): boolean { */ function getParsedComment(text: string): string { const parser = new ExpensiMark(); - return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text) : lodashEscape(text); + return text.length <= CONST.MAX_MARKUP_LENGTH ? parser.replace(text, {shouldEscapeText: !shouldAllowRawHTMLMessages()}) : lodashEscape(text); } function getReportDescriptionText(report: Report): string { @@ -3519,7 +3529,7 @@ function buildOptimisticHoldReportAction(comment: string, created = DateUtils.ge { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'normal', - text: `held this money request with the comment: ${comment}`, + text: Localize.translateLocal('iou.heldRequest', {comment}), }, { type: CONST.REPORT.MESSAGE.TYPE.COMMENT, @@ -3554,7 +3564,7 @@ function buildOptimisticUnHoldReportAction(created = DateUtils.getDBTime()): Opt { type: CONST.REPORT.MESSAGE.TYPE.TEXT, style: 'normal', - text: `unheld this money request`, + text: Localize.translateLocal('iou.unheldRequest'), }, ], person: [ @@ -4517,6 +4527,13 @@ function canEditWriteCapability(report: OnyxEntry, policy: OnyxEntry, policy: OnyxEntry): boolean { + return PolicyUtils.isPolicyAdmin(policy) && !isArchivedRoom(report); +} + /** * Returns the onyx data needed for the task assignee chat */ @@ -4679,6 +4696,61 @@ function getVisibleMemberIDs(report: OnyxEntry): number[] { return visibleChatMemberAccountIDs; } +/** + * Return iou report action display message + */ +function getIOUReportActionDisplayMessage(reportAction: OnyxEntry): string { + if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.IOU) { + return ''; + } + const originalMessage = reportAction.originalMessage; + const {IOUReportID} = originalMessage; + const iouReport = getReport(IOUReportID); + let translationKey: TranslationPaths; + if (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { + // The `REPORT_ACTION_TYPE.PAY` action type is used for both fulfilling existing requests and sending money. To + // differentiate between these two scenarios, we check if the `originalMessage` contains the `IOUDetails` + // property. If it does, it indicates that this is a 'Send money' action. + const {amount, currency} = originalMessage.IOUDetails ?? originalMessage; + const formattedAmount = CurrencyUtils.convertToDisplayString(Math.abs(amount), currency) ?? ''; + + switch (originalMessage.paymentType) { + case CONST.IOU.PAYMENT_TYPE.ELSEWHERE: + translationKey = 'iou.paidElsewhereWithAmount'; + break; + case CONST.IOU.PAYMENT_TYPE.EXPENSIFY: + case CONST.IOU.PAYMENT_TYPE.VBBA: + translationKey = 'iou.paidWithExpensifyWithAmount'; + break; + default: + translationKey = 'iou.payerPaidAmount'; + break; + } + return Localize.translateLocal(translationKey, {amount: formattedAmount, payer: ''}); + } + + const transaction = TransactionUtils.getTransaction(originalMessage.IOUTransactionID ?? ''); + const transactionDetails = getTransactionDetails(!isEmptyObject(transaction) ? transaction : null); + const formattedAmount = CurrencyUtils.convertToDisplayString(transactionDetails?.amount ?? 0, transactionDetails?.currency); + const isRequestSettled = isSettled(originalMessage.IOUReportID); + const isApproved = isReportApproved(iouReport); + if (isRequestSettled) { + return Localize.translateLocal('iou.payerSettled', { + amount: formattedAmount, + }); + } + if (isApproved) { + return Localize.translateLocal('iou.approvedAmount', { + amount: formattedAmount, + }); + } + translationKey = ReportActionsUtils.isSplitBillAction(reportAction) ? 'iou.didSplitAmount' : 'iou.requestedAmount'; + return Localize.translateLocal(translationKey, { + formattedAmount, + comment: transactionDetails?.comment ?? '', + }); +} + /** * Checks if a report is a group chat. * @@ -5116,6 +5188,7 @@ export { hasOnlyTransactionsWithPendingRoutes, hasNonReimbursableTransactions, hasMissingSmartscanFields, + getIOUReportActionDisplayMessage, isWaitingForAssigneeToCompleteTask, isGroupChat, isDraftExpenseReport, @@ -5150,6 +5223,7 @@ export { getAvailableReportFields, reportFieldsEnabled, getAllAncestorReportActionIDs, + canEditRoomVisibility, canEditPolicyDescription, getPolicyDescriptionText, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index f79db5d4e0c0..d9298817f6b7 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -59,22 +59,6 @@ function compareStringDates(a: string, b: string): 0 | 1 | -1 { return 0; } -// Define a cache object to store the memoized results -const reportIDsCache = new Map(); - -// Function to set a key-value pair while maintaining the maximum key limit -function setWithLimit(map: Map, key: TKey, value: TValue) { - if (map.size >= 5) { - // If the map has reached its limit, remove the first (oldest) key-value pair - const firstKey = map.keys().next().value; - map.delete(firstKey); - } - map.set(key, value); -} - -// Variable to verify if ONYX actions are loaded -let hasInitialReportActions = false; - /** * @returns An array of reportIDs sorted in the proper order */ @@ -89,26 +73,6 @@ function getOrderedReportIDs( currentPolicyID = '', policyMemberAccountIDs: number[] = [], ): string[] { - const currentReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${currentReportId}`]; - let reportActionCount = currentReportActions?.length ?? 0; - reportActionCount = Math.max(reportActionCount, 1); - - // Generate a unique cache key based on the function arguments - const cachedReportsKey = JSON.stringify( - [currentReportId, allReports, betas, policies, priorityMode, reportActionCount, currentPolicyID, policyMemberAccountIDs], - // Exclude some properties not to overwhelm a cached key value with huge data, which we don't need to store in a cacheKey - (key, value: unknown) => (['participantAccountIDs', 'participants', 'lastMessageText', 'visibleChatMemberAccountIDs'].includes(key) ? undefined : value), - ); - - // Check if the result is already in the cache - const cachedIDs = reportIDsCache.get(cachedReportsKey); - if (cachedIDs && hasInitialReportActions) { - return cachedIDs; - } - - // This is needed to prevent caching when Onyx is empty for a second render - hasInitialReportActions = Object.values(lastReportActions).length > 0; - const isInGSDMode = priorityMode === CONST.PRIORITY_MODE.GSD; const isInDefaultMode = !isInGSDMode; const allReportsDictValues = Object.values(allReports); @@ -197,7 +161,6 @@ function getOrderedReportIDs( // Now that we have all the reports grouped and sorted, they must be flattened into an array and only return the reportID. // The order the arrays are concatenated in matters and will determine the order that the groups are displayed in the sidebar. const LHNReports = [...pinnedAndGBRReports, ...draftReports, ...nonArchivedReports, ...archivedReports].map((report) => report.reportID); - setWithLimit(reportIDsCache, cachedReportsKey, LHNReports); return LHNReports; } @@ -279,7 +242,9 @@ function getOptionData({ result.policyID = report.policyID; result.stateNum = report.stateNum; result.statusNum = report.statusNum; - result.isUnread = ReportUtils.isUnread(report); + // When the only message of a report is deleted lastVisibileActionCreated is not reset leading to wrongly + // setting it Unread so we add additional condition here to avoid empty chat LHN from being bold. + result.isUnread = ReportUtils.isUnread(report) && !!report.lastActorAccountID; result.isUnreadWithMention = ReportUtils.isUnreadWithMention(report); result.hasDraftComment = report.hasDraft; result.isPinned = report.isPinned; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index d3eafc6554db..0a13d561891c 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -6,8 +6,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {RecentWaypoint, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates'; -import type {Comment, Receipt, TransactionChanges, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; +import type {Comment, Receipt, TransactionChanges, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {isCorporateCard, isExpensifyCard} from './CardUtils'; import DateUtils from './DateUtils'; import * as NumberUtils from './NumberUtils'; @@ -94,6 +95,7 @@ function buildOptimisticTransaction( category = '', tag = '', billable = false, + pendingFields: Partial<{[K in TransactionPendingFieldsKey]: ValueOf}> | undefined = undefined, ): Transaction { // transactionIDs are random, positive, 64-bit numeric strings. // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID) @@ -108,6 +110,7 @@ function buildOptimisticTransaction( } return { + ...(!isEmptyObject(pendingFields) ? {pendingFields} : {}), transactionID, amount, currency, diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index 761d4788584c..12b52524f113 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -2,7 +2,7 @@ import Str from 'expensify-common/lib/str'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import * as defaultAvatars from '@components/Icon/DefaultAvatars'; -import {ConciergeAvatar, FallbackAvatar} from '@components/Icon/Expensicons'; +import {ConciergeAvatar, FallbackAvatar, NotificationsAvatar} from '@components/Icon/Expensicons'; import CONST from '@src/CONST'; import type Login from '@src/types/onyx/Login'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -88,6 +88,9 @@ function getDefaultAvatar(accountID = -1, avatarURL?: string): IconAsset { if (Number(accountID) === CONST.ACCOUNT_ID.CONCIERGE) { return ConciergeAvatar; } + if (Number(accountID) === CONST.ACCOUNT_ID.NOTIFICATIONS) { + return NotificationsAvatar; + } // There are 24 possible default avatars, so we choose which one this user has based // on a simple modulo operation of their login number. Note that Avatar count starts at 1. @@ -170,7 +173,7 @@ function getAvatarUrl(avatarSource: AvatarSource | undefined, accountID: number) * Avatars uploaded by users will have a _128 appended so that the asset server returns a small version. * This removes that part of the URL so the full version of the image can load. */ -function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID: number): AvatarSource { +function getFullSizeAvatar(avatarSource: AvatarSource | undefined, accountID?: number): AvatarSource { const source = getAvatar(avatarSource, accountID); if (typeof source !== 'string') { return source; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 02ae638a41d3..56cf1c475812 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -5,7 +5,7 @@ import isDate from 'lodash/isDate'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import type {OnyxCollection} from 'react-native-onyx'; -import type {FormInputErrors, FormOnyxKeys, FormOnyxValues} from '@components/Form/types'; +import type {FormInputErrors, FormOnyxKeys, FormOnyxValues, FormValue} from '@components/Form/types'; import CONST from '@src/CONST'; import type {OnyxFormKey} from '@src/ONYXKEYS'; import type {Report} from '@src/types/onyx'; @@ -77,7 +77,7 @@ function isValidPastDate(date: string | Date): boolean { * Used to validate a value that is "required". * @param value - field value */ -function isRequiredFulfilled(value?: string | boolean | Date): boolean { +function isRequiredFulfilled(value?: FormValue): boolean { if (!value) { return false; } @@ -103,7 +103,7 @@ function getFieldRequiredErrors(values: FormOnyxVal const errors: FormInputErrors = {}; requiredFields.forEach((fieldKey) => { - if (isRequiredFulfilled(values[fieldKey] as keyof FormOnyxValues)) { + if (isRequiredFulfilled(values[fieldKey] as FormValue)) { return; } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index c5f68317bf18..0f4e1aed36a7 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -3,8 +3,7 @@ import * as API from '@libs/API'; import type { AddPersonalBankAccountParams, BankAccountHandlePlaidErrorParams, - ConnectBankAccountManuallyParams, - ConnectBankAccountWithPlaidParams, + ConnectBankAccountParams, DeletePaymentBankAccountParams, OpenReimbursementAccountPageParams, ValidateBankAccountWithTransactionsParams, @@ -152,13 +151,15 @@ function addBusinessWebsiteForDraft(websiteUrl: string) { * Submit Bank Account step with Plaid data so php can perform some checks. */ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount, policyID: string) { - const parameters: ConnectBankAccountWithPlaidParams = { + const parameters: ConnectBankAccountParams = { bankAccountID, routingNumber: selectedPlaidBankAccount.routingNumber, accountNumber: selectedPlaidBankAccount.accountNumber, bank: selectedPlaidBankAccount.bankName, plaidAccountID: selectedPlaidBankAccount.plaidAccountID, plaidAccessToken: selectedPlaidBankAccount.plaidAccessToken, + plaidMask: selectedPlaidBankAccount.mask, + isSavings: selectedPlaidBankAccount.isSavings, canUseNewVbbaFlow: true, policyID, }; @@ -254,22 +255,24 @@ function deletePaymentBankAccount(bankAccountID: number) { * @param bankAccountID - ID for bank account * @param params - User personal data */ -function updatePersonalInformationForBankAccount(bankAccountID: number, params: RequestorStepProps) { +function updatePersonalInformationForBankAccount(bankAccountID: number, params: RequestorStepProps, policyID: string) { API.write( WRITE_COMMANDS.UPDATE_PERSONAL_INFORMATION_FOR_BANK_ACCOUNT, { ...params, bankAccountID, + policyID, canUseNewVbbaFlow: true, }, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR), ); } -function validateBankAccount(bankAccountID: number, validateCode: string) { +function validateBankAccount(bankAccountID: number, validateCode: string, policyID: string) { const parameters: ValidateBankAccountWithTransactionsParams = { bankAccountID, validateCode, + policyID, }; const onyxData: OnyxData = { @@ -363,12 +366,13 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS * Updates the bank account in the database with the company step data * @param params - Business step form data */ -function updateCompanyInformationForBankAccount(bankAccountID: number, params: CompanyStepProps) { +function updateCompanyInformationForBankAccount(bankAccountID: number, params: Partial, policyID: string) { API.write( WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT, { ...params, bankAccountID, + policyID, canUseNewVbbaFlow: true, }, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY), @@ -379,12 +383,13 @@ function updateCompanyInformationForBankAccount(bankAccountID: number, params: C * Add beneficial owners for the bank account and verify the accuracy of the information provided * @param params - Beneficial Owners step form params */ -function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: BeneficialOwnersStepProps) { +function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: Partial, policyID: string) { API.write( WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT, { ...params, bankAccountID, + policyID, canUseNewVbbaFlow: true, }, getVBBADataForOnyx(), @@ -395,12 +400,13 @@ function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: Ben * Accept the ACH terms and conditions and verify the accuracy of the information provided * @param params - Verification step form params */ -function acceptACHContractForBankAccount(bankAccountID: number, params: ACHContractStepProps) { +function acceptACHContractForBankAccount(bankAccountID: number, params: ACHContractStepProps, policyID: string) { API.write( WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT, { ...params, bankAccountID, + policyID, canUseNewVbbaFlow: true, }, getVBBADataForOnyx(), @@ -409,14 +415,17 @@ function acceptACHContractForBankAccount(bankAccountID: number, params: ACHContr /** * Create the bank account with manually entered data. - * @param plaidMask - scheme for Plaid account number */ -function connectBankAccountManually(bankAccountID: number, accountNumber?: string, routingNumber?: string, plaidMask?: string, policyID?: string) { - const parameters: ConnectBankAccountManuallyParams = { +function connectBankAccountManually(bankAccountID: number, bankAccount: PlaidBankAccount, policyID: string) { + const parameters: ConnectBankAccountParams = { bankAccountID, - accountNumber, - routingNumber, - plaidMask, + routingNumber: bankAccount.routingNumber, + accountNumber: bankAccount.accountNumber, + bank: bankAccount.bankName, + plaidAccountID: bankAccount.plaidAccountID, + plaidAccessToken: bankAccount.plaidAccessToken, + plaidMask: bankAccount.mask, + isSavings: bankAccount.isSavings, canUseNewVbbaFlow: true, policyID, }; @@ -427,20 +436,23 @@ function connectBankAccountManually(bankAccountID: number, accountNumber?: strin /** * Verify the user's identity via Onfido */ -function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: Record) { +function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: Record, policyID: string) { const parameters: VerifyIdentityForBankAccountParams = { bankAccountID, onfidoData: JSON.stringify(onfidoData), + policyID, canUseNewVbbaFlow: true, }; API.write(WRITE_COMMANDS.VERIFY_IDENTITY_FOR_BANK_ACCOUNT, parameters, getVBBADataForOnyx()); } -function openWorkspaceView() { +function openWorkspaceView(policyID: string) { API.read( READ_COMMANDS.OPEN_WORKSPACE_VIEW, - {}, + { + policyID, + }, { optimisticData: [ { diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index 3a0bdb94d5f5..8207b78e8759 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -1,6 +1,5 @@ import Onyx from 'react-native-onyx'; import type {NullishDeep} from 'react-native-onyx'; -import FormUtils from '@libs/FormUtils'; import type {OnyxFormDraftKey, OnyxFormKey, OnyxValue} from '@src/ONYXKEYS'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; @@ -25,11 +24,11 @@ function clearErrorFields(formID: OnyxFormKey) { } function setDraftValues(formID: OnyxFormKey, draftValues: NullishDeep>) { - Onyx.merge(FormUtils.getDraftKey(formID), draftValues); + Onyx.merge(`${formID}Draft`, draftValues); } function clearDraftValues(formID: OnyxFormKey) { - Onyx.set(FormUtils.getDraftKey(formID), null); + Onyx.set(`${formID}Draft`, null); } export {setDraftValues, setErrorFields, setErrors, clearErrors, clearErrorFields, setIsLoading, clearDraftValues}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index fbc124735db3..547c187789eb 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -60,7 +60,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import * as Policy from './Policy'; import * as Report from './Report'; -type MoneyRequestRoute = StackScreenProps['route']; +type MoneyRequestRoute = StackScreenProps['route']; type IOURequestType = ValueOf; @@ -223,8 +223,7 @@ Onyx.connect({ * @param reportID to attach the transaction to * @param iouRequestType one of manual/scan/distance */ -// eslint-disable-next-line @typescript-eslint/naming-convention -function startMoneyRequest_temporaryForRefactor(reportID: string, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { +function initMoneyRequest(reportID: string, isFromGlobalCreate: boolean, iouRequestType: IOURequestType = CONST.IOU.REQUEST_TYPE.MANUAL) { // Generate a brand new transactionID const newTransactionID = CONST.IOU.OPTIMISTIC_TRANSACTION_ID; // Disabling this line since currentDate can be an empty string @@ -259,6 +258,12 @@ function clearMoneyRequest(transactionID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } +// eslint-disable-next-line @typescript-eslint/naming-convention +function startMoneyRequest_temporaryForRefactor(iouType: ValueOf, reportID: string) { + clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); + Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(iouType, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, reportID)); +} + // eslint-disable-next-line @typescript-eslint/naming-convention function setMoneyRequestAmount_temporaryForRefactor(transactionID: string, amount: number, currency: string, removeOriginalCurrency = false) { if (removeOriginalCurrency) { @@ -299,16 +304,10 @@ function setMoneyRequestPendingFields(transactionID: string, pendingFields: Pend Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); } -// eslint-disable-next-line @typescript-eslint/naming-convention -function setMoneyRequestCategory_temporaryForRefactor(transactionID: string, category: string) { +function setMoneyRequestCategory(transactionID: string, category: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category}); } -// eslint-disable-next-line @typescript-eslint/naming-convention -function resetMoneyRequestCategory_temporaryForRefactor(transactionID: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {category: null}); -} - function setMoneyRequestTag(transactionID: string, tag: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {tag}); } @@ -789,6 +788,8 @@ function getMoneyRequestInformation( receiptObject.state = receipt.state ?? CONST.IOU.RECEIPT_STATE.SCANREADY; filename = receipt.name; } + const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; + const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; let optimisticTransaction = TransactionUtils.buildOptimisticTransaction( ReportUtils.isExpenseReport(iouReport) ? -amount : amount, currency, @@ -804,6 +805,7 @@ function getMoneyRequestInformation( category, tag, billable, + isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, ); const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(iouReport.policyID, category); @@ -814,8 +816,7 @@ function getMoneyRequestInformation( // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 // to remind me to do this. - const existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; - if (existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE) { + if (isDistanceRequest) { optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false); } @@ -1003,7 +1004,7 @@ function calculateDiffAmount(iouReport: OnyxEntry, updatedTran // Subtract the diff from the total if we change the currency from the currency of IOU report to a different currency return -updatedAmount; } - if (updatedCurrency === iouReport?.currency && updatedTransaction?.modifiedAmount) { + if (updatedCurrency === iouReport?.currency && updatedAmount !== currentAmount) { // Calculate the diff between the updated amount and the current amount if we change the amount and the currency of the transaction is the currency of the report return updatedAmount - currentAmount; } @@ -1127,32 +1128,32 @@ function getUpdateMoneyRequestParams( }, }, }); + } - // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. - let updatedMoneyRequestReport = {...iouReport}; - const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction); - - if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { - // For expense report, the amount is negative so we should subtract total from diff - updatedMoneyRequestReport.total -= diff; - } else { - updatedMoneyRequestReport = iouReport - ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true) - : {}; - } - updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction?.modifiedCurrency); + // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. + let updatedMoneyRequestReport = {...iouReport}; + const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction); - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: updatedMoneyRequestReport, - }); - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null}, - }); + if (ReportUtils.isExpenseReport(iouReport) && typeof updatedMoneyRequestReport.total === 'number') { + // For expense report, the amount is negative so we should subtract total from diff + updatedMoneyRequestReport.total -= diff; + } else { + updatedMoneyRequestReport = iouReport + ? IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID ?? -1, diff, TransactionUtils.getCurrency(transaction), false, true) + : {}; } + updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, transactionDetails?.currency); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: updatedMoneyRequestReport, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null}, + }); // Optimistically modify the transaction and the transaction thread optimisticData.push({ @@ -3293,9 +3294,10 @@ function getSendMoneyParams( } function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxTypes.Report, recipient: Participant, paymentMethodType: PaymentMethodType): PayMoneyRequestData { + const total = iouReport.total ?? 0; const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.PAY, - -(iouReport.total ?? 0), + ReportUtils.isExpenseReport(iouReport) ? -total : total, iouReport.currency ?? '', '', [recipient], @@ -3869,14 +3871,6 @@ function setMoneyRequestCurrency(currency: string) { Onyx.merge(ONYXKEYS.IOU, {currency}); } -function setMoneyRequestCategory(category: string) { - Onyx.merge(ONYXKEYS.IOU, {category}); -} - -function resetMoneyRequestCategory() { - Onyx.merge(ONYXKEYS.IOU, {category: ''}); -} - function setMoneyRequestTaxRate(transactionID: string, taxRate: TaxRate) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {taxRate}); } @@ -3934,7 +3928,6 @@ function navigateToNextPage(iou: OnyxEntry, iouType: string, repo ? [{reportID: chatReport?.reportID, isPolicyExpenseChat: true, selected: true}] : (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true})); setMoneyRequestParticipants(participants); - resetMoneyRequestCategory(); } Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID)); return; @@ -4095,14 +4088,11 @@ export { payMoneyRequest, sendMoneyWithWallet, startMoneyRequest, + initMoneyRequest, startMoneyRequest_temporaryForRefactor, - resetMoneyRequestCategory, - resetMoneyRequestCategory_temporaryForRefactor, resetMoneyRequestInfo, - clearMoneyRequest, setMoneyRequestAmount_temporaryForRefactor, setMoneyRequestBillable_temporaryForRefactor, - setMoneyRequestCategory_temporaryForRefactor, setMoneyRequestCreated, setMoneyRequestCurrency_temporaryForRefactor, setMoneyRequestDescription, diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index ea675ff6b8f6..13e0a42e839f 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/AddDebitCardForm'; import type {BankAccountList, FundList} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type PaymentMethod from '@src/types/onyx/PaymentMethod'; @@ -205,7 +206,15 @@ function clearDebitCardFormErrorAndSubmit() { Onyx.set(ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM, { isLoading: false, errors: undefined, - setupComplete: false, + [INPUT_IDS.SETUP_COMPLETE]: false, + [INPUT_IDS.NAME_ON_CARD]: '', + [INPUT_IDS.CARD_NUMBER]: '', + [INPUT_IDS.EXPIRATION_DATE]: '', + [INPUT_IDS.SECURITY_CODE]: '', + [INPUT_IDS.ADDRESS_STREET]: '', + [INPUT_IDS.ADDRESS_ZIP_CODE]: '', + [INPUT_IDS.ADDRESS_STATE]: '', + [INPUT_IDS.ACCEPT_TERMS]: '', }); } diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 843a0744b617..bc695911b910 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -262,7 +262,9 @@ function deleteWorkspace(policyID: string, policyName: string) { : []), ]; - const reportsToArchive = Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report))); + const reportsToArchive = Object.values(allReports ?? {}).filter( + (report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report)), + ); reportsToArchive.forEach((report) => { const {reportID, ownerAccountID} = report ?? {}; optimisticData.push({ diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js index 962800fb2e55..2cc085e32f5e 100644 --- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -9,8 +9,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; * Reset user's reimbursement account. This will delete the bank account. * @param {Number} bankAccountID * @param {Object} session + * @param {String} policyID */ -function resetFreePlanBankAccount(bankAccountID, session) { +function resetFreePlanBankAccount(bankAccountID, session, policyID) { if (!bankAccountID) { throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); } @@ -23,6 +24,7 @@ function resetFreePlanBankAccount(bankAccountID, session) { { bankAccountID, ownerEmail: session.email, + policyID, }, { optimisticData: [ diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 9134f0a89e61..f29f8a4fbaab 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -40,6 +40,7 @@ import type { UpdateReportWriteCapabilityParams, UpdateRoomDescriptionParams, } from '@libs/API/parameters'; +import type UpdateRoomVisibilityParams from '@libs/API/parameters/UpdateRoomVisibilityParams'; import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CollectionUtils from '@libs/CollectionUtils'; import DateUtils from '@libs/DateUtils'; @@ -66,9 +67,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; +import INPUT_IDS from '@src/types/form/NewRoomForm'; import type {PersonalDetails, PersonalDetailsList, PolicyReportField, RecentlyUsedReportFields, ReportActionReactions, ReportMetadata, ReportUserIsTyping} from '@src/types/onyx'; import type {Decision, OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; -import type {NotificationPreference, WriteCapability} from '@src/types/onyx/Report'; +import type {NotificationPreference, 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'; @@ -85,12 +87,14 @@ type ActionSubscriber = { callback: SubscriberCallback; }; +let conciergeChatReportID: string | undefined; let currentUserAccountID = -1; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { // When signed out, val is undefined if (!value?.accountID) { + conciergeChatReportID = undefined; return; } @@ -167,7 +171,6 @@ Onyx.connect({ }); const allReports: OnyxCollection = {}; -let conciergeChatReportID: string | undefined; const typingWatchTimers: Record = {}; let reportIDDeeplinkedFromOldDot: string | undefined; @@ -1442,6 +1445,38 @@ function updateNotificationPreference( } } +function updateRoomVisibility(reportID: string, previousValue: RoomVisibility | undefined, newValue: RoomVisibility, navigate: boolean, report: OnyxEntry | EmptyObject = {}) { + if (previousValue === newValue) { + if (navigate && !isEmptyObject(report) && report.reportID) { + ReportUtils.goBackToDetailsPage(report); + } + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {visibility: newValue}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: {visibility: previousValue}, + }, + ]; + + const parameters: UpdateRoomVisibilityParams = {reportID, visibility: newValue}; + + API.write(WRITE_COMMANDS.UPDATE_ROOM_VISIBILITY, parameters, {optimisticData, failureData}); + if (navigate && !isEmptyObject(report)) { + ReportUtils.goBackToDetailsPage(report); + } +} + /** * This will subscribe to an existing thread, or create a new one and then subsribe to it if necessary * @@ -1610,7 +1645,7 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre const parameters = { reportID, - reportFields: JSON.stringify({[reportField.fieldID]: reportField}), + reportFields: JSON.stringify({[`expensify_${reportField.fieldID}`]: reportField}), }; API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData}); @@ -1683,24 +1718,29 @@ function updateWriteCapabilityAndNavigate(report: Report, newValue: WriteCapabil /** * Navigates to the 1:1 report with Concierge - * - * @param ignoreConciergeReportID - Flag to ignore conciergeChatReportID during navigation. The default behavior is to not ignore. */ -function navigateToConciergeChat(ignoreConciergeReportID = false, shouldDismissModal = false) { +function navigateToConciergeChat(shouldDismissModal = false, shouldPopCurrentScreen = false, checkIfCurrentPageActive = () => true) { // If conciergeChatReportID contains a concierge report ID, we navigate to the concierge chat using the stored report ID. // Otherwise, we would find the concierge chat and navigate to it. - // Now, when user performs sign-out and a sign-in again, conciergeChatReportID may contain a stale value. - // In order to prevent navigation to a stale value, we use ignoreConciergeReportID to forcefully find and navigate to concierge chat. - if (!conciergeChatReportID || ignoreConciergeReportID) { + if (!conciergeChatReportID) { // In order to avoid creating concierge repeatedly, // we need to ensure that the server data has been successfully pulled Welcome.serverDataIsReadyPromise().then(() => { // If we don't have a chat with Concierge then create it + if (!checkIfCurrentPageActive()) { + return; + } + if (shouldPopCurrentScreen && !shouldDismissModal) { + Navigation.goBack(); + } navigateToAndOpenReport([CONST.EMAIL.CONCIERGE], shouldDismissModal); }); } else if (shouldDismissModal) { Navigation.dismissModal(conciergeChatReportID); } else { + if (shouldPopCurrentScreen) { + Navigation.goBack(); + } Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeChatReportID)); } } @@ -2180,10 +2220,7 @@ function openReportFromDeepLink(url: string, isAuthenticated: boolean) { Session.waitForUserSignIn().then(() => { Navigation.waitForProtectedRoutes().then(() => { const route = ReportUtils.getRouteFromLink(url); - if (route === ROUTES.CONCIERGE) { - navigateToConciergeChat(true); - return; - } + if (route && Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { Session.signOutAndRedirectToSignIn(true); return; @@ -2805,6 +2842,11 @@ function clearNewRoomFormError() { isLoading: false, errorFields: null, errors: null, + [INPUT_IDS.ROOM_NAME]: '', + [INPUT_IDS.REPORT_DESCRIPTION]: '', + [INPUT_IDS.POLICY_ID]: '', + [INPUT_IDS.WRITE_CAPABILITY]: '', + [INPUT_IDS.VISIBILITY]: '', }); } @@ -2926,4 +2968,5 @@ export { updateReportField, updateReportName, resolveActionableMentionWhisper, + updateRoomVisibility, }; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 7416b4f07e5e..f384e38f6d55 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -890,7 +890,7 @@ const canAccessRouteByAnonymousUser = (route: string) => { if (route.startsWith('/')) { routeRemovedReportId = routeRemovedReportId.slice(1); } - const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route]; + const routesCanAccessByAnonymousUser = [ROUTES.SIGN_IN_MODAL, ROUTES.REPORT_WITH_ID_DETAILS.route, ROUTES.REPORT_WITH_ID_DETAILS_SHARE_CODE.route, ROUTES.CONCIERGE]; if ((routesCanAccessByAnonymousUser as string[]).includes(routeRemovedReportId)) { return true; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 28cecf460a5f..74e6a6c78eeb 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -881,6 +881,11 @@ function canModifyTask(taskReport: OnyxEntry, sessionAccountID return false; } + const parentReport = ReportUtils.getParentReport(taskReport); + if (ReportUtils.isArchivedRoom(parentReport)) { + return false; + } + if (sessionAccountID === getTaskOwnerAccountID(taskReport) || sessionAccountID === getTaskAssigneeAccountID(taskReport)) { return true; } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 54efe4ba4d8e..f2507a28d576 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -526,7 +526,7 @@ function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { } // mention user - if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail}`)) { return playSound(SOUNDS.ATTENTION); } diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.ts similarity index 71% rename from src/libs/migrateOnyx.js rename to src/libs/migrateOnyx.ts index 9b8b4056e3e5..1202275067a5 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.ts @@ -1,31 +1,26 @@ -import _ from 'underscore'; import Log from './Log'; import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID'; -import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID'; import RemoveEmptyReportActionsDrafts from './migrations/RemoveEmptyReportActionsDrafts'; import RenameReceiptFilename from './migrations/RenameReceiptFilename'; import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection'; -export default function () { +export default function (): Promise { const startTime = Date.now(); Log.info('[Migrate Onyx] start'); return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts]; + const migrationPromises = [RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. /* eslint-disable arrow-body-style */ - _.reduce( - migrationPromises, - (previousPromise, migrationPromise) => { + migrationPromises + .reduce((previousPromise, migrationPromise) => { return previousPromise.then(() => { return migrationPromise(); }); - }, - Promise.resolve(), - ) + }, Promise.resolve()) // Once all migrations are done, resolve the main promise .then(() => { diff --git a/src/libs/migrations/PersonalDetailsByAccountID.js b/src/libs/migrations/PersonalDetailsByAccountID.js deleted file mode 100644 index 24aece8f5a97..000000000000 --- a/src/libs/migrations/PersonalDetailsByAccountID.js +++ /dev/null @@ -1,274 +0,0 @@ -import lodashHas from 'lodash/has'; -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; -import Log from '@libs/Log'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const DEPRECATED_ONYX_KEYS = { - // Deprecated personal details object which was keyed by login instead of accountID. - PERSONAL_DETAILS: 'personalDetails', -}; - -/** - * @returns {Promise} - */ -function getReportActionsFromOnyx() { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, - waitForCollectionCallback: true, - callback: (allReportActions) => { - Onyx.disconnect(connectionID); - return resolve(allReportActions); - }, - }); - }); -} - -/** - * @returns {Promise} - */ -function getReportsFromOnyx() { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.REPORT, - waitForCollectionCallback: true, - callback: (allReports) => { - Onyx.disconnect(connectionID); - return resolve(allReports); - }, - }); - }); -} - -/** - * We use the old personalDetails object becuase it is more efficient for this migration since it is keyed by email address. - * Also, if the old personalDetails object is not available, that likely means the migration has already run successfully before on this account. - * - * @returns {Promise} - */ -function getDeprecatedPersonalDetailsFromOnyx() { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS, - callback: (allPersonalDetails) => { - Onyx.disconnect(connectionID); - return resolve(allPersonalDetails); - }, - }); - }); -} - -/** - * @returns {Promise} - */ -function getDeprecatedPolicyMemberListFromOnyx() { - return new Promise((resolve) => { - const connectionID = Onyx.connect({ - key: ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST, - waitForCollectionCallback: true, - callback: (allPolicyMembers) => { - Onyx.disconnect(connectionID); - return resolve(allPolicyMembers); - }, - }); - }); -} - -/** - * Migrate Onyx data for the email to accountID migration. - * - * - personalDetails -> personalDetailsList - * - policyMemberList_ -> policyMembers_ - * - reportAction_ - * - originalMessage.oldLogin -> originalMessage.oldAccountID - * - originalMessage.newLogin -> originalMessage.newAccountID - * - accountEmail -> accountID - * - actorEmail -> actorAccountID - * - childManagerEmail -> childManagerAccountID - * - whisperedTo -> whisperedToAccountIDs - * - childOldestFourEmails -> childOldestFourAccountIDs - * - originalMessage.participants -> originalMessage.participantAccountIDs - * - report_ - * - lastActorEmail -> lastActorAccountID - * - participants -> participantAccountIDs - * - * @returns {Promise} - */ -export default function () { - return Promise.all([getReportActionsFromOnyx(), getReportsFromOnyx(), getDeprecatedPersonalDetailsFromOnyx(), getDeprecatedPolicyMemberListFromOnyx()]).then( - ([oldReportActions, oldReports, oldPersonalDetails, oldPolicyMemberList]) => { - const onyxData = {}; - - // The personalDetails object has been replaced by personalDetailsList - // So if we find an instance of personalDetails we will clear it out - if (oldPersonalDetails) { - Log.info('[Migrate Onyx] PersonalDetailsByAccountID migration: removing personalDetails'); - onyxData[DEPRECATED_ONYX_KEYS.PERSONAL_DETAILS] = null; - } - - // The policyMemberList_ collection has been replaced by policyMembers_ - // So if we find any instances of policyMemberList_ we will clear them out - _.each(oldPolicyMemberList, (_policyMembersForPolicy, policyKey) => { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing policyMemberList ${policyKey}`); - onyxData[policyKey] = null; - }); - - // We migrate reportActions to remove old email-based data. - // If we do not find the equivalent accountID-based data in the reportAction, we will just clear the reportAction - // and let it be fetched from the API next time they open the report and scroll to that action. - // We do this because we know the reportAction from the API will include the needed accountID data. - _.each(oldReportActions, (reportActionsForReport, onyxKey) => { - if (_.isEmpty(reportActionsForReport)) { - Log.info(`[Migrate Onyx] Skipped migration PersonalDetailsByAccountID for ${onyxKey} because there were no reportActions`); - return; - } - const newReportActionsForReport = {}; - let reportActionsWereModified = false; - _.each(reportActionsForReport, (reportAction, reportActionID) => { - if (_.isEmpty(reportAction)) { - reportActionsWereModified = true; - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because the reportAction was empty`); - return; - } - const newReportAction = reportAction; - - if (lodashHas(reportAction, ['originalMessage', 'oldLogin'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['originalMessage', 'oldAccountID'])) { - delete newReportAction.originalMessage.oldLogin; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because originalMessage.oldAccountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['originalMessage', 'newLogin'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['originalMessage', 'newAccountID'])) { - delete newReportAction.originalMessage.newLogin; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because originalMessage.newAccountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['accountEmail'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['accountID'])) { - delete newReportAction.accountEmail; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because accountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['actorEmail'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['actorAccountID'])) { - delete newReportAction.actorEmail; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because actorAccountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['childManagerEmail'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['childManagerAccountID'])) { - delete newReportAction.childManagerEmail; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because childManagerAccountID not found`); - return; - } - } - - if (lodashHas(reportAction, ['whisperedTo'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['whisperedToAccountIDs'])) { - delete newReportAction.whisperedTo; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because whisperedToAccountIDs not found`); - return; - } - } - - if (lodashHas(reportAction, ['childOldestFourEmails'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['childOldestFourAccountIDs'])) { - delete newReportAction.childOldestFourEmails; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because childOldestFourAccountIDs not found`); - return; - } - } - - if (lodashHas(reportAction, ['originalMessage', 'participants'])) { - reportActionsWereModified = true; - - if (lodashHas(reportAction, ['originalMessage', 'participantAccountIDs'])) { - delete newReportAction.originalMessage.participants; - } else { - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing reportAction ${reportActionID} because originalMessage.participantAccountIDs not found`); - return; - } - } - - newReportActionsForReport[reportActionID] = newReportAction; - }); - - // Only include the reportActions from this report if at least one reportAction in this report - // was modified in any way. - if (reportActionsWereModified) { - onyxData[onyxKey] = newReportActionsForReport; - } - }); - - // For the reports migration, we don't need to look up emails from accountIDs. Instead, - // we will just look for old email data and automatically remove it if it exists. The reason for - // this is that we already stopped sending email based data in reports, and from everywhere else - // in the app by this point (since this is the last data we migrated). - _.each(oldReports, (report, onyxKey) => { - const newReport = report; - - // Delete report key if it's empty - if (_.isEmpty(newReport)) { - onyxData[onyxKey] = null; - return; - } - - let reportWasModified = false; - if (lodashHas(newReport, ['lastActorEmail'])) { - reportWasModified = true; - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing lastActorEmail from report ${newReport.reportID}`); - delete newReport.lastActorEmail; - } - - if (lodashHas(newReport, ['ownerEmail'])) { - reportWasModified = true; - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing ownerEmail from report ${newReport.reportID}`); - delete newReport.ownerEmail; - } - - if (lodashHas(newReport, ['managerEmail'])) { - reportWasModified = true; - Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing managerEmail from report ${newReport.reportID}`); - delete newReport.managerEmail; - } - - if (reportWasModified) { - onyxData[onyxKey] = newReport; - } - }); - - return Onyx.multiSet(onyxData); - }, - ); -} diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts index cd50938c70b9..68c750b05a5f 100644 --- a/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts +++ b/src/libs/setShouldShowComposeInputKeyboardAware/index.ios.ts @@ -1,5 +1,3 @@ import setShouldShowComposeInputKeyboardAwareBuilder from './setShouldShowComposeInputKeyboardAwareBuilder'; -// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event -// Because of that - on iOS we can use `keyboardWillHide` that is not available on android -export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardWillHide'); +export default setShouldShowComposeInputKeyboardAwareBuilder('keyboardDidHide'); diff --git a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts index 8d5ef578b66c..72df7a730e02 100644 --- a/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts +++ b/src/libs/setShouldShowComposeInputKeyboardAware/setShouldShowComposeInputKeyboardAwareBuilder.ts @@ -5,8 +5,6 @@ import * as Composer from '@userActions/Composer'; import type SetShouldShowComposeInputKeyboardAware from './types'; let keyboardEventListener: EmitterSubscription | null = null; -// On iOS, there is a visible delay in displaying input after the keyboard has been closed with the `keyboardDidHide` event -// Because of that - on iOS we can use `keyboardWillHide` that is not available on android const setShouldShowComposeInputKeyboardAwareBuilder: (keyboardEvent: KeyboardEventName) => SetShouldShowComposeInputKeyboardAware = (keyboardEvent: KeyboardEventName) => (shouldShow: boolean) => { diff --git a/src/libs/shouldAllowDownloadQRCode/index.native.ts b/src/libs/shouldAllowDownloadQRCode/index.native.ts new file mode 100644 index 000000000000..ea9b2b9c8aa1 --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/index.native.ts @@ -0,0 +1,5 @@ +import type ShouldAllowDownloadQRCode from './types'; + +const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = true; + +export default shouldAllowDownloadQRCode; diff --git a/src/libs/shouldAllowDownloadQRCode/index.ts b/src/libs/shouldAllowDownloadQRCode/index.ts new file mode 100644 index 000000000000..8331f7d4821f --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/index.ts @@ -0,0 +1,5 @@ +import type ShouldAllowDownloadQRCode from './types'; + +const shouldAllowDownloadQRCode: ShouldAllowDownloadQRCode = false; + +export default shouldAllowDownloadQRCode; diff --git a/src/libs/shouldAllowDownloadQRCode/types.ts b/src/libs/shouldAllowDownloadQRCode/types.ts new file mode 100644 index 000000000000..3bd6c5dc4dd7 --- /dev/null +++ b/src/libs/shouldAllowDownloadQRCode/types.ts @@ -0,0 +1,3 @@ +type ShouldAllowDownloadQRCode = boolean; + +export default ShouldAllowDownloadQRCode; diff --git a/src/libs/shouldAllowRawHTMLMessages/index.native.ts b/src/libs/shouldAllowRawHTMLMessages/index.native.ts new file mode 100644 index 000000000000..db886f7f6fe8 --- /dev/null +++ b/src/libs/shouldAllowRawHTMLMessages/index.native.ts @@ -0,0 +1,3 @@ +export default function () { + return false; +} diff --git a/src/libs/shouldAllowRawHTMLMessages/index.ts b/src/libs/shouldAllowRawHTMLMessages/index.ts new file mode 100644 index 000000000000..577dc3055441 --- /dev/null +++ b/src/libs/shouldAllowRawHTMLMessages/index.ts @@ -0,0 +1,5 @@ +window.shouldAllowRawHTMLMessages = false; + +export default function () { + return window.shouldAllowRawHTMLMessages; +} diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 251728866a54..4abf8f0d2033 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -1,11 +1,16 @@ import {useFocusEffect} from '@react-navigation/native'; import type {StackScreenProps} from '@react-navigation/stack'; -import React from 'react'; +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; +import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; +import * as App from '@userActions/App'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -24,19 +29,39 @@ type ConciergePageProps = ConciergePageOnyxProps & StackScreenProps { if (session && 'authToken' in session) { + App.confirmReadyToOpenApp(); // Pop the concierge loading page before opening the concierge report. Navigation.isNavigationReady().then(() => { - Navigation.goBack(); - Report.navigateToConciergeChat(); + if (isUnmounted.current) { + return; + } + Report.navigateToConciergeChat(undefined, true, () => !isUnmounted.current); }); } else { Navigation.navigate(); } }); - return ; + useEffect( + () => () => { + isUnmounted.current = true; + }, + [], + ); + + return ( + + + + + + + ); } ConciergePage.displayName = 'ConciergePage'; diff --git a/src/pages/EditRequestCategoryPage.js b/src/pages/EditRequestCategoryPage.js deleted file mode 100644 index 205b4bf66dfa..000000000000 --- a/src/pages/EditRequestCategoryPage.js +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import CategoryPicker from '@components/CategoryPicker'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; - -const propTypes = { - /** Transaction default category value */ - defaultCategory: PropTypes.string.isRequired, - - /** The policyID we are getting categories for */ - policyID: PropTypes.string.isRequired, - - /** Callback to fire when the Save button is pressed */ - onSubmit: PropTypes.func.isRequired, -}; - -function EditRequestCategoryPage({defaultCategory, policyID, onSubmit}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - const selectCategory = (category) => { - onSubmit({ - category: category.searchText, - }); - }; - - return ( - - - {translate('iou.categorySelection')} - - - ); -} - -EditRequestCategoryPage.propTypes = propTypes; -EditRequestCategoryPage.displayName = 'EditRequestCategoryPage'; - -export default EditRequestCategoryPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 29917154a527..c155fc74e0c3 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -1,5 +1,4 @@ import lodashGet from 'lodash/get'; -import lodashValues from 'lodash/values'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo} from 'react'; import {withOnyx} from 'react-native-onyx'; @@ -21,7 +20,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import EditRequestAmountPage from './EditRequestAmountPage'; -import EditRequestCategoryPage from './EditRequestCategoryPage'; import EditRequestDistancePage from './EditRequestDistancePage'; import EditRequestReceiptPage from './EditRequestReceiptPage'; import EditRequestTagPage from './EditRequestTagPage'; @@ -77,7 +75,7 @@ const defaultProps = { function EditRequestPage({report, route, policy, policyCategories, policyTags, parentReportActions, transaction}) { const parentReportActionID = lodashGet(report, 'parentReportActionID', '0'); const parentReportAction = lodashGet(parentReportActions, parentReportActionID, {}); - const {amount: transactionAmount, currency: transactionCurrency, category: transactionCategory, tag: transactionTag} = ReportUtils.getTransactionDetails(transaction); + const {amount: transactionAmount, currency: transactionCurrency, tag: transactionTag} = ReportUtils.getTransactionDetails(transaction); const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency; const fieldToEdit = lodashGet(route, ['params', 'field'], ''); @@ -90,9 +88,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p // A flag for verifying that the current report is a sub-report of a workspace chat const isPolicyExpenseChat = ReportUtils.isGroupPolicy(report); - // A flag for showing the categories page - const shouldShowCategories = isPolicyExpenseChat && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories))); - // A flag for showing the tags page const shouldShowTags = useMemo(() => isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)), [isPolicyExpenseChat, policyTagLists, transactionTag]); @@ -145,16 +140,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p [tag, transaction.transactionID, report.reportID, transactionTag, tagIndex, policy, policyTags, policyCategories], ); - const saveCategory = useCallback( - ({category: newCategory}) => { - // In case the same category has been selected, reset the category. - const updatedCategory = newCategory === transactionCategory ? '' : newCategory; - IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories); - Navigation.dismissModal(); - }, - [transactionCategory, transaction.transactionID, report.reportID, policy, policyTags, policyCategories], - ); - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG && shouldShowTags) { return ( { - setDraftSplitTransaction({category: transactionChanges.category.trim()}); - }} - /> - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.TAG) { return ( ; + return ( + + ); } return ( diff --git a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx index a0b91ffcdb0e..bb352acd4732 100644 --- a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx @@ -31,6 +31,8 @@ type BankInfoOnyxProps = { /** The draft values of the bank account being setup */ reimbursementAccountDraft: OnyxEntry; + + policyID: string; }; type BankInfoProps = BankInfoOnyxProps & { @@ -43,7 +45,7 @@ const manualSubsteps: Array> = [Manual, Confir const plaidSubsteps: Array> = [Plaid, Confirmation]; const receivedRedirectURI = getPlaidOAuthReceivedRedirectURI(); -function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkToken, onBackButtonPress}: BankInfoProps) { +function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkToken, onBackButtonPress, policyID}: BankInfoProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -57,15 +59,20 @@ function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkTok setupType = CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID; } - const policyID = reimbursementAccount?.achData?.policyID ?? ''; const bankAccountID = Number(reimbursementAccount?.achData?.bankAccountID ?? '0'); const submit = useCallback(() => { if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { BankAccounts.connectBankAccountManually( bankAccountID, - values[BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER], - values[BANK_INFO_STEP_KEYS.ROUTING_NUMBER], - values[BANK_INFO_STEP_KEYS.PLAID_MASK], + { + [BANK_INFO_STEP_KEYS.ROUTING_NUMBER]: values[BANK_INFO_STEP_KEYS.ROUTING_NUMBER] ?? '', + [BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER]: values[BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER] ?? '', + [BANK_INFO_STEP_KEYS.BANK_NAME]: values[BANK_INFO_STEP_KEYS.BANK_NAME] ?? '', + [BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID]: values[BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID] ?? '', + [BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN]: values[BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN] ?? '', + [BANK_INFO_STEP_KEYS.PLAID_MASK]: values[BANK_INFO_STEP_KEYS.PLAID_MASK] ?? '', + [BANK_INFO_STEP_KEYS.IS_SAVINGS]: values[BANK_INFO_STEP_KEYS.IS_SAVINGS] ?? false, + }, policyID, ); } else if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID) { @@ -77,6 +84,8 @@ function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkTok [BANK_INFO_STEP_KEYS.BANK_NAME]: values[BANK_INFO_STEP_KEYS.BANK_NAME] ?? '', [BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID]: values[BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID] ?? '', [BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN]: values[BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN] ?? '', + [BANK_INFO_STEP_KEYS.PLAID_MASK]: values[BANK_INFO_STEP_KEYS.PLAID_MASK] ?? '', + [BANK_INFO_STEP_KEYS.IS_SAVINGS]: values[BANK_INFO_STEP_KEYS.IS_SAVINGS] ?? false, }, policyID, ); diff --git a/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx b/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx index 406033b10b03..10b05e58deb7 100644 --- a/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx +++ b/src/pages/ReimbursementAccount/BeneficialOwnersStep.tsx @@ -47,6 +47,7 @@ function BeneficialOwnersStep({reimbursementAccount, reimbursementAccountDraft, const {translate} = useLocalize(); const styles = useThemeStyles(); const companyName = reimbursementAccount?.achData?.companyName ?? ''; + const policyID = reimbursementAccount?.achData?.policyID ?? ''; const defaultValues = { ownsMoreThan25Percent: reimbursementAccount?.achData?.ownsMoreThan25Percent ?? reimbursementAccountDraft?.ownsMoreThan25Percent ?? false, hasOtherBeneficialOwners: reimbursementAccount?.achData?.hasOtherBeneficialOwners ?? reimbursementAccountDraft?.hasOtherBeneficialOwners ?? false, @@ -76,11 +77,15 @@ function BeneficialOwnersStep({reimbursementAccount, reimbursementAccountDraft, ), ); - BankAccounts.updateBeneficialOwnersForBankAccount(Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), { - ownsMoreThan25Percent: isUserUBO, - beneficialOwners: JSON.stringify(beneficialOwners), - beneficialOwnerKeys, - }); + BankAccounts.updateBeneficialOwnersForBankAccount( + Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), + { + ownsMoreThan25Percent: isUserUBO, + beneficialOwners: JSON.stringify(beneficialOwners), + beneficialOwnerKeys, + }, + policyID, + ); }; const addBeneficialOwner = (beneficialOwnerID: string) => { diff --git a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx index 21f90c414b5d..f63cf72f8a4f 100644 --- a/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx +++ b/src/pages/ReimbursementAccount/BusinessInfo/BusinessInfo.tsx @@ -68,16 +68,21 @@ function BusinessInfo({reimbursementAccount, reimbursementAccountDraft, onBackBu [reimbursementAccount, reimbursementAccountDraft], ); + const policyID = reimbursementAccount?.achData?.policyID ?? ''; const values = useMemo(() => getSubstepValues(BUSINESS_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); const submit = useCallback(() => { - BankAccounts.updateCompanyInformationForBankAccount(Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), { - ...values, - ...getBankAccountFields(['routingNumber', 'accountNumber', 'bankName', 'plaidAccountID', 'plaidAccessToken', 'isSavings']), - companyTaxID: values.companyTaxID?.replace(CONST.REGEX.NON_NUMERIC, ''), - companyPhone: parsePhoneNumber(values.companyPhone ?? '', {regionCode: CONST.COUNTRY.US}).number?.significant, - }); - }, [reimbursementAccount, values, getBankAccountFields]); + BankAccounts.updateCompanyInformationForBankAccount( + Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), + { + ...values, + ...getBankAccountFields(['routingNumber', 'accountNumber', 'bankName', 'plaidAccountID', 'plaidAccessToken', 'isSavings']), + companyTaxID: values.companyTaxID?.replace(CONST.REGEX.NON_NUMERIC, ''), + companyPhone: parsePhoneNumber(values.companyPhone ?? '', {regionCode: CONST.COUNTRY.US}).number?.significant, + }, + policyID, + ); + }, [reimbursementAccount, values, getBankAccountFields, policyID]); const startFrom = useMemo(() => getInitialSubstepForBusinessInfo(values), [values]); diff --git a/src/pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx b/src/pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx index 2b80b890e4bd..2db3a4fdf7ad 100644 --- a/src/pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx +++ b/src/pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker/BusinessTypeSelectorModal.tsx @@ -3,6 +3,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -66,6 +67,7 @@ function BusinessTypeSelectorModal({isVisible, currentBusinessType, onBusinessTy onSelectRow={onBusinessTypeSelected} shouldStopPropagation shouldUseDynamicMaxToRenderPerBatch + ListItem={RadioListItem} /> diff --git a/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx b/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx index 20d4663dcf8d..1c440a52644c 100644 --- a/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx +++ b/src/pages/ReimbursementAccount/CompleteVerification/CompleteVerification.tsx @@ -40,14 +40,19 @@ function CompleteVerification({reimbursementAccount, reimbursementAccountDraft, const styles = useThemeStyles(); const values = useMemo(() => getSubstepValues(COMPLETE_VERIFICATION_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const policyID = reimbursementAccount?.achData?.policyID ?? ''; const submit = useCallback(() => { - BankAccounts.acceptACHContractForBankAccount(Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), { - isAuthorizedToUseBankAccount: values.isAuthorizedToUseBankAccount, - certifyTrueInformation: values.certifyTrueInformation, - acceptTermsAndConditions: values.acceptTermsAndConditions, - }); - }, [reimbursementAccount, values]); + BankAccounts.acceptACHContractForBankAccount( + Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), + { + isAuthorizedToUseBankAccount: values.isAuthorizedToUseBankAccount, + certifyTrueInformation: values.certifyTrueInformation, + acceptTermsAndConditions: values.acceptTermsAndConditions, + }, + policyID, + ); + }, [reimbursementAccount, values, policyID]); const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit}); diff --git a/src/pages/ReimbursementAccount/ConnectBankAccount/components/BankAccountValidationForm.tsx b/src/pages/ReimbursementAccount/ConnectBankAccount/components/BankAccountValidationForm.tsx index d921343c9557..0e8192b60416 100644 --- a/src/pages/ReimbursementAccount/ConnectBankAccount/components/BankAccountValidationForm.tsx +++ b/src/pages/ReimbursementAccount/ConnectBankAccount/components/BankAccountValidationForm.tsx @@ -89,10 +89,10 @@ function BankAccountValidationForm({requiresTwoFactorAuth, reimbursementAccount, // Send valid amounts to BankAccountAPI::validateBankAccount in Web-Expensify const bankAccountID = Number(reimbursementAccount?.achData?.bankAccountID ?? '0'); if (bankAccountID) { - BankAccounts.validateBankAccount(bankAccountID, validateCode); + BankAccounts.validateBankAccount(bankAccountID, validateCode, policyID); } }, - [reimbursementAccount], + [reimbursementAccount, policyID], ); return ( getSubstepValues(PERSONAL_INFO_STEP_KEYS, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); const bankAccountID = Number(reimbursementAccount?.achData?.bankAccountID ?? '0'); const submit = useCallback(() => { - BankAccounts.updatePersonalInformationForBankAccount(bankAccountID, {...values}); - }, [values, bankAccountID]); + BankAccounts.updatePersonalInformationForBankAccount(bankAccountID, {...values}, policyID); + }, [values, bankAccountID, policyID]); const startFrom = useMemo(() => getInitialSubstepForPersonalInfo(values), [values]); const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom, onFinished: submit}); diff --git a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx index 3f0470a873e2..412227570365 100644 --- a/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx +++ b/src/pages/ReimbursementAccount/PersonalInfo/substeps/FullName.tsx @@ -30,11 +30,11 @@ const STEP_FIELDS = [PERSONAL_INFO_STEP_KEY.FIRST_NAME, PERSONAL_INFO_STEP_KEY.L const validate = (values: FormOnyxValues): FormInputErrors => { const errors = ValidationUtils.getFieldRequiredErrors(values, STEP_FIELDS); - if (values.firstName && !ValidationUtils.isValidPersonName(values.firstName)) { + if (values.firstName && !ValidationUtils.isValidLegalName(values.firstName)) { errors.firstName = 'bankAccount.error.firstName'; } - if (values.lastName && !ValidationUtils.isValidPersonName(values.lastName)) { + if (values.lastName && !ValidationUtils.isValidLegalName(values.lastName)) { errors.lastName = 'bankAccount.error.lastName'; } return errors; diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js b/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js index c46ae5a65020..97bd1c508775 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes.js @@ -8,7 +8,7 @@ export default PropTypes.shape({ routingNumber: PropTypes.string, acceptTerms: PropTypes.bool, plaidAccountID: PropTypes.string, - plaidMask: PropTypes.string, + mask: PropTypes.string, /** Props needed for CompanyStep */ companyName: PropTypes.string, diff --git a/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx b/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx index 44563afd587c..d17166365a39 100644 --- a/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx +++ b/src/pages/ReimbursementAccount/VerifyIdentity/VerifyIdentity.tsx @@ -38,12 +38,13 @@ function VerifyIdentity({reimbursementAccount, onBackButtonPress, onfidoApplican const styles = useThemeStyles(); const {translate} = useLocalize(); + const policyID = reimbursementAccount?.achData?.policyID ?? ''; const handleOnfidoSuccess = useCallback( (onfidoData: Record) => { - BankAccounts.verifyIdentityForBankAccount(Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), {...onfidoData, applicantID: onfidoApplicantID}); + BankAccounts.verifyIdentityForBankAccount(Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), {...onfidoData, applicantID: onfidoApplicantID}, policyID); BankAccounts.updateReimbursementAccountDraft({isOnfidoSetupComplete: true}); }, - [reimbursementAccount, onfidoApplicantID], + [reimbursementAccount, onfidoApplicantID, policyID], ); const handleOnfidoError = () => { diff --git a/src/pages/ReportParticipantsPage.tsx b/src/pages/ReportParticipantsPage.tsx index b560ce672ae4..15490455ce09 100755 --- a/src/pages/ReportParticipantsPage.tsx +++ b/src/pages/ReportParticipantsPage.tsx @@ -88,6 +88,7 @@ function ReportParticipantsPage({report, personalDetails}: ReportParticipantsPag {({safeAreaPaddingBottomStyle}) => ( Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)) : undefined} title={translate( ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || @@ -113,7 +114,7 @@ function ReportParticipantsPage({report, personalDetails}: ReportParticipantsPag if (!option.accountID) { return; } - Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID)); + Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, report ? ROUTES.REPORT_PARTICIPANTS.getRoute(report.reportID) : undefined)); }} hideSectionHeaders showTitleTooltip diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index 2c26d148f54c..40a1b009b38d 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -12,6 +12,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import type {Section} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; @@ -225,6 +226,7 @@ function RoomInvitePage({betas, personalDetails, report, policies}: RoomInvitePa diff --git a/src/pages/SearchPage/index.js b/src/pages/SearchPage/index.js index 7c472296dfe1..0c17e58837c1 100644 --- a/src/pages/SearchPage/index.js +++ b/src/pages/SearchPage/index.js @@ -6,6 +6,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -143,6 +144,7 @@ function SearchPage({betas, reports, isSearchingForReports}) { includeSafeAreaPaddingBottom={false} testID={SearchPage.displayName} onEntryTransitionEnd={handleScreenTransitionEnd} + shouldEnableMaxHeight > {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( <> @@ -153,6 +155,7 @@ function SearchPage({betas, reports, isSearchingForReports}) { (null); - const {isSmallScreenWidth} = useWindowDimensions(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isReport = !!report?.reportID; @@ -71,72 +67,52 @@ function ShareCodePage({report}: ShareCodePageProps) { const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; return ( - + Navigation.goBack(isReport ? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID) : undefined)} - shouldShowBackButton={isReport || isSmallScreenWidth} - icon={Illustrations.QRCode} + shouldShowBackButton /> - -
- - - + + + - - Clipboard.setString(url)} - shouldLimitWidth={false} - wrapperStyle={themeStyles.sectionMenuItemTopDescription} - /> + + Clipboard.setString(url)} + shouldLimitWidth={false} + /> - {isNative && ( - qrCodeRef.current?.download?.()} - wrapperStyle={themeStyles.sectionMenuItemTopDescription} - /> - )} + {isNative && ( + qrCodeRef.current?.download?.()} + /> + )} - - Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE, Navigation.getActiveRouteWithoutParams())) - } - wrapperStyle={themeStyles.sectionMenuItemTopDescription} - shouldShowRightIcon - /> - -
+ Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SHARE_CODE, Navigation.getActiveRouteWithoutParams()))} + shouldShowRightIcon + />
diff --git a/src/pages/WorkspaceSwitcherPage.tsx b/src/pages/WorkspaceSwitcherPage.tsx index 245cc06d8c65..d361ba5137b6 100644 --- a/src/pages/WorkspaceSwitcherPage.tsx +++ b/src/pages/WorkspaceSwitcherPage.tsx @@ -250,7 +250,6 @@ function WorkspaceSwitcherPage({policies}: WorkspaceSwitcherPageProps) { highlightSelectedOptions shouldShowOptions autoFocus={false} - disableFocusOptions={!activeWorkspaceID} canSelectMultipleOptions={false} shouldShowSubscript={false} showTitleTooltip={false} diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index e024f1c3f7eb..faa70bb0633a 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -329,7 +329,7 @@ function HeaderView(props) { { if (ReportUtils.canEditPolicyDescription(props.policy)) { - Navigation.navigate(ROUTES.WORKSPACE_DESCRIPTION.getRoute(props.report.policyID)); + Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(props.report.policyID)); return; } Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(props.reportID)); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index 213d94f51f81..52b62c2d15b3 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -208,6 +208,7 @@ function BaseReportActionContextMenu({ undefined, undefined, filteredContextMenuActions, + true, ); }; diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index abd9d2a09fdf..51e6b25f1314 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -359,15 +359,7 @@ const ContextMenuActions: ContextMenuAction[] = [ const displayMessage = ReportUtils.getReimbursementDeQueuedActionMessage(reportAction, expenseReport); Clipboard.setString(displayMessage); } else if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { - const displayMessage = ReportUtils.getReportPreviewMessage( - ReportUtils.getReport(ReportUtils.getOriginalReportID(reportID, reportAction)), - reportAction, - false, - false, - null, - false, - true, - ); + const displayMessage = ReportUtils.getIOUReportActionDisplayMessage(reportAction); Clipboard.setString(displayMessage); } else if (ReportActionsUtils.isCreatedTaskReportAction(reportAction)) { const taskPreviewMessage = TaskUtils.getTaskCreatedMessage(reportAction); diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 9c8c6a8b37e7..0b4154a15e80 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -65,7 +65,7 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef([]); const contentRef = useRef(null); - const anchorRef = useRef(null); + const anchorRef = useRef(null); const dimensionsEventListener = useRef(null); const contextMenuAnchorRef = useRef(null); const contextMenuTargetNode = useRef(null); @@ -163,11 +163,16 @@ function PopoverReportActionContextMenu(_props: never, ref: ForwardedRef { const {pageX = 0, pageY = 0} = extractPointerEvent(event); contextMenuAnchorRef.current = contextMenuAnchor; contextMenuTargetNode.current = event.target as HTMLElement; - + if (shouldCloseOnTarget) { + anchorRef.current = event.target as HTMLDivElement; + } else { + anchorRef.current = null; + } setInstanceID(Math.random().toString(36).substr(2, 5)); onPopoverShow.current = onShow; diff --git a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts index d8570bd14510..6664a38d2e19 100644 --- a/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts +++ b/src/pages/home/report/ContextMenu/ReportActionContextMenu.ts @@ -34,6 +34,7 @@ type ShowContextMenu = ( isPinnedChat?: boolean, isUnreadChat?: boolean, disabledOptions?: ContextMenuAction[], + shouldCloseOnTarget?: boolean, ) => void; type ReportActionContextMenu = { @@ -113,6 +114,7 @@ function showContextMenu( isPinnedChat = false, isUnreadChat = false, disabledActions: ContextMenuAction[] = [], + shouldCloseOnTarget = false, ) { if (!contextMenuRef.current) { return; @@ -140,6 +142,7 @@ function showContextMenu( isPinnedChat, isUnreadChat, disabledActions, + shouldCloseOnTarget, ); } diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js index 62abfcf8545a..72727168cad6 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.js @@ -18,14 +18,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; -import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import * as IOU from '@userActions/IOU'; import * as Report from '@userActions/Report'; import * as Task from '@userActions/Task'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; const propTypes = { /** The report currently being looked at */ @@ -145,12 +143,12 @@ function AttachmentPickerWithMenuItems({ [CONST.IOU.TYPE.SPLIT]: { icon: Expensicons.Receipt, text: translate('iou.splitBill'), - onSelected: () => Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.SPLIT, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, report.reportID)), + onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.SPLIT, report.reportID), }, [CONST.IOU.TYPE.REQUEST]: { icon: Expensicons.MoneyCircle, text: translate('iou.requestMoney'), - onSelected: () => Navigation.navigate(ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, report.reportID)), + onSelected: () => IOU.startMoneyRequest_temporaryForRefactor(CONST.IOU.TYPE.REQUEST, report.reportID), }, [CONST.IOU.TYPE.SEND]: { icon: Expensicons.Send, diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index 8ec0bce9d1a7..4bbf3d393213 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -446,7 +446,12 @@ function ReportActionCompose({ onBlur={onBlur} measureParentContainer={measureContainer} listHeight={listHeight} - onValueChange={validateCommentMaxLength} + onValueChange={(value) => { + if (value.length === 0 && isComposerFullSize) { + Report.setIsComposerFullSize(reportID, false); + } + validateCommentMaxLength(value); + }} /> { diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js index 152ce54d5481..6bdea2cb4a27 100644 --- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js +++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js @@ -203,7 +203,8 @@ function SuggestionMention({ suggestionEndIndex = indexOfFirstSpecialCharOrEmojiAfterTheCursor + selectionEnd; } - const leftString = value.substring(0, suggestionEndIndex); + const newLineIndex = value.lastIndexOf('\n', selectionEnd - 1); + const leftString = value.substring(newLineIndex + 1, suggestionEndIndex); const words = leftString.split(CONST.REGEX.SPACE_OR_EMOJI); const lastWord = _.last(words); const secondToLastWord = words[words.length - 3]; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index e676c83c8b9b..66394190fde6 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -466,6 +466,10 @@ function ReportActionItem(props) { children = ; } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MARKEDREIMBURSED) { children = ; + } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.HOLD) { + children = ; + } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { + children = ; } else { const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision) && diff --git a/src/pages/home/report/ReportActionItemMessage.tsx b/src/pages/home/report/ReportActionItemMessage.tsx index becc8e094c97..0ec23e9aaf79 100644 --- a/src/pages/home/report/ReportActionItemMessage.tsx +++ b/src/pages/home/report/ReportActionItemMessage.tsx @@ -58,7 +58,7 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid const originalMessage = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? action.originalMessage : null; const iouReportID = originalMessage?.IOUReportID; if (iouReportID) { - iouMessage = ReportUtils.getReportPreviewMessage(ReportUtils.getReport(iouReportID), action, false, false, null, false, true); + iouMessage = ReportUtils.getIOUReportActionDisplayMessage(action); } } diff --git a/src/pages/home/report/ReportActionItemMessageEdit.tsx b/src/pages/home/report/ReportActionItemMessageEdit.tsx index 427c6ccdbfc4..2c9a4cbd21e8 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.tsx +++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx @@ -31,6 +31,7 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware'; +import * as ComposerActions from '@userActions/Composer'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; import * as InputFocus from '@userActions/InputFocus'; import * as Report from '@userActions/Report'; @@ -211,6 +212,9 @@ function ReportActionItemMessageEdit( // eslint-disable-next-line react-hooks/exhaustive-deps -- this cleanup needs to be called only on unmount }, [action.reportActionID]); + // show the composer after editing is complete for devices that hide the composer during editing. + useEffect(() => () => ComposerActions.setShouldShowComposeInput(true), []); + /** * Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft * allows one to navigate somewhere else and come back to the comment and still have it in edit mode. diff --git a/src/pages/home/report/ReportActionItemParentAction.tsx b/src/pages/home/report/ReportActionItemParentAction.tsx index 15a844ab5a72..d00e681ed313 100644 --- a/src/pages/home/report/ReportActionItemParentAction.tsx +++ b/src/pages/home/report/ReportActionItemParentAction.tsx @@ -71,9 +71,9 @@ function ReportActionItemParentAction({report, index = 0, shouldHideThreadDivide return ( <> - + - + {allAncestors.map((ancestor) => ( { - if (!isSmallScreenWidth) { - return; - } App.confirmReadyToOpenApp(); - }, [isSmallScreenWidth]); + }, []); useEffect(() => { InteractionManager.runAfterInteractions(() => { diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 3bd538e8beab..828077097580 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -101,7 +101,6 @@ const propTypes = { const defaultProps = { chatReports: {}, - allReportActions: {}, isLoadingApp: true, priorityMode: CONST.PRIORITY_MODE.DEFAULT, betas: [], @@ -111,6 +110,7 @@ const defaultProps = { accountID: '', }, transactionViolations: {}, + allReportActions: {}, }; function SidebarLinksData({ diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index 0df490fa4466..573cbe370aa7 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -166,10 +166,11 @@ function FloatingActionButtonAndPopover(props) { text: translate('iou.requestMoney'), onSelected: () => interceptAnonymousUser(() => - Navigation.navigate( + IOU.startMoneyRequest_temporaryForRefactor( + CONST.IOU.TYPE.REQUEST, // When starting to create a money request from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used // for all of the routes in the creation flow. - ROUTES.MONEY_REQUEST_CREATE.getRoute(CONST.IOU.TYPE.REQUEST, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, ReportUtils.generateReportID()), + ReportUtils.generateReportID(), ), ), }, diff --git a/src/pages/iou/HoldReasonPage.tsx b/src/pages/iou/HoldReasonPage.tsx index da3b98583630..25f318089c2e 100644 --- a/src/pages/iou/HoldReasonPage.tsx +++ b/src/pages/iou/HoldReasonPage.tsx @@ -84,7 +84,7 @@ function HoldReasonPage({route}: HoldReasonPageProps) { valueType="string" name="comment" defaultValue={undefined} - label="Reason" + label={translate('iou.reason')} accessibilityLabel={translate('iou.reason')} autoFocus /> diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 2a48897bfc85..7495efb43171 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -9,6 +9,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {withNetwork} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; @@ -165,6 +166,7 @@ function IOUCurrencySelection(props) { /> { - Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); - }; - - const updateCategory = (category) => { - if (category.searchText === iou.category) { - IOU.resetMoneyRequestCategory(); - } else { - IOU.setMoneyRequestCategory(category.searchText); - } - - Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); - }; - - return ( - - - {translate('iou.categorySelection')} - - - ); -} - -MoneyRequestCategoryPage.displayName = 'MoneyRequestCategoryPage'; -MoneyRequestCategoryPage.propTypes = propTypes; -MoneyRequestCategoryPage.defaultProps = defaultProps; - -export default compose( - withOnyx({ - iou: { - key: ONYXKEYS.IOU, - }, - }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file - withOnyx({ - report: { - key: ({route, iou}) => { - const reportID = IOU.getIOUReportID(iou, route); - - return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; - }, - }, - }), -)(MoneyRequestCategoryPage); diff --git a/src/pages/iou/request/IOURequestStartPage.js b/src/pages/iou/request/IOURequestStartPage.js index 3d80ab89347d..05e3d7c96311 100644 --- a/src/pages/iou/request/IOURequestStartPage.js +++ b/src/pages/iou/request/IOURequestStartPage.js @@ -80,14 +80,6 @@ function IOURequestStartPage({ const previousIOURequestType = usePrevious(transactionRequestType.current); const isFromGlobalCreate = _.isEmpty(report.reportID); - // Clear out the temporary money request when this component is unmounted - useEffect( - () => () => { - IOU.clearMoneyRequest(CONST.IOU.OPTIMISTIC_TRANSACTION_ID); - }, - [reportID], - ); - useEffect(() => { const handler = (event) => { if (event.code !== CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { @@ -106,7 +98,7 @@ function IOURequestStartPage({ if (transaction.reportID === reportID) { return; } - IOU.startMoneyRequest_temporaryForRefactor(reportID, isFromGlobalCreate, transactionRequestType.current); + IOU.initMoneyRequest(reportID, isFromGlobalCreate, transactionRequestType.current); }, [transaction, reportID, iouType, isFromGlobalCreate]); const isExpenseChat = ReportUtils.isPolicyExpenseChat(report); @@ -125,7 +117,7 @@ function IOURequestStartPage({ if (newIouType === previousIOURequestType) { return; } - IOU.startMoneyRequest_temporaryForRefactor(reportID, isFromGlobalCreate, newIouType); + IOU.initMoneyRequest(reportID, isFromGlobalCreate, newIouType); transactionRequestType.current = newIouType; }, [previousIOURequestType, reportID, isFromGlobalCreate], diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index d6c088c23e95..238b66c0e727 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -11,6 +11,7 @@ import {PressableWithFeedback} from '@components/Pressable'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import SelectCircle from '@components/SelectCircle'; import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; @@ -337,6 +338,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ { Navigation.goBack(backTo); @@ -49,11 +87,23 @@ function IOURequestStepCategory({ * @param {String} category.searchText */ const updateCategory = (category) => { - if (category.searchText === transaction.category) { - IOU.resetMoneyRequestCategory_temporaryForRefactor(transactionID); - } else { - IOU.setMoneyRequestCategory_temporaryForRefactor(transactionID, category.searchText); + const isSelectedCategory = category.searchText === transactionCategory; + const updatedCategory = isSelectedCategory ? '' : category.searchText; + + // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value + if (isEditingSplitBill) { + IOU.setDraftSplitTransaction(transaction.transactionID, {category: category.searchText}); + navigateBack(); + return; + } + + if (isEditing) { + IOU.updateMoneyRequestCategory(transaction.transactionID, report.reportID, updatedCategory, policy, policyTags, policyCategories); + navigateBack(); + return; } + + IOU.setMoneyRequestCategory(transactionID, updatedCategory); navigateBack(); }; @@ -62,11 +112,12 @@ function IOURequestStepCategory({ headerTitle={translate('common.category')} onBackButtonPress={navigateBack} shouldShowWrapper + shouldShowNotFoundPage={shouldShowNotFoundPage} testID={IOURequestStepCategory.displayName} > {translate('iou.categorySelection')} @@ -78,4 +129,24 @@ IOURequestStepCategory.displayName = 'IOURequestStepCategory'; IOURequestStepCategory.propTypes = propTypes; IOURequestStepCategory.defaultProps = defaultProps; -export default compose(withWritableReportOrNotFound, withFullTransactionOrNotFound)(IOURequestStepCategory); +export default compose( + withWritableReportOrNotFound, + withFullTransactionOrNotFound, + withOnyx({ + splitDraftTransaction: { + key: ({route}) => { + const transactionID = lodashGet(route, 'params.transactionID', 0); + return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`; + }, + }, + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + }, + policyCategories: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, + }, + }), +)(IOURequestStepCategory); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 98bb6851d0de..0744fbd600a7 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -133,7 +133,7 @@ function IOURequestStepConfirmation({ return; } if (policyCategories && policyCategories[transaction.category] && !policyCategories[transaction.category].enabled) { - IOU.resetMoneyRequestCategory_temporaryForRefactor(transactionID); + IOU.setMoneyRequestCategory(transactionID, ''); } }, [policyCategories, transaction.category, transactionID]); const defaultCategory = lodashGet( @@ -145,7 +145,7 @@ function IOURequestStepConfirmation({ if (requestType !== CONST.IOU.REQUEST_TYPE.DISTANCE || !_.isEmpty(transaction.category)) { return; } - IOU.setMoneyRequestCategory_temporaryForRefactor(transactionID, defaultCategory); + IOU.setMoneyRequestCategory(transactionID, defaultCategory); // Prevent resetting to default when unselect category // eslint-disable-next-line react-hooks/exhaustive-deps }, [transactionID, requestType, defaultCategory]); diff --git a/src/pages/iou/request/step/IOURequestStepCurrency.js b/src/pages/iou/request/step/IOURequestStepCurrency.js index 42e059d7d276..43e4e9bf0eaa 100644 --- a/src/pages/iou/request/step/IOURequestStepCurrency.js +++ b/src/pages/iou/request/step/IOURequestStepCurrency.js @@ -5,6 +5,7 @@ import {Keyboard} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; @@ -126,6 +127,7 @@ function IOURequestStepCurrency({ {({didScreenTransitionEnd}) => ( { const nextStepIOUType = numberOfParticipants.current === 1 ? iouType : CONST.IOU.TYPE.SPLIT; IOU.setMoneyRequestTag(transactionID, ''); - IOU.resetMoneyRequestCategory_temporaryForRefactor(transactionID); + IOU.setMoneyRequestCategory(transactionID, ''); Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(nextStepIOUType, transactionID, selectedReportID.current || reportID)); }, [iouType, transactionID, reportID]); diff --git a/src/pages/iou/request/step/StepScreenWrapper.js b/src/pages/iou/request/step/StepScreenWrapper.js index eae542f0f6f9..1d9129861db0 100644 --- a/src/pages/iou/request/step/StepScreenWrapper.js +++ b/src/pages/iou/request/step/StepScreenWrapper.js @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import _ from 'underscore'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -23,6 +24,9 @@ const propTypes = { /** Whether or not the wrapper should be shown (sometimes screens can be embedded inside another screen that already is using a wrapper) */ shouldShowWrapper: PropTypes.bool.isRequired, + /** Whether or not to display not found page */ + shouldShowNotFoundPage: PropTypes.bool, + /** An ID used for unit testing */ testID: PropTypes.string.isRequired, @@ -33,11 +37,16 @@ const propTypes = { const defaultProps = { onEntryTransitionEnd: () => {}, includeSafeAreaPaddingBottom: false, + shouldShowNotFoundPage: false, }; -function StepScreenWrapper({testID, headerTitle, onBackButtonPress, onEntryTransitionEnd, children, shouldShowWrapper, includeSafeAreaPaddingBottom}) { +function StepScreenWrapper({testID, headerTitle, onBackButtonPress, onEntryTransitionEnd, children, shouldShowWrapper, shouldShowNotFoundPage, includeSafeAreaPaddingBottom}) { const styles = useThemeStyles(); + if (shouldShowNotFoundPage) { + return ; + } + if (!shouldShowWrapper) { return children; } diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.tsx similarity index 79% rename from src/pages/iou/steps/MoneyRequestAmountForm.js rename to src/pages/iou/steps/MoneyRequestAmountForm.tsx index 9106ff28589e..cb1f73ae2207 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.js +++ b/src/pages/iou/steps/MoneyRequestAmountForm.tsx @@ -1,12 +1,11 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef, useState} from 'react'; +import type {ForwardedRef} from 'react'; import {ScrollView, View} from 'react-native'; -import _ from 'underscore'; +import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; +import type {ValueOf} from 'type-fest'; import BigNumberPad from '@components/BigNumberPad'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; -import refPropTypes from '@components/refPropTypes'; import TextInputWithCurrencySymbol from '@components/TextInputWithCurrencySymbol'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,78 +14,80 @@ import * as Browser from '@libs/Browser'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getOperatingSystem from '@libs/getOperatingSystem'; +import type {MaybePhraseKey} from '@libs/Localize'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; +import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types'; import CONST from '@src/CONST'; -const propTypes = { +type MoneyRequestAmountFormProps = { /** IOU amount saved in Onyx */ - amount: PropTypes.number, + amount?: number; /** Calculated tax amount based on selected tax rate */ - taxAmount: PropTypes.number, + taxAmount?: number; /** Currency chosen by user or saved in Onyx */ - currency: PropTypes.string, + currency?: string; /** Whether the amount is being edited or not */ - isEditing: PropTypes.bool, - - /** Refs forwarded to the TextInputWithCurrencySymbol */ - forwardedRef: refPropTypes, + isEditing?: boolean; /** Fired when back button pressed, navigates to currency selection page */ - onCurrencyButtonPress: PropTypes.func.isRequired, + onCurrencyButtonPress: () => void; /** Fired when submit button pressed, saves the given amount and navigates to the next page */ - onSubmitButtonPress: PropTypes.func.isRequired, + onSubmitButtonPress: ({amount, currency}: {amount: string; currency: string}) => void; /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ - selectedTab: PropTypes.oneOf([CONST.TAB_REQUEST.DISTANCE, CONST.TAB_REQUEST.MANUAL, CONST.TAB_REQUEST.SCAN]), + selectedTab?: ValueOf; }; -const defaultProps = { - amount: 0, - taxAmount: 0, - currency: CONST.CURRENCY.USD, - forwardedRef: null, - isEditing: false, - selectedTab: CONST.TAB_REQUEST.MANUAL, +type Selection = { + start: number; + end: number; }; /** * Returns the new selection object based on the updated amount's length - * - * @param {Object} oldSelection - * @param {Number} prevLength - * @param {Number} newLength - * @returns {Object} */ -const getNewSelection = (oldSelection, prevLength, newLength) => { +const getNewSelection = (oldSelection: Selection, prevLength: number, newLength: number): Selection => { const cursorPosition = oldSelection.end + (newLength - prevLength); return {start: cursorPosition, end: cursorPosition}; }; -const isAmountInvalid = (amount) => !amount.length || parseFloat(amount) < 0.01; -const isTaxAmountInvalid = (currentAmount, taxAmount, isTaxAmountForm) => isTaxAmountForm && currentAmount > CurrencyUtils.convertToFrontendAmount(taxAmount); +const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; +const isTaxAmountInvalid = (currentAmount: string, taxAmount: number, isTaxAmountForm: boolean) => + isTaxAmountForm && Number.parseFloat(currentAmount) > CurrencyUtils.convertToFrontendAmount(taxAmount); const AMOUNT_VIEW_ID = 'amountView'; const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; const NUM_PAD_VIEW_ID = 'numPadView'; -function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forwardedRef, onCurrencyButtonPress, onSubmitButtonPress, selectedTab}) { +function MoneyRequestAmountForm( + { + amount = 0, + taxAmount = 0, + currency = CONST.CURRENCY.USD, + isEditing = false, + onCurrencyButtonPress, + onSubmitButtonPress, + selectedTab = CONST.TAB_REQUEST.MANUAL, + }: MoneyRequestAmountFormProps, + forwardedRef: ForwardedRef, +) { const styles = useThemeStyles(); const {isExtraSmallScreenHeight} = useWindowDimensions(); const {translate, toLocaleDigit, numberFormat} = useLocalize(); - const textInput = useRef(null); + const textInput = useRef(null); const isTaxAmountForm = Navigation.getActiveRoute().includes('taxAmount'); const decimals = CurrencyUtils.getCurrencyDecimals(currency); const selectedAmountAsString = amount ? CurrencyUtils.convertToFrontendAmount(amount).toString() : ''; const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString); - const [formError, setFormError] = useState(''); + const [formError, setFormError] = useState(''); const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); const [selection, setSelection] = useState({ @@ -100,15 +101,13 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward /** * Event occurs when a user presses a mouse button over an DOM element. - * - * @param {Event} event - * @param {Array} ids */ - const onMouseDown = (event, ids) => { - const relatedTargetId = lodashGet(event, 'nativeEvent.target.id'); - if (!_.contains(ids, relatedTargetId)) { + const onMouseDown = (event: React.MouseEvent, ids: string[]) => { + const relatedTargetId = (event.nativeEvent?.target as HTMLElement)?.id; + if (!ids.includes(relatedTargetId)) { return; } + event.preventDefault(); if (!textInput.current) { return; @@ -118,7 +117,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward } }; - const initializeAmount = useCallback((newAmount) => { + const initializeAmount = useCallback((newAmount: number) => { const frontendAmount = newAmount ? CurrencyUtils.convertToFrontendAmount(newAmount).toString() : ''; setCurrentAmount(frontendAmount); setSelection({ @@ -128,7 +127,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward }, []); useEffect(() => { - if (!currency || !_.isNumber(amount)) { + if (!currency || typeof amount !== 'number') { return; } initializeAmount(amount); @@ -141,7 +140,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward * @param {String} newAmount - Changed amount from user input */ const setNewAmount = useCallback( - (newAmount) => { + (newAmount: string) => { // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value // More info: https://github.com/Expensify/App/issues/16974 const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); @@ -151,7 +150,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward setSelection((prevSelection) => ({...prevSelection})); return; } - if (!_.isEmpty(formError)) { + if (formError) { setFormError(''); } @@ -188,13 +187,11 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward /** * Update amount with number or Backspace pressed for BigNumberPad. * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button - * - * @param {String} key */ const updateAmountNumberPad = useCallback( - (key) => { - if (shouldUpdateSelection && !textInput.current.isFocused()) { - textInput.current.focus(); + (key: string) => { + if (shouldUpdateSelection && !textInput.current?.isFocused()) { + textInput.current?.focus(); } // Backspace button is pressed if (key === '<' || key === 'Backspace') { @@ -214,12 +211,12 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward /** * Update long press value, to remove items pressing on < * - * @param {Boolean} value - Changed text from user input + * @param value - Changed text from user input */ - const updateLongPressHandlerState = useCallback((value) => { + const updateLongPressHandlerState = useCallback((value: boolean) => { setShouldUpdateSelection(!value); - if (!value && !textInput.current.isFocused()) { - textInput.current.focus(); + if (!value && !textInput.current?.isFocused()) { + textInput.current?.focus(); } }, []); @@ -248,8 +245,8 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward /** * Input handler to check for a forward-delete key (or keyboard shortcut) press. */ - const textInputKeyPress = ({nativeEvent}) => { - const key = nativeEvent.key.toLowerCase(); + const textInputKeyPress = ({nativeEvent}: NativeSyntheticEvent) => { + const key = nativeEvent?.key.toLowerCase(); if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. @@ -258,7 +255,8 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward } // Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts. // Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device. - forwardDeletePressedRef.current = key === 'delete' || (_.contains([CONST.OS.MAC_OS, CONST.OS.IOS], getOperatingSystem()) && nativeEvent.ctrlKey && key === 'd'); + const operatingSystem = getOperatingSystem(); + forwardDeletePressedRef.current = key === 'delete' || ((operatingSystem === CONST.OS.MAC_OS || operatingSystem === CONST.OS.IOS) && nativeEvent?.ctrlKey && key === 'd'); }; const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); @@ -284,7 +282,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward ref={(ref) => { if (typeof forwardedRef === 'function') { forwardedRef(ref); - } else if (forwardedRef && _.has(forwardedRef, 'current')) { + } else if (forwardedRef?.current) { // eslint-disable-next-line no-param-reassign forwardedRef.current = ref; } @@ -292,7 +290,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward }} selectedCurrencyCode={currency} selection={selection} - onSelectionChange={(e) => { + onSelectionChange={(e: NativeSyntheticEvent) => { if (!shouldUpdateSelection) { return; } @@ -302,8 +300,9 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward setSelection({start, end}); }} onKeyPress={textInputKeyPress} + isCurrencyPressable /> - {!_.isEmpty(formError) && ( + {!!formError && ( ( - -)); - -MoneyRequestAmountFormWithRef.displayName = 'MoneyRequestAmountFormWithRef'; - -export default MoneyRequestAmountFormWithRef; +export default React.forwardRef(MoneyRequestAmountForm); diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index ea57d88579ae..fc522816b4ce 100644 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -87,7 +87,6 @@ function MoneyRequestParticipantsPage({iou, selectedTab, route, transaction}) { const navigateToConfirmationStep = (moneyRequestType) => { IOU.setMoneyRequestId(moneyRequestType); - IOU.resetMoneyRequestCategory(); Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(moneyRequestType, reportID)); }; diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 7006c2703b13..3fde970327d7 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -11,6 +11,7 @@ import {PressableWithFeedback} from '@components/Pressable'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import SelectCircle from '@components/SelectCircle'; import SelectionList from '@components/SelectionList'; +import UserListItem from '@components/SelectionList/UserListItem'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useSearchTermAndSearch from '@hooks/useSearchTermAndSearch'; @@ -348,6 +349,7 @@ function MoneyRequestParticipantsSelector({ ) : ( <> + + + Navigation.navigate(ROUTES.SETTINGS_SHARE_CODE)} + > + + + + + + + Navigation.navigate(ROUTES.SETTINGS_STATUS)} + > + + {emojiCode ? ( + {emojiCode} + ) : ( + + )} + + + + Navigation.dismissModal()} + onBackButtonPress={() => Navigation.closeFullScreen()} backgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.ROOT].backgroundColor} childrenContainerStyles={[styles.m0, styles.p0]} testID={InitialSettingsPage.displayName} diff --git a/src/pages/settings/Preferences/LanguagePage.js b/src/pages/settings/Preferences/LanguagePage.js index ce93e94222b5..1df1565214c9 100644 --- a/src/pages/settings/Preferences/LanguagePage.js +++ b/src/pages/settings/Preferences/LanguagePage.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import Navigation from '@libs/Navigation/Navigation'; import * as App from '@userActions/App'; @@ -35,6 +36,7 @@ function LanguagePage(props) { /> App.setLocaleAndNavigate(language.value)} initiallyFocusedOptionKey={_.find(localesToLanguages, (locale) => locale.isSelected).keyForList} /> diff --git a/src/pages/settings/Preferences/PriorityModePage.js b/src/pages/settings/Preferences/PriorityModePage.js index 983e3cb26746..05c0546c2e41 100644 --- a/src/pages/settings/Preferences/PriorityModePage.js +++ b/src/pages/settings/Preferences/PriorityModePage.js @@ -5,6 +5,7 @@ import _, {compose} from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -57,6 +58,7 @@ function PriorityModePage(props) { {props.translate('priorityModePage.explainerText')} mode.isSelected).keyForList} /> diff --git a/src/pages/settings/Preferences/ThemePage.js b/src/pages/settings/Preferences/ThemePage.js index 4907056be761..0724eb286620 100644 --- a/src/pages/settings/Preferences/ThemePage.js +++ b/src/pages/settings/Preferences/ThemePage.js @@ -5,6 +5,7 @@ import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -48,6 +49,7 @@ function ThemePage(props) { User.updateTheme(theme.value)} initiallyFocusedOptionKey={_.find(localesToThemes, (theme) => theme.isSelected).keyForList} /> diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js deleted file mode 100644 index a9acf37ae556..000000000000 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ /dev/null @@ -1,393 +0,0 @@ -import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {Component} from 'react'; -import {InteractionManager, Keyboard, ScrollView, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import ConfirmModal from '@components/ConfirmModal'; -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withTheme, {withThemePropTypes} from '@components/withTheme'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import compose from '@libs/compose'; -import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import {translatableTextPropTypes} from '@libs/Localize'; -import Navigation from '@libs/Navigation/Navigation'; -import * as Session from '@userActions/Session'; -import * as User from '@userActions/User'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import ValidateCodeForm from './ValidateCodeForm'; - -const propTypes = { - /* Onyx Props */ - - /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** Value of partner name */ - partnerName: PropTypes.string, - - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - - /** Date when login was validated */ - validatedDate: PropTypes.string, - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), - - /** Field-specific pending states for offline UI status */ - pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), - - /** Current user session */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }), - - /** User's security group IDs by domain */ - myDomainSecurityGroups: PropTypes.objectOf(PropTypes.string), - - /** All of the user's security groups and their settings */ - securityGroups: PropTypes.shape({ - hasRestrictedPrimaryLogin: PropTypes.bool, - }), - - /** Route params */ - route: PropTypes.shape({ - params: PropTypes.shape({ - /** Passed via route /settings/profile/contact-methods/:contactMethod/details */ - contactMethod: PropTypes.string, - }), - }), - - /** Indicated whether the report data is loading */ - isLoadingReportData: PropTypes.bool, - - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, - ...withThemePropTypes, -}; - -const defaultProps = { - loginList: {}, - session: { - email: null, - }, - myDomainSecurityGroups: {}, - securityGroups: {}, - route: { - params: { - contactMethod: '', - }, - }, - isLoadingReportData: true, -}; - -class ContactMethodDetailsPage extends Component { - constructor(props) { - super(props); - - this.deleteContactMethod = this.deleteContactMethod.bind(this); - this.toggleDeleteModal = this.toggleDeleteModal.bind(this); - this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); - this.getContactMethod = this.getContactMethod.bind(this); - this.setAsDefault = this.setAsDefault.bind(this); - - this.state = { - isDeleteModalOpen: false, - }; - - this.validateCodeFormRef = React.createRef(); - } - - componentDidMount() { - const contactMethod = this.getContactMethod(); - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - if (_.isEmpty(loginData)) { - return; - } - User.resetContactMethodValidateCodeSentState(this.getContactMethod()); - } - - componentDidUpdate(prevProps) { - const contactMethod = this.getContactMethod(); - const validatedDate = lodashGet(this.props.loginList, [contactMethod, 'validatedDate']); - const prevValidatedDate = lodashGet(prevProps.loginList, [contactMethod, 'validatedDate']); - - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - // Navigate to methods page on successful magic code verification - // validatedDate property is responsible to decide the status of the magic code verification - if (!prevValidatedDate && validatedDate) { - // If the selected contactMethod is the current session['login'] and the account is unvalidated, - // the current authToken is invalid after the successful magic code verification. - // So we need to sign out the user and redirect to the sign in page. - if (isDefaultContactMethod) { - Session.signOutAndRedirectToSignIn(); - return; - } - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); - } - } - - /** - * Gets the current contact method from the route params - * @returns {string} - */ - getContactMethod() { - const contactMethod = lodashGet(this.props.route, 'params.contactMethod'); - - // We find the number of times the url is encoded based on the last % sign and remove them. - const lastPercentIndex = contactMethod.lastIndexOf('%'); - const encodePercents = contactMethod.substring(lastPercentIndex).match(new RegExp('25', 'g')); - let numberEncodePercents = encodePercents ? encodePercents.length : 0; - const beforeAtSign = contactMethod.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => { - if (numberEncodePercents > 0) { - numberEncodePercents--; - return '%'; - } - return match; - }); - const afterAtSign = contactMethod.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%'); - - return decodeURIComponent(beforeAtSign + afterAtSign); - } - - /** - * Attempt to set this contact method as user's "Default contact method" - */ - setAsDefault() { - User.setContactMethodAsDefault(this.getContactMethod()); - } - - /** - * Checks if the user is allowed to change their default contact method. This should only be allowed if: - * 1. The viewed contact method is not already their default contact method - * 2. The viewed contact method is validated - * 3. If the user is on a private domain, their security group must allow primary login switching - * - * @returns {Boolean} - */ - canChangeDefaultContactMethod() { - const contactMethod = this.getContactMethod(); - const loginData = lodashGet(this.props.loginList, contactMethod, {}); - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - - // Cannot set this contact method as default if: - // 1. This contact method is already their default - // 2. This contact method is not validated - if (isDefaultContactMethod || !loginData.validatedDate) { - return false; - } - - const domainName = Str.extractEmailDomain(this.props.session.email); - const primaryDomainSecurityGroupID = lodashGet(this.props.myDomainSecurityGroups, domainName); - - // If there's no security group associated with the user for the primary domain, - // default to allowing the user to change their default contact method. - if (!primaryDomainSecurityGroupID) { - return true; - } - - // Allow user to change their default contact method if they don't have a security group OR if their security group - // does NOT restrict primary login switching. - return !lodashGet(this.props.securityGroups, [`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${primaryDomainSecurityGroupID}`, 'hasRestrictedPrimaryLogin'], false); - } - - /** - * Deletes the contact method if it has errors. Otherwise, it shows the confirmation alert and deletes it only if the user confirms. - */ - deleteContactMethod() { - if (!_.isEmpty(lodashGet(this.props.loginList, [this.getContactMethod(), 'errorFields'], {}))) { - User.deleteContactMethod(this.getContactMethod(), this.props.loginList); - return; - } - this.toggleDeleteModal(true); - } - - /** - * Toggle delete confirm modal visibility - * @param {Boolean} isOpen - */ - toggleDeleteModal(isOpen) { - if (canUseTouchScreen() && isOpen) { - InteractionManager.runAfterInteractions(() => { - this.setState({isDeleteModalOpen: isOpen}); - }); - Keyboard.dismiss(); - } else { - this.setState({isDeleteModalOpen: isOpen}); - } - } - - /** - * Delete the contact method and hide the modal - */ - confirmDeleteAndHideModal() { - this.toggleDeleteModal(false); - User.deleteContactMethod(this.getContactMethod(), this.props.loginList); - } - - render() { - const contactMethod = this.getContactMethod(); - - // Replacing spaces with "hard spaces" to prevent breaking the number - const formattedContactMethod = Str.isSMSLogin(contactMethod) ? this.props.formatPhoneNumber(contactMethod).replace(/ /g, '\u00A0') : contactMethod; - - if (this.props.isLoadingReportData && _.isEmpty(this.props.loginList)) { - return ; - } - - const loginData = this.props.loginList[contactMethod]; - if (!contactMethod || !loginData) { - return ( - - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - /> - - ); - } - - const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; - const hasMagicCodeBeenSent = lodashGet(this.props.loginList, [contactMethod, 'validateCodeSent'], false); - const isFailedAddContactMethod = Boolean(lodashGet(loginData, 'errorFields.addedLogin')); - const isFailedRemovedContactMethod = Boolean(lodashGet(loginData, 'errorFields.deletedLogin')); - - return ( - this.validateCodeFormRef.current && this.validateCodeFormRef.current.focus()} - testID={ContactMethodDetailsPage.displayName} - > - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} - /> - - this.toggleDeleteModal(false)} - onModalHide={() => { - InteractionManager.runAfterInteractions(() => { - if (!this.validateCodeFormRef.current) { - return; - } - this.validateCodeFormRef.current.focusLastSelected(); - }); - }} - prompt={this.props.translate('contacts.removeAreYouSure')} - confirmText={this.props.translate('common.yesContinue')} - cancelText={this.props.translate('common.cancel')} - isVisible={this.state.isDeleteModalOpen && !isDefaultContactMethod} - danger - /> - - {isFailedAddContactMethod && ( - - )} - - {!loginData.validatedDate && !isFailedAddContactMethod && ( - - - - - - )} - {this.canChangeDefaultContactMethod() ? ( - User.clearContactMethodErrors(contactMethod, 'defaultLogin')} - > - - - ) : null} - {isDefaultContactMethod ? ( - User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} - > - {this.props.translate('contacts.yourDefaultContactMethod')} - - ) : ( - User.clearContactMethodErrors(contactMethod, 'deletedLogin')} - > - this.toggleDeleteModal(true)} - /> - - )} - - - ); - } -} - -ContactMethodDetailsPage.propTypes = propTypes; -ContactMethodDetailsPage.defaultProps = defaultProps; -ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage'; - -export default compose( - withLocalize, - withOnyx({ - loginList: { - key: ONYXKEYS.LOGIN_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - myDomainSecurityGroups: { - key: ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, - }, - securityGroups: { - key: `${ONYXKEYS.COLLECTION.SECURITY_GROUP}`, - }, - isLoadingReportData: { - key: `${ONYXKEYS.IS_LOADING_REPORT_DATA}`, - }, - }), - withThemeStyles, - withTheme, -)(ContactMethodDetailsPage); diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx new file mode 100644 index 000000000000..7de22da728dd --- /dev/null +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -0,0 +1,305 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import Str from 'expensify-common/lib/str'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {InteractionManager, Keyboard, ScrollView, View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import ConfirmModal from '@components/ConfirmModal'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import usePrevious from '@hooks/usePrevious'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as Session from '@userActions/Session'; +import * as User from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {LoginList, SecurityGroup, Session as TSession} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import ValidateCodeForm from './ValidateCodeForm'; +import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; + +type ContactMethodDetailsPageOnyxProps = { + /** Login list for the user that is signed in */ + loginList: OnyxEntry; + + /** Current user session */ + session: OnyxEntry; + + /** User's security group IDs by domain */ + myDomainSecurityGroups: OnyxEntry>; + + /** All of the user's security groups and their settings */ + securityGroups: OnyxCollection; + + /** Indicated whether the report data is loading */ + isLoadingReportData: OnyxEntry; +}; + +type ContactMethodDetailsPageProps = ContactMethodDetailsPageOnyxProps & StackScreenProps; + +function ContactMethodDetailsPage({loginList, session, myDomainSecurityGroups, securityGroups, isLoadingReportData = true, route}: ContactMethodDetailsPageProps) { + const {formatPhoneNumber, translate} = useLocalize(); + const theme = useTheme(); + const themeStyles = useThemeStyles(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const validateCodeFormRef = useRef(null); + + /** + * Gets the current contact method from the route params + */ + const contactMethod: string = useMemo(() => { + const contactMethodParam = route.params.contactMethod; + + // We find the number of times the url is encoded based on the last % sign and remove them. + const lastPercentIndex = contactMethodParam.lastIndexOf('%'); + const encodePercents = contactMethodParam.substring(lastPercentIndex).match(new RegExp('25', 'g')); + let numberEncodePercents = encodePercents?.length ?? 0; + const beforeAtSign = contactMethodParam.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => { + if (numberEncodePercents > 0) { + numberEncodePercents--; + return '%'; + } + return match; + }); + const afterAtSign = contactMethodParam.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%'); + + return decodeURIComponent(beforeAtSign + afterAtSign); + }, [route.params.contactMethod]); + const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]); + const isDefaultContactMethod = useMemo(() => session?.email === loginData?.partnerUserID, [session?.email, loginData?.partnerUserID]); + + /** + * Attempt to set this contact method as user's "Default contact method" + */ + const setAsDefault = useCallback(() => { + User.setContactMethodAsDefault(contactMethod); + }, [contactMethod]); + + /** + * Checks if the user is allowed to change their default contact method. This should only be allowed if: + * 1. The viewed contact method is not already their default contact method + * 2. The viewed contact method is validated + * 3. If the user is on a private domain, their security group must allow primary login switching + */ + const canChangeDefaultContactMethod = useMemo(() => { + // Cannot set this contact method as default if: + // 1. This contact method is already their default + // 2. This contact method is not validated + if (isDefaultContactMethod || !loginData?.validatedDate) { + return false; + } + + const domainName = Str.extractEmailDomain(session?.email ?? ''); + const primaryDomainSecurityGroupID = myDomainSecurityGroups?.[domainName]; + + // If there's no security group associated with the user for the primary domain, + // default to allowing the user to change their default contact method. + if (!primaryDomainSecurityGroupID) { + return true; + } + + // Allow user to change their default contact method if they don't have a security group OR if their security group + // does NOT restrict primary login switching. + return !securityGroups?.[`${ONYXKEYS.COLLECTION.SECURITY_GROUP}${primaryDomainSecurityGroupID}`]?.hasRestrictedPrimaryLogin; + }, [isDefaultContactMethod, loginData?.validatedDate, session?.email, myDomainSecurityGroups, securityGroups]); + + /** + * Toggle delete confirm modal visibility + */ + const toggleDeleteModal = useCallback((isOpen: boolean) => { + if (canUseTouchScreen() && isOpen) { + InteractionManager.runAfterInteractions(() => { + setIsDeleteModalOpen(isOpen); + }); + Keyboard.dismiss(); + } else { + setIsDeleteModalOpen(isOpen); + } + }, []); + + /** + * Delete the contact method and hide the modal + */ + const confirmDeleteAndHideModal = useCallback(() => { + toggleDeleteModal(false); + User.deleteContactMethod(contactMethod, loginList ?? {}); + }, [contactMethod, loginList, toggleDeleteModal]); + + useEffect(() => { + if (isEmptyObject(loginData)) { + return; + } + User.resetContactMethodValidateCodeSentState(contactMethod); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const prevValidatedDate = usePrevious(loginData?.validatedDate); + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if (prevValidatedDate || !loginData?.validatedDate) { + return; + } + + // If the selected contactMethod is the current session['login'] and the account is unvalidated, + // the current authToken is invalid after the successful magic code verification. + // So we need to sign out the user and redirect to the sign in page. + if (isDefaultContactMethod) { + Session.signOutAndRedirectToSignIn(); + return; + } + // Navigate to methods page on successful magic code verification + // validatedDate property is responsible to decide the status of the magic code verification + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); + }, [prevValidatedDate, loginData?.validatedDate, isDefaultContactMethod]); + + if (isLoadingReportData && isEmptyObject(loginList)) { + return ; + } + + if (!contactMethod || !loginData) { + return ( + + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + onLinkPress={() => Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + /> + + ); + } + + // Replacing spaces with "hard spaces" to prevent breaking the number + const formattedContactMethod = Str.isSMSLogin(contactMethod) ? formatPhoneNumber(contactMethod).replace(/ /g, '\u00A0') : contactMethod; + const hasMagicCodeBeenSent = !!loginData.validateCodeSent; + const isFailedAddContactMethod = !!loginData.errorFields?.addedLogin; + const isFailedRemovedContactMethod = !!loginData.errorFields?.deletedLogin; + + return ( + validateCodeFormRef.current?.focus?.()} + testID={ContactMethodDetailsPage.displayName} + > + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route)} + /> + + toggleDeleteModal(false)} + onModalHide={() => { + InteractionManager.runAfterInteractions(() => { + validateCodeFormRef.current?.focusLastSelected?.(); + }); + }} + prompt={translate('contacts.removeAreYouSure')} + confirmText={translate('common.yesContinue')} + cancelText={translate('common.cancel')} + isVisible={isDeleteModalOpen && !isDefaultContactMethod} + danger + /> + + {isFailedAddContactMethod && ( + + )} + + {!loginData.validatedDate && !isFailedAddContactMethod && ( + + + + + + )} + {canChangeDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, 'defaultLogin')} + > + + + ) : null} + {isDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} + > + {translate('contacts.yourDefaultContactMethod')} + + ) : ( + User.clearContactMethodErrors(contactMethod, 'deletedLogin')} + > + toggleDeleteModal(true)} + /> + + )} + + + ); +} + +ContactMethodDetailsPage.displayName = 'ContactMethodDetailsPage'; + +export default withOnyx({ + loginList: { + key: ONYXKEYS.LOGIN_LIST, + }, + session: { + key: ONYXKEYS.SESSION, + }, + myDomainSecurityGroups: { + key: ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS, + }, + securityGroups: { + key: `${ONYXKEYS.COLLECTION.SECURITY_GROUP}`, + }, + isLoadingReportData: { + key: `${ONYXKEYS.IS_LOADING_REPORT_DATA}`, + }, +})(ContactMethodDetailsPage); diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx similarity index 53% rename from src/pages/settings/Profile/Contacts/ContactMethodsPage.js rename to src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index c85d123ad3fd..5d150e782c44 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -1,10 +1,9 @@ +import type {StackScreenProps} from '@react-navigation/stack'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; import {ScrollView, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import Button from '@components/Button'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import FixedFooter from '@components/FixedFooter'; @@ -13,86 +12,64 @@ import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {LoginList, Session} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; -const propTypes = { - /* Onyx Props */ - +type ContactMethodsPageOnyxProps = { /** Login list for the user that is signed in */ - loginList: PropTypes.shape({ - /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */ - partnerName: PropTypes.string, - - /** Phone/Email associated with user */ - partnerUserID: PropTypes.string, - - /** The date when the login was validated, used to show the brickroad status */ - validatedDate: PropTypes.string, - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), - - /** Field-specific pending states for offline UI status */ - pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), + loginList: OnyxEntry; /** Current user session */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, - }), - - ...withLocalizePropTypes, + session: OnyxEntry; }; -const defaultProps = { - loginList: {}, - session: { - email: null, - }, -}; +type ContactMethodsPageProps = ContactMethodsPageOnyxProps & StackScreenProps; -function ContactMethodsPage(props) { +function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps) { const styles = useThemeStyles(); - const loginNames = _.keys(props.loginList); - const navigateBackTo = lodashGet(props.route, 'params.backTo', ''); + const {formatPhoneNumber, translate} = useLocalize(); + const loginNames = Object.keys(loginList ?? {}); + const navigateBackTo = route?.params?.backTo || ROUTES.SETTINGS_PROFILE; // Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods. // The default contact method is determined by checking against the session email (the current login). - const sortedLoginNames = _.sortBy(loginNames, (loginName) => (props.loginList[loginName].partnerUserID === props.session.email ? 0 : 1)); + const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1)); - const loginMenuItems = _.map(sortedLoginNames, (loginName) => { - const login = props.loginList[loginName]; - const pendingAction = lodashGet(login, 'pendingFields.deletedLogin') || lodashGet(login, 'pendingFields.addedLogin'); - if (!login.partnerUserID && _.isEmpty(pendingAction)) { + const loginMenuItems = sortedLoginNames.map((loginName) => { + const login = loginList?.[loginName]; + const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined; + if (!login?.partnerUserID && !pendingAction) { return null; } let description = ''; - if (props.session.email === login.partnerUserID) { - description = props.translate('contacts.getInTouch'); - } else if (lodashGet(login, 'errorFields.addedLogin')) { - description = props.translate('contacts.failedNewContact'); - } else if (!login.validatedDate) { - description = props.translate('contacts.pleaseVerify'); + if (session?.email === login?.partnerUserID) { + description = translate('contacts.getInTouch'); + } else if (login?.errorFields?.addedLogin) { + description = translate('contacts.failedNewContact'); + } else if (!login?.validatedDate) { + description = translate('contacts.pleaseVerify'); } - let indicator = null; - if (_.some(lodashGet(login, 'errorFields', {}), (errorField) => !_.isEmpty(errorField))) { + let indicator; + if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } else if (!login.validatedDate) { + } else if (!login?.validatedDate) { indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; } // Default to using login key if we deleted login.partnerUserID optimistically // but still need to show the pending login being deleted while offline. - const partnerUserID = login.partnerUserID || loginName; - const menuItemTitle = Str.isSMSLogin(partnerUserID) ? props.formatPhoneNumber(partnerUserID) : partnerUserID; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const partnerUserID = login?.partnerUserID || loginName; + const menuItemTitle = Str.isSMSLogin(partnerUserID) ? formatPhoneNumber(partnerUserID) : partnerUserID; return ( ); @@ -126,25 +103,25 @@ function ContactMethodsPage(props) { testID={ContactMethodsPage.displayName} > Navigation.goBack(navigateBackTo)} /> - {props.translate('contacts.helpTextBeforeEmail')} + {translate('contacts.helpTextBeforeEmail')} - {props.translate('contacts.helpTextAfterEmail')} + {translate('contacts.helpTextAfterEmail')} {loginMenuItems}