diff --git a/.eslintignore b/.eslintignore index 26ecb1ae7cc7..aa10a3073f4e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -14,3 +14,4 @@ web/gtm.js src/libs/SearchParser/searchParser.js src/libs/SearchParser/autocompleteParser.js help/_scripts/** +modules/** diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml index 42a5f15f8910..c8027f93a2a5 100644 --- a/.github/workflows/testBuildHybrid.yml +++ b/.github/workflows/testBuildHybrid.yml @@ -1,4 +1,4 @@ -name: Build and deploy hybird apps for testing +name: Build and deploy hybrid apps for testing on: workflow_dispatch: diff --git a/Mobile-Expensify b/Mobile-Expensify index 561b7bcc5a68..9854d5bfa2e3 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 561b7bcc5a68e84d611fa87257e7cdafaed02e33 +Subproject commit 9854d5bfa2e31c702066e161256e0a9655051892 diff --git a/README.md b/README.md index e8a00927753c..fcc5e2b934e9 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ At this point, the default behavior of some `npm` scripts will change to target - `npm run pod-install` - install pods for HybridApp - `npm run clean` - clean native code of HybridApp -If for some reason, you need to target the standalone NewDot application, you can append `*-standalone` to each of these scripts (eg. `npm run ios-standalone` will build NewDot instead of HybridApp). +If for some reason, you need to target the standalone NewDot application, you can append `*-standalone` to each of these scripts (eg. `npm run ios-standalone` will build NewDot instead of HybridApp). The same concept applies to the installation of standalone NewDot node modules. To skip the installation of HybridApp-specific patches and node modules, use `npm run i-standalone` or `npm run install-standalone`. ## Working with HybridApp Day-to-day work with HybridApp shouldn't differ much from the work on the standalone NewDot repo. diff --git a/android/app/build.gradle b/android/app/build.gradle index 1f5d09b62f00..5ebefd8304c7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009007610 - versionName "9.0.76-10" + versionCode 1009007702 + versionName "9.0.77-2" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 6d6406551cdd..bef985265d7f 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -10,6 +10,7 @@ # Add any project specific keep options here: -keep class com.expensify.chat.BuildConfig { *; } -keep class com.facebook.** { *; } +-keep class com.margelo.nitro.** { *; } -keep, allowoptimization, allowobfuscation class expo.modules.** { *; } # Keep generic signature of Call, Response (R8 full mode strips signatures from non-kept items). diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 142d919a7a18..a859703ae719 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/assets/images/companyCards/card-bofa.svg b/assets/images/companyCards/card-bofa.svg index 3cc7cf1de2cc..c58229f1b242 100644 --- a/assets/images/companyCards/card-bofa.svg +++ b/assets/images/companyCards/card-bofa.svg @@ -1 +1,27 @@ - \ No newline at end of file + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/card-capitalone.svg b/assets/images/companyCards/card-capitalone.svg index a7c54c7bf529..9f1402298683 100644 --- a/assets/images/companyCards/card-capitalone.svg +++ b/assets/images/companyCards/card-capitalone.svg @@ -1 +1,23 @@ - \ No newline at end of file + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-bofa-large.svg b/assets/images/companyCards/large/card-bofa-large.svg index a842bc93d80b..c83e06ffb65d 100644 --- a/assets/images/companyCards/large/card-bofa-large.svg +++ b/assets/images/companyCards/large/card-bofa-large.svg @@ -1,6 +1,6 @@ - + - - - - - - - + + + + + + + \ No newline at end of file diff --git a/assets/images/companyCards/large/card-capital_one-large.svg b/assets/images/companyCards/large/card-capital_one-large.svg index b71e209a4c11..20f3bd442d9e 100644 --- a/assets/images/companyCards/large/card-capital_one-large.svg +++ b/assets/images/companyCards/large/card-capital_one-large.svg @@ -1,15 +1,15 @@ - + - - + + \ No newline at end of file diff --git a/assets/images/train.svg b/assets/images/train.svg new file mode 100644 index 000000000000..40d8c9d1af8a --- /dev/null +++ b/assets/images/train.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/articles/expensify-classic/connections/Expensify-API.md b/docs/articles/expensify-classic/connections/Expensify-API.md index 52e86a3c9f14..fba85dcd154a 100644 --- a/docs/articles/expensify-classic/connections/Expensify-API.md +++ b/docs/articles/expensify-classic/connections/Expensify-API.md @@ -17,7 +17,11 @@ If you’re seeking help with Expensify's API, do not share your partnerUserSecr **Is there a rate limit?** -Yes, the rate limit is currently 50 requests per minute. If you exceed this limit, you'll receive an error message. +To keep our platform stable and handle high traffic, Expensify limits how many API requests you can send: +- Up to 5 requests every 10 seconds +- Up to 20 requests every 60 seconds + +Sending more requests than allowed may result in an error with status code `429`. **What is a Policy ID?** diff --git a/docs/articles/expensify-classic/expenses/Apply-Tax.md b/docs/articles/expensify-classic/expenses/Apply-Tax.md deleted file mode 100644 index 9360962cb2ba..000000000000 --- a/docs/articles/expensify-classic/expenses/Apply-Tax.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: Apply Tax -description: This is article shows you how to apply taxes to your expenses! ---- - - - -# About - -There are two types of tax in Expensify: Simple Tax (i.e. one tax rate) and Complex Tax (i.e. more than one tax rate). This article shows you how to apply both to your expenses! - - -# How-to Apply Tax - -When Tax Tracking is enabled on a Workspace, the default tax rate is selected under **Settings > Workspace > _Workspace Name_ > Tax**, with the default tax rate applied to all expenses automatically. - -There may be multiple tax rates set up within your Workspace, so if the tax on your receipt is different to the default tax that has been applied, you can select the appropriate rate from the tax drop-down on the web expense editor or the mobile app. - -If the tax amount on your receipt is different to the calculated amount or the tax rate doesn’t show up, you can always manually type in the correct tax amount. - - -{% include faq-begin.md %} - -## How do I set up multiple taxes (GST/PST/QST) on indirect connections? -Expenses sometimes have more than one tax applied to them - for example in Canada, expenses can have both a Federal GST and a provincial PST or QST. - -To handle these, you can create a single tax that combines both taxes into a single effective tax rate. For example, if you have a GST of 5% and PST of 7%, adding the two tax rates together gives you an effective tax rate of 12%. - -From the Reports page, you can select Reports and then click **Export To > Tax Report** to generate a CSV containing all the expense information, including the split-out taxes. - -## Why is the tax amount different than I expect? - -In Expensify, tax is *inclusive*, meaning it's already part of the total amount shown. - -To determine the inclusive tax from a total price that already includes tax, you can use the following formula: - -### **Tax amount = (Total price x Tax rate) ÷ (1 + Tax Rate)** - -For example, if an item costs $100 and the tax rate is 20%: -Tax amount = (**$100** x .20) ÷ (1 + .**20**) = **$16.67** -This means the tax amount $16.67 is included in the total. - -If you are simply trying to calculate the price before tax, you can use the formula: - -### **Price before tax = (Total price) ÷ (1 + Tax rate)** - -# Deep Dive - -If you have a receipt that has more than one tax rate (i.e. Complex Tax) on it, then there are two options for handling this in Expensify! - -Many tax authorities do not require the reporting of tax amounts by rate and the easiest approach is to apply the highest rate on the receipt and then modify the tax amount to reflect the amount shown on the receipt if this is less. Please check with your local tax advisor if this approach will be allowed. - -Alternatively, you can apply each specific tax rate by splitting the expense into the components that each rate will be applied to. To do this, click on **Split Expense** and apply the correct tax rate to each part. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/workspaces/Tax-Tracking.md b/docs/articles/expensify-classic/workspaces/Tax-Tracking.md deleted file mode 100644 index c47e5ed51f32..000000000000 --- a/docs/articles/expensify-classic/workspaces/Tax-Tracking.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Tax -description: How to track expense taxes ---- -# Overview -Expensify’s tax tracking feature allows you to: -- Add tax names, rates, and codes whether you’re connected to an accounting system or not. -- Enable/disable taxes you’d like to make available to users. -- Set a default tax for Workspace currency expenses and, optionally, another default tax (including exempt) for foreign currency expenses which - will automatically apply to all new expenses. - -# How to Enable Tax Tracking -Tax tracking can be enabled in the Tax section of the Workspace settings of any Workspace, whether group or individual. -## If Connected to an Accounting Integration -If your group Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, make sure to first enable tax via the connection configuration page (Settings > Workspaces > Group > [Workspace Name] > Connections > Configure) and then sync the connection. Your tax rates will be imported from the accounting system and indicated by its logo. -## Not Connected to an Accounting Integration -If your Workspace is not connected to an accounting system, go to Settings > Workspaces > Group > [Workspace Name] > Tax to enable tax. - -# Tracking Tax by Expense Category -To set a different tax rate for a specific expense type in the Workspace currency, go to Settings > Workspaces > Group > [Workspace Name] > Categories page. Click "Edit Rules" next to the desired category and set the "Category default tax". This will be applied to new expenses, overriding the default Workspace currency tax rate. diff --git a/docs/articles/expensify-classic/workspaces/Track-Taxes.md b/docs/articles/expensify-classic/workspaces/Track-Taxes.md new file mode 100644 index 000000000000..c75058dc8447 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Track-Taxes.md @@ -0,0 +1,76 @@ +--- +title: Track Taxes +description: How to track taxes and apply them to expenses +--- +Expensify's tax tracking allows you to create tax rates and codes for domestic and foreign currencies, and even for different expense categories. Once you've enabled tax tracking, your default tax rate is automatically applied to all expenses. + +# Tax Tracking - Connected to an accounting integration + +If your Workspace is connected to Xero, QuickBooks Online, Sage Intacct, or NetSuite, you can run through the following steps to set up tax tracking: +1. Hover over **Settings**, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Connections** tab on the left. +4. Click **Configure**. +5. Click **Sync Connection**. + +Your tax rates will be imported from the accounting system and indicated by its logo. + +# Tax Tracking - Not connected to an accounting integration + +If your Workspace is not connected to an accounting system, you can run through the following steps to set up tax tracking: +1. Hover over **Settings**, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Tax** tab on the left. +4. Enable the toggle to allow taxes to be added to expenses. +5. You can modify the existing tax rate, or you can click New Option to add a new tax rate. For each tax rate, you can enable/disable them individually, add a specific name for the rate, add a percent value, and (if desired) add a unique tax code. +6. Once you have your tax codes added, go to the top of the screen to enter the name that taxes will appear as on expenses. You'll also select which of your tax rates you will use as your defaults for expenses submitted under your workspace currency and foreign currency. + +## Track tax by expense category + +You can also set tax rates for specific expense categories: +1. Hover over **Settings**, then click **Workspaces**. +2. Click the desired workspace name. +3. Click the **Categories** tab on the left. +4. Click **Edit** next to the desired category. +5. Click the Default Tax dropdown and select the desired tax rate. + +This rate will be applied to all new expenses under this category, overriding the workspace's default currency tax rate. + +{% include faq-begin.md %} + +## How do I set up multiple taxes (GST/PST/QST) for indirect connections? + +Expenses sometimes have more than one tax applied to them (for example in Canada, expenses can have both a Federal GST and a provincial PST or QST). + +To handle multiple tax rates, you can create a new tax rate that combines both into a single rate. For example, if you have a GST of 5% and PST of 7%, you can add them together and create a new tax rate of 12%. + +From the Reports page, you can generate a CSV containing all the expense information, including the split-out taxes, by going to the Reports tab, clicking **Export To**, and selecting **Tax Report**. + +## How do I handle the taxes for a receipt that includes more than one tax rate? + +If your receipt includes more than one tax rate, there are two ways you can handle the tax rate: + +- Many tax authorities do not require the reporting of tax amounts by rate; therefore, you can apply the highest rate on the receipt and then modify the tax amount on the receipt if necessary. Please check with your tax advisor to determine if this approach is appropriate for you. +- Alternatively, you can apply each specific tax rate by splitting the expense by the applicable expenses for each rate. To do this, open the expense and click **Split Expense**. Then apply the correct tax rate to each. + +## What if my workspace has multiple tax rates? + +You'll have the option to change the tax rate from within the expense as needed. + +## What should I do if the tax amount for my expense does not show up, or is it showing as a different amount than what I expected? + +In Expensify, tax is *inclusive*, meaning it's already part of the total amount shown. If the tax amount doesn't show up on your receipt or is different than the calculated amount, you can manually type in the correct tax amount. + +To determine the inclusive tax from a total price that already includes tax, you can use the following formula: + +**Tax amount = (Total price x Tax rate) ÷ (1 + Tax Rate)** + +For example, if an item costs $100 and the tax rate is 20%: +Tax amount = (**$100** x .20) ÷ (1 + .**20**) = **$16.67** +This means the tax amount of $16.67 is included in the total. + +If you are simply trying to calculate the price before tax, you can use the formula: + +**Price before tax = (Total price) ÷ (1 + Tax rate)** + +{% include faq-end.md %} diff --git a/docs/assets/images/search-hold-01.png b/docs/assets/images/search-hold-01.png new file mode 100644 index 000000000000..04745c570367 Binary files /dev/null and b/docs/assets/images/search-hold-01.png differ diff --git a/docs/assets/images/search-hold-02.png b/docs/assets/images/search-hold-02.png new file mode 100644 index 000000000000..3c7c39defd66 Binary files /dev/null and b/docs/assets/images/search-hold-02.png differ diff --git a/docs/assets/images/search-hold-03.png b/docs/assets/images/search-hold-03.png new file mode 100644 index 000000000000..81fbddcf5d75 Binary files /dev/null and b/docs/assets/images/search-hold-03.png differ diff --git a/docs/assets/images/search-hold-04.png b/docs/assets/images/search-hold-04.png new file mode 100644 index 000000000000..e5c1b71c0e37 Binary files /dev/null and b/docs/assets/images/search-hold-04.png differ diff --git a/docs/assets/images/search-hold-05.png b/docs/assets/images/search-hold-05.png new file mode 100644 index 000000000000..2d111abecb65 Binary files /dev/null and b/docs/assets/images/search-hold-05.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index 751e072fb13f..04eba2e6152c 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -385,7 +385,7 @@ https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Vac https://community.expensify.com/discussion/5678/deep-dive-secondary-login-merge-accounts-what-does-this-mean,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://community.expensify.com/discussion/5103/how-to-create-and-use-custom-units/,https://help.expensify.com/ https://community.expensify.com/discussion/6530/how-to-set-your-time-zone-for-report-history-comments,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-time-zone -https://community.expensify.com/discussion/5651/deep-dive--practices-when-youre-running-into-trouble-receiving-emails-from-expensify,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications +https://community.expensify.com/discussion/5651/deep-dive-best-practices-when-youre-running-into-trouble-receiving-emails-from-expensify,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Set-Notifications https://community.expensify.com/discussion/5793/how-to-connect-your-personal-card-to-import-expenses/,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/Personal-Credit-Cards https://community.expensify.com/discussion/5677/deep-dive-security-how-expensify-protects-your-information,https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security https://community.expensify.com/discussion/4641/how-to-add-a-u-s-deposit-account,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account @@ -608,3 +608,5 @@ https://help.expensify.com/articles/expensify-classic/travel/Edit-or-cancel-trav https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Create-and-Pay-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills https://help.expensify.com/articles/expensify-classic/settings/Set-Notifications,https://help.expensify.com/articles/expensify-classic/settings/Email-Notifications https://help.expensify.com/articles/new-expensify/expenses-&-payments/Export-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Export-download-expenses +https://help.expensify.com/articles/expensify-classic/expenses/Apply-Tax,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes +https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes \ No newline at end of file diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4be40aa649ac..fe4c07fdac9e 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.76 + 9.0.77 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.76.10 + 9.0.77.2 FullStory OrgId @@ -67,6 +67,8 @@ NSCameraUsageDescription Your camera is used to create chat attachments, documents, and facial capture. + NSContactsUsageDescription + Import contacts from your phone so your favorite people are always a tap away. NSLocationAlwaysAndWhenInUseUsageDescription Your location is used to determine your default currency and timezone. NSLocationWhenInUseUsageDescription diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index e34521b9561a..6a92f70d678f 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.76 + 9.0.77 CFBundleSignature ???? CFBundleVersion - 9.0.76.10 + 9.0.77.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 76fb08c2fdb9..7ae2ed9457e8 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.76 + 9.0.77 CFBundleVersion - 9.0.76.10 + 9.0.77.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile b/ios/Podfile index 41dc5179752d..bdad8a0ec396 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -24,6 +24,7 @@ prepare_react_native_project! setup_permissions([ 'Camera', + 'Contacts', 'LocationAccuracy', 'LocationAlways', 'LocationWhenInUse' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d4fe34fe3699..0389642465da 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -28,6 +28,28 @@ PODS: - AppAuth/Core - AppLogs (0.1.0) - boost (1.84.0) + - ContactsModule (0.0.1): + - DoubleConversion + - glog + - hermes-engine + - NitroModules + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - DoubleConversion (1.1.6) - EXAV (14.0.7): - ExpoModulesCore @@ -287,6 +309,29 @@ PODS: - nanopb/encode (= 2.30908.0) - nanopb/decode (2.30908.0) - nanopb/encode (2.30908.0) + - NitroModules (0.18.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - Onfido (29.7.2) - onfido-react-native-sdk (10.6.0): - DoubleConversion @@ -2410,7 +2455,7 @@ PODS: - RNGoogleSignin (10.0.1): - GoogleSignIn (~> 7.0) - React-Core - - RNLiveMarkdown (0.1.207): + - RNLiveMarkdown (0.1.209): - DoubleConversion - glog - hermes-engine @@ -2430,10 +2475,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - - RNLiveMarkdown/newarch (= 0.1.207) + - RNLiveMarkdown/newarch (= 0.1.209) - RNReanimated/worklets - Yoga - - RNLiveMarkdown/newarch (0.1.207): + - RNLiveMarkdown/newarch (0.1.209): - DoubleConversion - glog - hermes-engine @@ -2750,6 +2795,7 @@ DEPENDENCIES: - AirshipServiceExtension - AppLogs (from `../node_modules/react-native-app-logs/AppLogsPod`) - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) + - ContactsModule (from `../modules/ContactsNitroModule`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - EXAV (from `../node_modules/expo-av/ios`) - EXImageLoader (from `../node_modules/expo-image-loader/ios`) @@ -2765,6 +2811,7 @@ DEPENDENCIES: - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - lottie-react-native (from `../node_modules/lottie-react-native`) + - NitroModules (from `../node_modules/react-native-nitro-modules`) - "onfido-react-native-sdk (from `../node_modules/@onfido/react-native-sdk`)" - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) @@ -2915,6 +2962,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-app-logs/AppLogsPod" boost: :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" + ContactsModule: + :path: "../modules/ContactsNitroModule" DoubleConversion: :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" EXAV: @@ -2946,6 +2995,8 @@ EXTERNAL SOURCES: :tag: hermes-2024-08-15-RNv0.75.1-4b3bf912cc0f705b51b71ce1a5b8bd79b93a451b lottie-react-native: :path: "../node_modules/lottie-react-native" + NitroModules: + :path: "../node_modules/react-native-nitro-modules" onfido-react-native-sdk: :path: "../node_modules/@onfido/react-native-sdk" RCT-Folly: @@ -3160,6 +3211,7 @@ SPEC CHECKSUMS: AppAuth: 501c04eda8a8d11f179dbe8637b7a91bb7e5d2fa AppLogs: 3bc4e9b141dbf265b9464409caaa40416a9ee0e0 boost: 26992d1adf73c1c7676360643e687aee6dda994b + ContactsModule: 21671b28654413dc28795d1afc3b12eaffa28ed1 DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 EXAV: afa491e598334bbbb92a92a2f4dd33d7149ad37f EXImageLoader: ab589d67d6c5f2c33572afea9917304418566334 @@ -3199,6 +3251,7 @@ SPEC CHECKSUMS: MapboxMaps: e76b14f52c54c40b76ddecd04f40448e6f35a864 MapboxMobileEvents: de50b3a4de180dd129c326e09cd12c8adaaa46d6 nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 + NitroModules: ebe2ba2d01dc03c1f82441561fe6062b8c3c4366 Onfido: f3af62ea1c9a419589c133e3e511e5d2c4f3f8af onfido-react-native-sdk: 4ccfdeb10f9ccb4a5799d2555cdbc2a068a42c0d Plaid: c32f22ffce5ec67c9e6147eaf6c4d7d5f8086d89 @@ -3292,10 +3345,10 @@ SPEC CHECKSUMS: RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 8781e2529230a1bc3ea8d75e5c3cd071b6c6aed7 RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0 - RNLiveMarkdown: 8f9d9b32a25969ddb5f59eb92136b73823bbd141 + RNLiveMarkdown: f19d3c962fba4fb87bb9bc27ce9119216d86d92e RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 rnmapbox-maps: 460d6ff97ae49c7d5708c3212c6521697c36a0c4 - RNPermissions: 0b1429b55af59d1d08b75a8be2459f65a8ac3f28 + RNPermissions: 9e5c26aaa982fe00743281f6f47fbdc050ebc58f RNReactNativeHapticFeedback: 73756a3477a5a622fa16862a3ab0d0fc5e5edff5 RNReanimated: d95f865e1e42c34ca56b987e0719a8c72fc02dbc RNScreens: de6e57426ba0e6cbc3fb5b4f496e7f08cb2773c2 @@ -3311,6 +3364,6 @@ SPEC CHECKSUMS: VisionCamera: c95a8ad535f527562be1fb05fb2fd324578e769c Yoga: a1d7895431387402a674fd0d1c04ec85e87909b8 -PODFILE CHECKSUM: 615266329434ea4a994dccf622008a2197313c88 +PODFILE CHECKSUM: e744fa802b4bee097ff8d1977dd8f79d16b21547 COCOAPODS: 1.15.2 diff --git a/modules/ContactsNitroModule/.gitignore b/modules/ContactsNitroModule/.gitignore new file mode 100644 index 000000000000..d3b53dfce541 --- /dev/null +++ b/modules/ContactsNitroModule/.gitignore @@ -0,0 +1,78 @@ +# OSX +# +.DS_Store + +# XDE +.expo/ + +# VSCode +.vscode/ +jsconfig.json + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IJ +# +.classpath +.cxx +.gradle +.idea +.project +.settings +local.properties +android.iml + +# Cocoapods +# +example/ios/Pods + +# Ruby +example/vendor/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-debug.log +yarn-error.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Expo +.expo/ + +# Turborepo +.turbo/ + +# generated by bob +lib/ diff --git a/modules/ContactsNitroModule/.watchmanconfig b/modules/ContactsNitroModule/.watchmanconfig new file mode 100644 index 000000000000..0967ef424bce --- /dev/null +++ b/modules/ContactsNitroModule/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/modules/ContactsNitroModule/ContactsModule.podspec b/modules/ContactsNitroModule/ContactsModule.podspec new file mode 100644 index 000000000000..5f0b012c7b52 --- /dev/null +++ b/modules/ContactsNitroModule/ContactsModule.podspec @@ -0,0 +1,29 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "ContactsModule" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/mrousavy/nitro.git", :tag => "#{s.version}" } + + s.source_files = [ + # Implementation (Swift) + "ios/**/*.{swift}", + # Autolinking/Registration (Objective-C++) + "ios/**/*.{m,mm}", + # Implementation (C++ objects) + "cpp/**/*.{hpp,cpp}", + ] + + load 'nitrogen/generated/ios/ContactsModule+autolinking.rb' + add_nitrogen_files(s) + + install_modules_dependencies(s) +end diff --git a/modules/ContactsNitroModule/android/CMakeLists.txt b/modules/ContactsNitroModule/android/CMakeLists.txt new file mode 100644 index 000000000000..beb0c308df07 --- /dev/null +++ b/modules/ContactsNitroModule/android/CMakeLists.txt @@ -0,0 +1,29 @@ +project(ContactsModule) +cmake_minimum_required(VERSION 3.9.0) + +set (PACKAGE_NAME ContactsModule) +set (CMAKE_VERBOSE_MAKEFILE ON) +set (CMAKE_CXX_STANDARD 20) + +# Define C++ library and add all sources +add_library(${PACKAGE_NAME} SHARED + src/main/cpp/cpp-adapter.cpp +) + +# Add Nitrogen specs :) +include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/ContactsModule+autolinking.cmake) + +# Set up local includes +include_directories( + "src/main/cpp" + "../cpp" +) + +find_library(LOG_LIB log) + +# Link all libraries together +target_link_libraries( + ${PACKAGE_NAME} + ${LOG_LIB} + android # <-- Android core +) diff --git a/modules/ContactsNitroModule/android/build.gradle b/modules/ContactsNitroModule/android/build.gradle new file mode 100644 index 000000000000..0b414c88dea3 --- /dev/null +++ b/modules/ContactsNitroModule/android/build.gradle @@ -0,0 +1,130 @@ +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:7.2.1" + } +} + +def reactNativeArchitectures() { + def value = rootProject.getProperties().get("reactNativeArchitectures") + return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] +} + +def isNewArchitectureEnabled() { + return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true" +} + +apply plugin: "com.android.library" +apply plugin: 'org.jetbrains.kotlin.android' +apply from: '../nitrogen/generated/android/ContactsModule+autolinking.gradle' + +if (isNewArchitectureEnabled()) { + apply plugin: "com.facebook.react" +} + +def getExtOrDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["ContactsModule_" + name] +} + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["ContactsModule_" + name]).toInteger() +} + +android { + namespace "com.margelo.nitro.contacts" + + ndkVersion getExtOrDefault("ndkVersion") + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString() + + externalNativeBuild { + cmake { + cppFlags "-O2 -frtti -fexceptions -Wall -fstack-protector-all" + arguments "-DANDROID_STL=c++_shared" + abiFilters (*reactNativeArchitectures()) + } + } + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**", + "**/libc++_shared.so", + "**/libfbjni.so", + "**/libjsi.so", + "**/libfolly_json.so", + "**/libfolly_runtime.so", + "**/libglog.so", + "**/libhermes.so", + "**/libhermes-executor-debug.so", + "**/libhermes_executor.so", + "**/libreactnativejni.so", + "**/libturbomodulejsijni.so", + "**/libreact_nativemodule_core.so", + "**/libjscexecutor.so" + ] + } + + buildFeatures { + buildConfig true + prefab true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + if (isNewArchitectureEnabled()) { + java.srcDirs += [ + // React Codegen files + "${project.buildDir}/generated/source/codegen/java" + ] + } + } + } +} + +repositories { + mavenCentral() + google() +} + + +dependencies { + // For < 0.71, this will be from the local maven repo + // For > 0.71, this will be replaced by `com.facebook.react:react-android:$version` by react gradle plugin + //noinspection GradleDynamicVersion + implementation "com.facebook.react:react-native:+" + + // Add a dependency on NitroModules + implementation project(":react-native-nitro-modules") +} + diff --git a/modules/ContactsNitroModule/android/gradle.properties b/modules/ContactsNitroModule/android/gradle.properties new file mode 100644 index 000000000000..59d3858d1bb9 --- /dev/null +++ b/modules/ContactsNitroModule/android/gradle.properties @@ -0,0 +1,5 @@ +ContactsModule_kotlinVersion=1.9.24 +ContactsModule_minSdkVersion=23 +ContactsModule_targetSdkVersion=34 +ContactsModule_compileSdkVersion=34 +ContactsModule_ndkVersion=26.1.10909125 diff --git a/modules/ContactsNitroModule/android/src/main/AndroidManifest.xml b/modules/ContactsNitroModule/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..a2f47b6057db --- /dev/null +++ b/modules/ContactsNitroModule/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/ContactsNitroModule/android/src/main/cpp/cpp-adapter.cpp b/modules/ContactsNitroModule/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 000000000000..7a88410f3e4d --- /dev/null +++ b/modules/ContactsNitroModule/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,6 @@ +#include +#include "ContactsModuleOnLoad.hpp" + +JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + return margelo::nitro::contacts::initialize(vm); +} diff --git a/modules/ContactsNitroModule/android/src/main/java/com/margelo/nitro/contacts/ContactsModulePackage.java b/modules/ContactsNitroModule/android/src/main/java/com/margelo/nitro/contacts/ContactsModulePackage.java new file mode 100644 index 000000000000..e8c26844ce86 --- /dev/null +++ b/modules/ContactsNitroModule/android/src/main/java/com/margelo/nitro/contacts/ContactsModulePackage.java @@ -0,0 +1,34 @@ +package com.margelo.nitro.contacts; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.module.model.ReactModuleInfoProvider; +import com.facebook.react.TurboReactPackage; +import com.margelo.nitro.core.HybridObject; +import com.margelo.nitro.core.HybridObjectRegistry; + +import java.util.HashMap; +import java.util.function.Supplier; + +public class ContactsModulePackage extends TurboReactPackage { + @Nullable + @Override + public NativeModule getModule(String name, ReactApplicationContext reactContext) { + return null; + } + + @Override + public ReactModuleInfoProvider getReactModuleInfoProvider() { + return () -> { + return new HashMap<>(); + }; + } + + static { + System.loadLibrary("ContactsModule"); + } +} diff --git a/modules/ContactsNitroModule/android/src/main/java/com/margelo/nitro/contacts/HybridContactsModule.kt b/modules/ContactsNitroModule/android/src/main/java/com/margelo/nitro/contacts/HybridContactsModule.kt new file mode 100644 index 000000000000..00feaa7660c2 --- /dev/null +++ b/modules/ContactsNitroModule/android/src/main/java/com/margelo/nitro/contacts/HybridContactsModule.kt @@ -0,0 +1,166 @@ +package com.margelo.nitro.contacts + +import android.Manifest +import android.content.pm.PackageManager +import android.provider.ContactsContract +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.facebook.react.bridge.ReactApplicationContext +import com.margelo.nitro.NitroModules +import com.margelo.nitro.core.Promise + +class HybridContactsModule : HybridContactsModuleSpec() { + @Volatile + private var estimatedMemorySize: Long = 0 + + override val memorySize: Long + get() = estimatedMemorySize + + private val context: ReactApplicationContext? = NitroModules.applicationContext + + private fun requestContactPermission(): Boolean { + val currentActivity = context?.currentActivity + return if (currentActivity != null) { + ActivityCompat.requestPermissions( + currentActivity, arrayOf(REQUIRED_PERMISSION), PERMISSION_REQUEST_CODE + ) + true + } else { + false + } + } + + private fun hasPhoneContactsPermission(): Boolean { + return context?.let { + ContextCompat.checkSelfPermission(it, Manifest.permission.READ_CONTACTS) + } == PackageManager.PERMISSION_GRANTED + } + + override fun getAll(keys: Array): Promise> { + return Promise.parallel { + val contacts = mutableListOf() + if (!hasPhoneContactsPermission()) { + requestContactPermission() + return@parallel emptyArray() + } + + context?.contentResolver?.let { resolver -> + val projection = arrayOf( + ContactsContract.Data.MIMETYPE, + ContactsContract.Data.CONTACT_ID, + ContactsContract.Data.DISPLAY_NAME, + ContactsContract.Contacts.PHOTO_URI, + ContactsContract.Contacts.PHOTO_THUMBNAIL_URI, + ContactsContract.Data.DATA1, + ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME, + ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME, + ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME + ) + + val selection = "${ContactsContract.Data.MIMETYPE} IN (?, ?, ?)" + val selectionArgs = arrayOf( + ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE, + ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE, + ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE + ) + + val sortOrder = "${ContactsContract.Data.CONTACT_ID} ASC" + + resolver.query( + ContactsContract.Data.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder + )?.use { cursor -> + val mimeTypeIndex = cursor.getColumnIndex(ContactsContract.Data.MIMETYPE) + val contactIdIndex = cursor.getColumnIndex(ContactsContract.Data.CONTACT_ID) + val photoUriIndex = cursor.getColumnIndex(ContactsContract.Contacts.PHOTO_URI) + val thumbnailUriIndex = + cursor.getColumnIndex(ContactsContract.Contacts.PHOTO_THUMBNAIL_URI) + val data1Index = cursor.getColumnIndex(ContactsContract.Data.DATA1) + val givenNameIndex = + cursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.GIVEN_NAME) + val familyNameIndex = + cursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.FAMILY_NAME) + val middleNameIndex = + cursor.getColumnIndex(ContactsContract.CommonDataKinds.StructuredName.MIDDLE_NAME) + + var currentContact: Contact? = null + var currentContactId: String? = null + val currentPhoneNumbers = mutableListOf() + val currentEmailAddresses = mutableListOf() + + while (cursor.moveToNext()) { + val contactId = cursor.getString(contactIdIndex) + val mimeType = cursor.getString(mimeTypeIndex) + + if (contactId != currentContactId) { + currentContact?.let { contact -> + contacts.add( + contact.copy( + phoneNumbers = currentPhoneNumbers.toTypedArray(), + emailAddresses = currentEmailAddresses.toTypedArray() + ) + ) + } + currentPhoneNumbers.clear() + currentEmailAddresses.clear() + currentContact = Contact( + firstName = "", + lastName = "", + middleName = null, + phoneNumbers = emptyArray(), + emailAddresses = emptyArray(), + imageData = cursor.getString(photoUriIndex) ?: "", + thumbnailImageData = cursor.getString(thumbnailUriIndex) ?: "" + ) + currentContactId = contactId + } + + when (mimeType) { + ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE -> { + currentContact = currentContact?.copy( + firstName = cursor.getString(givenNameIndex) ?: "", + lastName = cursor.getString(familyNameIndex) ?: "", + middleName = cursor.getString(middleNameIndex) + ) + } + + ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE -> { + cursor.getString(data1Index)?.let { phone -> + currentPhoneNumbers.add(StringHolder(phone)) + } + } + + ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE -> { + cursor.getString(data1Index)?.let { email -> + currentEmailAddresses.add(StringHolder(email)) + } + } + } + } + + // Add the last contact + currentContact?.let { contact -> + contacts.add( + contact.copy( + phoneNumbers = currentPhoneNumbers.toTypedArray(), + emailAddresses = currentEmailAddresses.toTypedArray() + ) + ) + } + } + } + + // Update memory size based on contact count + estimatedMemorySize = contacts.size.toLong() * 1024 // Assume ~1KB per contact + contacts.toTypedArray() + } + } + + companion object { + const val PERMISSION_REQUEST_CODE = 1 + const val REQUIRED_PERMISSION = Manifest.permission.READ_CONTACTS + } +} diff --git a/modules/ContactsNitroModule/babel.config.js b/modules/ContactsNitroModule/babel.config.js new file mode 100644 index 000000000000..3e0218e68fc3 --- /dev/null +++ b/modules/ContactsNitroModule/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['module:@react-native/babel-preset'], +} diff --git a/modules/ContactsNitroModule/ios/HybridContactsModule.swift b/modules/ContactsNitroModule/ios/HybridContactsModule.swift new file mode 100644 index 000000000000..59cc3ea31702 --- /dev/null +++ b/modules/ContactsNitroModule/ios/HybridContactsModule.swift @@ -0,0 +1,72 @@ +import NitroModules +import Contacts +import Foundation + +final class HybridContactsModule: HybridContactsModuleSpec { + public var hybridContext = margelo.nitro.HybridContext() + public var memorySize: Int { MemoryLayout.size } + + private let contactStore = CNContactStore() + private let imageDirectory: URL + private let fieldToKeyDescriptor: [ContactFields: CNKeyDescriptor] = [ + .firstName: CNContactGivenNameKey as CNKeyDescriptor, + .lastName: CNContactFamilyNameKey as CNKeyDescriptor, + .phoneNumbers: CNContactPhoneNumbersKey as CNKeyDescriptor, + .emailAddresses: CNContactEmailAddressesKey as CNKeyDescriptor, + .middleName: CNContactMiddleNameKey as CNKeyDescriptor, + .imageData: CNContactImageDataKey as CNKeyDescriptor, + .thumbnailImageData: CNContactThumbnailImageDataKey as CNKeyDescriptor, + .givenNameKey: CNContactGivenNameKey as CNKeyDescriptor + ] + + init() { + imageDirectory = FileManager.default.temporaryDirectory.appendingPathComponent("ContactImages") + try? FileManager.default.createDirectory(at: imageDirectory, withIntermediateDirectories: true) + } + + func getAll(keys: [ContactFields]) throws -> Promise<[Contact]> { + Promise.async { [unowned self] in + let keysSet = Set(keys) + let keysToFetch = keys.compactMap { self.fieldToKeyDescriptor[$0] } + guard !keysToFetch.isEmpty else { return [] } + + let request = CNContactFetchRequest(keysToFetch: keysToFetch) + var contacts = [Contact]() + contacts.reserveCapacity(1000) + + try self.contactStore.enumerateContacts(with: request) { contact, _ in + contacts.append(self.processContact(contact, keysSet: keysSet)) + } + + return contacts + } + } + + @inline(__always) + private func processContact(_ contact: CNContact, keysSet: Set) -> Contact { + Contact( + firstName: keysSet.contains(.firstName) ? contact.givenName : nil, + lastName: keysSet.contains(.lastName) ? contact.familyName : nil, + middleName: keysSet.contains(.middleName) ? contact.middleName : nil, + phoneNumbers: keysSet.contains(.phoneNumbers) ? contact.phoneNumbers.map { StringHolder(value: $0.value.stringValue) } : nil, + emailAddresses: keysSet.contains(.emailAddresses) ? contact.emailAddresses.map { StringHolder(value: $0.value as String) } : nil, + imageData: keysSet.contains(.imageData) ? getImagePath(for: contact, isThumbnail: false) : nil, + thumbnailImageData: keysSet.contains(.thumbnailImageData) ? getImagePath(for: contact, isThumbnail: true) : nil + ) + } + + @inline(__always) + private func getImagePath(for contact: CNContact, isThumbnail: Bool) -> String? { + let imageData = isThumbnail ? contact.thumbnailImageData : contact.imageData + guard let data = imageData else { return nil } + + let fileName = "\(contact.identifier)_\(isThumbnail ? "thumb" : "full").jpg" + let fileURL = imageDirectory.appendingPathComponent(fileName) + + if !FileManager.default.fileExists(atPath: fileURL.path) { + try? data.write(to: fileURL, options: .atomic) + } + + return fileURL.path + } +} diff --git a/modules/ContactsNitroModule/nitro.json b/modules/ContactsNitroModule/nitro.json new file mode 100644 index 000000000000..426f8486118a --- /dev/null +++ b/modules/ContactsNitroModule/nitro.json @@ -0,0 +1,17 @@ +{ + "cxxNamespace": ["contacts"], + "ios": { + "iosModuleName": "ContactsModule" + }, + "android": { + "androidNamespace": ["contacts"], + "androidCxxLibName": "ContactsModule" + }, + "autolinking": { + "ContactsModule": { + "swift": "HybridContactsModule", + "kotlin": "HybridContactsModule" + } + }, + "ignorePaths": ["node_modules"] +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModule+autolinking.cmake b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModule+autolinking.cmake new file mode 100644 index 000000000000..5478bc224b05 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModule+autolinking.cmake @@ -0,0 +1,59 @@ +# +# ContactsModule+autolinking.cmake +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © 2024 Marc Rousavy @ Margelo +# + +# This is a CMake file that adds all files generated by Nitrogen +# to the current CMake project. +# +# To use it, add this to your CMakeLists.txt: +# ```cmake +# include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/ContactsModule+autolinking.cmake) +# ``` + +# Add all headers that were generated by Nitrogen +include_directories( + "../nitrogen/generated/shared/c++" + "../nitrogen/generated/android/c++" + "../nitrogen/generated/android/" +) + +# Add all .cpp sources that were generated by Nitrogen +target_sources( + # CMake project name (Android C++ library name) + ContactsModule PRIVATE + # Autolinking Setup + ../nitrogen/generated/android/ContactsModuleOnLoad.cpp + # Shared Nitrogen C++ sources + ../nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp + # Android-specific Nitrogen C++ sources + ../nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp +) + +# Add all libraries required by the generated specs +find_package(fbjni REQUIRED) # <-- Used for communication between Java <-> C++ +find_package(ReactAndroid REQUIRED) # <-- Used to set up React Native bindings (e.g. CallInvoker/TurboModule) +find_package(react-native-nitro-modules REQUIRED) # <-- Used to create all HybridObjects and use the Nitro core library + +# Link all libraries together +target_link_libraries( + ContactsModule + fbjni::fbjni # <-- Facebook C++ JNI helpers + ReactAndroid::jsi # <-- RN: JSI + react-native-nitro-modules::NitroModules # <-- NitroModules Core :) +) + +# Link react-native (different prefab between RN 0.75 and RN 0.76) +if(ReactAndroid_VERSION_MINOR GREATER_EQUAL 76) + target_link_libraries( + ContactsModule + ReactAndroid::reactnative # <-- RN: Native Modules umbrella prefab + ) +else() + target_link_libraries( + ContactsModule + ReactAndroid::react_nativemodule_core # <-- RN: TurboModules Core + ) +endif() diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModule+autolinking.gradle b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModule+autolinking.gradle new file mode 100644 index 000000000000..2d19cd2ced32 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModule+autolinking.gradle @@ -0,0 +1,27 @@ +/// +/// ContactsModule+autolinking.gradle +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +/// This is a Gradle file that adds all files generated by Nitrogen +/// to the current Gradle project. +/// +/// To use it, add this to your build.gradle: +/// ```gradle +/// apply from: '../nitrogen/generated/android/ContactsModule+autolinking.gradle' +/// ``` + +logger.warn("[NitroModules] 🔥 ContactsModule is boosted by nitro!") + +android { + sourceSets { + main { + java.srcDirs += [ + // Nitrogen files + "${project.projectDir}/../nitrogen/generated/android/kotlin" + ] + } + } +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.cpp b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.cpp new file mode 100644 index 000000000000..156ea811e509 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.cpp @@ -0,0 +1,42 @@ +/// +/// ContactsModuleOnLoad.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#include "ContactsModuleOnLoad.hpp" + +#include +#include +#include + +#include "JHybridContactsModuleSpec.hpp" +#include +#include + +namespace margelo::nitro::contacts { + +int initialize(JavaVM* vm) { + using namespace margelo::nitro; + using namespace margelo::nitro::contacts; + using namespace facebook; + + return facebook::jni::initialize(vm, [] { + // Register native JNI methods + margelo::nitro::contacts::JHybridContactsModuleSpec::registerNatives(); + + // Register Nitro Hybrid Objects + HybridObjectRegistry::registerHybridObjectConstructor( + "ContactsModule", + []() -> std::shared_ptr { + static DefaultConstructableObject object("com/margelo/nitro/contacts/HybridContactsModule"); + auto instance = object.create(); + auto globalRef = jni::make_global(instance); + return JNISharedPtr::make_shared_from_jni(globalRef); + } + ); + }); +} + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.hpp b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.hpp new file mode 100644 index 000000000000..b71adaca07bf --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.hpp @@ -0,0 +1,25 @@ +/// +/// ContactsModuleOnLoad.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#include +#include + +namespace margelo::nitro::contacts { + + /** + * Initializes the native (C++) part of ContactsModule, and autolinks all Hybrid Objects. + * Call this in your `JNI_OnLoad` function (probably inside `cpp-adapter.cpp`). + * Example: + * ```cpp (cpp-adapter.cpp) + * JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { + * return margelo::nitro::contacts::initialize(vm); + * } + * ``` + */ + int initialize(JavaVM* vm); + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.kt b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.kt new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/ContactsModuleOnLoad.kt @@ -0,0 +1 @@ + diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/c++/JContact.hpp b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JContact.hpp new file mode 100644 index 000000000000..bbd5354163a2 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JContact.hpp @@ -0,0 +1,114 @@ +/// +/// JContact.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include "Contact.hpp" + +#include "JStringHolder.hpp" +#include "StringHolder.hpp" +#include +#include +#include + +namespace margelo::nitro::contacts { + + using namespace facebook; + + /** + * The C++ JNI bridge between the C++ struct "Contact" and the the Kotlin data class "Contact". + */ + struct JContact final: public jni::JavaClass { + public: + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/contacts/Contact;"; + + public: + /** + * Convert this Java/Kotlin-based struct to the C++ struct Contact by copying all values to C++. + */ + [[maybe_unused]] + Contact toCpp() const { + static const auto clazz = javaClassStatic(); + static const auto fieldFirstName = clazz->getField("firstName"); + jni::local_ref firstName = this->getFieldValue(fieldFirstName); + static const auto fieldLastName = clazz->getField("lastName"); + jni::local_ref lastName = this->getFieldValue(fieldLastName); + static const auto fieldMiddleName = clazz->getField("middleName"); + jni::local_ref middleName = this->getFieldValue(fieldMiddleName); + static const auto fieldPhoneNumbers = clazz->getField>("phoneNumbers"); + jni::local_ref> phoneNumbers = this->getFieldValue(fieldPhoneNumbers); + static const auto fieldEmailAddresses = clazz->getField>("emailAddresses"); + jni::local_ref> emailAddresses = this->getFieldValue(fieldEmailAddresses); + static const auto fieldImageData = clazz->getField("imageData"); + jni::local_ref imageData = this->getFieldValue(fieldImageData); + static const auto fieldThumbnailImageData = clazz->getField("thumbnailImageData"); + jni::local_ref thumbnailImageData = this->getFieldValue(fieldThumbnailImageData); + return Contact( + firstName != nullptr ? std::make_optional(firstName->toStdString()) : std::nullopt, + lastName != nullptr ? std::make_optional(lastName->toStdString()) : std::nullopt, + middleName != nullptr ? std::make_optional(middleName->toStdString()) : std::nullopt, + phoneNumbers != nullptr ? std::make_optional([&]() { + size_t __size = phoneNumbers->size(); + std::vector __vector; + __vector.reserve(__size); + for (size_t __i = 0; __i < __size; __i++) { + auto __element = phoneNumbers->getElement(__i); + __vector.push_back(__element->toCpp()); + } + return __vector; + }()) : std::nullopt, + emailAddresses != nullptr ? std::make_optional([&]() { + size_t __size = emailAddresses->size(); + std::vector __vector; + __vector.reserve(__size); + for (size_t __i = 0; __i < __size; __i++) { + auto __element = emailAddresses->getElement(__i); + __vector.push_back(__element->toCpp()); + } + return __vector; + }()) : std::nullopt, + imageData != nullptr ? std::make_optional(imageData->toStdString()) : std::nullopt, + thumbnailImageData != nullptr ? std::make_optional(thumbnailImageData->toStdString()) : std::nullopt + ); + } + + public: + /** + * Create a Java/Kotlin-based struct by copying all values from the given C++ struct to Java. + */ + [[maybe_unused]] + static jni::local_ref fromCpp(const Contact& value) { + return newInstance( + value.firstName.has_value() ? jni::make_jstring(value.firstName.value()) : nullptr, + value.lastName.has_value() ? jni::make_jstring(value.lastName.value()) : nullptr, + value.middleName.has_value() ? jni::make_jstring(value.middleName.value()) : nullptr, + value.phoneNumbers.has_value() ? [&]() { + size_t __size = value.phoneNumbers.value().size(); + jni::local_ref> __array = jni::JArrayClass::newArray(__size); + for (size_t __i = 0; __i < __size; __i++) { + const auto& __element = value.phoneNumbers.value()[__i]; + __array->setElement(__i, *JStringHolder::fromCpp(__element)); + } + return __array; + }() : nullptr, + value.emailAddresses.has_value() ? [&]() { + size_t __size = value.emailAddresses.value().size(); + jni::local_ref> __array = jni::JArrayClass::newArray(__size); + for (size_t __i = 0; __i < __size; __i++) { + const auto& __element = value.emailAddresses.value()[__i]; + __array->setElement(__i, *JStringHolder::fromCpp(__element)); + } + return __array; + }() : nullptr, + value.imageData.has_value() ? jni::make_jstring(value.imageData.value()) : nullptr, + value.thumbnailImageData.has_value() ? jni::make_jstring(value.thumbnailImageData.value()) : nullptr + ); + } + }; + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/c++/JContactFields.hpp b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JContactFields.hpp new file mode 100644 index 000000000000..371b6607d105 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JContactFields.hpp @@ -0,0 +1,76 @@ +/// +/// JContactFields.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include "ContactFields.hpp" + +namespace margelo::nitro::contacts { + + using namespace facebook; + + /** + * The C++ JNI bridge between the C++ enum "ContactFields" and the the Kotlin enum "ContactFields". + */ + struct JContactFields final: public jni::JavaClass { + public: + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/contacts/ContactFields;"; + + public: + /** + * Convert this Java/Kotlin-based enum to the C++ enum ContactFields. + */ + [[maybe_unused]] + ContactFields toCpp() const { + static const auto clazz = javaClassStatic(); + static const auto fieldOrdinal = clazz->getField("_ordinal"); + int ordinal = this->getFieldValue(fieldOrdinal); + return static_cast(ordinal); + } + + public: + /** + * Create a Java/Kotlin-based enum with the given C++ enum's value. + */ + [[maybe_unused]] + static jni::alias_ref fromCpp(ContactFields value) { + static const auto clazz = javaClassStatic(); + static const auto fieldFIRST_NAME = clazz->getStaticField("FIRST_NAME"); + static const auto fieldLAST_NAME = clazz->getStaticField("LAST_NAME"); + static const auto fieldMIDDLE_NAME = clazz->getStaticField("MIDDLE_NAME"); + static const auto fieldPHONE_NUMBERS = clazz->getStaticField("PHONE_NUMBERS"); + static const auto fieldEMAIL_ADDRESSES = clazz->getStaticField("EMAIL_ADDRESSES"); + static const auto fieldIMAGE_DATA = clazz->getStaticField("IMAGE_DATA"); + static const auto fieldTHUMBNAIL_IMAGE_DATA = clazz->getStaticField("THUMBNAIL_IMAGE_DATA"); + static const auto fieldGIVEN_NAME_KEY = clazz->getStaticField("GIVEN_NAME_KEY"); + + switch (value) { + case ContactFields::FIRST_NAME: + return clazz->getStaticFieldValue(fieldFIRST_NAME); + case ContactFields::LAST_NAME: + return clazz->getStaticFieldValue(fieldLAST_NAME); + case ContactFields::MIDDLE_NAME: + return clazz->getStaticFieldValue(fieldMIDDLE_NAME); + case ContactFields::PHONE_NUMBERS: + return clazz->getStaticFieldValue(fieldPHONE_NUMBERS); + case ContactFields::EMAIL_ADDRESSES: + return clazz->getStaticFieldValue(fieldEMAIL_ADDRESSES); + case ContactFields::IMAGE_DATA: + return clazz->getStaticFieldValue(fieldIMAGE_DATA); + case ContactFields::THUMBNAIL_IMAGE_DATA: + return clazz->getStaticFieldValue(fieldTHUMBNAIL_IMAGE_DATA); + case ContactFields::GIVEN_NAME_KEY: + return clazz->getStaticFieldValue(fieldGIVEN_NAME_KEY); + default: + std::string stringValue = std::to_string(static_cast(value)); + throw std::invalid_argument("Invalid enum value (" + stringValue + "!"); + } + } + }; + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp new file mode 100644 index 000000000000..e0505ee46d36 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JHybridContactsModuleSpec.cpp @@ -0,0 +1,84 @@ +/// +/// JHybridContactsModuleSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#include "JHybridContactsModuleSpec.hpp" + +// Forward declaration of `Contact` to properly resolve imports. +namespace margelo::nitro::contacts { struct Contact; } +// Forward declaration of `StringHolder` to properly resolve imports. +namespace margelo::nitro::contacts { struct StringHolder; } +// Forward declaration of `ContactFields` to properly resolve imports. +namespace margelo::nitro::contacts { enum class ContactFields; } + +#include +#include +#include "Contact.hpp" +#include +#include "JContact.hpp" +#include +#include +#include "StringHolder.hpp" +#include "JStringHolder.hpp" +#include "ContactFields.hpp" +#include "JContactFields.hpp" + +namespace margelo::nitro::contacts { + + jni::local_ref JHybridContactsModuleSpec::initHybrid(jni::alias_ref jThis) { + return makeCxxInstance(jThis); + } + + void JHybridContactsModuleSpec::registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", JHybridContactsModuleSpec::initHybrid), + }); + } + + size_t JHybridContactsModuleSpec::getExternalMemorySize() noexcept { + static const auto method = _javaPart->getClass()->getMethod("getMemorySize"); + return method(_javaPart); + } + + // Properties + + + // Methods + std::shared_ptr>> JHybridContactsModuleSpec::getAll(const std::vector& keys) { + static const auto method = _javaPart->getClass()->getMethod(jni::alias_ref> /* keys */)>("getAll"); + auto __result = method(_javaPart, [&]() { + size_t __size = keys.size(); + jni::local_ref> __array = jni::JArrayClass::newArray(__size); + for (size_t __i = 0; __i < __size; __i++) { + const auto& __element = keys[__i]; + __array->setElement(__i, *JContactFields::fromCpp(__element)); + } + return __array; + }()); + return [&]() { + auto __promise = Promise>::create(); + __result->cthis()->addOnResolvedListener([=](const jni::alias_ref& __boxedResult) { + auto __result = jni::static_ref_cast>(__boxedResult); + __promise->resolve([&]() { + size_t __size = __result->size(); + std::vector __vector; + __vector.reserve(__size); + for (size_t __i = 0; __i < __size; __i++) { + auto __element = __result->getElement(__i); + __vector.push_back(__element->toCpp()); + } + return __vector; + }()); + }); + __result->cthis()->addOnRejectedListener([=](const jni::alias_ref& __throwable) { + jni::JniException __jniError(__throwable); + __promise->reject(std::make_exception_ptr(__jniError)); + }); + return __promise; + }(); + } + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp new file mode 100644 index 000000000000..6b94d3be37e7 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JHybridContactsModuleSpec.hpp @@ -0,0 +1,62 @@ +/// +/// HybridContactsModuleSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include +#include "HybridContactsModuleSpec.hpp" + + + + +namespace margelo::nitro::contacts { + + using namespace facebook; + + class JHybridContactsModuleSpec: public jni::HybridClass, + public virtual HybridContactsModuleSpec { + public: + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/contacts/HybridContactsModuleSpec;"; + static jni::local_ref initHybrid(jni::alias_ref jThis); + static void registerNatives(); + + protected: + // C++ constructor (called from Java via `initHybrid()`) + explicit JHybridContactsModuleSpec(jni::alias_ref jThis) : + HybridObject(HybridContactsModuleSpec::TAG), + _javaPart(jni::make_global(jThis)) {} + + public: + virtual ~JHybridContactsModuleSpec() { + // Hermes GC can destroy JS objects on a non-JNI Thread. + jni::ThreadScope::WithClassLoader([&] { _javaPart.reset(); }); + } + + public: + size_t getExternalMemorySize() noexcept override; + + public: + inline const jni::global_ref& getJavaPart() const noexcept { + return _javaPart; + } + + public: + // Properties + + + public: + // Methods + std::shared_ptr>> getAll(const std::vector& keys) override; + + private: + friend HybridBase; + using HybridBase::HybridBase; + jni::global_ref _javaPart; + }; + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/c++/JStringHolder.hpp b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JStringHolder.hpp new file mode 100644 index 000000000000..29695fe48d58 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/c++/JStringHolder.hpp @@ -0,0 +1,52 @@ +/// +/// JStringHolder.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#include +#include "StringHolder.hpp" + +#include + +namespace margelo::nitro::contacts { + + using namespace facebook; + + /** + * The C++ JNI bridge between the C++ struct "StringHolder" and the the Kotlin data class "StringHolder". + */ + struct JStringHolder final: public jni::JavaClass { + public: + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/contacts/StringHolder;"; + + public: + /** + * Convert this Java/Kotlin-based struct to the C++ struct StringHolder by copying all values to C++. + */ + [[maybe_unused]] + StringHolder toCpp() const { + static const auto clazz = javaClassStatic(); + static const auto fieldValue = clazz->getField("value"); + jni::local_ref value = this->getFieldValue(fieldValue); + return StringHolder( + value->toStdString() + ); + } + + public: + /** + * Create a Java/Kotlin-based struct by copying all values from the given C++ struct to Java. + */ + [[maybe_unused]] + static jni::local_ref fromCpp(const StringHolder& value) { + return newInstance( + jni::make_jstring(value.value) + ); + } + }; + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/Contact.kt b/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/Contact.kt new file mode 100644 index 000000000000..a6d9e59a2b2b --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/Contact.kt @@ -0,0 +1,27 @@ +/// +/// Contact.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.contacts + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.core.* + +/** + * Represents the JavaScript object/struct "Contact". + */ +@DoNotStrip +@Keep +data class Contact( + val firstName: String?, + val lastName: String?, + val middleName: String?, + val phoneNumbers: Array?, + val emailAddresses: Array?, + val imageData: String?, + val thumbnailImageData: String? +) diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/ContactFields.kt b/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/ContactFields.kt new file mode 100644 index 000000000000..841d6c82a32b --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/ContactFields.kt @@ -0,0 +1,31 @@ +/// +/// ContactFields.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.contacts + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip + +/** + * Represents the JavaScript enum/union "ContactFields". + */ +@DoNotStrip +@Keep +enum class ContactFields { + FIRST_NAME, + LAST_NAME, + MIDDLE_NAME, + PHONE_NUMBERS, + EMAIL_ADDRESSES, + IMAGE_DATA, + THUMBNAIL_IMAGE_DATA, + GIVEN_NAME_KEY; + + @DoNotStrip + @Keep + private val _ordinal = ordinal +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/HybridContactsModuleSpec.kt b/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/HybridContactsModuleSpec.kt new file mode 100644 index 000000000000..63a118b8be57 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/HybridContactsModuleSpec.kt @@ -0,0 +1,64 @@ +/// +/// HybridContactsModuleSpec.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.contacts + +import android.util.Log +import androidx.annotation.Keep +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.core.* + +/** + * A Kotlin class representing the ContactsModule HybridObject. + * Implement this abstract class to create Kotlin-based instances of ContactsModule. + */ +@DoNotStrip +@Keep +@Suppress("RedundantSuppression", "KotlinJniMissingFunction", "PropertyName", "RedundantUnitReturnType", "unused") +abstract class HybridContactsModuleSpec: HybridObject() { + @DoNotStrip + private var mHybridData: HybridData = initHybrid() + + init { + // Pass this `HybridData` through to it's base class, + // to represent inheritance to JHybridObject on C++ side + super.updateNative(mHybridData) + } + + /** + * Call from a child class to initialize HybridData with a child. + */ + override fun updateNative(hybridData: HybridData) { + mHybridData = hybridData + } + + // Properties + + + // Methods + @DoNotStrip + @Keep + abstract fun getAll(keys: Array): Promise> + + private external fun initHybrid(): HybridData + + companion object { + private const val TAG = "HybridContactsModuleSpec" + init { + try { + Log.i(TAG, "Loading ContactsModule C++ library...") + System.loadLibrary("ContactsModule") + Log.i(TAG, "Successfully loaded ContactsModule C++ library!") + } catch (e: Error) { + Log.e(TAG, "Failed to load ContactsModule C++ library! Is it properly installed and linked? " + + "Is the name correct? (see `CMakeLists.txt`, at `add_library(...)`)", e) + throw e + } + } + } +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/StringHolder.kt b/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/StringHolder.kt new file mode 100644 index 000000000000..b6af53e53217 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/android/kotlin/com/margelo/nitro/contacts/StringHolder.kt @@ -0,0 +1,21 @@ +/// +/// StringHolder.kt +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +package com.margelo.nitro.contacts + +import androidx.annotation.Keep +import com.facebook.proguard.annotations.DoNotStrip +import com.margelo.nitro.core.* + +/** + * Represents the JavaScript object/struct "StringHolder". + */ +@DoNotStrip +@Keep +data class StringHolder( + val value: String +) diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule+autolinking.rb b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule+autolinking.rb new file mode 100644 index 000000000000..35bc19c47bf7 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule+autolinking.rb @@ -0,0 +1,58 @@ +# +# ContactsModule+autolinking.rb +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © 2024 Marc Rousavy @ Margelo +# + +# This is a Ruby script that adds all files generated by Nitrogen +# to the given podspec. +# +# To use it, add this to your .podspec: +# ```ruby +# Pod::Spec.new do |spec| +# # ... +# +# # Add all files generated by Nitrogen +# load 'nitrogen/generated/ios/ContactsModule+autolinking.rb' +# add_nitrogen_files(spec) +# end +# ``` + +def add_nitrogen_files(spec) + Pod::UI.puts "[NitroModules] 🔥 ContactsModule is boosted by nitro!" + + spec.dependency "NitroModules" + + current_source_files = Array(spec.attributes_hash['source_files']) + spec.source_files = current_source_files + [ + # Generated cross-platform specs + "nitrogen/generated/shared/**/*.{h,hpp,c,cpp,swift}", + # Generated bridges for the cross-platform specs + "nitrogen/generated/ios/**/*.{h,hpp,c,cpp,mm,swift}", + ] + + current_public_header_files = Array(spec.attributes_hash['public_header_files']) + spec.public_header_files = current_public_header_files + [ + # Generated specs + "nitrogen/generated/shared/**/*.{h,hpp}", + # Swift to C++ bridging helpers + "nitrogen/generated/ios/ContactsModule-Swift-Cxx-Bridge.hpp" + ] + + current_private_header_files = Array(spec.attributes_hash['private_header_files']) + spec.private_header_files = current_private_header_files + [ + # iOS specific specs + "nitrogen/generated/ios/c++/**/*.{h,hpp}", + ] + + current_pod_target_xcconfig = spec.attributes_hash['pod_target_xcconfig'] || {} + spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({ + # Use C++ 20 + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + # Enables C++ <-> Swift interop (by default it's only C) + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + # Enables stricter modular headers + "DEFINES_MODULE" => "YES", + }) +end diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Bridge.cpp b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Bridge.cpp new file mode 100644 index 000000000000..4746dbadaa18 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Bridge.cpp @@ -0,0 +1,33 @@ +/// +/// ContactsModule-Swift-Cxx-Bridge.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#include "ContactsModule-Swift-Cxx-Bridge.hpp" + +// Include C++ implementation defined types +#include "ContactsModule-Swift-Cxx-Umbrella.hpp" +#include "HybridContactsModuleSpecSwift.hpp" +#include + +namespace margelo::nitro::contacts::bridge::swift { + + // pragma MARK: std::shared_ptr + std::shared_ptr create_std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_(void* _Nonnull swiftUnsafePointer) { + ContactsModule::HybridContactsModuleSpecCxx swiftPart = ContactsModule::HybridContactsModuleSpecCxxUnsafe::fromUnsafe(swiftUnsafePointer); + return HybridContext::getOrCreate(swiftPart); + } + void* _Nonnull get_std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_(std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_ cppType) { + std::shared_ptr swiftWrapper = std::dynamic_pointer_cast(cppType); + #ifdef NITRO_DEBUG + if (swiftWrapper == nullptr) [[unlikely]] { + throw std::runtime_error("Class \"HybridContactsModuleSpec\" is not implemented in Swift!"); + } + #endif + ContactsModule::HybridContactsModuleSpecCxx swiftPart = swiftWrapper->getSwiftPart(); + return ContactsModule::HybridContactsModuleSpecCxxUnsafe::toUnsafe(swiftPart); + } + +} // namespace margelo::nitro::contacts::bridge::swift diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Bridge.hpp b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Bridge.hpp new file mode 100644 index 000000000000..76d584613df2 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Bridge.hpp @@ -0,0 +1,167 @@ +/// +/// ContactsModule-Swift-Cxx-Bridge.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types +// Forward declaration of `ContactFields` to properly resolve imports. +namespace margelo::nitro::contacts { enum class ContactFields; } +// Forward declaration of `Contact` to properly resolve imports. +namespace margelo::nitro::contacts { struct Contact; } +// Forward declaration of `HybridContactsModuleSpec` to properly resolve imports. +namespace margelo::nitro::contacts { class HybridContactsModuleSpec; } +// Forward declaration of `StringHolder` to properly resolve imports. +namespace margelo::nitro::contacts { struct StringHolder; } + +// Forward declarations of Swift defined types +// Forward declaration of `HybridContactsModuleSpecCxx` to properly resolve imports. +namespace ContactsModule { class HybridContactsModuleSpecCxx; } + +// Include C++ defined types +#include "Contact.hpp" +#include "ContactFields.hpp" +#include "HybridContactsModuleSpec.hpp" +#include "StringHolder.hpp" +#include +#include +#include +#include +#include +#include +#include + +/** + * Contains specialized versions of C++ templated types so they can be accessed from Swift, + * as well as helper functions to interact with those C++ types from Swift. + */ +namespace margelo::nitro::contacts::bridge::swift { + + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_std__string_ = std::optional; + inline std::optional create_std__optional_std__string_(const std::string& value) { + return std::optional(value); + } + + // pragma MARK: std::vector + /** + * Specialized version of `std::vector`. + */ + using std__vector_StringHolder_ = std::vector; + inline std::vector create_std__vector_StringHolder_(size_t size) { + std::vector vector; + vector.reserve(size); + return vector; + } + + // pragma MARK: std::optional> + /** + * Specialized version of `std::optional>`. + */ + using std__optional_std__vector_StringHolder__ = std::optional>; + inline std::optional> create_std__optional_std__vector_StringHolder__(const std::vector& value) { + return std::optional>(value); + } + + // pragma MARK: std::vector + /** + * Specialized version of `std::vector`. + */ + using std__vector_Contact_ = std::vector; + inline std::vector create_std__vector_Contact_(size_t size) { + std::vector vector; + vector.reserve(size); + return vector; + } + + // pragma MARK: std::shared_ptr>> + /** + * Specialized version of `std::shared_ptr>>`. + */ + using std__shared_ptr_Promise_std__vector_Contact___ = std::shared_ptr>>; + inline std::shared_ptr>> create_std__shared_ptr_Promise_std__vector_Contact___() { + return Promise>::create(); + } + + // pragma MARK: std::function& /* result */)> + /** + * Specialized version of `std::function&)>`. + */ + using Func_void_std__vector_Contact_ = std::function& /* result */)>; + /** + * Wrapper class for a `std::function& / * result * /)>`, this can be used from Swift. + */ + class Func_void_std__vector_Contact__Wrapper final { + public: + explicit Func_void_std__vector_Contact__Wrapper(const std::function& /* result */)>& func): _function(func) {} + explicit Func_void_std__vector_Contact__Wrapper(std::function& /* result */)>&& func): _function(std::move(func)) {} + inline void call(std::vector result) const { + _function(result); + } + private: + std::function& /* result */)> _function; + }; + inline Func_void_std__vector_Contact_ create_Func_void_std__vector_Contact_(void* _Nonnull closureHolder, void(* _Nonnull call)(void* _Nonnull /* closureHolder */, std::vector), void(* _Nonnull destroy)(void* _Nonnull)) { + std::shared_ptr sharedClosureHolder(closureHolder, destroy); + return Func_void_std__vector_Contact_([sharedClosureHolder, call](const std::vector& result) -> void { + call(sharedClosureHolder.get(), result); + }); + } + inline std::shared_ptr share_Func_void_std__vector_Contact_(const Func_void_std__vector_Contact_& value) { + return std::make_shared(value); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_std__exception_ptr = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_std__exception_ptr_Wrapper final { + public: + explicit Func_void_std__exception_ptr_Wrapper(const std::function& func): _function(func) {} + explicit Func_void_std__exception_ptr_Wrapper(std::function&& func): _function(std::move(func)) {} + inline void call(std::exception_ptr error) const { + _function(error); + } + private: + std::function _function; + }; + inline Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* _Nonnull closureHolder, void(* _Nonnull call)(void* _Nonnull /* closureHolder */, std::exception_ptr), void(* _Nonnull destroy)(void* _Nonnull)) { + std::shared_ptr sharedClosureHolder(closureHolder, destroy); + return Func_void_std__exception_ptr([sharedClosureHolder, call](const std::exception_ptr& error) -> void { + call(sharedClosureHolder.get(), error); + }); + } + inline std::shared_ptr share_Func_void_std__exception_ptr(const Func_void_std__exception_ptr& value) { + return std::make_shared(value); + } + + // pragma MARK: std::vector + /** + * Specialized version of `std::vector`. + */ + using std__vector_ContactFields_ = std::vector; + inline std::vector create_std__vector_ContactFields_(size_t size) { + std::vector vector; + vector.reserve(size); + return vector; + } + + // pragma MARK: std::shared_ptr + /** + * Specialized version of `std::shared_ptr`. + */ + using std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_ = std::shared_ptr; + std::shared_ptr create_std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_(void* _Nonnull swiftUnsafePointer); + void* _Nonnull get_std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_(std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_ cppType); + +} // namespace margelo::nitro::contacts::bridge::swift diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Umbrella.hpp b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Umbrella.hpp new file mode 100644 index 000000000000..6f38d7c7e417 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModule-Swift-Cxx-Umbrella.hpp @@ -0,0 +1,54 @@ +/// +/// ContactsModule-Swift-Cxx-Umbrella.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types +// Forward declaration of `ContactFields` to properly resolve imports. +namespace margelo::nitro::contacts { enum class ContactFields; } +// Forward declaration of `Contact` to properly resolve imports. +namespace margelo::nitro::contacts { struct Contact; } +// Forward declaration of `HybridContactsModuleSpec` to properly resolve imports. +namespace margelo::nitro::contacts { class HybridContactsModuleSpec; } +// Forward declaration of `StringHolder` to properly resolve imports. +namespace margelo::nitro::contacts { struct StringHolder; } + +// Include C++ defined types +#include "Contact.hpp" +#include "ContactFields.hpp" +#include "HybridContactsModuleSpec.hpp" +#include "StringHolder.hpp" +#include +#include +#include +#include +#include + +// C++ helpers for Swift +#include "ContactsModule-Swift-Cxx-Bridge.hpp" + +// Common C++ types used in Swift +#include +#include +#include +#include + +// Forward declarations of Swift defined types +// Forward declaration of `HybridContactsModuleSpecCxx` to properly resolve imports. +namespace ContactsModule { class HybridContactsModuleSpecCxx; } + +// Include Swift defined types +#if __has_include("ContactsModule-Swift.h") +// This header is generated by Xcode/Swift on every app build. +// If it cannot be found, make sure the Swift module's name (= podspec name) is actually "ContactsModule". +#include "ContactsModule-Swift.h" +// Same as above, but used when building with frameworks (`use_frameworks`) +#elif __has_include() +#include +#else +#error ContactsModule's autogenerated Swift header cannot be found! Make sure the Swift module's name (= podspec name) is actually "ContactsModule", and try building the app first. +#endif diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModuleAutolinking.mm b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModuleAutolinking.mm new file mode 100644 index 000000000000..e769fb6a6806 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModuleAutolinking.mm @@ -0,0 +1,33 @@ +/// +/// ContactsModuleAutolinking.mm +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#import +#import +#import "ContactsModule-Swift-Cxx-Umbrella.hpp" +#import + +#include "HybridContactsModuleSpecSwift.hpp" + +@interface ContactsModuleAutolinking : NSObject +@end + +@implementation ContactsModuleAutolinking + ++ (void) load { + using namespace margelo::nitro; + using namespace margelo::nitro::contacts; + + HybridObjectRegistry::registerHybridObjectConstructor( + "ContactsModule", + []() -> std::shared_ptr { + std::shared_ptr hybridObject = ContactsModule::ContactsModuleAutolinking::createContactsModule(); + return hybridObject; + } + ); +} + +@end diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModuleAutolinking.swift b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModuleAutolinking.swift new file mode 100644 index 000000000000..15d9d9b9064e --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/ContactsModuleAutolinking.swift @@ -0,0 +1,26 @@ +/// +/// ContactsModuleAutolinking.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +public final class ContactsModuleAutolinking { + public typealias bridge = margelo.nitro.contacts.bridge.swift + + /** + * Creates an instance of a Swift class that implements `HybridContactsModuleSpec`, + * and wraps it in a Swift class that can directly interop with C++ (`HybridContactsModuleSpecCxx`) + * + * This is generated by Nitrogen and will initialize the class specified + * in the `"autolinking"` property of `nitro.json` (in this case, `HybridContactsModule`). + */ + public static func createContactsModule() -> bridge.std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_ { + let hybridObject = HybridContactsModule() + return { () -> bridge.std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_ in + let __cxxWrapped = HybridContactsModuleSpecCxx(hybridObject) + let __pointer = HybridContactsModuleSpecCxxUnsafe.toUnsafe(__cxxWrapped) + return bridge.create_std__shared_ptr_margelo__nitro__contacts__HybridContactsModuleSpec_(__pointer) + }() + } +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.cpp b/modules/ContactsNitroModule/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.cpp new file mode 100644 index 000000000000..71151f3c1883 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.cpp @@ -0,0 +1,11 @@ +/// +/// HybridContactsModuleSpecSwift.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#include "HybridContactsModuleSpecSwift.hpp" + +namespace margelo::nitro::contacts { +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp b/modules/ContactsNitroModule/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp new file mode 100644 index 000000000000..dbb4fe829dc2 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/c++/HybridContactsModuleSpecSwift.hpp @@ -0,0 +1,82 @@ +/// +/// HybridContactsModuleSpecSwift.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#include "HybridContactsModuleSpec.hpp" + +// Forward declaration of `HybridContactsModuleSpecCxx` to properly resolve imports. +namespace ContactsModule { class HybridContactsModuleSpecCxx; } + +// Forward declaration of `Contact` to properly resolve imports. +namespace margelo::nitro::contacts { struct Contact; } +// Forward declaration of `StringHolder` to properly resolve imports. +namespace margelo::nitro::contacts { struct StringHolder; } +// Forward declaration of `ContactFields` to properly resolve imports. +namespace margelo::nitro::contacts { enum class ContactFields; } + +#include +#include +#include "Contact.hpp" +#include +#include +#include "StringHolder.hpp" +#include "ContactFields.hpp" + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +#include "ContactsModule-Swift-Cxx-Umbrella.hpp" + +namespace margelo::nitro::contacts { + + /** + * The C++ part of HybridContactsModuleSpecCxx.swift. + * + * HybridContactsModuleSpecSwift (C++) accesses HybridContactsModuleSpecCxx (Swift), and might + * contain some additional bridging code for C++ <> Swift interop. + * + * Since this obviously introduces an overhead, I hope at some point in + * the future, HybridContactsModuleSpecCxx can directly inherit from the C++ class HybridContactsModuleSpec + * to simplify the whole structure and memory management. + */ + class HybridContactsModuleSpecSwift: public virtual HybridContactsModuleSpec { + public: + // Constructor from a Swift instance + explicit HybridContactsModuleSpecSwift(const ContactsModule::HybridContactsModuleSpecCxx& swiftPart): + HybridObject(HybridContactsModuleSpec::TAG), + _swiftPart(swiftPart) { } + + public: + // Get the Swift part + inline ContactsModule::HybridContactsModuleSpecCxx getSwiftPart() noexcept { return _swiftPart; } + + public: + // Get memory pressure + inline size_t getExternalMemorySize() noexcept override { + return _swiftPart.getMemorySize(); + } + + public: + // Properties + + + public: + // Methods + inline std::shared_ptr>> getAll(const std::vector& keys) override { + auto __result = _swiftPart.getAll(keys); + return __result; + } + + private: + ContactsModule::HybridContactsModuleSpecCxx _swiftPart; + }; + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/swift/Contact.swift b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/Contact.swift new file mode 100644 index 000000000000..404d6ba86b25 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/Contact.swift @@ -0,0 +1,251 @@ +/// +/// Contact.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `Contact`, backed by a C++ struct. + */ +public typealias Contact = margelo.nitro.contacts.Contact + +public extension Contact { + private typealias bridge = margelo.nitro.contacts.bridge.swift + + /** + * Create a new instance of `Contact`. + */ + init(firstName: String?, lastName: String?, middleName: String?, phoneNumbers: [StringHolder]?, emailAddresses: [StringHolder]?, imageData: String?, thumbnailImageData: String?) { + self.init({ () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = firstName { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = lastName { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = middleName { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__vector_StringHolder__ in + if let __unwrappedValue = phoneNumbers { + return bridge.create_std__optional_std__vector_StringHolder__({ () -> bridge.std__vector_StringHolder_ in + var __vector = bridge.create_std__vector_StringHolder_(__unwrappedValue.count) + for __item in __unwrappedValue { + __vector.push_back(__item) + } + return __vector + }()) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__vector_StringHolder__ in + if let __unwrappedValue = emailAddresses { + return bridge.create_std__optional_std__vector_StringHolder__({ () -> bridge.std__vector_StringHolder_ in + var __vector = bridge.create_std__vector_StringHolder_(__unwrappedValue.count) + for __item in __unwrappedValue { + __vector.push_back(__item) + } + return __vector + }()) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = imageData { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = thumbnailImageData { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }()) + } + + var firstName: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__firstName.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__firstName = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } + + var lastName: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__lastName.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__lastName = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } + + var middleName: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__middleName.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__middleName = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } + + var phoneNumbers: [StringHolder]? { + @inline(__always) + get { + return { () -> [StringHolder]? in + if let __unwrapped = self.__phoneNumbers.value { + return __unwrapped.map({ __item in __item }) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__phoneNumbers = { () -> bridge.std__optional_std__vector_StringHolder__ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__vector_StringHolder__({ () -> bridge.std__vector_StringHolder_ in + var __vector = bridge.create_std__vector_StringHolder_(__unwrappedValue.count) + for __item in __unwrappedValue { + __vector.push_back(__item) + } + return __vector + }()) + } else { + return .init() + } + }() + } + } + + var emailAddresses: [StringHolder]? { + @inline(__always) + get { + return { () -> [StringHolder]? in + if let __unwrapped = self.__emailAddresses.value { + return __unwrapped.map({ __item in __item }) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__emailAddresses = { () -> bridge.std__optional_std__vector_StringHolder__ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__vector_StringHolder__({ () -> bridge.std__vector_StringHolder_ in + var __vector = bridge.create_std__vector_StringHolder_(__unwrappedValue.count) + for __item in __unwrappedValue { + __vector.push_back(__item) + } + return __vector + }()) + } else { + return .init() + } + }() + } + } + + var imageData: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__imageData.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__imageData = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } + + var thumbnailImageData: String? { + @inline(__always) + get { + return { () -> String? in + if let __unwrapped = self.__thumbnailImageData.value { + return String(__unwrapped) + } else { + return nil + } + }() + } + @inline(__always) + set { + self.__thumbnailImageData = { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = newValue { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }() + } + } +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/swift/ContactFields.swift b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/ContactFields.swift new file mode 100644 index 000000000000..ce38940795d9 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/ContactFields.swift @@ -0,0 +1,64 @@ +/// +/// ContactFields.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +/** + * Represents the JS union `ContactFields`, backed by a C++ enum. + */ +public typealias ContactFields = margelo.nitro.contacts.ContactFields + +public extension ContactFields { + /** + * Get a ContactFields for the given String value, or + * return `nil` if the given value was invalid/unknown. + */ + init?(fromString string: String) { + switch string { + case "FIRST_NAME": + self = .firstName + case "LAST_NAME": + self = .lastName + case "MIDDLE_NAME": + self = .middleName + case "PHONE_NUMBERS": + self = .phoneNumbers + case "EMAIL_ADDRESSES": + self = .emailAddresses + case "IMAGE_DATA": + self = .imageData + case "THUMBNAIL_IMAGE_DATA": + self = .thumbnailImageData + case "GIVEN_NAME_KEY": + self = .givenNameKey + default: + return nil + } + } + + /** + * Get the String value this ContactFields represents. + */ + var stringValue: String { + switch self { + case .firstName: + return "FIRST_NAME" + case .lastName: + return "LAST_NAME" + case .middleName: + return "MIDDLE_NAME" + case .phoneNumbers: + return "PHONE_NUMBERS" + case .emailAddresses: + return "EMAIL_ADDRESSES" + case .imageData: + return "IMAGE_DATA" + case .thumbnailImageData: + return "THUMBNAIL_IMAGE_DATA" + case .givenNameKey: + return "GIVEN_NAME_KEY" + } + } +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift new file mode 100644 index 000000000000..611110efca1d --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/HybridContactsModuleSpec.swift @@ -0,0 +1,36 @@ +/// +/// HybridContactsModuleSpec.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +import Foundation +import NitroModules + +/** + * A Swift protocol representing the ContactsModule HybridObject. + * Implement this protocol to create Swift-based instances of ContactsModule. + * + * When implementing this protocol, make sure to initialize `hybridContext` - example: + * ``` + * public class HybridContactsModule : HybridContactsModuleSpec { + * // Initialize HybridContext + * var hybridContext = margelo.nitro.HybridContext() + * + * // Return size of the instance to inform JS GC about memory pressure + * var memorySize: Int { + * return getSizeOf(self) + * } + * + * // ... + * } + * ``` + */ +public protocol HybridContactsModuleSpec: AnyObject, HybridObjectSpec { + // Properties + + + // Methods + func getAll(keys: [ContactFields]) throws -> Promise<[Contact]> +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/swift/HybridContactsModuleSpecCxx.swift b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/HybridContactsModuleSpecCxx.swift new file mode 100644 index 000000000000..156cdf86bd7f --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/HybridContactsModuleSpecCxx.swift @@ -0,0 +1,123 @@ +/// +/// HybridContactsModuleSpecCxx.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +import Foundation +import NitroModules + +/** + * Helper class for converting instances of `HybridContactsModuleSpecCxx` from- and to unsafe pointers. + * This is useful to pass Swift classes to C++, without having to strongly type the C++ function signature. + * The actual Swift type can be included in the .cpp file, without having to forward-declare anything in .hpp. + */ +public final class HybridContactsModuleSpecCxxUnsafe { + /** + * Casts a `HybridContactsModuleSpecCxx` instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + public static func toUnsafe(_ instance: HybridContactsModuleSpecCxx) -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(instance).toOpaque() + } + + /** + * Casts an unsafe pointer to a `HybridContactsModuleSpecCxx`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridContactsModuleSpecCxx { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} + +/** + * A class implementation that bridges HybridContactsModuleSpec over to C++. + * In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined. + * + * Also, some Swift types need to be bridged with special handling: + * - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330) + * - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper + * - Throwing methods need to be wrapped with a Result type, as exceptions cannot be propagated to C++ + */ +public class HybridContactsModuleSpecCxx { + /** + * The Swift <> C++ bridge's namespace (`margelo::nitro::contacts::bridge::swift`) + * from `ContactsModule-Swift-Cxx-Bridge.hpp`. + * This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift. + */ + public typealias bridge = margelo.nitro.contacts.bridge.swift + + /** + * Holds an instance of the `HybridContactsModuleSpec` Swift protocol. + */ + private var __implementation: any HybridContactsModuleSpec + + /** + * Create a new `HybridContactsModuleSpecCxx` that wraps the given `HybridContactsModuleSpec`. + * All properties and methods bridge to C++ types. + */ + public init(_ implementation: some HybridContactsModuleSpec) { + self.__implementation = implementation + /* no base class */ + } + + /** + * Get the actual `HybridContactsModuleSpec` instance this class wraps. + */ + @inline(__always) + public func getHybridContactsModuleSpec() -> any HybridContactsModuleSpec { + return __implementation + } + + /** + * Contains a (weak) reference to the C++ HybridObject to cache it. + */ + public var hybridContext: margelo.nitro.HybridContext { + @inline(__always) + get { + return self.__implementation.hybridContext + } + @inline(__always) + set { + self.__implementation.hybridContext = newValue + } + } + + /** + * Get the memory size of the Swift class (plus size of any other allocations) + * so the JS VM can properly track it and garbage-collect the JS object if needed. + */ + @inline(__always) + public var memorySize: Int { + return self.__implementation.memorySize + } + + // Properties + + + // Methods + @inline(__always) + public func getAll(keys: bridge.std__vector_ContactFields_) -> bridge.std__shared_ptr_Promise_std__vector_Contact___ { + do { + let __result = try self.__implementation.getAll(keys: keys.map({ __item in __item })) + return { () -> bridge.std__shared_ptr_Promise_std__vector_Contact___ in + let __promise = bridge.create_std__shared_ptr_Promise_std__vector_Contact___() + __result + .then({ __result in __promise.pointee.resolve({ () -> bridge.std__vector_Contact_ in + var __vector = bridge.create_std__vector_Contact_(__result.count) + for __item in __result { + __vector.push_back(__item) + } + return __vector + }()) }) + .catch({ __error in __promise.pointee.reject(__error.toCpp()) }) + return __promise + }() + } catch { + let __message = "\(error.localizedDescription)" + fatalError("Swift errors can currently not be propagated to C++! See https://github.com/swiftlang/swift/issues/75290 (Error: \(__message))") + } + } +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/ios/swift/StringHolder.swift b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/StringHolder.swift new file mode 100644 index 000000000000..477279082456 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/ios/swift/StringHolder.swift @@ -0,0 +1,35 @@ +/// +/// StringHolder.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `StringHolder`, backed by a C++ struct. + */ +public typealias StringHolder = margelo.nitro.contacts.StringHolder + +public extension StringHolder { + private typealias bridge = margelo.nitro.contacts.bridge.swift + + /** + * Create a new instance of `StringHolder`. + */ + init(value: String) { + self.init(std.string(value)) + } + + var value: String { + @inline(__always) + get { + return String(self.__value) + } + @inline(__always) + set { + self.__value = std.string(newValue) + } + } +} diff --git a/modules/ContactsNitroModule/nitrogen/generated/shared/c++/Contact.hpp b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/Contact.hpp new file mode 100644 index 000000000000..6e4a5bd0c27e --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/Contact.hpp @@ -0,0 +1,96 @@ +/// +/// Contact.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `StringHolder` to properly resolve imports. +namespace margelo::nitro::contacts { struct StringHolder; } + +#include +#include +#include +#include "StringHolder.hpp" + +namespace margelo::nitro::contacts { + + /** + * A struct which can be represented as a JavaScript object (Contact). + */ + struct Contact { + public: + std::optional firstName SWIFT_PRIVATE; + std::optional lastName SWIFT_PRIVATE; + std::optional middleName SWIFT_PRIVATE; + std::optional> phoneNumbers SWIFT_PRIVATE; + std::optional> emailAddresses SWIFT_PRIVATE; + std::optional imageData SWIFT_PRIVATE; + std::optional thumbnailImageData SWIFT_PRIVATE; + + public: + explicit Contact(std::optional firstName, std::optional lastName, std::optional middleName, std::optional> phoneNumbers, std::optional> emailAddresses, std::optional imageData, std::optional thumbnailImageData): firstName(firstName), lastName(lastName), middleName(middleName), phoneNumbers(phoneNumbers), emailAddresses(emailAddresses), imageData(imageData), thumbnailImageData(thumbnailImageData) {} + }; + +} // namespace margelo::nitro::contacts + +namespace margelo::nitro { + + using namespace margelo::nitro::contacts; + + // C++ Contact <> JS Contact (object) + template <> + struct JSIConverter { + static inline Contact fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return Contact( + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "firstName")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "lastName")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "middleName")), + JSIConverter>>::fromJSI(runtime, obj.getProperty(runtime, "phoneNumbers")), + JSIConverter>>::fromJSI(runtime, obj.getProperty(runtime, "emailAddresses")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "imageData")), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, "thumbnailImageData")) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const Contact& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, "firstName", JSIConverter>::toJSI(runtime, arg.firstName)); + obj.setProperty(runtime, "lastName", JSIConverter>::toJSI(runtime, arg.lastName)); + obj.setProperty(runtime, "middleName", JSIConverter>::toJSI(runtime, arg.middleName)); + obj.setProperty(runtime, "phoneNumbers", JSIConverter>>::toJSI(runtime, arg.phoneNumbers)); + obj.setProperty(runtime, "emailAddresses", JSIConverter>>::toJSI(runtime, arg.emailAddresses)); + obj.setProperty(runtime, "imageData", JSIConverter>::toJSI(runtime, arg.imageData)); + obj.setProperty(runtime, "thumbnailImageData", JSIConverter>::toJSI(runtime, arg.thumbnailImageData)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "firstName"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "lastName"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "middleName"))) return false; + if (!JSIConverter>>::canConvert(runtime, obj.getProperty(runtime, "phoneNumbers"))) return false; + if (!JSIConverter>>::canConvert(runtime, obj.getProperty(runtime, "emailAddresses"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "imageData"))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, "thumbnailImageData"))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/modules/ContactsNitroModule/nitrogen/generated/shared/c++/ContactFields.hpp b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/ContactFields.hpp new file mode 100644 index 000000000000..c3e8c115465e --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/ContactFields.hpp @@ -0,0 +1,102 @@ +/// +/// ContactFields.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +namespace margelo::nitro::contacts { + + /** + * An enum which can be represented as a JavaScript union (ContactFields). + */ + enum class ContactFields { + FIRST_NAME SWIFT_NAME(firstName) = 0, + LAST_NAME SWIFT_NAME(lastName) = 1, + MIDDLE_NAME SWIFT_NAME(middleName) = 2, + PHONE_NUMBERS SWIFT_NAME(phoneNumbers) = 3, + EMAIL_ADDRESSES SWIFT_NAME(emailAddresses) = 4, + IMAGE_DATA SWIFT_NAME(imageData) = 5, + THUMBNAIL_IMAGE_DATA SWIFT_NAME(thumbnailImageData) = 6, + GIVEN_NAME_KEY SWIFT_NAME(givenNameKey) = 7, + } CLOSED_ENUM; + +} // namespace margelo::nitro::contacts + +namespace margelo::nitro { + + using namespace margelo::nitro::contacts; + + // C++ ContactFields <> JS ContactFields (union) + template <> + struct JSIConverter { + static inline ContactFields fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + std::string unionValue = JSIConverter::fromJSI(runtime, arg); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("FIRST_NAME"): return ContactFields::FIRST_NAME; + case hashString("LAST_NAME"): return ContactFields::LAST_NAME; + case hashString("MIDDLE_NAME"): return ContactFields::MIDDLE_NAME; + case hashString("PHONE_NUMBERS"): return ContactFields::PHONE_NUMBERS; + case hashString("EMAIL_ADDRESSES"): return ContactFields::EMAIL_ADDRESSES; + case hashString("IMAGE_DATA"): return ContactFields::IMAGE_DATA; + case hashString("THUMBNAIL_IMAGE_DATA"): return ContactFields::THUMBNAIL_IMAGE_DATA; + case hashString("GIVEN_NAME_KEY"): return ContactFields::GIVEN_NAME_KEY; + default: [[unlikely]] + throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum ContactFields - invalid value!"); + } + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, ContactFields arg) { + switch (arg) { + case ContactFields::FIRST_NAME: return JSIConverter::toJSI(runtime, "FIRST_NAME"); + case ContactFields::LAST_NAME: return JSIConverter::toJSI(runtime, "LAST_NAME"); + case ContactFields::MIDDLE_NAME: return JSIConverter::toJSI(runtime, "MIDDLE_NAME"); + case ContactFields::PHONE_NUMBERS: return JSIConverter::toJSI(runtime, "PHONE_NUMBERS"); + case ContactFields::EMAIL_ADDRESSES: return JSIConverter::toJSI(runtime, "EMAIL_ADDRESSES"); + case ContactFields::IMAGE_DATA: return JSIConverter::toJSI(runtime, "IMAGE_DATA"); + case ContactFields::THUMBNAIL_IMAGE_DATA: return JSIConverter::toJSI(runtime, "THUMBNAIL_IMAGE_DATA"); + case ContactFields::GIVEN_NAME_KEY: return JSIConverter::toJSI(runtime, "GIVEN_NAME_KEY"); + default: [[unlikely]] + throw std::invalid_argument("Cannot convert ContactFields to JS - invalid value: " + + std::to_string(static_cast(arg)) + "!"); + } + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isString()) { + return false; + } + std::string unionValue = JSIConverter::fromJSI(runtime, value); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("FIRST_NAME"): + case hashString("LAST_NAME"): + case hashString("MIDDLE_NAME"): + case hashString("PHONE_NUMBERS"): + case hashString("EMAIL_ADDRESSES"): + case hashString("IMAGE_DATA"): + case hashString("THUMBNAIL_IMAGE_DATA"): + case hashString("GIVEN_NAME_KEY"): + return true; + default: + return false; + } + } + }; + +} // namespace margelo::nitro diff --git a/modules/ContactsNitroModule/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp new file mode 100644 index 000000000000..eba17de8d910 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/HybridContactsModuleSpec.cpp @@ -0,0 +1,21 @@ +/// +/// HybridContactsModuleSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#include "HybridContactsModuleSpec.hpp" + +namespace margelo::nitro::contacts { + + void HybridContactsModuleSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("getAll", &HybridContactsModuleSpec::getAll); + }); + } + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp new file mode 100644 index 000000000000..6c298086f493 --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/HybridContactsModuleSpec.hpp @@ -0,0 +1,68 @@ +/// +/// HybridContactsModuleSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `Contact` to properly resolve imports. +namespace margelo::nitro::contacts { struct Contact; } +// Forward declaration of `ContactFields` to properly resolve imports. +namespace margelo::nitro::contacts { enum class ContactFields; } + +#include +#include +#include "Contact.hpp" +#include "ContactFields.hpp" + +namespace margelo::nitro::contacts { + + using namespace margelo::nitro; + + /** + * An abstract base class for `ContactsModule` + * Inherit this class to create instances of `HybridContactsModuleSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridContactsModule: public HybridContactsModuleSpec { + * public: + * HybridContactsModule(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridContactsModuleSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridContactsModuleSpec(): HybridObject(TAG) { } + + // Destructor + virtual ~HybridContactsModuleSpec() { } + + public: + // Properties + + + public: + // Methods + virtual std::shared_ptr>> getAll(const std::vector& keys) = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "ContactsModule"; + }; + +} // namespace margelo::nitro::contacts diff --git a/modules/ContactsNitroModule/nitrogen/generated/shared/c++/StringHolder.hpp b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/StringHolder.hpp new file mode 100644 index 000000000000..1a666ed1faca --- /dev/null +++ b/modules/ContactsNitroModule/nitrogen/generated/shared/c++/StringHolder.hpp @@ -0,0 +1,68 @@ +/// +/// StringHolder.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2024 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include + +namespace margelo::nitro::contacts { + + /** + * A struct which can be represented as a JavaScript object (StringHolder). + */ + struct StringHolder { + public: + std::string value SWIFT_PRIVATE; + + public: + explicit StringHolder(std::string value): value(value) {} + }; + +} // namespace margelo::nitro::contacts + +namespace margelo::nitro { + + using namespace margelo::nitro::contacts; + + // C++ StringHolder <> JS StringHolder (object) + template <> + struct JSIConverter { + static inline StringHolder fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return StringHolder( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, "value")) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const StringHolder& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, "value", JSIConverter::toJSI(runtime, arg.value)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, "value"))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/modules/ContactsNitroModule/package.json b/modules/ContactsNitroModule/package.json new file mode 100644 index 000000000000..6f70882a2193 --- /dev/null +++ b/modules/ContactsNitroModule/package.json @@ -0,0 +1,103 @@ +{ + "name": "contacts-nitro-module", + "version": "0.0.1", + "main": "src/index", + "react-native": "src/index", + "description": "React Native Contacts Module with Nitro optimization", + "source": "src/index", + "files": [ + "src", + "react-native.config.js", + "lib", + "android/build.gradle", + "android/gradle.properties", + "android/CMakeLists.txt", + "android/src", + "ios/**/*.h", + "ios/**/*.m", + "ios/**/*.mm", + "ios/**/*.cpp", + "ios/**/*.swift", + "app.plugin.js", + "*.podspec", + "README.md" + ], + "scripts": { + "postinstall": "tsc || exit 0;", + "typecheck": "tsc --noEmit", + "clean": "del-cli android/build node_modules/**/android/build lib", + "lint": "eslint \"**/*.{js,ts,tsx}\" --fix", + "lint-ci": "eslint \"**/*.{js,ts,tsx}\" -f @jamesacarr/github-actions", + "typescript": "tsc --noEmit false", + "specs-debug": "bun run --filter=\"**\" typescript && bun nitro-codegen --logLevel=\"debug\"", + "specs": "bun nitro-codegen" + }, + "keywords": [ + "react-native", + "nitro" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/mrousavy/nitro.git" + }, + "author": "Marc Rousavy (https://github.com/mrousavy)", + "license": "MIT", + "bugs": { + "url": "https://github.com/mrousavy/nitro/issues" + }, + "homepage": "https://github.com/mrousavy/nitro#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@react-native/eslint-config": "^0.75.2", + "@types/jest": "^29.5.12", + "@types/react": "^18.3.4", + "del-cli": "^5.1.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "nitro-codegen": "0.18.1", + "prettier": "^3.3.3", + "react": "^18.3.1", + "react-native": "0.75.2", + "react-native-nitro-modules": "*", + "typescript": "^5.5.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@react-native", + "prettier" + ], + "plugins": ["prettier"], + "rules": { + "prettier/prettier": [ + "warn", + { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + } + ] + } + }, + "eslintIgnore": [ + "node_modules/", + "lib/" + ], + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false, + "semi": false + } +} diff --git a/modules/ContactsNitroModule/src/ContactsModule.nitro.ts b/modules/ContactsNitroModule/src/ContactsModule.nitro.ts new file mode 100644 index 000000000000..8df839f550e8 --- /dev/null +++ b/modules/ContactsNitroModule/src/ContactsModule.nitro.ts @@ -0,0 +1,29 @@ +import type { HybridObject } from 'react-native-nitro-modules' + +interface StringHolder { + value: string +} + +export interface Contact { + firstName?: string + lastName?: string + middleName?: string + phoneNumbers?: StringHolder[] + emailAddresses?: StringHolder[] + imageData?: string + thumbnailImageData?: string +} +export type ContactFields = + | 'FIRST_NAME' + | 'LAST_NAME' + | 'MIDDLE_NAME' + | 'PHONE_NUMBERS' + | 'EMAIL_ADDRESSES' + | 'IMAGE_DATA' + | 'THUMBNAIL_IMAGE_DATA' + | 'GIVEN_NAME_KEY' + +export interface ContactsModule + extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { + getAll(keys: ContactFields[]): Promise +} diff --git a/modules/ContactsNitroModule/src/index.ts b/modules/ContactsNitroModule/src/index.ts new file mode 100644 index 000000000000..acc7c7c1fb76 --- /dev/null +++ b/modules/ContactsNitroModule/src/index.ts @@ -0,0 +1,8 @@ +import type { ContactsModule } from './ContactsModule.nitro' +import type { Contact } from './ContactsModule.nitro' +import { NitroModules } from 'react-native-nitro-modules' + +export const ContactsNitroModule = + NitroModules.createHybridObject('ContactsModule') + +export type { Contact } diff --git a/modules/ContactsNitroModule/tsconfig.json b/modules/ContactsNitroModule/tsconfig.json new file mode 100644 index 000000000000..e30dc47ac169 --- /dev/null +++ b/modules/ContactsNitroModule/tsconfig.json @@ -0,0 +1,29 @@ +{ + "include": ["src"], + "compilerOptions": { + "composite": true, + "outDir": "lib", + "rootDir": "src", + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "node", + "noEmit": false, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "esnext", + "verbatimModuleSyntax": true + } +} diff --git a/package-lock.json b/package-lock.json index 47310a40a55b..5182b78cf5e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,20 @@ { "name": "new.expensify", - "version": "9.0.76-10", + "version": "9.0.77-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.76-10", + "version": "9.0.77-2", "hasInstallScript": true, "license": "MIT", + "workspaces": [ + "modules/ContactsNitroModule" + ], "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.207", + "@expensify/react-native-live-markdown": "0.1.209", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -46,6 +49,7 @@ "awesome-phonenumber": "^5.4.0", "babel-polyfill": "^6.26.0", "canvas-size": "^1.2.6", + "contacts-nitro-module": "./modules/ContactsNitroModule", "core-js": "^3.32.0", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", @@ -95,6 +99,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", + "react-native-nitro-modules": "^0.18.1", "react-native-onyx": "2.0.86", "react-native-pager-view": "6.5.1", "react-native-pdf": "6.7.3", @@ -275,10 +280,114 @@ "webpack-dev-server": "^5.0.4", "webpack-merge": "^5.8.0", "xlsx": "file:vendor/xlsx-0.20.3.tgz" + } + }, + "modules/ContactsNitroModule": { + "name": "contacts-nitro-module", + "version": "0.0.1", + "hasInstallScript": true, + "license": "MIT", + "devDependencies": { + "@react-native/eslint-config": "^0.75.2", + "@types/jest": "^29.5.12", + "@types/react": "^18.3.4", + "del-cli": "^5.1.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "nitro-codegen": "0.18.1", + "prettier": "^3.3.3", + "react": "^18.3.1", + "react-native": "0.75.2", + "react-native-nitro-modules": "*", + "typescript": "^5.5.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "modules/ContactsNitroModule/node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "modules/ContactsNitroModule/node_modules/@types/react": { + "version": "18.3.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.17.tgz", + "integrity": "sha512-opAQ5no6LqJNo9TqnxBKsgnkIYHozW9KSTlFVoSUJYh1Fl/sswkEoqIugRSm7tbh6pABtYjGAjW+GOS23j8qbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "modules/ContactsNitroModule/node_modules/eslint-plugin-prettier": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", + "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.9.1" }, "engines": { - "node": "20.18.0", - "npm": "10.8.2" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "modules/ContactsNitroModule/node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "modules/ContactsNitroModule/node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" } }, "node_modules/@actions/core": { @@ -3498,9 +3607,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.207", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.207.tgz", - "integrity": "sha512-8snKeruLuHJCecnwQ+ru6pJhrDeI2Y3EywmXf/keT4aMk2xcW1fyCAr925zikTWANMDghcKkeuR/JqLe2b3rkA==", + "version": "0.1.209", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.209.tgz", + "integrity": "sha512-u+RRY+Jog/llEu9T1v0okSLgRhG5jGlX9H1Je0A8HWv0439XFLnAWSvN2eQ2T7bvT8Yjdj5CcC0hkgJiB9oCQw==", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -7199,6 +7308,19 @@ "node": ">=14" } }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.15.tgz", @@ -9309,6 +9431,83 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@react-native/eslint-config": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@react-native/eslint-config/-/eslint-config-0.75.4.tgz", + "integrity": "sha512-3KBHYwp4HnBdaCFx9KDPvQY+sGrv5fHX2qDkXGKmN3uYBz+zfnMQXTiht6OuBbWULUF0y0o8m+uH1yYAn/V9mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.20.0", + "@babel/eslint-parser": "^7.20.0", + "@react-native/eslint-plugin": "0.75.4", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-eslint-comments": "^3.2.0", + "eslint-plugin-ft-flow": "^2.0.1", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-react": "^7.30.1", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-native": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": ">=8", + "prettier": ">=2" + } + }, + "node_modules/@react-native/eslint-config/node_modules/eslint-config-prettier": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz", + "integrity": "sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/@react-native/eslint-config/node_modules/eslint-plugin-jest": { + "version": "27.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.9.0.tgz", + "integrity": "sha512-QIT7FH7fNmd9n4se7FFKHbsLKGQiw885Ds6Y/sxKgCZ6natwCsXdgPOADnYVxN2QrRweF0FZWbJ6S7Rsn7llug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^5.10.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0 || ^7.0.0", + "eslint": "^7.0.0 || ^8.0.0", + "jest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/@react-native/eslint-plugin": { + "version": "0.75.4", + "resolved": "https://registry.npmjs.org/@react-native/eslint-plugin/-/eslint-plugin-0.75.4.tgz", + "integrity": "sha512-1kEZzC8UKi3baHnH7tBVCNpF4aoAmT7g7hEa5/rtZ+Z7vcpaxeY6wjNYt3j02Z9n310yX0NKDJox30CqvzEvsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@react-native/gradle-plugin": { "version": "0.75.2", "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.75.2.tgz", @@ -13105,6 +13304,44 @@ "node": ">=10.13.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.9" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "dev": true, @@ -13485,7 +13722,9 @@ "license": "MIT" }, "node_modules/@types/http-proxy": { - "version": "1.17.9", + "version": "1.17.15", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz", + "integrity": "sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13623,6 +13862,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "0.7.34", "dev": true, @@ -13664,6 +13910,13 @@ "@types/node": "*" } }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.0", "dev": true, @@ -15496,6 +15749,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/asap": { "version": "2.0.6", "license": "MIT" @@ -17290,6 +17553,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelcase-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", + "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/camelize": { "version": "1.0.1", "license": "MIT", @@ -17653,6 +17948,13 @@ "node": ">= 0.12.0" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, "node_modules/collect-v8-coverage": { "version": "1.0.1", "dev": true, @@ -18144,6 +18446,10 @@ "dev": true, "license": "MIT" }, + "node_modules/contacts-nitro-module": { + "resolved": "modules/ContactsNitroModule", + "link": true + }, "node_modules/content-disposition": { "version": "0.5.4", "dev": true, @@ -18467,7 +18773,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -18858,6 +19166,56 @@ } } }, + "node_modules/decamelize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", + "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.4.3", "dev": true, @@ -18971,17 +19329,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/default-gateway": { - "version": "6.0.3", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "execa": "^5.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/defaults": { "version": "1.0.4", "license": "MIT", @@ -19061,6 +19408,184 @@ "node": ">=6" } }, + "node_modules/del-cli": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/del-cli/-/del-cli-5.1.0.tgz", + "integrity": "sha512-xwMeh2acluWeccsfzE7VLsG3yTr7nWikbfw+xhMnpRrF15pGSkw+3/vJZWlGoE4I86UiLRNHicmKt4tkIX9Jtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^7.1.0", + "meow": "^10.1.3" + }, + "bin": { + "del": "cli.js", + "del-cli": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/aggregate-error": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-4.0.1.tgz", + "integrity": "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^4.0.0", + "indent-string": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/clean-stack": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-4.2.0.tgz", + "integrity": "sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/del": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/del/-/del-7.1.0.tgz", + "integrity": "sha512-v2KyNk7efxhlyHpjEvfyxaAihKKK0nWCuf6ZtqZcFFpQRG0bJ12Qsr0RpvsICMjAAZ8DOVCxrlqpxISlMHC4Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globby": "^13.1.2", + "graceful-fs": "^4.2.10", + "is-glob": "^4.0.3", + "is-path-cwd": "^3.0.0", + "is-path-inside": "^4.0.0", + "p-map": "^5.5.0", + "rimraf": "^3.0.2", + "slash": "^4.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/is-path-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-3.0.0.tgz", + "integrity": "sha512-kyiNFFLU0Ampr6SDZitD/DwUo4Zs1nSdnygUBqsu3LooL00Qvb5j+UnvApUn/TTj1J3OuE6BTdQ5rudKmU2ZaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/p-map": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-5.5.0.tgz", + "integrity": "sha512-VFqfGDHlx87K66yZrNdI4YGtD70IRyd+zSvgks6mzHPRNkoKy+9EKP4SFC77/vTTQYmRmti7dvqC+m5jBrBAcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/del-cli/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/del/node_modules/array-union": { "version": "1.0.2", "dev": true, @@ -23403,6 +23928,16 @@ "node": ">=0.10.0" } }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/has": { "version": "1.0.3", "dev": true, @@ -23755,7 +24290,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.6", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "dev": true, "license": "MIT", "dependencies": { @@ -23779,6 +24316,8 @@ }, "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", "dev": true, "license": "MIT", "engines": { @@ -24186,9 +24725,10 @@ } }, "node_modules/internal-ip/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "license": "MIT", "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", @@ -24796,6 +25336,16 @@ "node": ">=6" } }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-plain-object": { "version": "5.0.0", "dev": true, @@ -25114,23 +25664,6 @@ "reflect.getprototypeof": "^1.0.3" } }, - "node_modules/jackspeak": { - "version": "2.3.6", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jake": { "version": "10.8.7", "dev": true, @@ -28446,6 +28979,19 @@ "tmpl": "1.0.5" } }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/map-or-similar": { "version": "1.5.0", "dev": true, @@ -28652,6 +29198,92 @@ "resolved": "https://registry.npmjs.org/memory-cache/-/memory-cache-0.2.0.tgz", "integrity": "sha512-OcjA+jzjOYzKmKS6IQVALHLVz+rNTMPoJvCztFaZxwG14wtAW7VRZjwTQu06vKCYOxh4jVnik7ya0SXTB0W+xA==" }, + "node_modules/meow": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz", + "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimist": "^1.2.2", + "camelcase-keys": "^7.0.0", + "decamelize": "^5.0.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.2", + "read-pkg-up": "^8.0.0", + "redent": "^4.0.0", + "trim-newlines": "^4.0.2", + "type-fest": "^1.2.2", + "yargs-parser": "^20.2.9" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/redent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", + "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^5.0.0", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/strip-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz", + "integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -29247,6 +29879,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/minipass": { "version": "3.3.6", "license": "ISC", @@ -29439,7 +30086,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -29484,6 +30133,37 @@ "version": "1.0.5", "license": "MIT" }, + "node_modules/nitro-codegen": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/nitro-codegen/-/nitro-codegen-0.18.1.tgz", + "integrity": "sha512-gDOHIIFFY89Ibo/Q8Dlzx4Rk9fCaGnby4Er5Dh1xV4J5hMqTfqo2VjG+RxScdUTYy/SKOc0UsB2faQybs5+GDw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "react-native-nitro-modules": "^0.18.1", + "ts-morph": "^24.0.0", + "yargs": "^17.7.2", + "zod": "^3.23.8" + }, + "bin": { + "nitro-codegen": "lib/index.js" + } + }, + "node_modules/nitro-codegen/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/no-case": { "version": "3.0.4", "dev": true, @@ -29717,6 +30397,22 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "license": "MIT", @@ -32173,6 +32869,17 @@ "react-native": ">=0.65.0" } }, + "node_modules/react-native-nitro-modules": { + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.18.2.tgz", + "integrity": "sha512-eHsq1cRfm/Bz1Nq7KctTqxAqhzVSNo0WGX281xARZh+vOq8633Qxn1NHRZ5/Rno2Bla6HOXlUW6RoW0wKM/7kg==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-onyx": { "version": "2.0.86", "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.86.tgz", @@ -32974,6 +33681,69 @@ "node": ">=6" } }, + "node_modules/read-pkg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", + "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^3.0.2", + "parse-json": "^5.2.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz", + "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^5.0.0", + "read-pkg": "^6.0.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "license": "MIT", @@ -34373,6 +35143,17 @@ "version": "0.0.2", "dev": true }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/spdx-exceptions": { "version": "2.3.0", "dev": true, @@ -35085,6 +35866,23 @@ "dev": true, "license": "MIT" }, + "node_modules/synckit": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.2.tgz", + "integrity": "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/tabbable": { "version": "6.2.0", "license": "MIT" @@ -35518,6 +36316,48 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", + "integrity": "sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", + "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinyqueue": { "version": "2.0.3", "license": "ISC" @@ -35633,6 +36473,19 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-newlines": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", + "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/trim-right": { "version": "1.0.1", "license": "MIT", @@ -35737,12 +36590,15 @@ "node": ">=10" } }, - "node_modules/ts-jest/node_modules/yargs-parser": { - "version": "21.1.1", + "node_modules/ts-morph": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.25.0", + "code-block-writer": "^13.0.3" } }, "node_modules/ts-node": { @@ -36518,6 +37374,17 @@ "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==" }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/validate-npm-package-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", @@ -36880,7 +37747,9 @@ } }, "node_modules/webpack-dev-server": { - "version": "5.0.4", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.0.tgz", + "integrity": "sha512-90SqqYXA2SK36KcT6o1bvwvZfJFcmoamqeJY7+boioffX9g9C0wjjJRGUrQIuh43pb0ttX7+ssavmj/WN2RHtA==", "dev": true, "license": "MIT", "dependencies": { @@ -36897,23 +37766,20 @@ "colorette": "^2.0.10", "compression": "^1.7.4", "connect-history-api-fallback": "^2.0.0", - "default-gateway": "^6.0.3", - "express": "^4.17.3", + "express": "^4.21.2", "graceful-fs": "^4.2.6", - "html-entities": "^2.4.0", - "http-proxy-middleware": "^2.0.3", + "http-proxy-middleware": "^2.0.7", "ipaddr.js": "^2.1.0", "launch-editor": "^2.6.1", "open": "^10.0.3", "p-retry": "^6.2.0", - "rimraf": "^5.0.5", "schema-utils": "^4.2.0", "selfsigned": "^2.4.1", "serve-index": "^1.9.1", "sockjs": "^0.3.24", "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.1.0", - "ws": "^8.16.0" + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" }, "bin": { "webpack-dev-server": "bin/webpack-dev-server.js" @@ -36937,16 +37803,10 @@ } } }, - "node_modules/webpack-dev-server/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/webpack-dev-server/node_modules/colorette": { "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true, "license": "MIT" }, @@ -36961,27 +37821,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/glob": { - "version": "10.3.12", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.6", - "minimatch": "^9.0.1", - "minipass": "^7.0.4", - "path-scurry": "^1.10.2" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/webpack-dev-server/node_modules/ipaddr.js": { "version": "2.1.0", "dev": true, @@ -37004,28 +37843,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/minimatch": { - "version": "9.0.4", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/webpack-dev-server/node_modules/minipass": { - "version": "7.0.4", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/webpack-dev-server/node_modules/open": { "version": "10.1.0", "dev": true, @@ -37043,25 +37860,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/webpack-dev-server/node_modules/rimraf": { - "version": "5.0.5", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.2.0", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.0.tgz", + "integrity": "sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==", "dev": true, "license": "MIT", "dependencies": { @@ -37071,7 +37873,7 @@ "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 10.13.0" }, "funding": { "type": "opencollective", @@ -37079,7 +37881,9 @@ } }, "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "7.2.1", + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz", + "integrity": "sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA==", "dev": true, "license": "MIT", "dependencies": { @@ -37573,18 +38377,20 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/y18n": { - "version": "5.0.8", + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "license": "ISC", "engines": { - "node": ">=10" + "node": ">=12" } }, - "node_modules/yargs/node_modules/yargs-parser": { - "version": "21.1.1", + "node_modules/yargs/node_modules/y18n": { + "version": "5.0.8", "license": "ISC", "engines": { - "node": ">=12" + "node": ">=10" } }, "node_modules/yauzl": { diff --git a/package.json b/package.json index 763d1ff8aa49..c67c99e28e2d 100644 --- a/package.json +++ b/package.json @@ -1,28 +1,30 @@ { "name": "new.expensify", - "version": "9.0.76-10", + "version": "9.0.77-2", "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.", "license": "MIT", "private": true, "scripts": { + "i-standalone": "STANDALONE_NEW_DOT=true npm i", + "install-standalone": "STANDALONE_NEW_DOT=true npm install", "configure-mapbox": "./scripts/setup-mapbox-sdk-walkthrough.sh", "setupNewDotWebForEmulators": "./scripts/setup-newdot-web-emulators.sh", "startAndroidEmulator": "./scripts/start-android.sh", "postinstall": "./scripts/postInstall.sh", "clean": "./scripts/clean.sh", - "clean-standalone": "./scripts/clean.sh --new-dot", + "clean-standalone": "STANDALONE_NEW_DOT=true ./scripts/clean.sh", "android": "./scripts/set-pusher-suffix.sh && ./scripts/run-build.sh --android", - "android-standalone": "./scripts/set-pusher-suffix.sh && ./scripts/run-build.sh --android --new-dot", + "android-standalone": "./scripts/set-pusher-suffix.sh && STANDALONE_NEW_DOT=true ./scripts/run-build.sh --android", "ios": "./scripts/set-pusher-suffix.sh && ./scripts/run-build.sh --ios", - "ios-standalone": "./scripts/set-pusher-suffix.sh && ./scripts/run-build.sh --ios --new-dot", + "ios-standalone": "./scripts/set-pusher-suffix.sh && STANDALONE_NEW_DOT=true ./scripts/run-build.sh --ios", "pod-install": "./scripts/pod-install.sh", - "pod-install-standalone": "./scripts/pod-install.sh --new-dot", + "pod-install-standalone": "STANDALONE_NEW_DOT=true ./scripts/pod-install.sh", "ipad": "concurrently \"./scripts/run-build.sh --ipad\"", - "ipad-standalone": "concurrently \"./scripts/run-build.sh --ipad --new-dot\"", + "ipad-standalone": "concurrently \"STANDALONE_NEW_DOT=true ./scripts/run-build.sh --ipad\"", "ipad-sm": "concurrently \"./scripts/run-build.sh --ipad-sm\"", - "ipad-sm-standalone": "concurrently \"./scripts/run-build.sh --ipad-sm --new-dot\"", + "ipad-sm-standalone": "concurrently \"STANDALONE_NEW_DOT=true ./scripts/run-build.sh --ipad-sm\"", "start": "npx react-native start", "web": "./scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server", "web-proxy": "ts-node web/proxy.ts", @@ -42,7 +44,7 @@ "perf-test": "NODE_OPTIONS=--experimental-vm-modules npx reassure", "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc", "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=0 --cache --cache-location=node_modules/.cache/eslint", - "lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 eslint --max-warnings=0 --config ./.eslintrc.changed.js $(git diff --diff-filter=AM --name-only origin/main HEAD -- \"*.ts\" \"*.tsx\")", + "lint-changed": "NODE_OPTIONS=--max_old_space_size=8192 eslint --max-warnings=0 --config ./.eslintrc.changed.js $(git diff --diff-filter=AM --name-only origin/main HEAD -- \"*.ts\" \"*.tsx\" \":!modules/**\")", "lint-watch": "npx eslint-watch --watch --changed", "shellcheck": "./scripts/shellCheck.sh", "prettier": "prettier --write .", @@ -74,7 +76,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.207", + "@expensify/react-native-live-markdown": "0.1.209", "@expo/metro-runtime": "~3.2.3", "@firebase/app": "^0.10.10", "@firebase/performance": "^0.6.8", @@ -109,6 +111,7 @@ "awesome-phonenumber": "^5.4.0", "babel-polyfill": "^6.26.0", "canvas-size": "^1.2.6", + "contacts-nitro-module": "./modules/ContactsNitroModule", "core-js": "^3.32.0", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", @@ -159,6 +162,7 @@ "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", "react-native-onyx": "2.0.86", + "react-native-nitro-modules": "^0.18.1", "react-native-pager-view": "6.5.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -374,17 +378,7 @@ ] } }, - "electronmon": { - "patterns": [ - "!src/**", - "!ios/**", - "!android/**", - "!tests/**", - "*.test.*" - ] - }, - "engines": { - "node": "20.18.0", - "npm": "10.8.2" - } + "workspaces": [ + "modules/ContactsNitroModule" + ] } diff --git a/patches/react-native+0.75.2+026+fix-dropping-mutations-in-transactions.patch b/patches/react-native+0.75.2+026+fix-dropping-mutations-in-transactions.patch new file mode 100644 index 000000000000..974a0d090fb9 --- /dev/null +++ b/patches/react-native+0.75.2+026+fix-dropping-mutations-in-transactions.patch @@ -0,0 +1,67 @@ +diff --git a/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp b/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp +index 572fb3d..0efa1ed 100644 +--- a/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp ++++ b/node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/Binding.cpp +@@ -468,7 +468,7 @@ void Binding::schedulerDidFinishTransaction( + mountingTransaction->getSurfaceId(); + }); + +- if (pendingTransaction != pendingTransactions_.end()) { ++ if (pendingTransaction != pendingTransactions_.end() && pendingTransaction->canMergeWith(*mountingTransaction)) { + pendingTransaction->mergeWith(std::move(*mountingTransaction)); + } else { + pendingTransactions_.push_back(std::move(*mountingTransaction)); +diff --git a/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.cpp b/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.cpp +index d7dd1bc..d95d779 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.cpp ++++ b/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.cpp +@@ -5,6 +5,8 @@ + * LICENSE file in the root directory of this source tree. + */ + ++#include ++ + #include "MountingTransaction.h" + + namespace facebook::react { +@@ -54,4 +56,21 @@ void MountingTransaction::mergeWith(MountingTransaction&& transaction) { + telemetry_ = std::move(transaction.telemetry_); + } + ++bool MountingTransaction::canMergeWith(MountingTransaction& transaction) { ++ std::set deletedTags; ++ for (const auto& mutation : mutations_) { ++ if (mutation.type == ShadowViewMutation::Type::Delete) { ++ deletedTags.insert(mutation.oldChildShadowView.tag); ++ } ++ } ++ ++ for (const auto& mutation : transaction.getMutations()) { ++ if (mutation.type == ShadowViewMutation::Type::Create && deletedTags.contains(mutation.newChildShadowView.tag)) { ++ return false; ++ } ++ } ++ ++ return true; ++} ++ + } // namespace facebook::react +diff --git a/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.h b/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.h +index 277e9f4..38629db 100644 +--- a/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.h ++++ b/node_modules/react-native/ReactCommon/react/renderer/mounting/MountingTransaction.h +@@ -85,6 +85,14 @@ class MountingTransaction final { + */ + void mergeWith(MountingTransaction&& transaction); + ++ /* ++ * Checks whether the two transactions can be safely merged. Due to ++ * reordering of mutations during mount, the sequence of ++ * REMOVE -> DELETE | CREATE -> INSERT (2 transactions) may get changed to ++ * INSERT -> REMOVE -> DELETE and the state will diverge from there. ++ */ ++ bool canMergeWith(MountingTransaction& transaction); ++ + private: + SurfaceId surfaceId_; + Number number_; diff --git a/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch b/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch deleted file mode 100644 index 4364052e08ec..000000000000 --- a/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch +++ /dev/null @@ -1,695 +0,0 @@ -diff --git a/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js -index 9d16738..bbc66a0 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js -+++ b/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js -@@ -3,7 +3,7 @@ - Object.defineProperty(exports, "__esModule", { - value: true - }); --exports.default = exports.tchildrenRendererDefaultProps = void 0; -+exports.default = void 0; - - var _renderChildren = _interopRequireDefault(require("./renderChildren")); - -@@ -15,15 +15,6 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de - */ - const TChildrenRenderer = _renderChildren.default.bind(null); - --const tchildrenRendererDefaultProps = { -- propsForChildren: {} --}; --/** -- * @ignore -- */ -- --exports.tchildrenRendererDefaultProps = tchildrenRendererDefaultProps; --TChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; - var _default = TChildrenRenderer; - exports.default = _default; - //# sourceMappingURL=TChildrenRenderer.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js -index 50b43ca..5ecf4a4 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js -+++ b/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js -@@ -8,8 +8,6 @@ exports.default = void 0; - - var _SharedPropsProvider = require("./context/SharedPropsProvider"); - --var _TChildrenRenderer = require("./TChildrenRenderer"); -- - var _renderChildren = _interopRequireDefault(require("./renderChildren")); - - function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } -@@ -78,12 +76,7 @@ function TNodeChildrenRenderer(props) { - - return (0, _renderChildren.default)(useTNodeChildrenProps(props)); - } --/** -- * @ignore -- */ -- - --TNodeChildrenRenderer.defaultProps = _TChildrenRenderer.tchildrenRendererDefaultProps; - var _default = TNodeChildrenRenderer; - exports.default = _default; - //# sourceMappingURL=TNodeChildrenRenderer.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js -index eafc942..e083941 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js -+++ b/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js -@@ -57,7 +57,11 @@ const TNodeRenderer = /*#__PURE__*/(0, _react.memo)(function MemoizedTNodeRender - const sharedProps = (0, _SharedPropsProvider.useSharedProps)(); - const renderRegistry = (0, _RenderRegistryProvider.useRendererRegistry)(); - const TNodeChildrenRenderer = (0, _TChildrenRendererContext.useTNodeChildrenRenderer)(); -- const tnodeProps = { ...props, -+ const tnodeProps = { -+ propsFromParent: { -+ collapsedMarginTop: null -+ }, -+ ...props, - TNodeChildrenRenderer, - sharedProps - }; -@@ -109,13 +113,6 @@ const TNodeRenderer = /*#__PURE__*/(0, _react.memo)(function MemoizedTNodeRender - const renderFn = tnode.type === 'block' || tnode.type === 'document' ? _renderBlockContent.default : _renderTextualContent.default; - return Renderer === null ? renderFn(assembledProps) : /*#__PURE__*/_react.default.createElement(Renderer, assembledProps); - }); --const defaultProps = { -- propsFromParent: { -- collapsedMarginTop: null -- } --}; // @ts-expect-error default props must be defined -- --TNodeRenderer.defaultProps = defaultProps; - var _default = TNodeRenderer; - exports.default = _default; - //# sourceMappingURL=TNodeRenderer.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js b/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js -index 3a700b6..89a4dd4 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js -+++ b/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js -@@ -5,89 +5,16 @@ Object.defineProperty(exports, "__esModule", { - }); - exports.useAmbientTRenderEngine = useAmbientTRenderEngine; - exports.default = TRenderEngineProvider; --exports.defaultTRenderEngineProviderProps = exports.defaultFallbackFonts = exports.tRenderEngineProviderPropTypes = void 0; - - var _react = _interopRequireDefault(require("react")); - --var _reactNative = require("react-native"); -- --var _propTypes = _interopRequireDefault(require("prop-types")); -- - var _useTRenderEngine = _interopRequireDefault(require("./hooks/useTRenderEngine")); - --var _defaultSystemFonts = _interopRequireDefault(require("./defaultSystemFonts")); -- - function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - - const defaultTRenderEngine = {}; - - const TRenderEngineContext = /*#__PURE__*/_react.default.createContext(defaultTRenderEngine); -- --const tRenderEngineProviderPropTypes = { -- customHTMLElementModels: _propTypes.default.object.isRequired, -- enableCSSInlineProcessing: _propTypes.default.bool, -- enableUserAgentStyles: _propTypes.default.bool, -- idsStyles: _propTypes.default.object, -- ignoredDomTags: _propTypes.default.array, -- ignoreDomNode: _propTypes.default.func, -- domVisitors: _propTypes.default.object, -- ignoredStyles: _propTypes.default.array.isRequired, -- allowedStyles: _propTypes.default.array, -- htmlParserOptions: _propTypes.default.object, -- tagsStyles: _propTypes.default.object, -- classesStyles: _propTypes.default.object, -- emSize: _propTypes.default.number.isRequired, -- baseStyle: _propTypes.default.object, -- systemFonts: _propTypes.default.arrayOf(_propTypes.default.string), -- fallbackFonts: _propTypes.default.shape({ -- serif: _propTypes.default.string, -- 'sans-serif': _propTypes.default.string, -- monospace: _propTypes.default.string -- }), -- setMarkersForTNode: _propTypes.default.func, -- dangerouslyDisableHoisting: _propTypes.default.bool, -- dangerouslyDisableWhitespaceCollapsing: _propTypes.default.bool, -- selectDomRoot: _propTypes.default.func --}; --/** -- * Default fallback font for special keys such as 'sans-serif', 'monospace', -- * 'serif', based on current platform. -- */ -- --exports.tRenderEngineProviderPropTypes = tRenderEngineProviderPropTypes; --const defaultFallbackFonts = { -- 'sans-serif': _reactNative.Platform.select({ -- ios: 'system', -- default: 'sans-serif' -- }), -- monospace: _reactNative.Platform.select({ -- ios: 'Menlo', -- default: 'monospace' -- }), -- serif: _reactNative.Platform.select({ -- ios: 'Times New Roman', -- default: 'serif' -- }) --}; --exports.defaultFallbackFonts = defaultFallbackFonts; --const defaultTRenderEngineProviderProps = { -- htmlParserOptions: { -- decodeEntities: true -- }, -- emSize: 14, -- ignoredDomTags: [], -- ignoredStyles: [], -- baseStyle: { -- fontSize: 14 -- }, -- tagsStyles: {}, -- classesStyles: {}, -- enableUserAgentStyles: true, -- enableCSSInlineProcessing: true, -- customHTMLElementModels: {}, -- fallbackFonts: defaultFallbackFonts, -- systemFonts: _defaultSystemFonts.default --}; - /** - * Use the ambient transient render engine. - * -@@ -96,7 +23,6 @@ const defaultTRenderEngineProviderProps = { - * @public - */ - --exports.defaultTRenderEngineProviderProps = defaultTRenderEngineProviderProps; - - function useAmbientTRenderEngine() { - const engine = _react.default.useContext(TRenderEngineContext); -@@ -126,15 +52,4 @@ function TRenderEngineProvider({ - value: engine - }, children); - } --/** -- * @ignore -- */ -- -- --TRenderEngineProvider.defaultProps = defaultTRenderEngineProviderProps; --/** -- * @ignore -- */ -- --TRenderEngineProvider.propTypes = tRenderEngineProviderPropTypes; - //# sourceMappingURL=TRenderEngineProvider.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js b/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js -index 1be151a..3a076d4 100644 ---- a/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js -+++ b/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js -@@ -7,8 +7,6 @@ exports.default = void 0; - - var _react = _interopRequireDefault(require("react")); - --var _propTypes = _interopRequireDefault(require("prop-types")); -- - var _useIMGElementState = _interopRequireDefault(require("./useIMGElementState")); - - var _IMGElementContentSuccess = _interopRequireDefault(require("./IMGElementContentSuccess")); -@@ -19,15 +17,10 @@ var _IMGElementContentLoading = _interopRequireDefault(require("./IMGElementCont - - var _IMGElementContentError = _interopRequireDefault(require("./IMGElementContentError")); - --var _defaultInitialImageDimensions = _interopRequireDefault(require("./defaultInitialImageDimensions")); -- - function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } - - function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } - --function identity(arg) { -- return arg; --} - /** - * A component to render images based on an internal loading state. - * -@@ -37,8 +30,6 @@ function identity(arg) { - * {@link IMGElementContentSuccess}, {@link IMGElementContentLoading} - * and {@link IMGElementContentError} for customization. - */ -- -- - function IMGElement(props) { - const state = (0, _useIMGElementState.default)(props); - let content; -@@ -59,43 +50,6 @@ function IMGElement(props) { - }), content); - } - --const imgDimensionsType = _propTypes.default.shape({ -- width: _propTypes.default.number, -- height: _propTypes.default.number --}); -- --const propTypes = { -- source: _propTypes.default.object.isRequired, -- alt: _propTypes.default.string, -- altColor: _propTypes.default.string, -- height: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.number]), -- width: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.number]), -- style: _propTypes.default.oneOfType([_propTypes.default.object, _propTypes.default.array]), -- computeMaxWidth: _propTypes.default.func.isRequired, -- contentWidth: _propTypes.default.number, -- enableExperimentalPercentWidth: _propTypes.default.bool, -- initialDimensions: imgDimensionsType, -- onPress: _propTypes.default.func, -- testID: _propTypes.default.string, -- objectFit: _propTypes.default.string, -- cachedNaturalDimensions: imgDimensionsType, -- containerProps: _propTypes.default.object --}; --/** -- * @ignore -- */ -- --IMGElement.propTypes = propTypes; --/** -- * @ignore -- */ -- --IMGElement.defaultProps = { -- enableExperimentalPercentWidth: false, -- computeMaxWidth: identity, -- imagesInitialDimensions: _defaultInitialImageDimensions.default, -- style: {} --}; - var _default = IMGElement; - exports.default = _default; - //# sourceMappingURL=IMGElement.js.map -\ No newline at end of file -diff --git a/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx b/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx -index 0df5375..925062a 100644 ---- a/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx -+++ b/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx -@@ -1,5 +1,4 @@ - import React, { PropsWithChildren, ReactElement, useMemo } from 'react'; --import PropTypes from 'prop-types'; - import RenderersPropsProvider from './context/RenderersPropsProvider'; - import SharedPropsProvider from './context/SharedPropsProvider'; - import TChildrenRenderersContext from './context/TChildrenRendererContext'; -@@ -20,29 +19,6 @@ const childrenRendererContext = { - TNodeChildrenRenderer - }; - --export type RenderHTMLConfigPropTypes = Record; -- --export const renderHTMLConfigPropTypes: RenderHTMLConfigPropTypes = { -- bypassAnonymousTPhrasingNodes: PropTypes.bool, -- defaultTextProps: PropTypes.object, -- defaultViewProps: PropTypes.object, -- enableExperimentalBRCollapsing: PropTypes.bool, -- enableExperimentalGhostLinesPrevention: PropTypes.bool, -- enableExperimentalMarginCollapsing: PropTypes.bool, -- remoteErrorView: PropTypes.func, -- remoteLoadingView: PropTypes.func, -- debug: PropTypes.bool, -- computeEmbeddedMaxWidth: PropTypes.func, -- renderersProps: PropTypes.object, -- WebView: PropTypes.any, -- GenericPressable: PropTypes.any, -- defaultWebViewProps: PropTypes.object, -- pressableHightlightColor: PropTypes.string, -- customListStyleSpecs: PropTypes.object, -- renderers: PropTypes.object, -- provideEmbeddedHeaders: PropTypes.func --}; -- - /** - * A component to provide configuration for {@link RenderHTMLSource} - * descendants, to be used in conjunction with {@link TRenderEngineProvider}. -@@ -85,8 +61,3 @@ export default function RenderHTMLConfigProvider( - - ); - } -- --/** -- * @ignore -- */ --RenderHTMLConfigProvider.propTypes = renderHTMLConfigPropTypes; -diff --git a/node_modules/react-native-render-html/src/RenderHTMLSource.tsx b/node_modules/react-native-render-html/src/RenderHTMLSource.tsx -index c91da52..fd0e052 100644 ---- a/node_modules/react-native-render-html/src/RenderHTMLSource.tsx -+++ b/node_modules/react-native-render-html/src/RenderHTMLSource.tsx -@@ -1,7 +1,6 @@ - import equals from 'ramda/src/equals'; - import React, { memo, ReactElement, useMemo } from 'react'; - import { Dimensions } from 'react-native'; --import PropTypes from 'prop-types'; - import ttreeEventsContext from './context/ttreeEventsContext'; - import isUriSource from './helpers/isUriSource'; - import { SourceLoaderProps, TTreeEvents } from './internal-types'; -@@ -25,29 +24,6 @@ export type RenderHTMLSourcePropTypes = Record< - any - >; - --export const renderSourcePropTypes: RenderHTMLSourcePropTypes = { -- source: PropTypes.oneOfType([ -- PropTypes.shape({ -- html: PropTypes.string.isRequired, -- baseUrl: PropTypes.string -- }), -- PropTypes.shape({ -- dom: PropTypes.object.isRequired, -- baseUrl: PropTypes.string -- }), -- PropTypes.shape({ -- uri: PropTypes.string.isRequired, -- method: PropTypes.string, -- body: PropTypes.any, -- headers: PropTypes.object -- }) -- ]), -- onTTreeChange: PropTypes.func, -- onHTMLLoaded: PropTypes.func, -- onDocumentMetadataLoaded: PropTypes.func, -- contentWidth: PropTypes.number --}; -- - function isEmptySource(source: undefined | HTMLSource) { - return ( - !source || -@@ -136,9 +112,4 @@ const RenderHTMLSource = memo( - } - ); - --/** -- * @ignore -- */ --(RenderHTMLSource as any).propTypes = renderSourcePropTypes; -- - export default RenderHTMLSource; -diff --git a/node_modules/react-native-render-html/src/TChildrenRenderer.tsx b/node_modules/react-native-render-html/src/TChildrenRenderer.tsx -index 618a592..e12888e 100644 ---- a/node_modules/react-native-render-html/src/TChildrenRenderer.tsx -+++ b/node_modules/react-native-render-html/src/TChildrenRenderer.tsx -@@ -9,16 +9,4 @@ import renderChildren from './renderChildren'; - const TChildrenRenderer: FunctionComponent = - renderChildren.bind(null); - --export const tchildrenRendererDefaultProps: Pick< -- TChildrenRendererProps, -- 'propsForChildren' --> = { -- propsForChildren: {} --}; -- --/** -- * @ignore -- */ --TChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; -- - export default TChildrenRenderer; -diff --git a/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx b/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx -index bf5aef6..b820de0 100644 ---- a/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx -+++ b/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx -@@ -1,7 +1,6 @@ - import { ReactElement } from 'react'; - import { TNode } from '@native-html/transient-render-engine'; - import { useSharedProps } from './context/SharedPropsProvider'; --import { tchildrenRendererDefaultProps } from './TChildrenRenderer'; - import { - TChildrenRendererProps, - TNodeChildrenRendererProps -@@ -73,9 +72,4 @@ function TNodeChildrenRenderer( - return renderChildren(useTNodeChildrenProps(props)); - } - --/** -- * @ignore -- */ --TNodeChildrenRenderer.defaultProps = tchildrenRendererDefaultProps; -- - export default TNodeChildrenRenderer; -diff --git a/node_modules/react-native-render-html/src/TNodeRenderer.tsx b/node_modules/react-native-render-html/src/TNodeRenderer.tsx -index d32140f..0804ba7 100644 ---- a/node_modules/react-native-render-html/src/TNodeRenderer.tsx -+++ b/node_modules/react-native-render-html/src/TNodeRenderer.tsx -@@ -49,6 +49,7 @@ const TNodeRenderer = memo(function MemoizedTNodeRenderer( - const renderRegistry = useRendererRegistry(); - const TNodeChildrenRenderer = useTNodeChildrenRenderer(); - const tnodeProps = { -+ propsFromParent: { collapsedMarginTop: null }, - ...props, - TNodeChildrenRenderer, - sharedProps -@@ -120,16 +121,6 @@ const TNodeRenderer = memo(function MemoizedTNodeRenderer( - : React.createElement(Renderer as any, assembledProps); - }); - --const defaultProps: Required, 'propsFromParent'>> = -- { -- propsFromParent: { -- collapsedMarginTop: null -- } -- }; -- --// @ts-expect-error default props must be defined --TNodeRenderer.defaultProps = defaultProps; -- - export { - TDefaultBlockRenderer, - TDefaultPhrasingRenderer, -diff --git a/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx b/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx -index 95b60df..96604c8 100644 ---- a/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx -+++ b/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx -@@ -1,73 +1,13 @@ - import TRenderEngine from '@native-html/transient-render-engine'; - import React, { PropsWithChildren, ReactElement } from 'react'; --import { Platform } from 'react-native'; --import PropTypes from 'prop-types'; - import useTRenderEngine from './hooks/useTRenderEngine'; - import { TRenderEngineConfig } from './shared-types'; --import defaultSystemFonts from './defaultSystemFonts'; - - const defaultTRenderEngine = {} as any; - - const TRenderEngineContext = - React.createContext(defaultTRenderEngine); - --export const tRenderEngineProviderPropTypes: Record< -- keyof TRenderEngineConfig, -- any --> = { -- customHTMLElementModels: PropTypes.object.isRequired, -- enableCSSInlineProcessing: PropTypes.bool, -- enableUserAgentStyles: PropTypes.bool, -- idsStyles: PropTypes.object, -- ignoredDomTags: PropTypes.array, -- ignoreDomNode: PropTypes.func, -- domVisitors: PropTypes.object, -- ignoredStyles: PropTypes.array.isRequired, -- allowedStyles: PropTypes.array, -- htmlParserOptions: PropTypes.object, -- tagsStyles: PropTypes.object, -- classesStyles: PropTypes.object, -- emSize: PropTypes.number.isRequired, -- baseStyle: PropTypes.object, -- systemFonts: PropTypes.arrayOf(PropTypes.string), -- fallbackFonts: PropTypes.shape({ -- serif: PropTypes.string, -- 'sans-serif': PropTypes.string, -- monospace: PropTypes.string -- }), -- setMarkersForTNode: PropTypes.func, -- dangerouslyDisableHoisting: PropTypes.bool, -- dangerouslyDisableWhitespaceCollapsing: PropTypes.bool, -- selectDomRoot: PropTypes.func --}; -- --/** -- * Default fallback font for special keys such as 'sans-serif', 'monospace', -- * 'serif', based on current platform. -- */ --export const defaultFallbackFonts = { -- 'sans-serif': Platform.select({ ios: 'system', default: 'sans-serif' }), -- monospace: Platform.select({ ios: 'Menlo', default: 'monospace' }), -- serif: Platform.select({ ios: 'Times New Roman', default: 'serif' }) --}; -- --export const defaultTRenderEngineProviderProps: TRenderEngineConfig = { -- htmlParserOptions: { -- decodeEntities: true -- }, -- emSize: 14, -- ignoredDomTags: [], -- ignoredStyles: [], -- baseStyle: { fontSize: 14 }, -- tagsStyles: {}, -- classesStyles: {}, -- enableUserAgentStyles: true, -- enableCSSInlineProcessing: true, -- customHTMLElementModels: {}, -- fallbackFonts: defaultFallbackFonts, -- systemFonts: defaultSystemFonts --}; -- - /** - * Use the ambient transient render engine. - * -@@ -106,13 +46,3 @@ export default function TRenderEngineProvider({ - - ); - } -- --/** -- * @ignore -- */ --TRenderEngineProvider.defaultProps = defaultTRenderEngineProviderProps; -- --/** -- * @ignore -- */ --TRenderEngineProvider.propTypes = tRenderEngineProviderPropTypes; -diff --git a/node_modules/react-native-render-html/src/elements/IMGElement.tsx b/node_modules/react-native-render-html/src/elements/IMGElement.tsx -index 573e7c1..a6fc90b 100644 ---- a/node_modules/react-native-render-html/src/elements/IMGElement.tsx -+++ b/node_modules/react-native-render-html/src/elements/IMGElement.tsx -@@ -1,19 +1,13 @@ - import React, { ReactElement, ReactNode } from 'react'; --import PropTypes from 'prop-types'; - import useIMGElementState from './useIMGElementState'; - import IMGElementContentSuccess from './IMGElementContentSuccess'; - import IMGElementContainer from './IMGElementContainer'; - import IMGElementContentLoading from './IMGElementContentLoading'; - import IMGElementContentError from './IMGElementContentError'; - import type { IMGElementProps } from './img-types'; --import defaultImageInitialDimensions from './defaultInitialImageDimensions'; - - export type { IMGElementProps } from './img-types'; - --function identity(arg: any) { -- return arg; --} -- - /** - * A component to render images based on an internal loading state. - * -@@ -44,42 +38,4 @@ function IMGElement(props: IMGElementProps): ReactElement { - ); - } - --const imgDimensionsType = PropTypes.shape({ -- width: PropTypes.number, -- height: PropTypes.number --}); -- --const propTypes: Record = { -- source: PropTypes.object.isRequired, -- alt: PropTypes.string, -- altColor: PropTypes.string, -- height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), -- width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), -- style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), -- computeMaxWidth: PropTypes.func.isRequired, -- contentWidth: PropTypes.number, -- enableExperimentalPercentWidth: PropTypes.bool, -- initialDimensions: imgDimensionsType, -- onPress: PropTypes.func, -- testID: PropTypes.string, -- objectFit: PropTypes.string, -- cachedNaturalDimensions: imgDimensionsType, -- containerProps: PropTypes.object --}; -- --/** -- * @ignore -- */ --IMGElement.propTypes = propTypes; -- --/** -- * @ignore -- */ --IMGElement.defaultProps = { -- enableExperimentalPercentWidth: false, -- computeMaxWidth: identity, -- imagesInitialDimensions: defaultImageInitialDimensions, -- style: {} --}; -- - export default IMGElement; -diff --git a/node_modules/react-native-render-html/src/elements/useIMGElementState.ts b/node_modules/react-native-render-html/src/elements/useIMGElementState.ts -index 6590d21..b603f26 100644 ---- a/node_modules/react-native-render-html/src/elements/useIMGElementState.ts -+++ b/node_modules/react-native-render-html/src/elements/useIMGElementState.ts -@@ -63,6 +63,10 @@ function useImageNaturalDimensions

(props: { - }; - } - -+function identity(arg: any) { -+ return arg; -+} -+ - function useFetchedNaturalDimensions(props: { - cachedNaturalDimensions?: ImageDimensions; - source: ImageURISource; -@@ -116,7 +120,7 @@ export default function useIMGElementState( - altColor, - source, - contentWidth, -- computeMaxWidth, -+ computeMaxWidth = identity, - objectFit, - initialDimensions = defaultImageInitialDimensions, - cachedNaturalDimensions -diff --git a/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts b/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts -index 5d6271b..710c73f 100644 ---- a/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts -+++ b/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts -@@ -71,8 +71,7 @@ function deriveSpecifiedDimensionsFromProps({ - export default function useImageSpecifiedDimensions( - props: UseIMGElementStateProps - ) { -- const { contentWidth, enableExperimentalPercentWidth, style, width, height } = -- props; -+ const { contentWidth, enableExperimentalPercentWidth = false, style = {}, width, height } = props - const flatStyle = useMemo(() => StyleSheet.flatten(style) || {}, [style]); - const specifiedDimensions = useMemo( - () => -diff --git a/node_modules/react-native-render-html/src/index.ts b/node_modules/react-native-render-html/src/index.ts -index 8569583..b59ec49 100644 ---- a/node_modules/react-native-render-html/src/index.ts -+++ b/node_modules/react-native-render-html/src/index.ts -@@ -128,7 +128,6 @@ export { - export { default as TNodeRenderer } from './TNodeRenderer'; - export { - default as TRenderEngineProvider, -- defaultFallbackFonts, - useAmbientTRenderEngine - } from './TRenderEngineProvider'; - export { default as RenderHTMLConfigProvider } from './RenderHTMLConfigProvider'; -diff --git a/node_modules/react-native-render-html/src/renderChildren.tsx b/node_modules/react-native-render-html/src/renderChildren.tsx -index a669402..be9ffd6 100644 ---- a/node_modules/react-native-render-html/src/renderChildren.tsx -+++ b/node_modules/react-native-render-html/src/renderChildren.tsx -@@ -4,8 +4,6 @@ import TNodeRenderer from './TNodeRenderer'; - import { TChildrenRendererProps } from './shared-types'; - import collapseTopMarginForChild from './helpers/collapseTopMarginForChild'; - --const empty = {}; -- - const mapCollapsibleChildren = ( - propsForChildren: TChildrenRendererProps['propsForChildren'], - renderChild: TChildrenRendererProps['renderChild'], -@@ -39,7 +37,7 @@ const mapCollapsibleChildren = ( - - export default function renderChildren({ - tchildren, -- propsForChildren = empty, -+ propsForChildren = {}, - disableMarginCollapsing, - renderChild - }: TChildrenRendererProps): ReactElement { diff --git a/patches/react-native-render-html+6.3.1+001+initial.patch b/patches/react-native-render-html+6.3.1.patch similarity index 100% rename from patches/react-native-render-html+6.3.1+001+initial.patch rename to patches/react-native-render-html+6.3.1.patch diff --git a/patches/react-native-screens+3.34.0+004+ios-custom-animations-native-transitions.patch b/patches/react-native-screens+3.34.0+004+ios-custom-animations-native-transitions.patch index 62cbf68f458d..52f8d76c4fe1 100644 --- a/patches/react-native-screens+3.34.0+004+ios-custom-animations-native-transitions.patch +++ b/patches/react-native-screens+3.34.0+004+ios-custom-animations-native-transitions.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm b/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm -index abb2cf6..fb81d52 100644 +index abb2cf6..c21b3e9 100644 --- a/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm +++ b/node_modules/react-native-screens/ios/RNSScreenStackAnimator.mm @@ -5,13 +5,14 @@ @@ -32,7 +32,7 @@ index abb2cf6..fb81d52 100644 } @@ -129,6 +130,8 @@ - (void)animateSimplePushWithShadowEnabled:(BOOL)shadowEnabled } - + [UIView animateWithDuration:[self transitionDuration:transitionContext] + delay:0 + options:UIViewAnimationOptionCurveDefaultTransition @@ -66,25 +66,7 @@ index abb2cf6..fb81d52 100644 animations:animationBlock completion:completionBlock]; } else { -@@ -251,6 +260,8 @@ - (void)animateFadeWithTransitionContext:(id; replaceAnimation?: WithDefault; swipeDirection?: WithDefault; - hideKeyboardOnSwipe?: boolean; \ No newline at end of file + hideKeyboardOnSwipe?: boolean; diff --git a/react-native.config.js b/react-native.config.js index 773375378acd..ffbc8f5c65cb 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -1,3 +1,5 @@ +const path = require('path'); +const pak = require('./modules/ContactsNitroModule/package.json'); const iosSourceDir = process.env.PROJECT_ROOT_PATH ? process.env.PROJECT_ROOT_PATH + 'ios' : 'ios'; const androidSourceDir = process.env.PROJECT_ROOT_PATH ? process.env.PROJECT_ROOT_PATH + 'android' : 'android'; @@ -7,4 +9,9 @@ module.exports = { android: {sourceDir: androidSourceDir}, }, assets: ['./assets/fonts/native'], + dependencies: { + [pak.name]: { + root: path.join(__dirname, 'modules', 'ContactsNitroModule'), + }, + }, }; diff --git a/scripts/applyPatches.sh b/scripts/applyPatches.sh index 29e121acc968..9ba8360ea39f 100755 --- a/scripts/applyPatches.sh +++ b/scripts/applyPatches.sh @@ -11,11 +11,13 @@ source "$SCRIPTS_DIR/shellUtils.sh" function patchPackage { # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) + NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" OS="$(uname)" if [[ "$OS" == "Darwin" || "$OS" == "Linux" ]]; then npx patch-package --error-on-fail --color=always - if [[ "$IS_HYBRID_APP_REPO" == "true" ]]; then + if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then + echo -e "\n${GREEN}Applying HybridApp patches!${NC}" npx patch-package --patch-dir 'Mobile-Expensify/patches' --error-on-fail --color=always fi else diff --git a/scripts/clean.sh b/scripts/clean.sh index 1ecd73731b61..fbbfa070d442 100755 --- a/scripts/clean.sh +++ b/scripts/clean.sh @@ -7,7 +7,10 @@ NC='\033[0m' # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) -if [[ "$IS_HYBRID_APP_REPO" == "true" && "$1" != "--new-dot" ]]; then +# See if we should force standalone NewDot build +NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" + +if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then echo -e "${BLUE}Cleaning HybridApp project...${NC}" # Navigate to Mobile-Expensify repository, and clean cd Mobile-Expensify diff --git a/scripts/pod-install.sh b/scripts/pod-install.sh index 8e38f1706d6f..77237bb207b4 100755 --- a/scripts/pod-install.sh +++ b/scripts/pod-install.sh @@ -45,11 +45,9 @@ fi # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) -NEW_DOT_FLAG="false" -if [ "$1" == "--new-dot" ]; then - NEW_DOT_FLAG="true" -fi +# See if we should force standalone NewDot build +NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then echo -e "${BLUE}Executing npm run pod-install for HybridApp...${NC}" diff --git a/scripts/postInstall.sh b/scripts/postInstall.sh index db24f04f8a6c..c2adcadc4f43 100755 --- a/scripts/postInstall.sh +++ b/scripts/postInstall.sh @@ -10,7 +10,11 @@ cd "$ROOT_DIR" || exit 1 # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) -if [[ "$IS_HYBRID_APP_REPO" == "true" ]]; then +# See if we should force standalone NewDot build +NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" + +if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then + echo -e "\n${GREEN}Installing node modules in Mobile-Expensify submodule!${NC}" cd Mobile-Expensify || exit 1 npm i diff --git a/scripts/run-build.sh b/scripts/run-build.sh index 7689aabbbf59..67da6285e1a0 100755 --- a/scripts/run-build.sh +++ b/scripts/run-build.sh @@ -3,8 +3,6 @@ set -e export PROJECT_ROOT_PATH -BUILD="$1" -NEW_DOT_FLAG="false" IOS_MODE="DebugDevelopment" ANDROID_MODE="developmentDebug" SCHEME="New Expensify Dev" @@ -20,31 +18,24 @@ function print_error_and_exit { exit 1 } -# Assign the arguments to variables -if [ "$#" -eq 1 ]; then - BUILD="$1" -elif [ "$#" -eq 2 ]; then - if [ "$1" == "--new-dot" ]; then - BUILD="$2" - NEW_DOT_FLAG="true" - elif [ "$2" == "--new-dot" ]; then - BUILD="$1" - NEW_DOT_FLAG="true" - else - print_error_and_exit - fi -else +# Assign the arguments to variables if arguments are correct +if [ "$#" -ne 1 ] || [[ "$1" != "--ios" && "$1" != "--ipad" && "$1" != "--ipad-sm" && "$1" != "--android" ]]; then print_error_and_exit fi +BUILD="$1" + # See if we're in the HybridApp repo IS_HYBRID_APP_REPO=$(scripts/is-hybrid-app.sh) +# See if we should force standalone NewDot build +NEW_DOT_FLAG="${STANDALONE_NEW_DOT:-false}" + if [[ "$IS_HYBRID_APP_REPO" == "true" && "$NEW_DOT_FLAG" == "false" ]]; then # Set HybridApp-specific arguments IOS_MODE="Debug" ANDROID_MODE="Debug" - SCHEME="Expensify" + SCHEME="Expensify Dev" APP_ID="org.me.mobiexpensifyg" echo -e "\n${GREEN}Starting a HybridApp build!${NC}" diff --git a/src/CONST.ts b/src/CONST.ts index 4bfaad7b6d1b..53197d41b85e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -514,6 +514,7 @@ const CONST = { MAX_DATE: '9999-12-31', MIN_DATE: '0001-01-01', ORDINAL_DAY_OF_MONTH: 'do', + MONTH_DAY_YEAR_ORDINAL_FORMAT: 'MMMM do, yyyy', }, SMS: { DOMAIN: '@expensify.sms', @@ -900,6 +901,7 @@ const CONST = { DEEP_DIVE_EXPENSIFY_CARD: 'https://community.expensify.com/discussion/4848/deep-dive-expensify-card-and-quickbooks-online-auto-reconciliation-how-it-works', DEEP_DIVE_ERECEIPTS: 'https://community.expensify.com/discussion/5542/deep-dive-what-are-ereceipts/', DEEP_DIVE_PER_DIEM: 'https://community.expensify.com/discussion/4772/how-to-add-a-single-rate-per-diem', + SET_NOTIFICATION_LINK: 'https://community.expensify.com/discussion/5651/deep-dive--practices-when-youre-running-into-trouble-receiving-emails-from-expensify', GITHUB_URL: 'https://github.com/Expensify/App', HELP_LINK_URL: `${USE_EXPENSIFY_URL}/usa-patriot-act`, ELECTRONIC_DISCLOSURES_URL: `${USE_EXPENSIFY_URL}/esignagreement`, @@ -4452,7 +4454,7 @@ const CONST = { BOOK_TRAVEL_DEMO_URL: 'https://calendly.com/d/ck2z-xsh-q97/expensify-travel-demo-travel-page', TRAVEL_DOT_URL: 'https://travel.expensify.com', STAGING_TRAVEL_DOT_URL: 'https://staging.travel.expensify.com', - TRIP_ID_PATH: (tripID: string) => `trips/${tripID}`, + TRIP_ID_PATH: (tripID?: string) => (tripID ? `trips/${tripID}` : undefined), SPOTNANA_TMC_ID: '8e8e7258-1cf3-48c0-9cd1-fe78a6e31eed', STAGING_SPOTNANA_TMC_ID: '7a290c6e-5328-4107-aff6-e48765845b81', SCREEN_READER_STATES: { @@ -5953,6 +5955,7 @@ const CONST = { CAR: 'car', HOTEL: 'hotel', FLIGHT: 'flight', + TRAIN: 'train', }, DOT_SEPARATOR: '•', @@ -6432,6 +6435,14 @@ const CONST = { }, }, + DEVICE_CONTACT: { + FIRST_NAME: 'FIRST_NAME', + LAST_NAME: 'LAST_NAME', + PHONE_NUMBERS: 'PHONE_NUMBERS', + EMAIL_ADDRESSES: 'EMAIL_ADDRESSES', + IMAGE_DATA: 'IMAGE_DATA', + }, + HYBRID_APP: { REORDERING_REACT_NATIVE_ACTIVITY_TO_FRONT: 'reorderingReactNativeActivityToFront', }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 462ca9e22d2d..2a2959f43f66 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -470,6 +470,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /** The user's session that will be preserved when using imported state */ + PRESERVED_USER_SESSION: 'preservedUserSession', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -1038,6 +1041,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; + [ONYXKEYS.PRESERVED_USER_SESSION]: OnyxTypes.Session; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b9544d81bece..f48a5cae92f0 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -716,6 +716,10 @@ const ROUTES = { route: 'settings/workspaces/:policyID/profile/address', getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/profile/address` as const, backTo), }, + WORKSPACE_PROFILE_PLAN: { + route: 'settings/workspaces/:policyID/profile/plan', + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/profile/plan` as const, backTo), + }, WORKSPACE_ACCOUNTING: { route: 'settings/workspaces/:policyID/accounting', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting` as const, @@ -979,9 +983,9 @@ const ROUTES = { getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/category/${encodeURIComponent(categoryName)}` as const, }, WORKSPACE_UPGRADE: { - route: 'settings/workspaces/:policyID/upgrade/:featureName', - getRoute: (policyID: string, featureName: string, backTo?: string) => - getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName)}` as const, backTo), + route: 'settings/workspaces/:policyID/upgrade/:featureName?', + getRoute: (policyID: string, featureName?: string, backTo?: string) => + getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName ?? '')}` as const, backTo), }, WORKSPACE_DOWNGRADE: { route: 'settings/workspaces/:policyID/downgrade/', @@ -1162,16 +1166,16 @@ const ROUTES = { }, WORKSPACE_REPORT_FIELDS_LIST_VALUES: { route: 'settings/workspaces/:policyID/reportFields/listValues/:reportFieldID?', - getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/listValues/${encodeURIComponent(reportFieldID ?? '')}` as const, + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/listValues/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, }, WORKSPACE_REPORT_FIELDS_ADD_VALUE: { route: 'settings/workspaces/:policyID/reportFields/addValue/:reportFieldID?', - getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/addValue/${encodeURIComponent(reportFieldID ?? '')}` as const, + getRoute: (policyID: string, reportFieldID?: string) => `settings/workspaces/${policyID}/reportFields/addValue/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, }, WORKSPACE_REPORT_FIELDS_VALUE_SETTINGS: { route: 'settings/workspaces/:policyID/reportFields/:valueIndex/:reportFieldID?', getRoute: (policyID: string, valueIndex: number, reportFieldID?: string) => - `settings/workspaces/${policyID}/reportFields/${valueIndex}/${encodeURIComponent(reportFieldID ?? '')}` as const, + `settings/workspaces/${policyID}/reportFields/${valueIndex}/${reportFieldID ? encodeURIComponent(reportFieldID) : ''}` as const, }, WORKSPACE_REPORT_FIELDS_EDIT_VALUE: { route: 'settings/workspaces/:policyID/reportFields/new/:valueIndex/edit', @@ -1365,6 +1369,15 @@ const ROUTES = { TRAVEL_MY_TRIPS: 'travel', TRAVEL_TCS: 'travel/terms', TRACK_TRAINING_MODAL: 'track-training', + TRAVEL_TRIP_SUMMARY: { + route: 'r/:reportID/trip/:transactionID', + getRoute: (reportID: string, transactionID: string, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}`, backTo), + }, + TRAVEL_TRIP_DETAILS: { + route: 'r/:reportID/trip/:transactionID/:reservationIndex', + getRoute: (reportID: string, transactionID: string, reservationIndex: number, backTo?: string) => + getUrlWithBackToParam(`r/${reportID}/trip/${transactionID}/${reservationIndex}`, backTo), + }, ONBOARDING_ROOT: { route: 'onboarding', getRoute: (backTo?: string) => getUrlWithBackToParam(`onboarding`, backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6c4547e94c37..86c71e182a2b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -27,6 +27,8 @@ const SCREENS = { TRAVEL: { MY_TRIPS: 'Travel_MyTrips', TCS: 'Travel_TCS', + TRIP_SUMMARY: 'Travel_TripSummary', + TRIP_DETAILS: 'Travel_TripDetails', }, SEARCH: { CENTRAL_PANE: 'Search_Central_Pane', @@ -503,6 +505,7 @@ const SCREENS = { TAG_GL_CODE: 'Tag_GL_Code', CURRENCY: 'Workspace_Profile_Currency', ADDRESS: 'Workspace_Profile_Address', + PLAN: 'Workspace_Profile_Plan_Type', WORKFLOWS: 'Workspace_Workflows', WORKFLOWS_PAYER: 'Workspace_Workflows_Payer', WORKFLOWS_APPROVALS_NEW: 'Workspace_Approvals_New', diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts index c443b1ab8093..c0010af468af 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts +++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts @@ -7,7 +7,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; -import type {ReportAction, ReportActions} from '@src/types/onyx'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {Note} from '@src/types/onyx/Report'; /** @@ -20,12 +20,11 @@ function extractAttachments( accountID, parentReportAction, reportActions, - reportID, - }: {privateNotes?: Record; accountID?: number; parentReportAction?: OnyxEntry; reportActions?: OnyxEntry; reportID: string}, + report, + }: {privateNotes?: Record; accountID?: number; parentReportAction?: OnyxEntry; reportActions?: OnyxEntry; report: OnyxEntry}, ) { const targetNote = privateNotes?.[Number(accountID)]?.note ?? ''; const attachments: Attachment[] = []; - const report = ReportUtils.getReport(reportID); const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // We handle duplicate image sources by considering the first instance as original. Selecting any duplicate diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index 9aa619eb1cda..68668ccc6ab0 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -34,9 +34,9 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, reportID: report.reportID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, report}); } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions, reportID: report.reportID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions, report}); } let newIndex = newAttachments.findIndex(compareImage); @@ -68,7 +68,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi } } // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [reportActions, compareImage]); + }, [reportActions, compareImage, report]); /** Updates the page state when the user navigates between attachments */ const updatePage = useCallback( diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index f169416f1812..50caaac3dd81 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -89,9 +89,9 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, reportID: report.reportID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID, report}); } else { - newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined, reportID: report.reportID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined, report}); } if (isEqual(attachments, newAttachments)) { @@ -130,19 +130,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi onNavigate(attachment); } } - }, [ - report.privateNotes, - reportActions, - parentReportActions, - compareImage, - report.parentReportActionID, - attachments, - setDownloadButtonVisibility, - onNavigate, - accountID, - type, - report.reportID, - ]); + }, [reportActions, parentReportActions, compareImage, attachments, setDownloadButtonVisibility, onNavigate, accountID, type, report]); // Scroll position is affected when window width is resized, so we readjust it on width changes useEffect(() => { diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 07edd148778d..84767c6347e7 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -122,6 +122,9 @@ type ButtonProps = Partial & { /** Id to use for this button */ id?: string; + /** Used to locate this button in ui tests */ + testID?: string; + /** Accessibility label for the component */ accessibilityLabel?: string; @@ -237,6 +240,7 @@ function Button( shouldShowRightIcon = false, id = '', + testID = undefined, accessibilityLabel = '', isSplitButton = false, link = false, @@ -405,6 +409,7 @@ function Button( ]} disabledStyle={disabledStyle} id={id} + testID={testID} accessibilityLabel={accessibilityLabel} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index a95cf9bf87d2..7b55f2317d46 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -82,6 +82,7 @@ function ConfirmationPage({ success large text={buttonText} + testID="confirmation-button" style={styles.mt6} pressOnEnter onPress={onButtonPress} diff --git a/src/components/ContactPermissionModal/index.native.tsx b/src/components/ContactPermissionModal/index.native.tsx new file mode 100644 index 000000000000..825c8bc4afbe --- /dev/null +++ b/src/components/ContactPermissionModal/index.native.tsx @@ -0,0 +1,73 @@ +import React, {useEffect, useState} from 'react'; +import {InteractionManager} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import ConfirmModal from '@components/ConfirmModal'; +import * as Illustrations from '@components/Icon/Illustrations'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getContactPermission} from '@libs/ContactPermission'; +import type {ContactPermissionModalProps} from './types'; + +function ContactPermissionModal({startPermissionFlow, resetPermissionFlow, onDeny, onGrant}: ContactPermissionModalProps) { + const [isModalVisible, setIsModalVisible] = useState(false); + + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + if (!startPermissionFlow) { + return; + } + getContactPermission().then((status) => { + if (status === RESULTS.GRANTED || status === RESULTS.LIMITED) { + return onGrant(); + } + if (status === RESULTS.BLOCKED) { + return; + } + setIsModalVisible(true); + }); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- We only want to run this effect when startPermissionFlow changes + }, [startPermissionFlow]); + + const handleGrantPermission = () => { + setIsModalVisible(false); + InteractionManager.runAfterInteractions(onGrant); + }; + + const handleDenyPermission = () => { + onDeny(RESULTS.DENIED); + setIsModalVisible(false); + }; + + const handleCloseModal = () => { + setIsModalVisible(false); + resetPermissionFlow(); + }; + + return ( + + ); +} + +ContactPermissionModal.displayName = 'ContactPermissionModal'; + +export default ContactPermissionModal; diff --git a/src/components/ContactPermissionModal/index.tsx b/src/components/ContactPermissionModal/index.tsx new file mode 100644 index 000000000000..3f7e25bac590 --- /dev/null +++ b/src/components/ContactPermissionModal/index.tsx @@ -0,0 +1,10 @@ +import type {ContactPermissionModalProps} from './types'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function ContactPermissionModal(props: ContactPermissionModalProps) { + return null; +} + +ContactPermissionModal.displayName = 'ContactPermissionModal'; + +export default ContactPermissionModal; diff --git a/src/components/ContactPermissionModal/types.ts b/src/components/ContactPermissionModal/types.ts new file mode 100644 index 000000000000..5c831410656f --- /dev/null +++ b/src/components/ContactPermissionModal/types.ts @@ -0,0 +1,19 @@ +import type {PermissionStatus} from 'react-native-permissions'; + +type ContactPermissionModalProps = { + /** A callback to call when the permission has been granted */ + onGrant: () => void; + + /** A callback to call when the permission has been denied */ + onDeny: (permission: PermissionStatus) => void; + + /** Should start the permission flow? */ + startPermissionFlow: boolean; + + /** Reset the permission flow */ + resetPermissionFlow: () => void; +}; + +export default {}; + +export type {ContactPermissionModalProps}; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 6fa006cfb4fb..b4d097e90994 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -25,6 +25,9 @@ import type HeaderWithBackButtonProps from './types'; function HeaderWithBackButton({ icon, iconFill, + iconWidth, + iconHeight, + iconStyles, guidesCallTaskID = '', onBackButtonPress = () => Navigation.goBack(), onCloseButtonPress = () => Navigation.dismissModal(), @@ -45,6 +48,7 @@ function HeaderWithBackButton({ shouldSetModalVisibility = true, shouldShowThreeDotsButton = false, shouldDisableThreeDotsButton = false, + shouldUseHeadlineHeader = false, stepCounter, subtitle = '', title = '', @@ -72,9 +76,6 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); - // If the icon is present, the header bar should be taller and use different font. - const isCentralPaneSettings = !!icon; - const middleContent = useMemo(() => { if (progressBarPercentage) { return ( @@ -106,14 +107,14 @@ function HeaderWithBackButton({

); }, [ StyleUtils, subTitleLink, - isCentralPaneSettings, + shouldUseHeadlineHeader, policy, progressBarPercentage, report, @@ -138,7 +139,7 @@ function HeaderWithBackButton({ dataSet={{dragArea: false}} style={[ styles.headerBar, - isCentralPaneSettings && styles.headerBarDesktopHeight, + shouldUseHeadlineHeader && styles.headerBarDesktopHeight, shouldShowBorderBottom && styles.borderBottom, // progressBarPercentage can be 0 which would // be falsey, hence using !== undefined explicitly @@ -178,9 +179,10 @@ function HeaderWithBackButton({ {!!icon && ( )} {!!policyAvatar && ( diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 6eef2b072eee..d2d4ba9e4e0f 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -38,6 +38,15 @@ type HeaderWithBackButtonProps = Partial & { * */ icon?: IconAsset; + /** Icon Width */ + iconWidth?: number; + + /** Icon Height */ + iconHeight?: number; + + /** Any additional styles to pass to the icon container. */ + iconStyles?: StyleProp; + /** Method to trigger when pressing download button of the header */ onDownloadButtonPress?: () => void; @@ -119,6 +128,9 @@ type HeaderWithBackButtonProps = Partial & { /** Whether we should navigate to report page when the route have a topMostReport */ shouldNavigateToTopMostReport?: boolean; + /** Whether the header should use the headline header style */ + shouldUseHeadlineHeader?: boolean; + /** The fill color for the icon. Can be hex, rgb, rgba, or valid react-native named color such as 'red' or 'blue'. */ iconFill?: string; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 51db1bc12c8e..4093b44743fe 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -187,6 +187,7 @@ import Task from '@assets/images/task.svg'; import Thread from '@assets/images/thread.svg'; import ThreeDots from '@assets/images/three-dots.svg'; import ThumbsUp from '@assets/images/thumbs-up.svg'; +import Train from '@assets/images/train.svg'; import Transfer from '@assets/images/transfer.svg'; import Trashcan from '@assets/images/trashcan.svg'; import Unlock from '@assets/images/unlock.svg'; @@ -413,5 +414,6 @@ export { Star, QBDSquare, GalleryNotFound, + Train, boltSlash, }; diff --git a/src/components/ImportOnyxState/index.native.tsx b/src/components/ImportOnyxState/index.native.tsx index 2258da4c8f6c..bdd805241c55 100644 --- a/src/components/ImportOnyxState/index.native.tsx +++ b/src/components/ImportOnyxState/index.native.tsx @@ -1,11 +1,12 @@ import React, {useState} from 'react'; import ReactNativeBlobUtil from 'react-native-blob-util'; -import Onyx from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; @@ -45,8 +46,9 @@ function applyStateInChunks(state: OnyxValues) { return promise; } -export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { +export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [session] = useOnyx(ONYXKEYS.SESSION); const handleFileRead = (file: FileObject) => { if (!file.uri) { @@ -57,6 +59,8 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta readOnyxFile(file.uri) .then((fileContent: string) => { const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; + setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); Onyx.clear(KEYS_TO_PRESERVE).then(() => { applyStateInChunks(transformedState).then(() => { @@ -67,14 +71,7 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta }) .catch(() => { setIsErrorModalVisible(true); - }) - .finally(() => { - setIsLoading(false); }); - - if (isLoading) { - setIsLoading(false); - } }; return ( diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx index 8add2d9172fd..2f9a2b70b65b 100644 --- a/src/components/ImportOnyxState/index.tsx +++ b/src/components/ImportOnyxState/index.tsx @@ -1,17 +1,19 @@ import React, {useState} from 'react'; -import Onyx from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; import {cleanAndTransformState} from './utils'; -export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { +export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [session] = useOnyx(ONYXKEYS.SESSION); const handleFileRead = (file: FileObject) => { if (!file.uri) { @@ -27,26 +29,20 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta .then((text) => { const fileContent = text; const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; + setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); Onyx.clear(KEYS_TO_PRESERVE).then(() => { - Onyx.multiSet(transformedState) - .then(() => { - setIsUsingImportedState(true); - Navigation.navigate(ROUTES.HOME); - }) - .finally(() => { - setIsLoading(false); - }); + Onyx.multiSet(transformedState).then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }); }); }) .catch(() => { setIsErrorModalVisible(true); setIsLoading(false); }); - - if (isLoading) { - setIsLoading(false); - } }; return ( diff --git a/src/components/ImportOnyxState/types.ts b/src/components/ImportOnyxState/types.ts index 8e504c493529..2b4b56a3b20c 100644 --- a/src/components/ImportOnyxState/types.ts +++ b/src/components/ImportOnyxState/types.ts @@ -1,5 +1,4 @@ type ImportOnyxStateProps = { - isLoading: boolean; setIsLoading: (isLoading: boolean) => void; }; diff --git a/src/components/ImportOnyxState/utils.ts b/src/components/ImportOnyxState/utils.ts index a5f24fa80714..94779868384d 100644 --- a/src/components/ImportOnyxState/utils.ts +++ b/src/components/ImportOnyxState/utils.ts @@ -3,7 +3,7 @@ import type {UnknownRecord} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; // List of Onyx keys from the .txt file we want to keep for the local override -const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.SESSION, ONYXKEYS.PREFERRED_THEME]; +const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.PREFERRED_THEME]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && !Array.isArray(value) && value !== null; diff --git a/src/components/ParentNavigationSubtitle.tsx b/src/components/ParentNavigationSubtitle.tsx index 6abf72e9e520..1896bc4f5f07 100644 --- a/src/components/ParentNavigationSubtitle.tsx +++ b/src/components/ParentNavigationSubtitle.tsx @@ -66,7 +66,9 @@ function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportAct {reportName} )} - {!!workspaceName && {` ${translate('threads.in')} ${workspaceName}`}} + {!!workspaceName && workspaceName !== reportName && ( + {` ${translate('threads.in')} ${workspaceName}`} + )} ); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index e3d4a8d31cf6..18750bfc7a29 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -33,7 +33,6 @@ import ViolationsUtils from '@libs/Violations/ViolationsUtils'; import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import * as IOU from '@userActions/IOU'; -import * as Link from '@userActions/Link'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -82,7 +81,6 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const session = useSession(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const parentReportID = report?.parentReportID ?? '-1'; const policyID = report?.policyID ?? '-1'; const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); @@ -91,7 +89,8 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals }); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); - const targetPolicyID = updatedTransaction?.reportID ? ReportUtils.getReport(updatedTransaction?.reportID)?.policyID : policyID; + const [transactionReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${updatedTransaction?.reportID}`); + const targetPolicyID = updatedTransaction?.reportID ? transactionReport?.policyID : policyID; const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${targetPolicyID}`); const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`, { canEvict: false, @@ -187,7 +186,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const shouldShowAttendees = useMemo(() => TransactionUtils.shouldShowAttendees(iouType, policy), [iouType, policy]); const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest); - const tripID = ReportUtils.getTripIDFromTransactionParentReport(parentReport); + const tripID = ReportUtils.getTripIDFromTransactionParentReportID(parentReport?.parentReportID); const shouldShowViewTripDetails = TransactionUtils.hasReservationList(transaction) && !!tripID; const {getViolationsForField} = useViolations(transactionViolations ?? [], isReceiptBeingScanned || !ReportUtils.isPaidGroupPolicy(report)); @@ -698,10 +697,12 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals { - Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID)); + const reservations = transaction?.receipt?.reservationList?.length ?? 0; + if (reservations > 1) { + Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(report?.reportID ?? '-1', transaction?.transactionID ?? '-1', Navigation.getReportRHPActiveRoute())); + } + Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(report?.reportID ?? '-1', transaction?.transactionID ?? '-1', 0, Navigation.getReportRHPActiveRoute())); }} /> )} diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index 65cdb4a7d00b..79497e5fab88 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -365,7 +365,8 @@ function ReportPreview({ const shouldShowSettlementButton = (shouldShowPayButton || shouldShowApproveButton) && !showRTERViolationMessage && !shouldShowBrokenConnectionViolation; - const shouldPromptUserToAddBankAccount = ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID) || ReportUtils.hasMissingInvoiceBankAccount(iouReportID); + const shouldPromptUserToAddBankAccount = + (ReportUtils.hasMissingPaymentMethod(userWallet, iouReportID) || ReportUtils.hasMissingInvoiceBankAccount(iouReportID)) && !ReportUtils.isSettled(iouReportID); const shouldShowRBR = hasErrors && !iouSettled; /* diff --git a/src/components/ReportActionItem/TripDetailsView.tsx b/src/components/ReportActionItem/TripDetailsView.tsx index 32cbc5dd853e..a7fdef547bf9 100644 --- a/src/components/ReportActionItem/TripDetailsView.tsx +++ b/src/components/ReportActionItem/TripDetailsView.tsx @@ -11,16 +11,18 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; +import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import * as ReportUtils from '@src/libs/ReportUtils'; import * as TripReservationUtils from '@src/libs/TripReservationUtils'; +import ROUTES from '@src/ROUTES'; import type {Reservation, ReservationTimeDetails} from '@src/types/onyx/Transaction'; type TripDetailsViewProps = { /** The active tripRoomReportID, used for Onyx subscription */ - tripRoomReportID?: string; + tripRoomReportID: string; /** Whether we should display the horizontal rule below the component */ shouldShowHorizontalRule: boolean; @@ -28,9 +30,12 @@ type TripDetailsViewProps = { type ReservationViewProps = { reservation: Reservation; + transactionID: string; + tripRoomReportID: string; + reservationIndex: number; }; -function ReservationView({reservation}: ReservationViewProps) { +function ReservationView({reservation, transactionID, tripRoomReportID, reservationIndex}: ReservationViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -75,11 +80,14 @@ function ReservationView({reservation}: ReservationViewProps) { const vendor = reservation.vendor ? `${reservation.vendor} • ` : ''; return `${vendor}${reservation.start.location}`; } + if (reservation.type === CONST.RESERVATION_TYPE.TRAIN) { + return reservation.route?.name; + } return reservation.start.address ?? reservation.start.location; }, [reservation]); const titleComponent = () => { - if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT) { + if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT || reservation.type === CONST.RESERVATION_TYPE.TRAIN) { return ( @@ -129,6 +137,7 @@ function ReservationView({reservation}: ReservationViewProps) { iconWidth={20} iconStyles={[StyleUtils.getTripReservationIconContainer(false), styles.mr3]} secondaryIconFill={theme.icon} + onPress={() => Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(tripRoomReportID, transactionID, reservationIndex, Navigation.getReportRHPActiveRoute()))} /> ); } @@ -138,7 +147,7 @@ function TripDetailsView({tripRoomReportID, shouldShowHorizontalRule}: TripDetai const {translate} = useLocalize(); const tripTransactions = ReportUtils.getTripTransactions(tripRoomReportID); - const reservations: Reservation[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); + const reservationsData: TripReservationUtils.ReservationData[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); return ( @@ -153,11 +162,18 @@ function TripDetailsView({tripRoomReportID, shouldShowHorizontalRule}: TripDetai <> - {reservations.map((reservation) => ( - - - - ))} + {reservationsData.map(({reservation, transactionID, reservationIndex}) => { + return ( + + + + ); + })} + {title} + + ); + + if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT || reservation.type === CONST.RESERVATION_TYPE.TRAIN) { + const startName = reservation.type === CONST.RESERVATION_TYPE.FLIGHT ? reservation.start.shortName : reservation.start.longName; + const endName = reservation.type === CONST.RESERVATION_TYPE.FLIGHT ? reservation.end.shortName : reservation.end.longName; + + titleComponent = ( - {reservation.start.shortName} + {startName} - {reservation.end.shortName} + {endName} - ) : ( - - {title} - ); + } return ( ; +const renderItem = ({item}: {item: TripReservationUtils.ReservationData}) => ; function TripRoomPreview({action, chatReportID, containerStyles, contextMenuAnchor, isHovered = false, checkIfContextMenuActive = () => {}}: TripRoomPreviewProps) { const styles = useThemeStyles(); @@ -112,31 +117,22 @@ function TripRoomPreview({action, chatReportID, containerStyles, contextMenuAnch const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`); - const tripTransactions = ReportUtils.getTripTransactions(chatReport?.iouReportID, 'reportID'); - const reservations: Reservation[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); + const tripTransactions = ReportUtils.getTripTransactions(chatReport?.reportID); + const reservationsData: TripReservationUtils.ReservationData[] = TripReservationUtils.getReservationsFromTripTransactions(tripTransactions); const dateInfo = chatReport?.tripData ? DateUtils.getFormattedDateRange(new Date(chatReport.tripData.startDate), new Date(chatReport.tripData.endDate)) : ''; const {totalDisplaySpend} = ReportUtils.getMoneyRequestSpendBreakdown(chatReport); + const currency = iouReport?.currency ?? chatReport?.currency; const displayAmount = useMemo(() => { if (totalDisplaySpend) { - return CurrencyUtils.convertToDisplayString(totalDisplaySpend, iouReport?.currency); + return CurrencyUtils.convertToDisplayString(totalDisplaySpend, currency); } - // If iouReport is not available, get amount from the action message (Ex: "Domain20821's Workspace owes $33.00" or "paid ₫60" or "paid -₫60 elsewhere") - let displayAmountValue = ''; - const actionMessage = getReportActionText(action) ?? ''; - const splits = actionMessage.split(' '); - - splits.forEach((split) => { - if (!/\d/.test(split)) { - return; - } - - displayAmountValue = split; - }); - - return displayAmountValue; - }, [action, iouReport?.currency, totalDisplaySpend]); + return CurrencyUtils.convertToDisplayString( + tripTransactions.reduce((acc, transaction) => acc + Math.abs(transaction.amount), 0), + currency, + ); + }, [currency, totalDisplaySpend, tripTransactions]); return ( diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index ad69d27e7385..dabcaf90e4b2 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -118,6 +118,7 @@ function BaseSelectionList( shouldPreventActiveCellVirtualization = false, shouldScrollToFocusedIndex = true, onContentSizeChange, + listItemTitleStyles, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -524,6 +525,7 @@ function BaseSelectionList( normalizedIndex={normalizedIndex} shouldSyncFocus={!isTextInputFocusedRef.current} wrapperStyle={listItemWrapperStyle} + titleStyles={listItemTitleStyles} shouldHighlightSelectedItem={shouldHighlightSelectedItem} singleExecution={singleExecution} /> diff --git a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx index b08d2ae2cfbc..915e2c0fcf80 100644 --- a/src/components/SelectionList/BaseSelectionListItemRenderer.tsx +++ b/src/components/SelectionList/BaseSelectionListItemRenderer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; import type useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager'; import type useSingleExecution from '@hooks/useSingleExecution'; import * as SearchUIUtils from '@libs/SearchUIUtils'; @@ -11,6 +12,7 @@ type BaseSelectionListItemRendererProps = Omit[1]; normalizedIndex: number; singleExecution: ReturnType['singleExecution']; + titleStyles?: StyleProp; }; function BaseSelectionListItemRenderer({ @@ -37,6 +39,7 @@ function BaseSelectionListItemRenderer({ shouldSyncFocus, shouldHighlightSelectedItem, wrapperStyle, + titleStyles, singleExecution, }: BaseSelectionListItemRendererProps) { const handleOnCheckboxPress = () => { @@ -82,6 +85,7 @@ function BaseSelectionListItemRenderer({ shouldSyncFocus={shouldSyncFocus} shouldHighlightSelectedItem={shouldHighlightSelectedItem} wrapperStyle={wrapperStyle} + titleStyles={titleStyles} /> {item.footerContent && item.footerContent} diff --git a/src/components/SelectionList/RadioListItem.tsx b/src/components/SelectionList/RadioListItem.tsx index 3a8a4f0b57a6..256c3b0a876f 100644 --- a/src/components/SelectionList/RadioListItem.tsx +++ b/src/components/SelectionList/RadioListItem.tsx @@ -22,6 +22,7 @@ function RadioListItem({ onFocus, shouldSyncFocus, wrapperStyle, + titleStyles, }: RadioListItemProps) { const styles = useThemeStyles(); const fullTitle = isMultilineSupported ? item.text?.trimStart() : item.text; @@ -59,6 +60,7 @@ function RadioListItem({ item.alternateText ? styles.mb1 : null, isDisabled && styles.colorMuted, isMultilineSupported ? {paddingLeft} : null, + titleStyles, ]} numberOfLines={isMultilineSupported ? 2 : 1} /> diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 8e94b2f0069e..3774821ce35f 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -297,6 +297,9 @@ type ListItemProps = CommonListItemProps & { /** Whether we highlight all the selected items */ shouldHighlightSelectedItem?: boolean; + + /** Styles applied for the title */ + titleStyles?: StyleProp; }; type BaseListItemProps = CommonListItemProps & { @@ -563,6 +566,9 @@ type BaseSelectionListProps = Partial & { /** Styles for the section title */ sectionTitleStyles?: StyleProp; + /** Styles applid for the title of the list item */ + listItemTitleStyles?: StyleProp; + /** This may improve scroll performance for large lists */ removeClippedSubviews?: boolean; diff --git a/src/components/SubStepForms/ConfirmationStep.tsx b/src/components/SubStepForms/ConfirmationStep.tsx index c3832628a7ca..376a31872cff 100644 --- a/src/components/SubStepForms/ConfirmationStep.tsx +++ b/src/components/SubStepForms/ConfirmationStep.tsx @@ -38,9 +38,12 @@ type ConfirmationStepProps = SubStepProps & { /** The error message to display */ error?: string; + + /** Whether to apply safe area padding bottom */ + shouldApplySafeAreaPaddingBottom?: boolean; }; -function ConfirmationStep({pageTitle, summaryItems, showOnfidoLinks, onfidoLinksTitle, isLoading, error, onNext}: ConfirmationStepProps) { +function ConfirmationStep({pageTitle, summaryItems, showOnfidoLinks, onfidoLinksTitle, isLoading, error, onNext, shouldApplySafeAreaPaddingBottom = true}: ConfirmationStepProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); @@ -50,7 +53,7 @@ function ConfirmationStep({pageTitle, summaryItems, showOnfidoLinks, onfidoLinks {({safeAreaPaddingBottomStyle}) => ( {pageTitle} {summaryItems.map(({description, title, shouldShowRightIcon, onPress}) => ( diff --git a/src/components/TagPicker/index.tsx b/src/components/TagPicker/index.tsx index 0368f15a9752..08668c4aa38d 100644 --- a/src/components/TagPicker/index.tsx +++ b/src/components/TagPicker/index.tsx @@ -98,6 +98,7 @@ function TagPicker({selectedTag, tagListName, policyID, tagListIndex, shouldShow - + {descriptionPrimary} {!!descriptionSecondary && {descriptionSecondary}} ; }; -type WorkspaceSwitcherButtonProps = WorkspaceSwitcherButtonOnyxProps & { - /** - * Callback used to keep track of the workspace switching process in the BaseSidebarScreen. - */ - onSwitchWorkspace?: () => void; -}; +type WorkspaceSwitcherButtonProps = WorkspaceSwitcherButtonOnyxProps; -function WorkspaceSwitcherButton({policy, onSwitchWorkspace}: WorkspaceSwitcherButtonProps) { +function WorkspaceSwitcherButton({policy}: WorkspaceSwitcherButtonProps) { const {translate} = useLocalize(); const theme = useTheme(); @@ -41,7 +36,7 @@ function WorkspaceSwitcherButton({policy, onSwitchWorkspace}: WorkspaceSwitcherB source: avatar, name: policy?.name ?? '', type: CONST.ICON_TYPE_WORKSPACE, - id: policy?.id ?? '-1', + id: policy?.id ?? CONST.DEFAULT_NUMBER_ID, }; }, [policy]); @@ -54,7 +49,6 @@ function WorkspaceSwitcherButton({policy, onSwitchWorkspace}: WorkspaceSwitcherB accessible testID="WorkspaceSwitcherButton" onPress={() => { - onSwitchWorkspace?.(); pressableRef?.current?.blur(); interceptAnonymousUser(() => { Navigation.navigate(ROUTES.WORKSPACE_SWITCHER); diff --git a/src/hooks/useReportIDs.tsx b/src/hooks/useReportIDs.tsx index 284d80f737f2..878e83cf4d87 100644 --- a/src/hooks/useReportIDs.tsx +++ b/src/hooks/useReportIDs.tsx @@ -57,6 +57,7 @@ function ReportIDsContextProvider({ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)}); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const [reportsDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); + const draftAmount = Object.keys(reportsDrafts ?? {}).length; const [betas] = useOnyx(ONYXKEYS.BETAS); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -70,9 +71,9 @@ function ReportIDsContextProvider({ const getOrderedReportIDs = useCallback( (currentReportID?: string) => SidebarUtils.getOrderedReportIDs(currentReportID ?? null, chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs), - // we need reports draft in deps array for reloading of list when reportsDrafts will change + // we need reports draft in deps array to reload the list when a draft is added or removed // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, reportsDrafts], + [chatReports, betas, policies, priorityMode, transactionViolations, activeWorkspaceID, policyMemberAccountIDs, draftAmount], ); const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); diff --git a/src/hooks/useWaitForNavigation.ts b/src/hooks/useWaitForNavigation.ts index 73c0eb2bb14c..05981ec3322b 100644 --- a/src/hooks/useWaitForNavigation.ts +++ b/src/hooks/useWaitForNavigation.ts @@ -1,5 +1,5 @@ -import {useNavigation} from '@react-navigation/native'; -import {useEffect, useRef} from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import {useCallback, useRef} from 'react'; type UseWaitForNavigation = (navigate: () => void) => () => Promise; @@ -8,21 +8,18 @@ type UseWaitForNavigation = (navigate: () => void) => () => Promise; * Only use when navigating by react-navigation */ export default function useWaitForNavigation(): UseWaitForNavigation { - const navigation = useNavigation(); const resolvePromises = useRef void>>([]); - useEffect(() => { - const unsubscribeBlur = navigation.addListener('blur', () => { - resolvePromises.current.forEach((resolve) => { - resolve(); - }); - resolvePromises.current = []; - }); - - return () => { - unsubscribeBlur(); - }; - }, [navigation]); + useFocusEffect( + useCallback(() => { + return () => { + resolvePromises.current.forEach((resolve) => { + resolve(); + }); + resolvePromises.current = []; + }; + }, []), + ); return (navigate: () => void) => () => { navigate(); diff --git a/src/languages/en.ts b/src/languages/en.ts index 2b73661a432c..13aa4b17e358 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -69,6 +69,7 @@ import type { FeatureNameParams, FileLimitParams, FiltersAmountBetweenParams, + FlightLayoverParams, FormattedMaxLengthParams, ForwardedAmountParams, GoBackMessageParams, @@ -192,6 +193,7 @@ import type { WelcomeNoteParams, WelcomeToRoomParams, WeSentYouMagicSignInLinkParams, + WorkspaceLockedPlanTypeParams, WorkspaceMemberList, WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams, WorkspaceYouMayJoin, @@ -480,9 +482,13 @@ const translations = { links: 'Links', days: 'days', rename: 'Rename', + address: 'Address', + hourAbbreviation: 'h', + minuteAbbreviation: 'm', skip: 'Skip', chatWithAccountManager: ({accountManagerDisplayName}: ChatWithAccountManagerParams) => `Need something specific? Chat with your account manager, ${accountManagerDisplayName}.`, chatNow: 'Chat now', + validate: 'Validate', }, supportalNoAccess: { title: 'Not so fast', @@ -496,6 +502,13 @@ const translations = { allowPermission: 'allow location access in settings', tryAgain: 'and try again.', }, + contact: { + importContacts: 'Import contacts', + importContactsTitle: 'Import your contacts', + importContactsText: 'Import contacts from your phone so your favorite people are always a tap away.', + importContactsExplanation: 'so your favorite people are always a tap away.', + importContactsNativeText: 'Just one more step! Give us the green light to import your contacts.', + }, anonymousReportFooter: { logoTagline: 'Join the discussion.', }, @@ -1852,7 +1865,10 @@ const translations = { toUnblock: ' to unblock your login.', }, smsDeliveryFailurePage: { - smsDeliveryFailureMessage: ({login}: OurEmailProviderParams) => `We've been unable to deliver SMS messages to ${login}, so we've suspended it for 24 hours.`, + smsDeliveryFailureMessage: ({login}: OurEmailProviderParams) => + `We've been unable to deliver SMS messages to ${login}, so we've suspended it for 24 hours. Please try validating your number:`, + validationFailed: 'Validation failed because it hasn’t been 24 hours since your last attempt.', + validationSuccess: 'Your number has been validated! Click below to send a new magic sign-in code.', }, welcomeSignUpForm: { join: 'Join', @@ -2451,9 +2467,50 @@ const translations = { error: 'You must accept the Terms & Conditions for travel to continue', }, flight: 'Flight', + flightDetails: { + passenger: 'Passenger', + layover: ({layover}: FlightLayoverParams) => `You have a ${layover} layover before this flight`, + takeOff: 'Take-off', + landing: 'Landing', + seat: 'Seat', + class: 'Cabin Class', + recordLocator: 'Record locator', + }, hotel: 'Hotel', + hotelDetails: { + guest: 'Guest', + checkIn: 'Check-in', + checkOut: 'Check-out', + roomType: 'Room type', + cancellation: 'Cancellation policy', + cancellationUntil: 'Free cancellation until', + confirmation: 'Confirmation number', + }, car: 'Car', + carDetails: { + rentalCar: 'Car rental', + pickUp: 'Pick-up', + dropOff: 'Drop-off', + driver: 'Driver', + carType: 'Car type', + cancellation: 'Cancellation policy', + cancellationUntil: 'Free cancellation until', + confirmation: 'Confirmation number', + }, + train: 'Rail', + trainDetails: { + passenger: 'Passenger', + departs: 'Departs', + arrives: 'Arrives', + coachNumber: 'Coach number', + seat: 'Seat', + fareDetails: 'Fare details', + confirmation: 'Confirmation number', + }, viewTrip: 'View trip', + modifyTrip: 'Modify trip', + tripSupport: 'Trip support', + tripDetails: 'Trip details', viewTripDetails: 'View trip details', trip: 'Trip', trips: 'Trips', @@ -2556,6 +2613,7 @@ const translations = { return 'Member'; } }, + planType: 'Plan type', submitExpense: 'Submit expenses using your workspace chat below:', defaultCategory: 'Default category', }, @@ -4310,6 +4368,19 @@ const translations = { moreDetails: 'for more details.', gotIt: 'Got it, thanks', }, + commonFeatures: { + title: 'Upgrade to the Control plan', + note: 'Unlock our most powerful features, including:', + benefits: { + note: 'The Control plan starts at $9 per active member per month.', + learnMore: 'Learn more', + pricing: 'about our plans and pricing.', + benefit1: 'Advanced accounting connections (NetSuite, Sage Intacct, and more)', + benefit2: 'Smart expense rules', + benefit3: 'Multi-level approval workflows', + benefit4: 'Enhanced security controls', + }, + }, }, restrictedAction: { restricted: 'Restricted', @@ -4413,6 +4484,25 @@ const translations = { andEnableWorkflows: 'and enable workflows, then add approvals to unlock this feature.', }, }, + planTypePage: { + planTypes: { + team: { + label: 'Collect', + description: 'For teams looking to automate their processes.', + }, + corporate: { + label: 'Control', + description: 'For organizations with advanced requirements.', + }, + }, + description: "Choose a plan that's right for you. For a detailed list of features and pricing, check out our", + subscriptionLink: 'plan types and pricing help page', + lockedPlanDescription: ({count, annualSubscriptionEndDate}: WorkspaceLockedPlanTypeParams) => ({ + one: `You've committed to 1 active member on the Control plan until your annual subscription ends on ${annualSubscriptionEndDate}. You can switch to pay-per-use subscription and downgrade to the Collect plan starting ${annualSubscriptionEndDate} by disabling auto-renew in`, + other: `You've committed to ${count} active members on the Control plan until your annual subscription ends on ${annualSubscriptionEndDate}. You can switch to pay-per-use subscription and downgrade to the Collect plan starting ${annualSubscriptionEndDate} by disabling auto-renew in`, + }), + subscriptions: 'Subscriptions', + }, }, getAssistancePage: { title: 'Get assistance', diff --git a/src/languages/es.ts b/src/languages/es.ts index 529ee6442dad..20202b2e176f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -68,6 +68,7 @@ import type { FeatureNameParams, FileLimitParams, FiltersAmountBetweenParams, + FlightLayoverParams, FormattedMaxLengthParams, ForwardedAmountParams, GoBackMessageParams, @@ -192,6 +193,7 @@ import type { WelcomeNoteParams, WelcomeToRoomParams, WeSentYouMagicSignInLinkParams, + WorkspaceLockedPlanTypeParams, WorkspaceMemberList, WorkspaceOwnerWillNeedToAddOrUpdatePaymentCardParams, WorkspaceYouMayJoin, @@ -472,8 +474,12 @@ const translations = { sent: 'Enviado', links: 'Enlaces', days: 'días', + address: 'Dirección', + hourAbbreviation: 'h', + minuteAbbreviation: 'm', chatWithAccountManager: ({accountManagerDisplayName}: ChatWithAccountManagerParams) => `¿Necesitas algo específico? Habla con tu gerente de cuenta, ${accountManagerDisplayName}.`, chatNow: 'Chatear ahora', + validate: 'Validar', }, supportalNoAccess: { title: 'No tan rápido', @@ -491,6 +497,13 @@ const translations = { allowPermission: 'habilita el permiso de ubicación en la configuración', tryAgain: 'e inténtalo de nuevo.', }, + contact: { + importContacts: 'Importar contactos', + importContactsTitle: 'Importa tus contactos', + importContactsText: 'Importa contactos desde tu teléfono para que tus personas favoritas siempre estén a un toque de distancia.', + importContactsExplanation: 'para que tus personas favoritas estén siempre a un toque de distancia.', + importContactsNativeText: '¡Solo un paso más! Danos luz verde para importar tus contactos.', + }, anonymousReportFooter: { logoTagline: 'Únete a la discusión.', }, @@ -1857,7 +1870,10 @@ const translations = { toUnblock: ' para desbloquear el inicio de sesión.', }, smsDeliveryFailurePage: { - smsDeliveryFailureMessage: ({login}: OurEmailProviderParams) => `No hemos podido entregar mensajes SMS a ${login}, por lo que lo hemos suspendido durante 24 horas.`, + smsDeliveryFailureMessage: ({login}: OurEmailProviderParams) => + `No hemos podido entregar mensajes SMS a ${login}, así que lo hemos suspendido durante 24 horas. Por favor, intenta validar tu número:`, + validationFailed: 'La validación falló porque no han pasado 24 horas desde tu último intento.', + validationSuccess: '¡Tu número ha sido validado! Haz clic abajo para enviar un nuevo código mágico de inicio de sesión.', }, welcomeSignUpForm: { join: 'Unirse', @@ -2475,9 +2491,50 @@ const translations = { error: 'Debes aceptar los Términos y condiciones para que el viaje continúe', }, flight: 'Vuelo', + flightDetails: { + passenger: 'Pasajero', + layover: ({layover}: FlightLayoverParams) => `Tienes una escala de ${layover} antes de este vuelo`, + takeOff: 'Despegue', + landing: 'Aterrizaje', + seat: 'Asiento', + class: 'Clase de cabina', + recordLocator: 'Localizador de la reserva', + }, hotel: 'Hotel', + hotelDetails: { + guest: 'Cliente', + checkIn: 'Entrada', + checkOut: 'Salida', + roomType: 'Tipo de habitación', + cancellation: 'Política de cancelación', + cancellationUntil: 'Cancelación gratuita hasta el', + confirmation: 'Número de confirmación', + }, car: 'Auto', + carDetails: { + rentalCar: 'Coche de alquiler', + pickUp: 'Recogida', + dropOff: 'Devolución', + driver: 'Conductor', + carType: 'Tipo de coche', + cancellation: 'Política de cancelación', + cancellationUntil: 'Cancelación gratuita hasta el', + confirmation: 'Número de confirmación', + }, + train: 'Tren', + trainDetails: { + passenger: 'Pasajero', + departs: 'Sale', + arrives: 'Llega', + coachNumber: 'Número de vagón', + seat: 'Asiento', + fareDetails: 'Detalles de la tarifa', + confirmation: 'Número de confirmación', + }, viewTrip: 'Ver viaje', + modifyTrip: 'Modificar viaje', + tripSupport: 'Soporte de Viaje', + tripDetails: 'Detalles del viaje', viewTripDetails: 'Ver detalles del viaje', trip: 'Viaje', trips: 'Viajes', @@ -2580,6 +2637,7 @@ const translations = { return 'Miembro'; } }, + planType: 'Tipo de plan', submitExpense: 'Envíe los gastos utilizando el chat de su espacio de trabajo:', defaultCategory: 'Categoría predeterminada', }, @@ -4277,6 +4335,25 @@ const translations = { confirmText: 'Sí, exportar de nuevo', cancelText: 'Cancelar', }, + planTypePage: { + planTypes: { + team: { + label: 'Collect', + description: 'Para equipos que buscan automatizar sus procesos.', + }, + corporate: { + label: 'Recolectar', + description: 'Para organizaciones con requisitos avanzados.', + }, + }, + description: 'Elige el plan adecuado para ti. Para ver una lista detallada de funciones y precios, consulta nuestra', + subscriptionLink: 'página de ayuda sobre tipos de planes y precios', + lockedPlanDescription: ({count, annualSubscriptionEndDate}: WorkspaceLockedPlanTypeParams) => ({ + one: `Tienes un compromiso anual de 1 miembro activo en el plan Control hasta el ${annualSubscriptionEndDate}. Puedes cambiar a una suscripción de pago por uso y desmejorar al plan Recopilar a partir del ${annualSubscriptionEndDate} desactivando la renovación automática en`, + other: `Tienes un compromiso anual de ${count} miembros activos en el plan Control hasta el ${annualSubscriptionEndDate}. Puedes cambiar a una suscripción de pago por uso y desmejorar al plan Recopilar a partir del ${annualSubscriptionEndDate} desactivando la renovación automática en`, + }), + subscriptions: 'Suscripciones', + }, upgrade: { reportFields: { title: 'Los campos', @@ -4358,6 +4435,19 @@ const translations = { moreDetails: 'para obtener más información.', gotIt: 'Entendido, gracias.', }, + commonFeatures: { + title: 'Mejorar al plan Controlar', + note: 'Desbloquea nuestras funciones más potentes, incluyendo:', + benefits: { + note: 'El plan Controlar comienza desde $9 por miembro activo al mes.', + learnMore: 'Más información', + pricing: 'sobre nuestros planes y precios.', + benefit1: 'Conexiones avanzadas de contabilidad (NetSuite, Sage Intacct y más)', + benefit2: 'Reglas inteligentes de gastos', + benefit3: 'Flujos de aprobación de varios niveles', + benefit4: 'Controles de seguridad mejorados', + }, + }, }, restrictedAction: { restricted: 'Restringido', diff --git a/src/languages/params.ts b/src/languages/params.ts index eb592d751116..59e1a061f7bf 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -569,6 +569,11 @@ type CurrencyCodeParams = { currencyCode: string; }; +type WorkspaceLockedPlanTypeParams = { + count: number; + annualSubscriptionEndDate: string; +}; + type CompanyNameParams = { companyName: string; }; @@ -581,6 +586,10 @@ type ChatWithAccountManagerParams = { accountManagerDisplayName: string; }; +type FlightLayoverParams = { + layover: string; +}; + export type { AuthenticationErrorParams, ImportMembersSuccessfullDescriptionParams, @@ -785,7 +794,9 @@ export type { WorkspaceMemberList, ImportPerDiemRatesSuccessfullDescriptionParams, CurrencyCodeParams, + WorkspaceLockedPlanTypeParams, CompanyNameParams, CustomUnitRateParams, ChatWithAccountManagerParams, + FlightLayoverParams, }; diff --git a/src/libs/API/parameters/OpenWorkspacePlanPage.ts b/src/libs/API/parameters/OpenWorkspacePlanPage.ts new file mode 100644 index 000000000000..6081d5b289e7 --- /dev/null +++ b/src/libs/API/parameters/OpenWorkspacePlanPage.ts @@ -0,0 +1,5 @@ +type OpenWorkspacePlanPageParams = { + policyID: string; +}; + +export default OpenWorkspacePlanPageParams; diff --git a/src/libs/API/parameters/ResetSMSDeliveryFailureParams.ts b/src/libs/API/parameters/ResetSMSDeliveryFailureParams.ts new file mode 100644 index 000000000000..f9a0bf41a218 --- /dev/null +++ b/src/libs/API/parameters/ResetSMSDeliveryFailureParams.ts @@ -0,0 +1,5 @@ +type ResetSMSDeliveryFailureParams = { + login: string; +}; + +export default ResetSMSDeliveryFailureParams; diff --git a/src/libs/API/parameters/UpgradeToCorporateParams.ts b/src/libs/API/parameters/UpgradeToCorporateParams.ts index ee9d1359c4dd..7b7ff3e0adcc 100644 --- a/src/libs/API/parameters/UpgradeToCorporateParams.ts +++ b/src/libs/API/parameters/UpgradeToCorporateParams.ts @@ -1,6 +1,6 @@ type UpgradeToCorporateParams = { policyID: string; - featureName: string; + featureName?: string; }; export default UpgradeToCorporateParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 8e743777bdea..ca68f3fcd4b7 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -356,3 +356,5 @@ export type {default as JoinAccessiblePolicyParams} from './JoinAccessiblePolicy export type {default as ImportPerDiemRatesParams} from './ImportPerDiemRatesParams'; export type {default as ExportPerDiemCSVParams} from './ExportPerDiemCSVParams'; export type {default as DismissProductTrainingParams} from './DismissProductTraining'; +export type {default as OpenWorkspacePlanPageParams} from './OpenWorkspacePlanPage'; +export type {default as ResetSMSDeliveryFailureParams} from './ResetSMSDeliveryFailureParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 47c19c158ff6..f63be7c72f45 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -441,6 +441,7 @@ const WRITE_COMMANDS = { UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES: 'ValidateUserAndGetAccessiblePolicies', DISMISS_PRODUCT_TRAINING: 'DismissProductTraining', + RESET_SMS_DELIVERY_FAILURE: 'ResetSMSDeliveryFailure', } as const; type WriteCommand = ValueOf; @@ -766,6 +767,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams; [WRITE_COMMANDS.REQUEST_TAX_EXEMPTION]: null; + [WRITE_COMMANDS.RESET_SMS_DELIVERY_FAILURE]: Parameters.ResetSMSDeliveryFailureParams; [WRITE_COMMANDS.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; [WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH]: Parameters.HoldMoneyRequestOnSearchParams; @@ -956,6 +958,7 @@ const READ_COMMANDS = { START_ISSUE_NEW_CARD_FLOW: 'StartIssueNewCardFlow', OPEN_CARD_DETAILS_PAGE: 'OpenCardDetailsPage', GET_ASSIGNED_SUPPORT_DATA: 'GetAssignedSupportData', + OPEN_WORKSPACE_PLAN_PAGE: 'OpenWorkspacePlanPage', } as const; type ReadCommand = ValueOf; @@ -1019,6 +1022,7 @@ type ReadCommandParameters = { [READ_COMMANDS.START_ISSUE_NEW_CARD_FLOW]: Parameters.StartIssueNewCardFlowParams; [READ_COMMANDS.OPEN_CARD_DETAILS_PAGE]: Parameters.OpenCardDetailsPageParams; [READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA]: Parameters.GetAssignedSupportDataParams; + [READ_COMMANDS.OPEN_WORKSPACE_PLAN_PAGE]: Parameters.OpenWorkspacePlanPageParams; }; const SIDE_EFFECT_REQUEST_COMMANDS = { diff --git a/src/libs/ContactImport/index.native.tsx b/src/libs/ContactImport/index.native.tsx new file mode 100644 index 000000000000..564bbba6805f --- /dev/null +++ b/src/libs/ContactImport/index.native.tsx @@ -0,0 +1,32 @@ +import {ContactsNitroModule} from 'contacts-nitro-module'; +import type {Contact} from 'contacts-nitro-module'; +import {RESULTS} from 'react-native-permissions'; +import type {PermissionStatus} from 'react-native-permissions'; +import {requestContactPermission} from '@libs/ContactPermission'; +import CONST from '@src/CONST'; +import type {ContactImportResult} from './types'; + +function contactImport(): Promise { + let permissionStatus: PermissionStatus = RESULTS.UNAVAILABLE; + + return requestContactPermission() + .then((response: PermissionStatus) => { + permissionStatus = response; + if (response === RESULTS.GRANTED) { + return ContactsNitroModule.getAll([ + CONST.DEVICE_CONTACT.FIRST_NAME, + CONST.DEVICE_CONTACT.LAST_NAME, + CONST.DEVICE_CONTACT.PHONE_NUMBERS, + CONST.DEVICE_CONTACT.EMAIL_ADDRESSES, + CONST.DEVICE_CONTACT.IMAGE_DATA, + ]); + } + return [] as Contact[]; + }) + .then((deviceContacts) => ({ + contactList: Array.isArray(deviceContacts) ? deviceContacts : [], + permissionStatus, + })); +} + +export default contactImport; diff --git a/src/libs/ContactImport/index.tsx b/src/libs/ContactImport/index.tsx new file mode 100644 index 000000000000..b3fe3dc27868 --- /dev/null +++ b/src/libs/ContactImport/index.tsx @@ -0,0 +1,11 @@ +import {RESULTS} from 'react-native-permissions'; +import type {ContactImportResult} from './types'; + +const contactImport = (): Promise => { + return Promise.resolve({ + contactList: [], + permissionStatus: RESULTS.UNAVAILABLE, + }); +}; + +export default contactImport; diff --git a/src/libs/ContactImport/types.ts b/src/libs/ContactImport/types.ts new file mode 100644 index 000000000000..8247bdf8e2ec --- /dev/null +++ b/src/libs/ContactImport/types.ts @@ -0,0 +1,20 @@ +import type {PermissionStatus} from 'react-native-permissions'; + +type StringHolder = { + value: string; +}; + +type ContactImportResult = { + contactList: DeviceContact[] | []; + permissionStatus: PermissionStatus; +}; + +type DeviceContact = { + firstName?: string; + lastName?: string; + emailAddresses?: StringHolder[]; + phoneNumbers?: Array<{value: string}>; + imageData?: string; +}; + +export type {StringHolder, ContactImportResult, DeviceContact}; diff --git a/src/libs/ContactPermission/index.android.ts b/src/libs/ContactPermission/index.android.ts new file mode 100644 index 000000000000..8ef0eace7135 --- /dev/null +++ b/src/libs/ContactPermission/index.android.ts @@ -0,0 +1,11 @@ +import {check, PERMISSIONS, request} from 'react-native-permissions'; + +function requestContactPermission() { + return request(PERMISSIONS.ANDROID.READ_CONTACTS); +} + +function getContactPermission() { + return check(PERMISSIONS.ANDROID.READ_CONTACTS); +} + +export {requestContactPermission, getContactPermission}; diff --git a/src/libs/ContactPermission/index.ios.ts b/src/libs/ContactPermission/index.ios.ts new file mode 100644 index 000000000000..590373c9bd7d --- /dev/null +++ b/src/libs/ContactPermission/index.ios.ts @@ -0,0 +1,11 @@ +import {check, PERMISSIONS, request} from 'react-native-permissions'; + +function requestContactPermission() { + return request(PERMISSIONS.IOS.CONTACTS); +} + +function getContactPermission() { + return check(PERMISSIONS.IOS.CONTACTS); +} + +export {requestContactPermission, getContactPermission}; diff --git a/src/libs/ContactPermission/index.ts b/src/libs/ContactPermission/index.ts new file mode 100644 index 000000000000..0216a5022dda --- /dev/null +++ b/src/libs/ContactPermission/index.ts @@ -0,0 +1,16 @@ +import {RESULTS} from 'react-native-permissions'; +import type {PermissionStatus} from 'react-native-permissions'; + +function requestContactPermission(): Promise { + return new Promise((resolve) => { + resolve(RESULTS.GRANTED); + }); +} + +function getContactPermission(): Promise { + return new Promise((resolve) => { + resolve(RESULTS.GRANTED); + }); +} + +export {requestContactPermission, getContactPermission}; diff --git a/src/libs/ContactUtils.ts b/src/libs/ContactUtils.ts new file mode 100644 index 000000000000..5769e6cde2a4 --- /dev/null +++ b/src/libs/ContactUtils.ts @@ -0,0 +1,50 @@ +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {DeviceContact, StringHolder} from './ContactImport/types'; +import * as OptionsListUtils from './OptionsListUtils'; +import {getAvatarForContact} from './RandomAvatarUtils'; + +function sortEmailObjects(emails?: StringHolder[]): string[] { + if (!emails?.length) { + return []; + } + + const expensifyDomain = CONST.EMAIL.EXPENSIFY_EMAIL_DOMAIN.toLowerCase(); + + return emails + .filter((email) => email?.value) + .map((email) => email.value) + .sort((a, b) => { + const isExpensifyA = a.toLowerCase().includes(expensifyDomain); + const isExpensifyB = b.toLowerCase().includes(expensifyDomain); + + // Prioritize Expensify emails, then sort alphabetically + return isExpensifyA !== isExpensifyB ? Number(isExpensifyB) - Number(isExpensifyA) : a.localeCompare(b); + }); +} + +const getContacts = (deviceContacts: DeviceContact[] | []): Array> => { + return deviceContacts + .map((contact) => { + const email = sortEmailObjects(contact?.emailAddresses ?? [])?.at(0) ?? ''; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const avatarSource = (contact?.imageData || getAvatarForContact(`${contact?.firstName}${email}${contact?.lastName}`)) ?? ''; + const phoneNumber = contact.phoneNumbers?.[0]?.value ?? ''; + const firstName = contact?.firstName ?? ''; + const lastName = contact?.lastName ?? ''; + + return OptionsListUtils.getUserToInviteContactOption({ + selectedOptions: [], + optionsToExclude: [], + searchValue: email || phoneNumber || firstName || '', + firstName, + lastName, + email, + phone: phoneNumber, + avatar: avatarSource, + }); + }) + .filter((contact): contact is OptionsListUtils.SearchOption => contact !== null); +}; + +export default getContacts; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 2cab87639d2f..5ea3fd605d6f 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -13,6 +13,7 @@ import { formatDistance, getDate, getDay, + intervalToDuration, isAfter, isBefore, isSameDay, @@ -36,6 +37,7 @@ import {es} from 'date-fns/locale/es'; import throttle from 'lodash/throttle'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {timezoneBackwardMap} from '@src/TIMEZONES'; @@ -798,8 +800,8 @@ function getFormattedReservationRangeDate(date1: Date, date2: Date): string { /** * Returns a formatted date of departure. * Dates are formatted as follows: - * 1. When the date refers to the current day: Departs on Sunday, Mar 17 at 8:00 - * 2. When the date refers not to the current day: Departs on Wednesday, Mar 17, 2023 at 8:00 + * 1. When the date refers to the current year: Departs on Sunday, Mar 17 at 8:00. + * 2. When the date refers not to the current year: Departs on Wednesday, Mar 17, 2023 at 8:00. */ function getFormattedTransportDate(date: Date): string { const {translateLocal} = Localize; @@ -809,6 +811,45 @@ function getFormattedTransportDate(date: Date): string { return `${translateLocal('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy')} ${translateLocal('common.conjunctionAt')} ${format(date, 'HH:MM')}`; } +/** + * Returns a formatted flight date and hour. + * Dates are formatted as follows: + * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM + * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM + */ +function getFormattedTransportDateAndHour(date: Date): {date: string; hour: string} { + if (isThisYear(date)) { + return { + date: format(date, 'EEEE, MMM d'), + hour: format(date, 'h:mm a'), + }; + } + return { + date: format(date, 'EEEE, MMM d, yyyy'), + hour: format(date, 'h:mm a'), + }; +} + +/** + * Returns a formatted layover duration in format "2h 30m". + */ +function getFormattedDurationBetweenDates(translate: LocaleContextProps['translate'], start: Date, end: Date): string | undefined { + const {days, hours, minutes} = intervalToDuration({start, end}); + + if (days && days > 0) { + return; + } + + return `${hours ? `${hours}${translate('common.hourAbbreviation')} ` : ''}${minutes}${translate('common.minuteAbbreviation')}`; +} + +function getFormattedDuration(translate: LocaleContextProps['translate'], durationInSeconds: number): string { + const hours = Math.floor(durationInSeconds / 3600); + const minutes = Math.floor((durationInSeconds % 3600) / 60); + + return `${hours ? `${hours}${translate('common.hourAbbreviation')} ` : ''}${minutes}${translate('common.minuteAbbreviation')}`; +} + function doesDateBelongToAPastYear(date: string): boolean { const transactionYear = new Date(date).getFullYear(); return transactionYear !== new Date().getFullYear(); @@ -889,10 +930,13 @@ const DateUtils = { getFormattedDateRange, getFormattedReservationRangeDate, getFormattedTransportDate, + getFormattedTransportDateAndHour, doesDateBelongToAPastYear, isCardExpired, getDifferenceInDaysFromNow, isValidDateString, + getFormattedDurationBetweenDates, + getFormattedDuration, }; export default DateUtils; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 9343164db1e8..cfc56559ed35 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -921,6 +921,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) return validateString(value); case 'created': case 'modifiedCreated': + case 'inserted': case 'posted': return validateDate(value); case 'isLoading': @@ -1046,6 +1047,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) cardNumber: CONST.RED_BRICK_ROAD_PENDING_ACTION, managedCard: CONST.RED_BRICK_ROAD_PENDING_ACTION, posted: CONST.RED_BRICK_ROAD_PENDING_ACTION, + inserted: CONST.RED_BRICK_ROAD_PENDING_ACTION, }, 'string', ); diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index e01d0fe3115f..e3f34ea3bea3 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -25,6 +25,7 @@ import Log from '@libs/Log'; import NavBarManager from '@libs/NavBarManager'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; import shouldOpenOnAdminRoom from '@libs/Navigation/shouldOpenOnAdminRoom'; @@ -147,7 +148,7 @@ Onyx.connect({ return; } - currentAccountID = value.accountID ?? -1; + currentAccountID = value.accountID ?? CONST.DEFAULT_NUMBER_ID; if (Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) { // This means sign in in RHP was successful, so we can subscribe to user events @@ -249,7 +250,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie } const initialReport = ReportUtils.findLastAccessedReport(!canUseDefaultRooms, shouldOpenOnAdminRoom(), activeWorkspaceID); - return initialReport?.reportID ?? ''; + return initialReport?.reportID; }); useEffect(() => { @@ -464,7 +465,7 @@ function AuthScreens({session, lastOpenedPublicRoomID, initialLastUpdateIDApplie options={{ headerShown: false, presentation: Presentation.TRANSPARENT_MODAL, - animation: 'none', + animation: Animations.NONE, }} getComponent={loadProfileAvatar} listeners={modalScreenListeners} diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index edc32bb705b6..82e762cf033d 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -105,6 +105,8 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator({ [SCREENS.TRAVEL.MY_TRIPS]: () => require('../../../../pages/Travel/MyTripsPage').default, [SCREENS.TRAVEL.TCS]: () => require('../../../../pages/Travel/TravelTerms').default, + [SCREENS.TRAVEL.TRIP_SUMMARY]: () => require('../../../../pages/Travel/TripSummaryPage').default, + [SCREENS.TRAVEL.TRIP_DETAILS]: () => require('../../../../pages/Travel/TripDetailsPage').default, }); const SplitDetailsModalStackNavigator = createModalStackNavigator({ @@ -269,6 +271,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/WorkspaceProfileCurrencyPage').default, [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: () => require('../../../../pages/workspace/categories/CategorySettingsPage').default, [SCREENS.WORKSPACE.ADDRESS]: () => require('../../../../pages/workspace/WorkspaceProfileAddressPage').default, + [SCREENS.WORKSPACE.PLAN]: () => require('../../../../pages/workspace/WorkspaceProfilePlanTypePage').default, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default, [SCREENS.WORKSPACE.CATEGORIES_IMPORT]: () => require('../../../../pages/workspace/categories/ImportCategoriesPage').default, [SCREENS.WORKSPACE.CATEGORIES_IMPORTED]: () => require('../../../../pages/workspace/categories/ImportedCategoriesPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx index 62cceee9f400..b4b71549f7ec 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/BottomTabNavigator.tsx @@ -2,6 +2,7 @@ import {useNavigationState} from '@react-navigation/native'; import React from 'react'; import createCustomBottomTabNavigator from '@libs/Navigation/AppNavigator/createCustomBottomTabNavigator'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; +import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation'; import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types'; import type {BottomTabNavigatorParamList, CentralPaneName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; import SidebarScreen from '@pages/home/sidebar/SidebarScreen'; @@ -14,6 +15,7 @@ const loadInitialSettingsPage = () => require('../../../.. const Tab = createCustomBottomTabNavigator(); const screenOptions: PlatformStackNavigationOptions = { + animation: Animations.FADE, headerShown: false, }; diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx index 01caa79692f1..c72c4de01e4e 100644 --- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/TopBar.tsx @@ -22,15 +22,9 @@ type TopBarProps = { activeWorkspaceID?: string; shouldDisplaySearch?: boolean; shouldDisplayCancelSearch?: boolean; - - /** - * Callback used to keep track of the workspace switching process in the BaseSidebarScreen. - * Passed to the WorkspaceSwitcherButton component. - */ - onSwitchWorkspace?: () => void; }; -function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, shouldDisplayCancelSearch = false, onSwitchWorkspace}: TopBarProps) { +function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, shouldDisplayCancelSearch = false}: TopBarProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const policy = usePolicy(activeWorkspaceID); @@ -53,10 +47,7 @@ function TopBar({breadcrumbLabel, activeWorkspaceID, shouldDisplaySearch = true, dataSet={{dragArea: true}} > - + > = { [SCREENS.WORKSPACE.PROFILE]: [ SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.ADDRESS, + SCREENS.WORKSPACE.PLAN, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 90de67f61f87..30b64a79dca2 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -365,6 +365,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ADDRESS]: { path: ROUTES.WORKSPACE_PROFILE_ADDRESS.route, }, + [SCREENS.WORKSPACE.PLAN]: { + path: ROUTES.WORKSPACE_PROFILE_PLAN.route, + }, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CLASSES]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.route}, @@ -1345,6 +1348,13 @@ const config: LinkingOptions['config'] = { screens: { [SCREENS.TRAVEL.MY_TRIPS]: ROUTES.TRAVEL_MY_TRIPS, [SCREENS.TRAVEL.TCS]: ROUTES.TRAVEL_TCS, + [SCREENS.TRAVEL.TRIP_SUMMARY]: ROUTES.TRAVEL_TRIP_SUMMARY.route, + [SCREENS.TRAVEL.TRIP_DETAILS]: { + path: ROUTES.TRAVEL_TRIP_DETAILS.route, + parse: { + reservationIndex: (reservationIndex: string) => parseInt(reservationIndex, 10), + }, + }, }, }, [SCREENS.RIGHT_MODAL.SEARCH_REPORT]: { diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts index 144a56c7e522..ecb0a2f1220b 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -1,4 +1,4 @@ -import {CommonActions, getActionFromState} from '@react-navigation/core'; +import {getActionFromState} from '@react-navigation/core'; import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import {getPathFromState} from '@react-navigation/native'; import type {Writable} from 'type-fest'; @@ -52,17 +52,6 @@ function getActionForBottomTabNavigator(action: StackNavigationAction, state: Na params.policyID = policyID; } - // If the last route in the BottomTabNavigator is already a 'Home' route, we want to change the params rather than pushing a new 'Home' route, - // so that the screen does not get re-mounted. This would cause an empty screen/white flash when navigating back from the workspace switcher. - const homeRoute = bottomTabNavigatorRoute.state.routes.at(-1); - if (homeRoute && homeRoute.name === SCREENS.HOME) { - return { - ...CommonActions.setParams(params), - source: homeRoute?.key, - }; - } - - // If there is no 'Home' route in the BottomTabNavigator or if we are updating a different navigator, we want to push a new route. return { type: CONST.NAVIGATION.ACTION_TYPE.PUSH, payload: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 462426d2de14..27671fac401f 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -246,7 +246,7 @@ type SettingsNavigatorParamList = { }; [SCREENS.WORKSPACE.UPGRADE]: { policyID: string; - featureName: string; + featureName?: string; backTo?: Routes; categoryId?: string; }; @@ -1407,6 +1407,17 @@ type RightModalNavigatorParamList = { type TravelNavigatorParamList = { [SCREENS.TRAVEL.MY_TRIPS]: undefined; + [SCREENS.TRAVEL.TRIP_SUMMARY]: { + reportID: string; + transactionID: string; + backTo?: string; + }; + [SCREENS.TRAVEL.TRIP_DETAILS]: { + reportID: string; + transactionID: string; + reservationIndex: number; + backTo?: string; + }; }; type FullScreenNavigatorParamList = { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index a7f738790f92..35b7b6a023ce 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -115,6 +115,11 @@ type GetUserToInviteConfig = { searchValue: string; optionsToExclude?: Array>; reportActions?: ReportActions; + firstName?: string; + lastName?: string; + email?: string; + phone?: string; + avatar?: UserUtils.AvatarSource; shouldAcceptName?: boolean; } & Pick; @@ -214,6 +219,15 @@ Onyx.connect({ callback: (val) => (allPolicies = val), }); +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, +}); + const lastReportActions: ReportActions = {}; const allSortedReportActions: Record = {}; let allReportActions: OnyxCollection; @@ -254,7 +268,7 @@ Onyx.connect({ lastReportActions[reportID] = firstReportAction; } - const report = ReportUtils.getReport(reportID); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); // The report is only visible if it is the last action not deleted that @@ -351,25 +365,29 @@ function getParticipantsOption(participant: ReportUtils.OptionData | Participant const detail = getPersonalDetailsForAccountIDs([participant.accountID ?? -1], personalDetails)[participant.accountID ?? -1]; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const login = detail?.login || participant.login || ''; - const displayName = LocalePhoneNumber.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(detail, login || participant.text)); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const displayName = participant?.displayName || LocalePhoneNumber.formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(detail, login || participant.text)); return { keyForList: String(detail?.accountID), login, accountID: detail?.accountID ?? -1, text: displayName, - firstName: detail?.firstName ?? '', - lastName: detail?.lastName ?? '', + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + firstName: (detail?.firstName || ('firstName' in participant ? participant.firstName : '')) ?? '', + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + lastName: (detail?.lastName || ('lastName' in participant ? participant.lastName : '')) ?? '', alternateText: LocalePhoneNumber.formatPhoneNumber(login) || displayName, icons: [ { - source: detail?.avatar ?? FallbackAvatar, + source: ('avatar' in participant ? participant.avatar : detail?.avatar) ?? FallbackAvatar, name: login, type: CONST.ICON_TYPE_AVATAR, id: detail?.accountID, }, ], - phoneNumber: detail?.phoneNumber ?? '', + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + phoneNumber: (detail?.phoneNumber || participant?.phoneNumber) ?? '', selected: participant.selected, isSelected: participant.selected, searchText: participant.searchText ?? undefined, @@ -767,7 +785,7 @@ function getPolicyExpenseReportOption(participant: Participant | ReportUtils.Opt const expenseReport = ReportUtils.isPolicyExpenseChat(participant) ? ReportUtils.getReportOrDraftReport(participant.reportID) : null; const visibleParticipantAccountIDs = Object.entries(expenseReport?.participants ?? {}) - .filter(([, reportParticipant]) => reportParticipant && reportParticipant.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) + .filter(([, reportParticipant]) => reportParticipant && !ReportUtils.isHiddenForCurrentUser(reportParticipant.notificationPreference)) .map(([accountID]) => Number(accountID)); const option = createOption( @@ -1083,6 +1101,101 @@ function getUserToInviteOption({ return userToInvite; } +function getUserToInviteContactOption({ + searchValue, + optionsToExclude = [], + selectedOptions = [], + firstName, + lastName, + email, + phone, + avatar, +}: GetUserToInviteConfig): SearchOption | null { + // If email is provided, use it as the primary identifier + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const effectiveSearchValue = email || searchValue; + + // Handle phone number parsing for either provided phone or searchValue + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const phoneToCheck = phone || searchValue; + const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(phoneToCheck))); + + const isCurrentUserLogin = isCurrentUser({login: effectiveSearchValue} as PersonalDetails); + const isInSelectedOption = selectedOptions.some((option) => 'login' in option && option.login === effectiveSearchValue); + + // Validate email (either provided email or searchValue) + const isValidEmail = Str.isValidEmail(effectiveSearchValue) && !Str.isDomainEmail(effectiveSearchValue) && !Str.endsWith(effectiveSearchValue, CONST.SMS.DOMAIN); + + const isValidPhoneNumber = parsedPhoneNumber.possible && Str.isValidE164Phone(LoginUtils.getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')); + + const isInOptionToExclude = + optionsToExclude.findIndex((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === PhoneNumber.addSMSDomainIfPhoneNumber(effectiveSearchValue).toLowerCase()) !== + -1; + + if (!effectiveSearchValue || isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber) || isInOptionToExclude) { + return null; + } + + // Generates an optimistic account ID for new users not yet saved in Onyx + const optimisticAccountID = UserUtils.generateAccountID(effectiveSearchValue); + + // Construct display name if firstName/lastName are provided + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const displayName = firstName && lastName ? `${firstName} ${lastName}` : firstName || lastName || effectiveSearchValue; + + // Create the base user details that will be used in both item and participantsList + const userDetails = { + accountID: optimisticAccountID, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + avatar: avatar || FallbackAvatar, + firstName: firstName ?? '', + lastName: lastName ?? '', + displayName, + login: effectiveSearchValue, + pronouns: '', + phoneNumber: phone ?? '', + validated: true, + }; + + const userToInvite = { + item: userDetails, + text: displayName, + alternateText: displayName !== effectiveSearchValue ? effectiveSearchValue : undefined, + brickRoadIndicator: null, + icons: [ + { + source: userDetails.avatar, + type: CONST.ICON_TYPE_AVATAR, + name: effectiveSearchValue, + id: optimisticAccountID, + }, + ], + tooltipText: null, + participantsList: [userDetails], + accountID: optimisticAccountID, + login: effectiveSearchValue, + reportID: '', + phoneNumber: phone ?? '', + hasDraftComment: false, + keyForList: optimisticAccountID.toString(), + isDefaultRoom: false, + isPinned: false, + isWaitingOnBankAccount: false, + isIOUReportOwner: false, + iouReportAmount: 0, + isChatRoom: false, + shouldShowSubscript: false, + isPolicyExpenseChat: false, + isOwnPolicyExpenseChat: false, + isExpenseReport: false, + lastMessageText: '', + isBold: true, + isOptimisticAccount: true, + }; + + return userToInvite; +} + /** * Options are reports and personal details. This function filters out the options that are not valid to be displayed. */ @@ -1746,6 +1859,9 @@ function filterAndOrderOptions(options: Options, searchInputValue: string, confi let {recentReports: filteredReports, personalDetails: filteredPersonalDetails} = filterResult; + // on staging server, in specific cases (see issue) BE returns duplicated personalDetails entries + filteredPersonalDetails = filteredPersonalDetails.filter((detail, index, array) => array.findIndex((i) => i.login === detail.login) === index); + if (typeof config?.maxRecentReportsToShow === 'number') { filteredReports = orderReportOptionsWithSearch(filteredReports, searchInputValue, config); filteredReports = filteredReports.slice(0, config.maxRecentReportsToShow); @@ -1787,7 +1903,7 @@ function getEmptyOptions(): Options { function shouldUseBoldText(report: ReportUtils.OptionData): boolean { const notificationPreference = report.notificationPreference ?? ReportUtils.getReportNotificationPreference(report); - return report.isUnread === true && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + return report.isUnread === true && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE && !ReportUtils.isHiddenForCurrentUser(notificationPreference); } export { @@ -1830,6 +1946,7 @@ export { getFirstKeyForList, canCreateOptimisticPersonalDetailOption, getUserToInviteOption, + getUserToInviteContactOption, getPersonalDetailSearchTerms, getCurrentUserSearchTerms, getEmptyOptions, @@ -1839,4 +1956,4 @@ export { hasReportErrors, }; -export type {Section, SectionBase, MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree}; +export type {MemberForList, Options, OptionList, SearchOption, PayeePersonalDetails, Option, OptionTree, GetUserToInviteConfig, Section, SectionBase}; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index f77f992ede37..bebd54698288 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -28,7 +28,7 @@ function canUseCategoryAndTagApprovers(betas: OnyxEntry): boolean { function canUseCombinedTrackSubmit(): boolean { // We don't need to show this to all betas since this will be used for developing a feature for A/B testing. const session = SessionUtils.getSession(); - return isAccountIDEven(session?.accountID ?? -1); + return isAccountIDEven(session?.accountID ?? CONST.DEFAULT_NUMBER_ID); } function canUsePerDiem(betas: OnyxEntry): boolean { @@ -47,6 +47,14 @@ function canUseLinkPreviews(): boolean { return false; } +/** + * Workspace downgrade is temporarily disabled + * API is being integrated in this GH issue https://github.com/Expensify/App/issues/51494 + */ +function canUseWorkspaceDowngrade() { + return false; +} + export default { canUseDefaultRooms, canUseLinkPreviews, @@ -55,5 +63,6 @@ export default { canUseCombinedTrackSubmit, canUseCategoryAndTagApprovers, canUsePerDiem, + canUseWorkspaceDowngrade, shouldShowProductTrainingElements, }; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 9bf68b2c5432..458adfd311d4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -39,7 +39,7 @@ import * as Localize from './Localize'; import Navigation from './Navigation/Navigation'; import * as NetworkStore from './Network/NetworkStore'; import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils'; -import {getAllReportTransactions, getCategory, getTag} from './TransactionUtils'; +import {getAllSortedTransactions, getCategory, getTag} from './TransactionUtils'; type MemberEmailsToAccountIDs = Record; @@ -197,7 +197,15 @@ function getPolicyBrickRoadIndicatorStatus(policy: OnyxEntry, isConnecti } function getPolicyRole(policy: OnyxInputOrEntry | SearchPolicy, currentUserLogin: string | undefined) { - return policy?.role ?? policy?.employeeList?.[currentUserLogin ?? '-1']?.role; + if (policy?.role) { + return policy.role; + } + + if (!currentUserLogin) { + return; + } + + return policy?.employeeList?.[currentUserLogin]?.role; } /** @@ -391,7 +399,7 @@ function isPaidGroupPolicy(policy: OnyxEntry | SearchPolicy): boolean { } function getOwnedPaidPolicies(policies: OnyxCollection | null, currentUserAccountID: number): Policy[] { - return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? -1) && isPaidGroupPolicy(policy)); + return Object.values(policies ?? {}).filter((policy): policy is Policy => isPolicyOwner(policy, currentUserAccountID ?? CONST.DEFAULT_NUMBER_ID) && isPaidGroupPolicy(policy)); } function isControlPolicy(policy: OnyxEntry): boolean { @@ -400,7 +408,7 @@ function isControlPolicy(policy: OnyxEntry): boolean { function isTaxTrackingEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry, isDistanceRequest: boolean): boolean { const distanceUnit = getDistanceRateCustomUnit(policy); - const customUnitID = distanceUnit?.customUnitID ?? 0; + const customUnitID = distanceUnit?.customUnitID ?? CONST.DEFAULT_NUMBER_ID; const isPolicyTaxTrackingEnabled = isPolicyExpenseChat && policy?.tax?.trackingEnabled; const isTaxEnabledForDistance = isPolicyTaxTrackingEnabled && policy?.customUnits?.[customUnitID]?.attributes?.taxEnabled; @@ -534,37 +542,35 @@ function getDefaultApprover(policy: OnyxEntry | SearchPolicy): string { return policy?.approver ?? policy?.owner ?? ''; } -/** - * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. - */ -function getSubmitToAccountID(policy: OnyxEntry | SearchPolicy, expenseReport: OnyxEntry): number { - const employeeAccountID = expenseReport?.ownerAccountID ?? -1; - const employeeLogin = getLoginsByAccountIDs([employeeAccountID]).at(0) ?? ''; - const defaultApprover = getDefaultApprover(policy); - - let categoryAppover; - let tagApprover; - const allTransactions = getAllReportTransactions(expenseReport?.reportID).sort((transA, transB) => (transA.created < transB.created ? -1 : 1)); +function getRuleApprovers(policy: OnyxEntry | SearchPolicy, expenseReport: OnyxEntry) { + const categoryAppovers: string[] = []; + const tagApprovers: string[] = []; + const allReportTransactions = getAllSortedTransactions(expenseReport?.reportID); // Before submitting to their `submitsTo` (in a policy on Advanced Approvals), submit to category/tag approvers. // Category approvers are prioritized, then tag approvers. - for (let i = 0; i < allTransactions.length; i++) { - const transaction = allTransactions.at(i); + for (let i = 0; i < allReportTransactions.length; i++) { + const transaction = allReportTransactions.at(i); const tag = getTag(transaction); const category = getCategory(transaction); - categoryAppover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const categoryAppover = getCategoryApproverRule(policy?.rules?.approvalRules ?? [], category)?.approver; + const tagApprover = getTagApproverRule(policy, tag)?.approver; if (categoryAppover) { - return getAccountIDsByLogins([categoryAppover]).at(0) ?? -1; + categoryAppovers.push(categoryAppover); } - if (!tagApprover && getTagApproverRule(policy ?? '-1', tag)?.approver) { - tagApprover = getTagApproverRule(policy ?? '-1', tag)?.approver; + if (tagApprover) { + tagApprovers.push(tagApprover); } } - if (tagApprover) { - return getAccountIDsByLogins([tagApprover]).at(0) ?? -1; - } + return [...categoryAppovers, ...tagApprovers]; +} + +function getManagerAccountID(policy: OnyxEntry | SearchPolicy, expenseReport: OnyxEntry) { + const employeeAccountID = expenseReport?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID; + const employeeLogin = getLoginsByAccountIDs([employeeAccountID]).at(0) ?? ''; + const defaultApprover = getDefaultApprover(policy); // For policy using the optional or basic workflow, the manager is the policy default approver. if (([CONST.POLICY.APPROVAL_MODE.OPTIONAL, CONST.POLICY.APPROVAL_MODE.BASIC] as Array>).includes(getApprovalWorkflow(policy))) { @@ -579,9 +585,21 @@ function getSubmitToAccountID(policy: OnyxEntry | SearchPolicy, expenseR return getAccountIDsByLogins([employee.submitsTo ?? defaultApprover]).at(0) ?? -1; } -function getSubmitToEmail(policy: OnyxEntry, expenseReport: OnyxEntry): string { - const submitToAccountID = getSubmitToAccountID(policy, expenseReport); - return getLoginsByAccountIDs([submitToAccountID]).at(0) ?? ''; +/** + * Returns the accountID to whom the given expenseReport submits reports to in the given Policy. + */ +function getSubmitToAccountID(policy: OnyxEntry | SearchPolicy, expenseReport: OnyxEntry): number { + const ruleApprovers = getRuleApprovers(policy, expenseReport); + if (ruleApprovers.length > 0 && !isSubmitAndClose(policy)) { + return getAccountIDsByLogins([ruleApprovers.at(0) ?? '']).at(0) ?? -1; + } + + return getManagerAccountID(policy, expenseReport); +} + +function getManagerAccountEmail(policy: OnyxEntry, expenseReport: OnyxEntry): string { + const managerAccountID = getManagerAccountID(policy, expenseReport); + return getLoginsByAccountIDs([managerAccountID]).at(0) ?? ''; } /** @@ -709,7 +727,10 @@ function settingsPendingAction(settings?: string[], pendingFields?: PendingField } const key = Object.keys(pendingFields).find((setting) => settings.includes(setting)); - return pendingFields[key ?? '-1']; + if (!key) { + return; + } + return pendingFields[key]; } function findSelectedVendorWithDefaultSelect(vendors: NetSuiteVendor[] | undefined, selectedVendorId: string | undefined) { @@ -1078,7 +1099,7 @@ function getWorkspaceAccountID(policyID: string) { if (!policy) { return 0; } - return policy.workspaceAccountID ?? 0; + return policy.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; } function hasVBBA(policyID: string) { @@ -1121,6 +1142,17 @@ function getActivePolicy(): OnyxEntry { return getPolicy(activePolicyId); } +function getUserFriendlyWorkspaceType(workspaceType: ValueOf) { + switch (workspaceType) { + case CONST.POLICY.TYPE.CORPORATE: + return Localize.translateLocal('workspace.type.control'); + case CONST.POLICY.TYPE.TEAM: + return Localize.translateLocal('workspace.type.collect'); + default: + return Localize.translateLocal('workspace.type.free'); + } +} + function isPolicyAccessible(policy: OnyxEntry): boolean { return !isEmptyObject(policy) && (Object.keys(policy).length !== 1 || isEmptyObject(policy.errors)) && !!policy?.id; } @@ -1241,7 +1273,6 @@ export { getCurrentTaxID, areSettingsInErrorFields, settingsPendingAction, - getSubmitToEmail, getForwardsToAccount, getSubmitToAccountID, getWorkspaceAccountID, @@ -1254,8 +1285,11 @@ export { getNetSuiteImportCustomFieldLabel, getAllPoliciesLength, getActivePolicy, + getUserFriendlyWorkspaceType, isPolicyAccessible, areAllGroupPoliciesExpenseChatDisabled, + getManagerAccountEmail, + getRuleApprovers, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/RandomAvatarUtils.ts b/src/libs/RandomAvatarUtils.ts new file mode 100644 index 000000000000..2aad23b13583 --- /dev/null +++ b/src/libs/RandomAvatarUtils.ts @@ -0,0 +1,99 @@ +import type {FC} from 'react'; +import type {SvgProps} from 'react-native-svg'; +import Avatar1 from '@assets/images/avatars/user/default-avatar_1.svg'; +import Avatar2 from '@assets/images/avatars/user/default-avatar_2.svg'; +import Avatar3 from '@assets/images/avatars/user/default-avatar_3.svg'; +import Avatar4 from '@assets/images/avatars/user/default-avatar_4.svg'; +import Avatar5 from '@assets/images/avatars/user/default-avatar_5.svg'; +import Avatar6 from '@assets/images/avatars/user/default-avatar_6.svg'; +import Avatar7 from '@assets/images/avatars/user/default-avatar_7.svg'; +import Avatar8 from '@assets/images/avatars/user/default-avatar_8.svg'; +import Avatar9 from '@assets/images/avatars/user/default-avatar_9.svg'; +import Avatar10 from '@assets/images/avatars/user/default-avatar_10.svg'; +import Avatar11 from '@assets/images/avatars/user/default-avatar_11.svg'; +import Avatar12 from '@assets/images/avatars/user/default-avatar_12.svg'; +import Avatar13 from '@assets/images/avatars/user/default-avatar_13.svg'; +import Avatar14 from '@assets/images/avatars/user/default-avatar_14.svg'; +import Avatar15 from '@assets/images/avatars/user/default-avatar_15.svg'; +import Avatar16 from '@assets/images/avatars/user/default-avatar_16.svg'; +import Avatar17 from '@assets/images/avatars/user/default-avatar_17.svg'; +import Avatar18 from '@assets/images/avatars/user/default-avatar_18.svg'; +import Avatar19 from '@assets/images/avatars/user/default-avatar_19.svg'; +import Avatar20 from '@assets/images/avatars/user/default-avatar_20.svg'; +import Avatar21 from '@assets/images/avatars/user/default-avatar_21.svg'; +import Avatar22 from '@assets/images/avatars/user/default-avatar_22.svg'; +import Avatar23 from '@assets/images/avatars/user/default-avatar_23.svg'; +import Avatar24 from '@assets/images/avatars/user/default-avatar_24.svg'; + +type AvatarComponent = FC; +type AvatarArray = readonly AvatarComponent[]; + +const avatars: AvatarArray = [ + Avatar1, + Avatar2, + Avatar3, + Avatar4, + Avatar5, + Avatar6, + Avatar7, + Avatar8, + Avatar9, + Avatar10, + Avatar11, + Avatar12, + Avatar13, + Avatar14, + Avatar15, + Avatar16, + Avatar17, + Avatar18, + Avatar19, + Avatar20, + Avatar21, + Avatar22, + Avatar23, + Avatar24, +] as const; + +const AVATAR_LENGTH: number = avatars.length; +const DEFAULT_AVATAR: AvatarComponent = Avatar1; + +// Prime numbers for better distribution +const MULTIPLIER = AVATAR_LENGTH + 7; // First prime after length +const OFFSET = AVATAR_LENGTH - 11; // First prime before length + +/** + * Generate a deterministic avatar based on multiple letters from the name. + * Uses a rolling hash of the first 5 letters (or available letters if name is shorter) + * for better distribution while maintaining deterministic results. + * + * @example + * // These will always return the same avatar for the same name + * const avatar1 = getAvatarForContact("Jonathan") // Uses 'Jonat' for hash + * const avatar2 = getAvatarForContact("Jane") // Uses 'Jane' for hash + * const avatar3 = getAvatarForContact("J") // Uses 'J' for hash + * + * @param name - Contact name or null/undefined + * @returns Avatar component + */ +const getAvatarForContact = (name?: string | null): AvatarComponent => { + if (!name?.length) { + return DEFAULT_AVATAR; + } + + // Take up to first 8 characters, or all if name is shorter + const chars = name.slice(0, 8); + + // Create a rolling hash from the characters + let hash = 0; + for (let i = 0; i < chars.length; i++) { + const charCode = chars.charCodeAt(i); + // Use position-based multiplier for better distribution + hash = (hash * MULTIPLIER + charCode * (i + 1) + OFFSET) % AVATAR_LENGTH; + } + + return avatars.at(Math.abs(hash)) ?? DEFAULT_AVATAR; +}; + +export type {AvatarComponent}; +export {getAvatarForContact}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index eb728586f8f2..c20ec7386b0a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1294,12 +1294,9 @@ function getDefaultNotificationPreferenceForReport(report: OnyxEntry): V } /** - * Get the notification preference given a report + * Get the notification preference given a report. This should ALWAYS default to 'hidden'. Do not change this! */ -function getReportNotificationPreference(report: OnyxEntry, shouldDefaltToHidden = true): ValueOf { - if (!shouldDefaltToHidden) { - return report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? getDefaultNotificationPreferenceForReport(report); - } +function getReportNotificationPreference(report: OnyxEntry): ValueOf { return report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; } @@ -1415,6 +1412,23 @@ function canCreateTaskInReport(report: OnyxEntry): boolean { return true; } +/** + * For all intents and purposes a report that has no notificationPreference at all should be considered "hidden". + * We will remove the 'hidden' field entirely once the backend changes for https://github.com/Expensify/Expensify/issues/450891 are done. + */ +function isHiddenForCurrentUser(notificationPreference: string | null | undefined): boolean; +function isHiddenForCurrentUser(report: OnyxEntry): boolean; +function isHiddenForCurrentUser(reportOrPreference: OnyxEntry | string | null | undefined): boolean { + if (typeof reportOrPreference === 'object' && reportOrPreference !== null) { + const notificationPreference = getReportNotificationPreference(reportOrPreference); + return isHiddenForCurrentUser(notificationPreference); + } + if (reportOrPreference === undefined || reportOrPreference === null || reportOrPreference === '') { + return true; + } + return reportOrPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; +} + /** * Returns true if there are any guides accounts (team.expensify.com) in a list of accountIDs * by cross-referencing the accountIDs with personalDetails since guides that are participants @@ -1426,7 +1440,7 @@ function hasExpensifyGuidesEmails(accountIDs: number[]): boolean { function getMostRecentlyVisitedReport(reports: Array>, reportMetadata: OnyxCollection): OnyxEntry { const filteredReports = reports.filter((report) => { - const shouldKeep = !isChatThread(report) || getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldKeep = !isChatThread(report) || !isHiddenForCurrentUser(report); return shouldKeep && !!report?.reportID && !!(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report.reportID}`]?.lastVisitTime ?? report?.lastReadTime); }); return lodashMaxBy(filteredReports, (a) => new Date(reportMetadata?.[`${ONYXKEYS.COLLECTION.REPORT_METADATA}${a?.reportID}`]?.lastVisitTime ?? a?.lastReadTime ?? '').valueOf()); @@ -2233,7 +2247,7 @@ function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldEx return false; } - if (shouldExcludeHidden && reportParticipants[accountID]?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (shouldExcludeHidden && isHiddenForCurrentUser(reportParticipants[accountID]?.notificationPreference)) { return false; } @@ -2833,7 +2847,7 @@ function getReasonAndReportActionThatRequiresAttention( }; } - if (hasMissingInvoiceBankAccount(optionOrReport.reportID)) { + if (hasMissingInvoiceBankAccount(optionOrReport.reportID) && !isSettled(optionOrReport.reportID)) { return { reason: CONST.REQUIRES_ATTENTION_REASONS.HAS_MISSING_INVOICE_BANK_ACCOUNT, }; @@ -2841,7 +2855,11 @@ function getReasonAndReportActionThatRequiresAttention( if (isInvoiceRoom(optionOrReport)) { const reportAction = Object.values(reportActions).find( - (action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && action.childReportID && hasMissingInvoiceBankAccount(action.childReportID), + (action) => + action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && + action.childReportID && + hasMissingInvoiceBankAccount(action.childReportID) && + !isSettled(action.childReportID), ); return reportAction @@ -2880,8 +2898,8 @@ function hasNonReimbursableTransactions(iouReportID: string | undefined): boolea function getMoneyRequestSpendBreakdown(report: OnyxInputOrEntry, allReportsDict?: OnyxCollection): SpendBreakdown { const allAvailableReports = allReportsDict ?? allReports; - let moneyRequestReport; - if (isMoneyRequestReport(report) || isInvoiceReport(report)) { + let moneyRequestReport: OnyxEntry; + if (report && (isMoneyRequestReport(report) || isInvoiceReport(report))) { moneyRequestReport = report; } if (allAvailableReports && report?.iouReportID) { @@ -3900,7 +3918,7 @@ function getReportActionMessage(reportAction: OnyxEntry, reportID? if (reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.UNHOLD) { return Localize.translateLocal('iou.unheldExpense'); } - if (ReportActionsUtils.isApprovedOrSubmittedReportAction(reportAction)) { + if (ReportActionsUtils.isApprovedOrSubmittedReportAction(reportAction) || ReportActionsUtils.isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REIMBURSED)) { return ReportActionsUtils.getReportActionMessageText(reportAction); } if (ReportActionsUtils.isReimbursementQueuedAction(reportAction)) { @@ -4074,11 +4092,7 @@ function getReportName( } if (isInvoiceReport(report)) { - if (!isInvoiceRoom(getReport(report?.chatReportID ?? ''))) { - return report?.reportName ?? getMoneyRequestReportName(report, policy, invoiceReceiverPolicy); - } - - formattedName = getMoneyRequestReportName(report, policy, invoiceReceiverPolicy); + formattedName = report?.reportName ?? getMoneyRequestReportName(report, policy, invoiceReceiverPolicy); } if (isInvoiceRoom(report)) { @@ -5193,7 +5207,7 @@ function buildOptimisticReportPreview( const hasReceipt = TransactionUtils.hasReceipt(transaction); const message = getReportPreviewMessage(iouReport); const created = DateUtils.getDBTime(); - const reportActorAccountID = (isInvoiceReport(iouReport) ? iouReport?.ownerAccountID : iouReport?.managerID) ?? -1; + const reportActorAccountID = (isInvoiceReport(iouReport) || isExpenseReport(iouReport) ? iouReport?.ownerAccountID : iouReport?.managerID) ?? -1; return { reportActionID: reportActionID ?? NumberUtils.rand64(), reportID: chatReport?.reportID, @@ -8084,8 +8098,8 @@ function getTripTransactions(tripRoomReportID: string | undefined, reportFieldTo return tripTransactionReportIDs.flatMap((reportID) => reportsTransactions[reportID ?? ''] ?? []); } -function getTripIDFromTransactionParentReport(transactionParentReport: OnyxEntry | undefined | null): string | undefined { - return getReportOrDraftReport(transactionParentReport?.parentReportID)?.tripData?.tripID; +function getTripIDFromTransactionParentReportID(transactionParentReportID: string | undefined): string | undefined { + return getReportOrDraftReport(transactionParentReportID)?.tripData?.tripID; } /** @@ -8119,7 +8133,7 @@ function canJoinChat(report: OnyxEntry, parentReportAction: OnyxInputOrE } // If the notification preference of the chat is not hidden that means we have already joined the chat - if (getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (!isHiddenForCurrentUser(report)) { return false; } @@ -8153,7 +8167,7 @@ function canLeaveChat(report: OnyxEntry, policy: OnyxEntry): boo return false; } - if (getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (isHiddenForCurrentUser(report)) { return false; } @@ -8472,20 +8486,41 @@ function isExported(reportActions: OnyxEntry) { function getApprovalChain(policy: OnyxEntry, expenseReport: OnyxEntry): string[] { const approvalChain: string[] = []; + const fullApprovalChain: string[] = []; const reportTotal = expenseReport?.total ?? 0; + const submitterEmail = PersonalDetailsUtils.getLoginsByAccountIDs([expenseReport?.ownerAccountID ?? -1]).at(0) ?? ''; - // If the policy is not on advanced approval mode, we should not use the approval chain even if it exists. - if (!PolicyUtils.isControlOnAdvancedApprovalMode(policy)) { + if (PolicyUtils.isSubmitAndClose(policy)) { return approvalChain; } - let nextApproverEmail = PolicyUtils.getSubmitToEmail(policy, expenseReport); + // Get category/tag approver list + const ruleApprovers = PolicyUtils.getRuleApprovers(policy, expenseReport); + + // Push rule approvers to approvalChain list before submitsTo/forwardsTo approvers + ruleApprovers.forEach((ruleApprover) => { + // Don't push submiiter to approve as a rule approver + if (fullApprovalChain.includes(ruleApprover) || ruleApprover === submitterEmail) { + return; + } + fullApprovalChain.push(ruleApprover); + }); + + let nextApproverEmail = PolicyUtils.getManagerAccountEmail(policy, expenseReport); while (nextApproverEmail && !approvalChain.includes(nextApproverEmail)) { approvalChain.push(nextApproverEmail); nextApproverEmail = PolicyUtils.getForwardsToAccount(policy, nextApproverEmail, reportTotal); } - return approvalChain; + + approvalChain.forEach((approver) => { + if (fullApprovalChain.includes(approver)) { + return; + } + + fullApprovalChain.push(approver); + }); + return fullApprovalChain; } /** @@ -8794,7 +8829,7 @@ export { updateReportPreview, temporary_getMoneyRequestOptions, getTripTransactions, - getTripIDFromTransactionParentReport, + getTripIDFromTransactionParentReportID, buildOptimisticInvoiceReport, getInvoiceChatByParticipants, shouldShowMerchantColumn, @@ -8817,7 +8852,6 @@ export { getReportLastMessage, getMostRecentlyVisitedReport, getSourceIDFromReportAction, - getReport, getReportNameValuePairs, hasReportViolations, isPayAtEndExpenseReport, @@ -8835,6 +8869,7 @@ export { getAllReportActionsErrorsAndReportActionThatRequiresAttention, hasInvoiceReports, getReportMetadata, + isHiddenForCurrentUser, }; export type { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b5ae73bed2e4..68b3ce60963a 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -305,9 +305,11 @@ function getAction(data: OnyxTypes.SearchResults['data'], key: string): SearchTr ) { return CONST.SEARCH.ACTION_TYPES.PAY; } + const hasOnlyPendingTransactions = + allReportTransactions.length > 0 && allReportTransactions.every((t) => TransactionUtils.isExpensifyCardTransaction(t) && TransactionUtils.isPending(t)); const isAllowedToApproveExpenseReport = ReportUtils.isAllowedToApproveExpenseReport(report, undefined, policy); - if (IOU.canApproveIOU(report, policy) && isAllowedToApproveExpenseReport) { + if (IOU.canApproveIOU(report, policy) && isAllowedToApproveExpenseReport && !hasOnlyPendingTransactions) { return CONST.SEARCH.ACTION_TYPES.APPROVE; } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 01cbad41b128..626dc8d5ed68 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -35,6 +35,16 @@ Onyx.connect({ allPersonalDetails = value ?? {}; }, }); + +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + allReports = value; + }, +}); + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (actions, key) => { @@ -42,7 +52,7 @@ Onyx.connect({ return; } const reportID = CollectionUtils.extractCollectionItemID(key); - const report = ReportUtils.getReport(reportID); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const canUserPerformWriteAction = ReportUtils.canUserPerformWriteAction(report); const actionsArray: ReportAction[] = ReportActionsUtils.getSortedReportActions(Object.values(actions)); @@ -91,7 +101,7 @@ function ensureSingleSpacing(text: string) { */ function getOrderedReportIDs( currentReportId: string | null, - allReports: OnyxCollection, + reports: OnyxCollection, betas: OnyxEntry, policies: OnyxCollection, priorityMode: OnyxEntry, @@ -102,7 +112,7 @@ function getOrderedReportIDs( Performance.markStart(CONST.TIMING.GET_ORDERED_REPORT_IDS); const isInFocusMode = priorityMode === CONST.PRIORITY_MODE.GSD; const isInDefaultMode = !isInFocusMode; - const allReportsDictValues = Object.values(allReports ?? {}); + const allReportsDictValues = Object.values(reports ?? {}); // Filter out all the reports that shouldn't be displayed let reportsToDisplay: Array = []; @@ -115,7 +125,7 @@ function getOrderedReportIDs( } const parentReportAction = ReportActionsUtils.getReportAction(report?.parentReportID ?? '-1', report?.parentReportActionID ?? '-1'); const doesReportHaveViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(report, transactionViolations); - const isHidden = ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const isHidden = ReportUtils.isHiddenForCurrentUser(report); const isFocused = report.reportID === currentReportId; const hasErrorsOtherThanFailedReceipt = ReportUtils.hasReportErrorsOtherThanFailedReceipt(report, doesReportHaveViolations, transactionViolations); const isReportInAccessible = report?.errorFields?.notFound; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 81a738f724e0..2304132e79f1 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -189,6 +189,7 @@ function buildOptimisticTransaction( billable, reimbursable, attendees, + inserted: DateUtils.getDBTime(), }; } @@ -1239,6 +1240,23 @@ function buildTransactionsMergeParams(reviewDuplicates: OnyxEntry> { + return getAllReportTransactions(iouReportID).sort((transA, transB) => { + if (transA.created < transB.created) { + return -1; + } + + if (transA.created > transB.created) { + return 1; + } + + return (transA.inserted ?? '') < (transB.inserted ?? '') ? -1 : 1; + }); +} + export { buildOptimisticTransaction, calculateTaxAmount, @@ -1322,6 +1340,7 @@ export { getCardName, hasReceiptSource, shouldShowAttendees, + getAllSortedTransactions, getFormattedPostedDate, }; diff --git a/src/libs/TripReservationUtils.ts b/src/libs/TripReservationUtils.ts index f2ce5113af81..2c774637b4a0 100644 --- a/src/libs/TripReservationUtils.ts +++ b/src/libs/TripReservationUtils.ts @@ -50,7 +50,7 @@ Onyx.connect({ }, }); -function getTripReservationIcon(reservationType: ReservationType): IconAsset { +function getTripReservationIcon(reservationType?: ReservationType): IconAsset { switch (reservationType) { case CONST.RESERVATION_TYPE.FLIGHT: return Expensicons.Plane; @@ -58,16 +58,27 @@ function getTripReservationIcon(reservationType: ReservationType): IconAsset { return Expensicons.Bed; case CONST.RESERVATION_TYPE.CAR: return Expensicons.CarWithKey; + case CONST.RESERVATION_TYPE.TRAIN: + return Expensicons.Train; default: return Expensicons.Luggage; } } -function getReservationsFromTripTransactions(transactions: Transaction[]): Reservation[] { +type ReservationData = {reservation: Reservation; transactionID: string; reportID: string; reservationIndex: number}; + +function getReservationsFromTripTransactions(transactions: Transaction[]): ReservationData[] { return transactions - .map((item) => item?.receipt?.reservationList ?? []) - .filter((item) => item.length > 0) - .flat(); + .flatMap( + (item) => + item?.receipt?.reservationList?.map((reservation, reservationIndex) => ({ + reservation, + transactionID: item.transactionID, + reportID: item.reportID, + reservationIndex, + })) ?? [], + ) + .sort((a, b) => new Date(a.reservation.start.date).getTime() - new Date(b.reservation.start.date).getTime()); } function getTripEReceiptIcon(transaction?: Transaction): IconAsset | undefined { @@ -115,3 +126,4 @@ function bookATrip(translate: LocaleContextProps['translate'], setCtaErrorMessag }); } export {getTripReservationIcon, getReservationsFromTripTransactions, getTripEReceiptIcon, bookATrip}; +export type {ReservationData}; diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index 4d38d410cbfd..1c85781806ac 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -40,7 +40,7 @@ function getUnreadReportsForUnreadIndicator(reports: OnyxCollection, cur * Furthermore, muted reports may or may not appear in the LHN depending on priority mode, * but they should not be considered in the unread indicator count. */ - notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN && + !ReportUtils.isHiddenForCurrentUser(notificationPreference) && notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE ); }); diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index ee0a70d3b7fe..60dff1c97247 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -40,6 +40,15 @@ Onyx.connect({ }, }); +let reportsCollection: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => { + reportsCollection = value; + }, +}); + let allTransactionViolations: NonNullable> = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, @@ -74,7 +83,7 @@ const getBrickRoadForPolicy = (report: Report, altReportActions?: OnyxCollection } if (oneTransactionThreadReportID && !doesReportContainErrors) { - const oneTransactionThreadReport = ReportUtils.getReport(oneTransactionThreadReportID); + const oneTransactionThreadReport = reportsCollection?.[`${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReportID}`]; if (ReportUtils.shouldDisplayViolationsRBRInLHN(oneTransactionThreadReport, allTransactionViolations)) { doesReportContainErrors = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index 61ce04655ae5..931f9e226995 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -89,6 +89,14 @@ Onyx.connect({ }, }); +let preservedUserSession: OnyxTypes.Session | undefined; +Onyx.connect({ + key: ONYXKEYS.PRESERVED_USER_SESSION, + callback: (value) => { + preservedUserSession = value; + }, +}); + const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.ACCOUNT, ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, @@ -102,6 +110,7 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.CREDENTIALS, + ONYXKEYS.PRESERVED_USER_SESSION, ]; Onyx.connect({ @@ -524,6 +533,10 @@ function setIsUsingImportedState(usingImportedState: boolean) { Onyx.set(ONYXKEYS.IS_USING_IMPORTED_STATE, usingImportedState); } +function setPreservedUserSession(session: OnyxTypes.Session) { + Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, session); +} + function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { // The value of isUsingImportedState will be lost once Onyx is cleared, so we need to store it const isStateImported = isUsingImportedState; @@ -538,6 +551,11 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) { Navigation.navigate(ROUTES.HOME); } + if (preservedUserSession) { + Onyx.set(ONYXKEYS.SESSION, preservedUserSession); + Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, null); + } + // Requests in a sequential queue should be called even if the Onyx state is reset, so we do not lose any pending data. // However, the OpenApp request must be called before any other request in a queue to ensure data consistency. // To do that, sequential queue is cleared together with other keys, and then it's restored once the OpenApp request is resolved. @@ -574,5 +592,6 @@ export { updateLastRoute, setIsUsingImportedState, clearOnyxAndResetApp, + setPreservedUserSession, KEYS_TO_PRESERVE, }; diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 1c60d49e9170..c8dce813c895 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -392,8 +392,8 @@ function clearIssueNewCardFlow() { }); } -function clearIssueNewCardError(issueNewCard: IssueNewCardFlowData) { - Onyx.set(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {...issueNewCard, errors: null}); +function clearIssueNewCardError() { + Onyx.merge(ONYXKEYS.ISSUE_NEW_EXPENSIFY_CARD, {errors: null}); } function updateExpensifyCardLimit(workspaceAccountID: number, cardID: number, newLimit: number, newAvailableSpend: number, oldLimit?: number, oldAvailableSpend?: number) { diff --git a/src/libs/actions/Debug.ts b/src/libs/actions/Debug.ts index 4c3479ee9741..6fbf18505074 100644 --- a/src/libs/actions/Debug.ts +++ b/src/libs/actions/Debug.ts @@ -11,7 +11,12 @@ function setDebugData(on Onyx.set(onyxKey, onyxValue); } +function mergeDebugData(onyxKey: TKey, onyxValue: OnyxMergeInput) { + Onyx.merge(onyxKey, onyxValue); +} + export default { resetDebugDetailsDraftForm, setDebugData, + mergeDebugData, }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 696853f49fd7..5f8b83d4b5c2 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1377,7 +1377,6 @@ function buildOnyxDataForInvoice( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateInvoiceFailureMessage'), - pendingAction: null, pendingFields: clearedPendingFields, }, }, @@ -2097,7 +2096,14 @@ function getSendInvoiceInformation( } // STEP 2: Create a new optimistic invoice report. - const optimisticInvoiceReport = ReportUtils.buildOptimisticInvoiceReport(chatReport.reportID, senderWorkspaceID, receiverAccountID, receiver.displayName ?? '', amount, currency); + const optimisticInvoiceReport = ReportUtils.buildOptimisticInvoiceReport( + chatReport.reportID, + senderWorkspaceID, + receiverAccountID, + receiver.displayName ?? (receiverParticipant as Participant)?.login ?? '', + amount, + currency, + ); // STEP 3: Build optimistic receipt and transaction const receiptObject: Receipt = {}; diff --git a/src/libs/actions/Policy/Plan.ts b/src/libs/actions/Policy/Plan.ts new file mode 100644 index 000000000000..a30e8bb67704 --- /dev/null +++ b/src/libs/actions/Policy/Plan.ts @@ -0,0 +1,52 @@ +import type {OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {OpenWorkspacePlanPageParams} from '@libs/API/parameters'; +import {READ_COMMANDS} from '@libs/API/types'; +import Log from '@libs/Log'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function OpenWorkspacePlanPage(policyID: string) { + if (!policyID) { + Log.warn('OpenWorkspacePlanPage invalid params', {policyID}); + return; + } + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: true, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: false, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + isLoading: false, + }, + }, + ]; + + const params: OpenWorkspacePlanPageParams = { + policyID, + }; + + API.read(READ_COMMANDS.OPEN_WORKSPACE_PLAN_PAGE, params, {optimisticData, successData, failureData}); +} + +export default OpenWorkspacePlanPage; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index f81539d1e921..f855ea477856 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -196,7 +196,7 @@ Onyx.connect({ key: ONYXKEYS.SESSION, callback: (val) => { sessionEmail = val?.email ?? ''; - sessionAccountID = val?.accountID ?? -1; + sessionAccountID = val?.accountID ?? CONST.DEFAULT_NUMBER_ID; }, }); @@ -258,8 +258,8 @@ function hasInvoicingDetails(policy: OnyxEntry): boolean { * Returns a primary invoice workspace for the user */ function getInvoicePrimaryWorkspace(currentUserLogin: string | undefined): Policy | undefined { - if (PolicyUtils.canSendInvoiceFromWorkspace(activePolicyID ?? '-1')) { - return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID ?? '-1'}`]; + if (PolicyUtils.canSendInvoiceFromWorkspace(activePolicyID)) { + return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`]; } const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies, currentUserLogin); return activeAdminWorkspaces.find((policy) => PolicyUtils.canSendInvoiceFromWorkspace(policy.id)); @@ -1700,6 +1700,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName outputCurrency: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, address: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, description: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + type: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, }, }, @@ -1772,6 +1773,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName outputCurrency: null, address: null, description: null, + type: null, }, }, }, @@ -1862,7 +1864,7 @@ function buildPolicyData(policyOwnerEmail = '', makeMeAdmin = false, policyName failureData.push({ onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, - value: activePolicyID ?? '', + value: activePolicyID, }); } @@ -2234,7 +2236,7 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF expenseCreatedReportActionID: workspaceChatCreatedReportActionID, } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); - if (!employeeAccountID) { + if (!employeeAccountID || !oldPersonalPolicyID) { return; } @@ -2512,17 +2514,18 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF const parentReportActionID = iouReport.parentReportActionID; const reportPreview = iouReport?.parentReportID && parentReportActionID ? parentReport?.[parentReportActionID] : undefined; - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, - value: {[reportPreview?.reportActionID ?? '-1']: null}, - }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, - value: {[reportPreview?.reportActionID ?? '-1']: reportPreview}, - }); - + if (reportPreview?.reportActionID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview?.reportActionID]: null}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview?.reportActionID]: reportPreview}, + }); + } // To optimistically remove the GBR from the DM we need to update the hasOutstandingChildRequest param to false optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, @@ -2559,14 +2562,16 @@ function createWorkspaceFromIOUPayment(iouReport: OnyxEntry): WorkspaceF }); } - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, - value: {[reportPreview?.reportActionID ?? '-1']: null}, - }); + if (reportPreview?.reportActionID) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, + value: {[reportPreview?.reportActionID]: null}, + }); + } // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved - const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID ?? '-1', policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); + const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID, policyID, memberData.workspaceChatReportID, iouReportID, workspaceName); optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, @@ -3385,7 +3390,7 @@ function setForeignCurrencyDefault(policyID: string, taxCode: string) { API.write(WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT, parameters, onyxData); } -function upgradeToCorporate(policyID: string, featureName: string) { +function upgradeToCorporate(policyID: string, featureName?: string) { const policy = getPolicy(policyID); const optimisticData: OnyxUpdate[] = [ { @@ -3437,7 +3442,7 @@ function upgradeToCorporate(policyID: string, featureName: string) { }, ]; - const parameters: UpgradeToCorporateParams = {policyID, featureName}; + const parameters: UpgradeToCorporateParams = {policyID, ...(featureName ? {featureName} : {})}; API.write(WRITE_COMMANDS.UPGRADE_TO_CORPORATE, parameters, {optimisticData, successData, failureData}); } diff --git a/src/libs/actions/QuickActionNavigation.ts b/src/libs/actions/QuickActionNavigation.ts new file mode 100644 index 000000000000..b89d1a981417 --- /dev/null +++ b/src/libs/actions/QuickActionNavigation.ts @@ -0,0 +1,54 @@ +import {generateReportID} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type {QuickActionName} from '@src/types/onyx/QuickAction'; +import type QuickAction from '@src/types/onyx/QuickAction'; +import * as IOU from './IOU'; +import * as Task from './Task'; + +function getQuickActionRequestType(action: QuickActionName | undefined): IOU.IOURequestType | undefined { + if (!action) { + return; + } + + let requestType; + if ([CONST.QUICK_ACTIONS.REQUEST_MANUAL, CONST.QUICK_ACTIONS.SPLIT_MANUAL, CONST.QUICK_ACTIONS.TRACK_MANUAL].some((a) => a === action)) { + requestType = CONST.IOU.REQUEST_TYPE.MANUAL; + } else if ([CONST.QUICK_ACTIONS.REQUEST_SCAN, CONST.QUICK_ACTIONS.SPLIT_SCAN, CONST.QUICK_ACTIONS.TRACK_SCAN].some((a) => a === action)) { + requestType = CONST.IOU.REQUEST_TYPE.SCAN; + } else if ([CONST.QUICK_ACTIONS.REQUEST_DISTANCE, CONST.QUICK_ACTIONS.SPLIT_DISTANCE, CONST.QUICK_ACTIONS.TRACK_DISTANCE].some((a) => a === action)) { + requestType = CONST.IOU.REQUEST_TYPE.DISTANCE; + } + + return requestType; +} + +function navigateToQuickAction(isValidReport: boolean, quickActionReportID: string, quickAction: QuickAction, selectOption: (onSelected: () => void, shouldRestrictAction: boolean) => void) { + const reportID = isValidReport ? quickActionReportID : generateReportID(); + const requestType = getQuickActionRequestType(quickAction?.action); + + switch (quickAction?.action) { + case CONST.QUICK_ACTIONS.REQUEST_MANUAL: + case CONST.QUICK_ACTIONS.REQUEST_SCAN: + case CONST.QUICK_ACTIONS.REQUEST_DISTANCE: + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SUBMIT, reportID, requestType, true), true); + return; + case CONST.QUICK_ACTIONS.SPLIT_MANUAL: + case CONST.QUICK_ACTIONS.SPLIT_SCAN: + case CONST.QUICK_ACTIONS.SPLIT_DISTANCE: + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SPLIT, reportID, requestType, true), true); + return; + case CONST.QUICK_ACTIONS.SEND_MONEY: + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.PAY, reportID, undefined, true), false); + return; + case CONST.QUICK_ACTIONS.ASSIGN_TASK: + selectOption(() => Task.startOutCreateTaskQuickAction(isValidReport ? reportID : '', quickAction.targetAccountID ?? CONST.DEFAULT_NUMBER_ID), false); + break; + case CONST.QUICK_ACTIONS.TRACK_MANUAL: + case CONST.QUICK_ACTIONS.TRACK_SCAN: + case CONST.QUICK_ACTIONS.TRACK_DISTANCE: + selectOption(() => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK, reportID, requestType, true), false); + break; + default: + } +} +export {navigateToQuickAction, getQuickActionRequestType}; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 7baf66adc5c5..9fe8269bee90 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -545,9 +545,8 @@ function addActions(reportID: string, text = '', file?: FileObject) { }; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const shouldUpdateNotificationPrefernece = !isEmptyObject(report) && ReportUtils.getReportNotificationPreference(report) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; - - if (shouldUpdateNotificationPrefernece) { + const shouldUpdateNotificationPreference = !isEmptyObject(report) && ReportUtils.isHiddenForCurrentUser(report); + if (shouldUpdateNotificationPreference) { optimisticReport.participants = { [currentUserAccountID]: {notificationPreference: ReportUtils.getDefaultNotificationPreferenceForReport(report)}, }; @@ -1932,7 +1931,7 @@ function toggleSubscribeToChildReport(childReportID = '-1', parentReportAction: if (childReportID !== '-1') { openReport(childReportID); const parentReportActionID = parentReportAction?.reportActionID ?? '-1'; - if (!prevNotificationPreference || prevNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { + if (!prevNotificationPreference || ReportUtils.isHiddenForCurrentUser(prevNotificationPreference)) { updateNotificationPreference(childReportID, prevNotificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, parentReportID, parentReportActionID); } else { updateNotificationPreference(childReportID, prevNotificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, parentReportID, parentReportActionID); @@ -1957,8 +1956,9 @@ function toggleSubscribeToChildReport(childReportID = '-1', parentReportAction: const participantLogins = PersonalDetailsUtils.getLoginsByAccountIDs(participantAccountIDs); openReport(newChat.reportID, '', participantLogins, newChat, parentReportAction.reportActionID); - const notificationPreference = - prevNotificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const notificationPreference = ReportUtils.isHiddenForCurrentUser(prevNotificationPreference) + ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS + : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; updateNotificationPreference(newChat.reportID, prevNotificationPreference, notificationPreference, parentReportID, parentReportAction?.reportActionID); } } @@ -3062,7 +3062,12 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, - value: {[report.parentReportActionID]: {childReportNotificationPreference: ReportUtils.getReportNotificationPreference(report, false)}}, + value: { + [report.parentReportActionID]: { + childReportNotificationPreference: + report?.participants?.[currentUserAccountID ?? -1]?.notificationPreference ?? ReportUtils.getDefaultNotificationPreferenceForReport(report), + }, + }, }); } diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 1dbb01b008dd..8685a0363e31 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -17,6 +17,7 @@ import type { RequestAccountValidationLinkParams, RequestNewValidateCodeParams, RequestUnlinkValidationLinkParams, + ResetSMSDeliveryFailureParams, SignInUserWithLinkParams, SignUpUserParams, UnlinkLoginParams, @@ -1200,6 +1201,52 @@ function isUserOnPrivateDomain() { return false; } +/** + * To reset SMS delivery failure + */ +function resetSMSDeliveryFailure(login: string) { + const params: ResetSMSDeliveryFailureParams = {login}; + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + errors: null, + smsDeliveryFailureStatus: { + isLoading: true, + }, + }, + }, + ]; + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + smsDeliveryFailureStatus: { + isLoading: false, + isReset: true, + }, + }, + }, + ]; + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + smsDeliveryFailureStatus: { + isLoading: false, + }, + }, + }, + ]; + + API.write(WRITE_COMMANDS.RESET_SMS_DELIVERY_FAILURE, params, {optimisticData, successData, failureData}); +} + export { beginSignIn, beginAppleSignIn, @@ -1239,4 +1286,5 @@ export { signInAfterTransitionFromOldDot, validateUserAndGetAccessiblePolicies, isUserOnPrivateDomain, + resetSMSDeliveryFailure, }; diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index df8c0474fbaa..bd68d15caadc 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -252,7 +252,7 @@ function createTaskAndNavigate( }, ); - const shouldUpdateNotificationPreference = !isEmptyObject(parentReport) && ReportUtils.getReportNotificationPreference(parentReport) === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldUpdateNotificationPreference = !isEmptyObject(parentReport) && ReportUtils.isHiddenForCurrentUser(parentReport); if (shouldUpdateNotificationPreference) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 8f8c416ceeb3..fd9d5f1820e6 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -32,6 +32,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as Pusher from '@libs/Pusher/pusher'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile'; import Visibility from '@libs/Visibility'; @@ -795,9 +796,7 @@ const isChannelMuted = (reportId: string) => Onyx.disconnect(connection); const notificationPreference = report?.participants?.[currentUserAccountID]?.notificationPreference; - resolve( - !notificationPreference || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, - ); + resolve(!notificationPreference || notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || ReportUtils.isHiddenForCurrentUser(notificationPreference)); }, }); }); diff --git a/src/pages/Debug/DebugDetails.tsx b/src/pages/Debug/DebugDetails.tsx index 60126ef1937a..c5b0b068e65d 100644 --- a/src/pages/Debug/DebugDetails.tsx +++ b/src/pages/Debug/DebugDetails.tsx @@ -15,8 +15,6 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {ObjectType, OnyxDataType} from '@libs/DebugUtils'; import DebugUtils from '@libs/DebugUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; import Debug from '@userActions/Debug'; import type CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -34,6 +32,13 @@ type DebugDetailsProps = { /** The report or report action data to be displayed and editted. */ data: OnyxEntry | OnyxEntry | OnyxEntry | OnyxEntry; + /** Whether the provided policy has enabled tags */ + policyHasEnabledTags?: boolean; + + /** ID of the provided policy */ + policyID?: string; + + /** Metadata UI */ children?: React.ReactNode; /** Callback to be called when user saves the debug data. */ @@ -47,13 +52,10 @@ type DebugDetailsProps = { validate: (key: any, value: string) => void; }; -function DebugDetails({formType, data, children, onSave, onDelete, validate}: DebugDetailsProps) { +function DebugDetails({formType, data, policyHasEnabledTags, policyID, children, onSave, onDelete, validate}: DebugDetailsProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [formDraftData] = useOnyx(ONYXKEYS.FORMS.DEBUG_DETAILS_FORM_DRAFT); - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${(data as OnyxEntry)?.reportID ?? ''}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); - const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); const booleanFields = useMemo( () => Object.entries(data ?? {}) @@ -66,13 +68,13 @@ function DebugDetails({formType, data, children, onSave, onDelete, validate}: De Object.entries(data ?? {}) .filter((entry): entry is [string, string] => { // Tag picker needs to be hidden when the policy has no tags available to pick - if (entry[0] === TRANSACTION_FORM_INPUT_IDS.TAG && !TagsOptionsListUtils.hasEnabledTags(policyTagLists)) { + if (entry[0] === TRANSACTION_FORM_INPUT_IDS.TAG && !policyHasEnabledTags) { return false; } return DETAILS_CONSTANT_FIELDS[formType].some(({fieldName}) => fieldName === entry[0]); }) .sort((a, b) => a[0].localeCompare(b[0])), - [data, formType, policyTagLists], + [data, formType, policyHasEnabledTags], ); const numberFields = useMemo( () => @@ -209,7 +211,7 @@ function DebugDetails({formType, data, children, onSave, onDelete, validate}: De name={key} shouldSaveDraft defaultValue={String(value)} - policyID={report?.policyID} + policyID={policyID} /> ))} {constantFields.length === 0 && {translate('debug.none')}} @@ -248,9 +250,7 @@ function DebugDetails({formType, data, children, onSave, onDelete, validate}: De danger large text={translate('common.delete')} - onPress={() => { - onDelete(); - }} + onPress={onDelete} /> diff --git a/src/pages/Debug/Report/DebugReportPage.tsx b/src/pages/Debug/Report/DebugReportPage.tsx index 16e23ed4c608..a31597bb59dd 100644 --- a/src/pages/Debug/Report/DebugReportPage.tsx +++ b/src/pages/Debug/Report/DebugReportPage.tsx @@ -144,7 +144,6 @@ function DebugReportPage({ Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, data); }} onDelete={() => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, null); navigateToConciergeChatAndDeleteReport(reportID, true, true); }} validate={DebugUtils.validateReportDraftProperty} diff --git a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx index 2066ab71c639..1162146b8f4d 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionCreatePage.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; @@ -51,6 +51,29 @@ function DebugReportActionCreatePage({ const [draftReportAction, setDraftReportAction] = useState(() => getInitialReportAction(reportID, session, personalDetailsList)); const [error, setError] = useState(); + const createReportAction = useCallback(() => { + const parsedReportAction = JSON.parse(draftReportAction.replaceAll('\n', '')) as ReportAction; + Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, { + [parsedReportAction.reportActionID]: parsedReportAction, + }); + Navigation.navigate(ROUTES.DEBUG_REPORT_TAB_ACTIONS.getRoute(reportID)); + }, [draftReportAction, reportID]); + + const editJSON = useCallback( + (updatedJSON: string) => { + try { + DebugUtils.validateReportActionJSON(updatedJSON); + setError(''); + } catch (e) { + const {cause, message} = e as SyntaxError; + setError(cause ? translate(message as TranslationPaths, cause as never) : message); + } finally { + setDraftReportAction(updatedJSON); + } + }, + [translate], + ); + return ( { - try { - DebugUtils.validateReportActionJSON(updatedJSON); - setError(''); - } catch (e) { - const {cause, message} = e as SyntaxError; - setError(cause ? translate(message as TranslationPaths, cause as never) : message); - } finally { - setDraftReportAction(updatedJSON); - } - }} + onChangeText={editJSON} textInputContainerStyles={[styles.border, styles.borderBottom, styles.p5]} /> @@ -112,11 +125,7 @@ function DebugReportActionCreatePage({ success text={translate('common.save')} isDisabled={!draftReportAction || !!error} - onPress={() => { - const parsedReportAction = JSON.parse(draftReportAction.replaceAll('\n', '')) as ReportAction; - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[parsedReportAction.reportActionID]: parsedReportAction}); - Navigation.navigate(ROUTES.DEBUG_REPORT_TAB_ACTIONS.getRoute(reportID)); - }} + onPress={createReportAction} /> diff --git a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx index e072913c0c03..8c9e33af7f85 100644 --- a/src/pages/Debug/ReportAction/DebugReportActionPage.tsx +++ b/src/pages/Debug/ReportAction/DebugReportActionPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -61,11 +61,15 @@ function DebugReportActionPage({ formType={CONST.DEBUG.FORMS.REPORT_ACTION} data={reportAction} onSave={(data) => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data}); + Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: data}); }} onDelete={() => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null}); Navigation.goBack(); + // We need to wait for navigation animations to finish before deleting an action, + // otherwise the user will see a not found page briefly. + InteractionManager.runAfterInteractions(() => { + Debug.mergeDebugData(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: null}); + }); }} validate={DebugUtils.validateReportActionDraftProperty} > diff --git a/src/pages/Debug/Transaction/DebugTransactionPage.tsx b/src/pages/Debug/Transaction/DebugTransactionPage.tsx index b729d18374e9..86a8e3ded86a 100644 --- a/src/pages/Debug/Transaction/DebugTransactionPage.tsx +++ b/src/pages/Debug/Transaction/DebugTransactionPage.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import {View} from 'react-native'; +import React, {useMemo} from 'react'; +import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -14,6 +14,8 @@ import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {DebugParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; import DebugDetails from '@pages/Debug/DebugDetails'; import DebugJSON from '@pages/Debug/DebugJSON'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -32,6 +34,10 @@ function DebugTransactionPage({ }: DebugTransactionPageProps) { const {translate} = useLocalize(); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${report?.policyID}`); + const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); + const styles = useThemeStyles(); if (!transaction) { @@ -60,12 +66,18 @@ function DebugTransactionPage({ { Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, data); }} onDelete={() => { - Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null); Navigation.goBack(); + // We need to wait for navigation animations to finish before deleting a transaction, + // otherwise the user will see a not found page briefly. + InteractionManager.runAfterInteractions(() => { + Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, null); + }); }} validate={DebugUtils.validateTransactionDraftProperty} > diff --git a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx index 2c44e936f10f..f615060ab6df 100644 --- a/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx +++ b/src/pages/Debug/TransactionViolation/DebugTransactionViolationPage.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -45,8 +45,12 @@ function DebugTransactionViolationPage({ const deleteTransactionViolation = useCallback(() => { const updatedTransactionViolations = [...(transactionViolations ?? [])]; updatedTransactionViolations.splice(Number(index), 1); - Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations); Navigation.goBack(); + // We need to wait for navigation animations to finish before deleting a violation, + // otherwise the user will see a not found page briefly. + InteractionManager.runAfterInteractions(() => { + Debug.setDebugData(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, updatedTransactionViolations); + }); }, [index, transactionID, transactionViolations]); if (!transactionViolation) { diff --git a/src/pages/ErrorPage/NotFoundPage.tsx b/src/pages/ErrorPage/NotFoundPage.tsx index e118057b143e..26b6aae5bc0b 100644 --- a/src/pages/ErrorPage/NotFoundPage.tsx +++ b/src/pages/ErrorPage/NotFoundPage.tsx @@ -1,10 +1,11 @@ import React from 'react'; +import {useOnyx} from 'react-native-onyx'; import type {FullPageNotFoundViewProps} from '@components/BlockingViews/FullPageNotFoundView'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; type NotFoundPageProps = { onBackButtonPress?: () => void; @@ -16,6 +17,8 @@ function NotFoundPage({onBackButtonPress = () => Navigation.goBack(), isReportRe // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to go back to the not found page on large screens and to the home page on small screen // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); + const topmostReportId = Navigation.getTopmostReportId(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${topmostReportId}`); return ( @@ -26,8 +29,7 @@ function NotFoundPage({onBackButtonPress = () => Navigation.goBack(), isReportRe onBackButtonPress(); return; } - const topmostReportId = Navigation.getTopmostReportId(); - const report = ReportUtils.getReport(topmostReportId ?? ''); + // detect the report is invalid if (topmostReportId && (!report || report.errorFields?.notFound)) { Navigation.dismissModal(); diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 2a7f1d0cb81b..8ca443a92a70 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -222,7 +222,6 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I ); } diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 9d2d7e4ada75..dc751bae7bff 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -288,7 +288,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta roomDescription = translate('newRoomPage.roomName'); } - const shouldShowNotificationPref = !isMoneyRequestReport && ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN; + const shouldShowNotificationPref = !isMoneyRequestReport && !ReportUtils.isHiddenForCurrentUser(report); const shouldShowWriteCapability = !isMoneyRequestReport; const shouldShowMenuItem = shouldShowNotificationPref || shouldShowWriteCapability || (!!report?.visibility && report.chatType !== CONST.REPORT.CHAT_TYPE.INVOICE); diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index 79d7cfe4acc5..4bcf12623d04 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -63,7 +63,7 @@ function RoomInvitePage({ // Any existing participants and Expensify emails should not be eligible for invitation const excludedUsers = useMemo(() => { const visibleParticipantAccountIDs = Object.entries(report.participants ?? {}) - .filter(([, participant]) => participant && participant.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) + .filter(([, participant]) => participant && !ReportUtils.isHiddenForCurrentUser(participant.notificationPreference)) .map(([accountID]) => Number(accountID)); return [...PersonalDetailsUtils.getLoginsByAccountIDs(visibleParticipantAccountIDs), ...CONST.EXPENSIFY_EMAILS].map((participant) => PhoneNumber.addSMSDomainIfPhoneNumber(participant), diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage.tsx index cf7e50960b9e..7c5bc5e924eb 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersInPage.tsx @@ -24,7 +24,6 @@ function SearchFiltersInPage() { return ( Navigation.goBack()} icon={Illustrations.TeachersUnite} + shouldUseHeadlineHeader /> diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx new file mode 100644 index 000000000000..09ffd3d2cad1 --- /dev/null +++ b/src/pages/Travel/CarTripDetails.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; + +type CarTripDetailsProps = { + reservation: Reservation; + personalDetails: OnyxEntry; +}; + +function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const pickUpDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); + const dropOffDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); + const cancellationText = reservation.cancellationDeadline + ? `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedTransportDateAndHour(new Date(reservation.cancellationDeadline)).date}` + : reservation.cancellationPolicy; + + const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; + + return ( + <> + {reservation.vendor} + + {pickUpDate.date} {CONST.DOT_SEPARATOR} {pickUpDate.hour} + + } + interactive={false} + helperText={reservation.start.location} + helperTextStyle={[styles.pb3, styles.mtn2]} + /> + + {dropOffDate.date} {CONST.DOT_SEPARATOR} {dropOffDate.hour} + + } + interactive={false} + helperText={reservation.end.location} + helperTextStyle={[styles.pb3, styles.mtn2]} + /> + {!!reservation.carInfo?.name && ( + + )} + {!!cancellationText && ( + + )} + {!!reservation.reservationID && ( + + )} + {!!displayName && ( + + )} + + ); +} + +CarTripDetails.displayName = 'CarTripDetails'; + +export default CarTripDetails; diff --git a/src/pages/Travel/FlightTripDetails.tsx b/src/pages/Travel/FlightTripDetails.tsx new file mode 100644 index 000000000000..901286ef33b8 --- /dev/null +++ b/src/pages/Travel/FlightTripDetails.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; + +type FlightTripDetailsProps = { + reservation: Reservation; + prevReservation: Reservation | undefined; + personalDetails: OnyxEntry; +}; + +function FlightTripDetails({reservation, prevReservation, personalDetails}: FlightTripDetailsProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + + const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); + const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); + + const prevFlightEndDate = prevReservation?.end.date; + const layover = prevFlightEndDate && DateUtils.getFormattedDurationBetweenDates(translate, new Date(prevFlightEndDate), new Date(reservation.start.date)); + const flightDuration = DateUtils.getFormattedDuration(translate, reservation.duration); + const flightRouteDescription = `${reservation.start.cityName} (${reservation.start.shortName}) ${translate('common.conjunctionTo')} ${reservation.end.cityName} (${ + reservation.end.shortName + })`; + + const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; + + return ( + <> + {flightRouteDescription} + + {!!layover && ( + + + + + )} + + + + {startDate.hour}} + helperText={`${reservation.start.longName} (${reservation.start.shortName})${reservation.arrivalGate?.terminal ? `, ${reservation.arrivalGate?.terminal}` : ''}`} + helperTextStyle={[styles.pb3, styles.mtn2]} + interactive={false} + /> + {endDate.hour}} + helperText={`${reservation.end.longName} (${reservation.end.shortName})`} + helperTextStyle={[styles.pb3, styles.mtn2]} + interactive={false} + /> + + + {!!reservation.route?.number && ( + + + + )} + {!!reservation.route?.class && ( + + + + )} + {!!reservation.confirmations?.at(0)?.value && ( + + + + )} + + {!!displayName && ( + + )} + + ); +} + +FlightTripDetails.displayName = 'FlightTripDetails'; + +export default FlightTripDetails; diff --git a/src/pages/Travel/HotelTripDetails.tsx b/src/pages/Travel/HotelTripDetails.tsx new file mode 100644 index 000000000000..747dc3ceca70 --- /dev/null +++ b/src/pages/Travel/HotelTripDetails.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; + +type HotelTripDetailsProps = { + reservation: Reservation; + personalDetails: OnyxEntry; +}; + +function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const checkInDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); + const checkOutDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); + const cancellationText = reservation.cancellationDeadline + ? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedTransportDateAndHour(new Date(reservation.cancellationDeadline)).date}` + : reservation.cancellationPolicy; + + const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; + + return ( + <> + {reservation.start.longName} + + {checkInDate.date}} + interactive={false} + /> + {checkOutDate.date}} + interactive={false} + /> + + {!!reservation.roomClass && ( + + )} + {!!cancellationText && ( + + )} + {!!reservation.confirmations?.at(0)?.value && ( + + )} + {!!displayName && ( + + )} + + ); +} + +HotelTripDetails.displayName = 'HotelTripDetails'; + +export default HotelTripDetails; diff --git a/src/pages/Travel/TrainTripDetails.tsx b/src/pages/Travel/TrainTripDetails.tsx new file mode 100644 index 000000000000..c83245981321 --- /dev/null +++ b/src/pages/Travel/TrainTripDetails.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import DateUtils from '@libs/DateUtils'; +import CONST from '@src/CONST'; +import type {PersonalDetails} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; + +type TrainTripDetailsProps = { + reservation: Reservation; + personalDetails: OnyxEntry; +}; + +function TrainTripDetails({reservation, personalDetails}: TrainTripDetailsProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date)); + const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date)); + const trainRouteDescription = `${reservation.start.longName} (${reservation.start.shortName}) ${translate('common.conjunctionTo')} ${reservation.end.longName} (${ + reservation.end.shortName + })`; + const trainDuration = DateUtils.getFormattedDurationBetweenDates(translate, new Date(reservation.start.date), new Date(reservation.end.date)); + + const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; + + return ( + <> + {trainRouteDescription} + + + + + {startDate.hour}} + helperText={`${reservation.start.longName} (${reservation.start.shortName})`} + helperTextStyle={[styles.pb3, styles.mtn2]} + interactive={false} + /> + {endDate.hour}} + helperText={`${reservation.end.longName} (${reservation.end.shortName})`} + helperTextStyle={[styles.pb3, styles.mtn2]} + interactive={false} + /> + + + {!!reservation.coachNumber && ( + + + + )} + {!!reservation.seatNumber && ( + + + + )} + + {!!reservation.confirmations?.at(0)?.value && ( + + )} + + {!!displayName && ( + + )} + + ); +} + +TrainTripDetails.displayName = 'TrainTripDetails'; + +export default TrainTripDetails; diff --git a/src/pages/Travel/TripDetailsPage.tsx b/src/pages/Travel/TripDetailsPage.tsx new file mode 100644 index 000000000000..d7a93e7cdeba --- /dev/null +++ b/src/pages/Travel/TripDetailsPage.tsx @@ -0,0 +1,146 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useState} from 'react'; +import {NativeModules} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {TravelNavigatorParamList} from '@libs/Navigation/types'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as TripReservationUtils from '@libs/TripReservationUtils'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import type {Reservation} from '@src/types/onyx/Transaction'; +import CarTripDetails from './CarTripDetails'; +import FlightTripDetails from './FlightTripDetails'; +import HotelTripDetails from './HotelTripDetails'; +import TrainTripDetails from './TrainTripDetails'; + +function pickTravelerPersonalDetails(personalDetails: OnyxEntry, reservation: Reservation | undefined) { + return Object.values(personalDetails ?? {})?.find((personalDetail) => personalDetail?.login === reservation?.travelerPersonalInfo?.email); +} + +type TripDetailsPageProps = StackScreenProps; + +function TripDetailsPage({route}: TripDetailsPageProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {canUseSpotnanaTravel} = usePermissions(); + const {isOffline} = useNetwork(); + + const [isModifyTripLoading, setIsModifyTripLoading] = useState(false); + const [isTripSupportLoading, setIsTripSupportLoading] = useState(false); + + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID ?? '-1'}`); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? '-1'}`); + + const tripID = ReportUtils.getTripIDFromTransactionParentReportID(parentReport?.reportID); + const reservationType = transaction?.receipt?.reservationList?.at(route.params.reservationIndex ?? 0)?.type; + const reservation = transaction?.receipt?.reservationList?.at(route.params.reservationIndex ?? 0); + const reservationIcon = TripReservationUtils.getTripReservationIcon(reservation?.type); + const [travelerPersonalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: (personalDetails) => pickTravelerPersonalDetails(personalDetails, reservation)}); + + return ( + + + + + {!!reservation && reservationType === CONST.RESERVATION_TYPE.FLIGHT && ( + 0 ? transaction?.receipt?.reservationList?.at(route.params.reservationIndex - 1) : undefined} + reservation={reservation} + personalDetails={travelerPersonalDetails} + /> + )} + {!!reservation && reservationType === CONST.RESERVATION_TYPE.HOTEL && ( + + )} + {!!reservation && reservationType === CONST.RESERVATION_TYPE.CAR && ( + + )} + {!!reservation && reservationType === CONST.RESERVATION_TYPE.TRAIN && ( + + )} + { + setIsModifyTripLoading(true); + Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID))?.finally(() => { + setIsModifyTripLoading(false); + }); + }} + wrapperStyle={styles.mt3} + shouldShowLoadingSpinnerIcon={isModifyTripLoading} + disabled={isModifyTripLoading || isOffline} + /> + { + setIsTripSupportLoading(true); + Link.openTravelDotLink(activePolicyID, CONST.TRIP_ID_PATH(tripID))?.finally(() => { + setIsTripSupportLoading(false); + }); + }} + shouldShowLoadingSpinnerIcon={isTripSupportLoading} + disabled={isTripSupportLoading || isOffline} + /> + + + + ); +} + +TripDetailsPage.displayName = 'TripDetailsPage'; + +export default TripDetailsPage; diff --git a/src/pages/Travel/TripSummaryPage.tsx b/src/pages/Travel/TripSummaryPage.tsx new file mode 100644 index 000000000000..8a0a4f9c38b7 --- /dev/null +++ b/src/pages/Travel/TripSummaryPage.tsx @@ -0,0 +1,64 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {NativeModules} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {ReservationView} from '@components/ReportActionItem/TripDetailsView'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import type {TravelNavigatorParamList} from '@libs/Navigation/types'; +import * as TripReservationUtils from '@src/libs/TripReservationUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; + +type TripSummaryPageProps = StackScreenProps; + +function TripSummaryPage({route}: TripSummaryPageProps) { + const {translate} = useLocalize(); + const {canUseSpotnanaTravel} = usePermissions(); + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID}`); + const reservationsData: TripReservationUtils.ReservationData[] = TripReservationUtils.getReservationsFromTripTransactions(transaction ? [transaction] : []); + + return ( + + + + + {reservationsData.map(({reservation, transactionID, reservationIndex}) => { + return ( + + + + ); + })} + + + + ); +} + +TripSummaryPage.displayName = 'TripSummaryPage'; + +export default TripSummaryPage; diff --git a/src/pages/WorkspaceSwitcherPage/index.tsx b/src/pages/WorkspaceSwitcherPage/index.tsx index 87ba17b6504d..cb52c52cb64c 100644 --- a/src/pages/WorkspaceSwitcherPage/index.tsx +++ b/src/pages/WorkspaceSwitcherPage/index.tsx @@ -20,6 +20,7 @@ import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import switchPolicyAfterInteractions from './switchPolicyAfterInteractions'; import WorkspaceCardCreateAWorkspace from './WorkspaceCardCreateAWorkspace'; type WorkspaceListItem = { @@ -87,7 +88,9 @@ function WorkspaceSwitcherPage() { setActiveWorkspaceID(newPolicyID); Navigation.goBack(); if (newPolicyID !== activeWorkspaceID) { - Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); + // On native platforms, we will see a blank screen if we navigate to a new HomeScreen route while navigating back at the same time. + // Therefore we delay switching the workspace until after back navigation, using the InteractionManager. + switchPolicyAfterInteractions(newPolicyID); } }, [activeWorkspaceID, setActiveWorkspaceID, isFocused], @@ -102,7 +105,7 @@ function WorkspaceSwitcherPage() { .filter((policy) => PolicyUtils.shouldShowPolicy(policy, !!isOffline, currentUserLogin) && !policy?.isJoinRequestPending) .map((policy) => ({ text: policy?.name ?? '', - policyID: policy?.id ?? '-1', + policyID: policy?.id, brickRoadIndicator: getIndicatorTypeForPolicy(policy?.id), icons: [ { diff --git a/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx new file mode 100644 index 000000000000..a3df127564b1 --- /dev/null +++ b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.native.tsx @@ -0,0 +1,10 @@ +import {InteractionManager} from 'react-native'; +import Navigation from '@libs/Navigation/Navigation'; + +function switchPolicyAfterInteractions(newPolicyID: string | undefined) { + InteractionManager.runAfterInteractions(() => { + Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); + }); +} + +export default switchPolicyAfterInteractions; diff --git a/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx new file mode 100644 index 000000000000..612759a8601c --- /dev/null +++ b/src/pages/WorkspaceSwitcherPage/switchPolicyAfterInteractions/index.tsx @@ -0,0 +1,7 @@ +import Navigation from '@libs/Navigation/Navigation'; + +function switchPolicyAfterInteractions(newPolicyID: string | undefined) { + Navigation.navigateWithSwitchPolicyID({policyID: newPolicyID}); +} + +export default switchPolicyAfterInteractions; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 97582f75b7b1..3d314d555881 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -296,7 +296,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro Navigation.dismissModal(); return; } - Navigation.goBack(undefined, false, true); + Navigation.goBack(ROUTES.HOME, false, true); }, [isInNarrowPaneModal]); let headerView = ( @@ -558,14 +558,7 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro // If a user has chosen to leave a thread, and then returns to it (e.g. with the back button), we need to call `openReport` again in order to allow the user to rejoin and to receive real-time updates useEffect(() => { - if ( - !shouldUseNarrowLayout || - !isFocused || - prevIsFocused || - !ReportUtils.isChatThread(report) || - ReportUtils.getReportNotificationPreference(report) !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN || - isSingleTransactionView - ) { + if (!shouldUseNarrowLayout || !isFocused || prevIsFocused || !ReportUtils.isChatThread(report) || !ReportUtils.isHiddenForCurrentUser(report) || isSingleTransactionView) { return; } Report.openReport(reportID ?? ''); diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx index b88a5a0bd33e..6877de271946 100755 --- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -135,7 +135,8 @@ function BaseReportActionContextMenu({ const transactionID = ReportActionsUtils.getLinkedTransactionID(reportActionID, reportID); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const [user] = useOnyx(ONYXKEYS.USER); - const policyID = ReportUtils.getReport(reportID)?.policyID; + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const policyID = report?.policyID; const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID ?? '-1'); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`); @@ -151,7 +152,7 @@ function BaseReportActionContextMenu({ const [download] = useOnyx(`${ONYXKEYS.COLLECTION.DOWNLOAD}${sourceID}`); - const childReport = ReportUtils.getReport(reportAction?.childReportID ?? '-1'); + const [childReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportAction?.childReportID}`); const parentReportAction = ReportActionsUtils.getReportAction(childReport?.parentReportID ?? '', childReport?.parentReportActionID ?? ''); const {reportActions: paginatedReportActions} = usePaginatedReportActions(childReport?.reportID ?? '-1'); @@ -178,7 +179,7 @@ function BaseReportActionContextMenu({ const moneyRequestAction = transactionThreadReportID ? requestParentReportAction : parentReportAction; const [parentReportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${childReport?.parentReportID ?? '-1'}`); - const parentReport = ReportUtils.getReport(childReport?.parentReportID ?? '-1'); + const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${childReport?.parentReportID}`); const isMoneyRequest = useMemo(() => ReportUtils.isMoneyRequest(childReport), [childReport]); const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(childReport); @@ -323,6 +324,7 @@ function BaseReportActionContextMenu({ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style reportAction: (reportAction ?? null) as ReportAction, reportID, + report, draftMessage, selection, close: () => setShouldKeepOpen(false), diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx index a31137e53c6a..b8cdde2ecff3 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx @@ -29,7 +29,7 @@ import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; -import type {Beta, Download as DownloadOnyx, OnyxInputOrEntry, ReportAction, ReportActionReactions, Transaction, User} from '@src/types/onyx'; +import type {Beta, Download as DownloadOnyx, OnyxInputOrEntry, ReportAction, ReportActionReactions, Report as ReportType, Transaction, User} from '@src/types/onyx'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ContextMenuAnchor} from './ReportActionContextMenu'; import {hideContextMenu, showDeleteModal} from './ReportActionContextMenu'; @@ -75,6 +75,7 @@ type ContextMenuActionPayload = { reportAction: ReportAction; transaction?: OnyxEntry; reportID: string; + report: OnyxEntry; draftMessage: string; selection: string; close: () => void; @@ -374,7 +375,7 @@ const ContextMenuActions: ContextMenuAction[] = [ // If return value is true, we switch the `text` and `icon` on // `ContextMenuItem` with `successText` and `successIcon` which will fall back to // the `text` and `icon` - onPress: (closePopover, {reportAction, transaction, selection, reportID, hasCard}) => { + onPress: (closePopover, {reportAction, transaction, selection, report, reportID, hasCard}) => { const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); const messageHtml = getActionHtml(reportAction); const messageText = ReportActionsUtils.getReportActionMessageText(reportAction); @@ -476,7 +477,6 @@ const ContextMenuActions: ContextMenuAction[] = [ const {label, errorMessage} = ReportActionsUtils.getOriginalMessage(reportAction) ?? {label: '', errorMessage: ''}; setClipboardMessage(Localize.translateLocal('report.actions.type.integrationSyncFailed', {label, errorMessage})); } else if (ReportActionsUtils.isCardIssuedAction(reportAction)) { - const report = ReportUtils.getReport(reportID); setClipboardMessage(ReportActionsUtils.getCardIssuedMessage(reportAction, true, report?.policyID, hasCard)); } else if (ReportActionsUtils.isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION)) { setClipboardMessage(ReportActionsUtils.getRemovedConnectionMessage(reportAction)); @@ -603,8 +603,7 @@ const ContextMenuActions: ContextMenuAction[] = [ successTextTranslateKey: 'reportActionContextMenu.copied', successIcon: Expensicons.Checkmark, shouldShow: ({type, isProduction}) => type === CONST.CONTEXT_MENU_TYPES.REPORT && !isProduction, - onPress: (closePopover, {reportID}) => { - const report = ReportUtils.getReport(reportID); + onPress: (closePopover, {report}) => { Clipboard.setString(JSON.stringify(report, null, 4)); hideContextMenu(true, ReportActionComposeFocusManager.focus); }, diff --git a/src/pages/home/report/ReportActionItemMessage.tsx b/src/pages/home/report/ReportActionItemMessage.tsx index da2f3dd151c8..647c17f70d88 100644 --- a/src/pages/home/report/ReportActionItemMessage.tsx +++ b/src/pages/home/report/ReportActionItemMessage.tsx @@ -37,6 +37,7 @@ type ReportActionItemMessageProps = { function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHidden = false}: ReportActionItemMessageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${ReportActionsUtils.getLinkedTransactionID(action) ?? -1}`); const fragments = ReportActionsUtils.getReportActionMessageFragments(action); @@ -122,7 +123,7 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid }; const openWorkspaceInvoicesPage = () => { - const policyID = ReportUtils.getReport(reportID)?.policyID; + const policyID = report?.policyID; if (!policyID) { return; @@ -131,12 +132,14 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)); }; + const shouldShowAddBankAccountButton = action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && ReportUtils.hasMissingInvoiceBankAccount(reportID) && !ReportUtils.isSettled(reportID); + return ( {!isHidden ? ( <> {renderReportActionItemFragments(isApprovedOrSubmittedReportAction)} - {action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && ReportUtils.hasMissingInvoiceBankAccount(reportID) && ( + {shouldShowAddBankAccountButton && (