diff --git a/.github/actions/composite/buildAndroidE2EAPK/action.yml b/.github/actions/composite/buildAndroidE2EAPK/action.yml index b4fc05c7ebe9..0c5f70929c27 100644 --- a/.github/actions/composite/buildAndroidE2EAPK/action.yml +++ b/.github/actions/composite/buildAndroidE2EAPK/action.yml @@ -14,6 +14,24 @@ inputs: MAPBOX_SDK_DOWNLOAD_TOKEN: description: The token to use to download the MapBox SDK required: true + PATH_ENV_FILE: + description: The path to the .env file to use for the build + required: true + EXPENSIFY_PARTNER_NAME: + description: The name of the Expensify partner to use for the build + required: true + EXPENSIFY_PARTNER_PASSWORD: + description: The password of the Expensify partner to use for the build + required: true + EXPENSIFY_PARTNER_USER_ID: + description: The user ID of the Expensify partner to use for the build + required: true + EXPENSIFY_PARTNER_USER_SECRET: + description: The user secret of the Expensify partner to use for the build + required: true + EXPENSIFY_PARTNER_PASSWORD_EMAIL: + description: The email address of the Expensify partner to use for the build + required: true runs: using: composite @@ -37,9 +55,24 @@ runs: - uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef + - name: Append environment variables to env file + shell: bash + run: | + echo "EXPENSIFY_PARTNER_NAME=${EXPENSIFY_PARTNER_NAME}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_PASSWORD=${EXPENSIFY_PARTNER_PASSWORD}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_USER_ID=${EXPENSIFY_PARTNER_USER_ID}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_USER_SECRET=${EXPENSIFY_PARTNER_USER_SECRET}" >> ${{ inputs.PATH_ENV_FILE }} + echo "EXPENSIFY_PARTNER_PASSWORD_EMAIL=${EXPENSIFY_PARTNER_PASSWORD_EMAIL}" >> ${{ inputs.PATH_ENV_FILE }} + - name: Build APK run: npm run ${{ inputs.PACKAGE_SCRIPT_NAME }} shell: bash + env: + EXPENSIFY_PARTNER_NAME: ${{ inputs.EXPENSIFY_PARTNER_NAME }} + EXPENSIFY_PARTNER_PASSWORD: ${{ inputs.EXPENSIFY_PARTNER_PASSWORD }} + EXPENSIFY_PARTNER_USER_ID: ${{ inputs.EXPENSIFY_PARTNER_USER_ID }} + EXPENSIFY_PARTNER_USER_SECRET: ${{ inputs.EXPENSIFY_PARTNER_USER_SECRET }} + EXPENSIFY_PARTNER_PASSWORD_EMAIL: ${{ inputs.EXPENSIFY_PARTNER_PASSWORD_EMAIL }} - name: Upload APK uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05 diff --git a/.github/scripts/createHelpRedirects.sh b/.github/scripts/createHelpRedirects.sh index 04f55e19b4fb..1ae2220253c4 100755 --- a/.github/scripts/createHelpRedirects.sh +++ b/.github/scripts/createHelpRedirects.sh @@ -41,13 +41,13 @@ while read -r line; do # Basic sanity checking to make sure that the source and destination are in expected # subdomains. - if ! [[ $SOURCE_URL =~ ^https://community\.expensify\.com ]]; then - error "Found source URL that is not a community URL: $SOURCE_URL" + if ! [[ $SOURCE_URL =~ ^https://(community|help)\.expensify\.com ]]; then + error "Found source URL that is not a communityDot or helpDot URL: $SOURCE_URL" exit 1 fi - if ! [[ $DEST_URL =~ ^https://help\.expensify\.com ]]; then - error "Found destination URL that is not a help URL: $DEST_URL" + if ! [[ $DEST_URL =~ ^https://(help|use)\.expensify\.com ]]; then + error "Found destination URL that is not a helpDot or useDot URL: $DEST_URL" exit 1 fi diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index bd3af08ae25e..70f70fca60de 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -52,6 +52,12 @@ jobs: PACKAGE_SCRIPT_NAME: android-build-e2e APP_OUTPUT_PATH: android/app/build/outputs/apk/e2e/release/app-e2e-release.apk MAPBOX_SDK_DOWNLOAD_TOKEN: ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + EXPENSIFY_PARTNER_NAME: ${{ secrets.EXPENSIFY_PARTNER_NAME }} + EXPENSIFY_PARTNER_PASSWORD: ${{ secrets.EXPENSIFY_PARTNER_PASSWORD }} + EXPENSIFY_PARTNER_USER_ID: ${{ secrets.EXPENSIFY_PARTNER_USER_ID }} + EXPENSIFY_PARTNER_USER_SECRET: ${{ secrets.EXPENSIFY_PARTNER_USER_SECRET }} + EXPENSIFY_PARTNER_PASSWORD_EMAIL: ${{ secrets.EXPENSIFY_PARTNER_PASSWORD_EMAIL }} + PATH_ENV_FILE: tests/e2e/.env.e2e buildDelta: runs-on: ubuntu-latest-xl @@ -114,6 +120,12 @@ jobs: PACKAGE_SCRIPT_NAME: android-build-e2edelta APP_OUTPUT_PATH: android/app/build/outputs/apk/e2edelta/release/app-e2edelta-release.apk MAPBOX_SDK_DOWNLOAD_TOKEN: ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + EXPENSIFY_PARTNER_NAME: ${{ secrets.EXPENSIFY_PARTNER_NAME }} + EXPENSIFY_PARTNER_PASSWORD: ${{ secrets.EXPENSIFY_PARTNER_PASSWORD }} + EXPENSIFY_PARTNER_USER_ID: ${{ secrets.EXPENSIFY_PARTNER_USER_ID }} + EXPENSIFY_PARTNER_USER_SECRET: ${{ secrets.EXPENSIFY_PARTNER_USER_SECRET }} + EXPENSIFY_PARTNER_PASSWORD_EMAIL: ${{ secrets.EXPENSIFY_PARTNER_PASSWORD_EMAIL }} + PATH_ENV_FILE: tests/e2e/.env.e2edelta runTestsInAWS: runs-on: ubuntu-latest diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml index 64b4536d9241..ebe31f41f3d8 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -33,6 +33,7 @@ jobs: npx reassure --baseline git switch --force --detach - git merge --no-commit --allow-unrelated-histories "$BASELINE_BRANCH" -X ours + git checkout --ours . npm install --force npx reassure --branch diff --git a/android/app/build.gradle b/android/app/build.gradle index 49f1b017d5e0..4050db634f62 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -71,7 +71,7 @@ project.ext.envConfigFiles = [ /** * Set this to true to Run Proguard on Release builds to minify the Java bytecode. */ -def enableProguardInReleaseBuilds = false +def enableProguardInReleaseBuilds = true /** * The preferred build flavor of JavaScriptCore (JSC) @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043102 - versionName "1.4.31-2" + versionCode 1001043202 + versionName "1.4.32-2" } flavorDimensions "default" @@ -152,8 +152,9 @@ android { } release { productFlavors.production.signingConfig signingConfigs.release + shrinkResources enableProguardInReleaseBuilds minifyEnabled enableProguardInReleaseBuilds - proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" signingConfig null // buildTypes take precedence over productFlavors when it comes to the signing configuration, diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 7dab035002a2..e553222dd682 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -8,5 +8,31 @@ # http://developer.android.com/guide/developing/tools/proguard.html # Add any project specific keep options here: --keep class com.facebook.hermes.unicode.** { *; } --keep class com.facebook.jni.** { *; } +-keep class com.expensify.chat.BuildConfig { *; } +-keep, allowoptimization, allowobfuscation class expo.modules.** { *; } + +# Added from auto-generated missingrules.txt to allow build to succeed +-dontwarn com.onfido.javax.inject.Inject +-dontwarn javax.lang.model.element.Element +-dontwarn javax.lang.model.type.TypeMirror +-dontwarn javax.lang.model.type.TypeVisitor +-dontwarn javax.lang.model.util.SimpleTypeVisitor7 +-dontwarn net.sf.scuba.data.Gender +-dontwarn net.sf.scuba.smartcards.CardFileInputStream +-dontwarn net.sf.scuba.smartcards.CardService +-dontwarn net.sf.scuba.smartcards.CardServiceException +-dontwarn org.jmrtd.AccessKeySpec +-dontwarn org.jmrtd.BACKey +-dontwarn org.jmrtd.BACKeySpec +-dontwarn org.jmrtd.PACEKeySpec +-dontwarn org.jmrtd.PassportService +-dontwarn org.jmrtd.lds.CardAccessFile +-dontwarn org.jmrtd.lds.PACEInfo +-dontwarn org.jmrtd.lds.SecurityInfo +-dontwarn org.jmrtd.lds.icao.DG15File +-dontwarn org.jmrtd.lds.icao.DG1File +-dontwarn org.jmrtd.lds.icao.MRZInfo +-dontwarn org.jmrtd.protocol.AAResult +-dontwarn org.jmrtd.protocol.BACResult +-dontwarn org.jmrtd.protocol.PACEResult +-dontwarn org.spongycastle.jce.provider.BouncyCastleProvider \ No newline at end of file diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml new file mode 100644 index 000000000000..972e0416855c --- /dev/null +++ b/android/app/src/main/res/raw/keep.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle index d9d1e903423c..40aefc6f2405 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -17,14 +17,5 @@ apply from: file("../node_modules/@react-native-community/cli-platform-android/n include ':app' includeBuild('../node_modules/@react-native/gradle-plugin') -includeBuild('../node_modules/react-native') { - dependencySubstitution { - substitute(module("com.facebook.react:react-android")).using(project(":packages:react-native:ReactAndroid")) - substitute(module("com.facebook.react:react-native")).using(project(":packages:react-native:ReactAndroid")) - substitute(module("com.facebook.react:hermes-android")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) - substitute(module("com.facebook.react:hermes-engine")).using(project(":packages:react-native:ReactAndroid:hermes-engine")) - } -} - apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle") -useExpoModules() \ No newline at end of file +useExpoModules() diff --git a/docs/articles/expensify-classic/billing-and-subscriptions/Receipt-Breakdown.md b/docs/articles/expensify-classic/billing-and-subscriptions/Receipt-Breakdown.md index d4181735298e..fd137aab62fb 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Receipt-Breakdown.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Receipt-Breakdown.md @@ -16,8 +16,8 @@ Your receipt is broken up into multiple sections that include: The top section will show the total amount you paid as the billing owner of Expensify workspaces and give you a breakdown of price per member. Every member of your workspace(s) gets to store data, review data, and access free features like Expensify Chat. Thus, we show the total price and then use all of the members across all of the workspaces you own to calculate the price per member. Further down in the receipt, and in this article, we break down the members who generated billable activity. ## How-to reduce your bill and get paid to use Expensify -Chances are you can actually get paid to use Expensify with the Expensify Card. In this section of the receipt, we outline how much money you're leaving on the table by not using the Expensify Card. You can click `Get started` to connect with your account manager (if you have one) or Concierge, both of whom can help get you started with the card. - +Chances are you can actually get paid to use Expensify with the Expensify Card. In this section of the receipt, we outline how much money you're leaving on the table by not using the Expensify Card. You can click `Get started` to connect with your account manager (if you have one) or Concierge, both of whom can help get you started with the card. + _Note: Currently, we offer Expensify Cards to companies with USD bank accounts._ ## How-to understand your billing breakdown @@ -25,7 +25,7 @@ Your receipt will have a detailed breakdown of activity and discounts across all - [Number of] Inactive workspace members @ $0.00 - All inactive members from any of your workspaces. - [Number of] Chat-only members @ $0.00 - - Any workspace members who chatted but didn't generate any other billable activity. Learn more about [chatting for free.](https://help.expensify.com/articles/new-expensify/getting-started/chat/Everything-About-Chat) + - Any workspace members who chatted but didn't generate any other billable activity. Learn more about [chatting for free.](https://help.expensify.com/articles/new-expensify/chat/Introducing-Expensify-Chat) - [Number of] Annual Control members @ $18.00 - Any members included in your annual subscription on the Control plan. - [Number of] Pay-per-use Control members @ $36.00 @@ -37,9 +37,9 @@ Your receipt will have a detailed breakdown of activity and discounts across all - [Number of] Free members @ $0.00 - All members across any of your Free workspaces. - X% Expensify Card discount with $Y spend - - This shows the % discount you're getting based on total spend across your Expensify Cards. This is only available in the US. + - This shows the % discount you're getting based on total approved spend across your Expensify Cards. This is only available in the US. - X% Expensify Card cash back credit for $Y spend - - The amount of cash back you've earned based on total spend across your Expensify Cards. This is only available in the US. + - The amount of cash back you've earned based on total approved spend across your Expensify Cards. This is only available in the US. - 50% ExpensifyApproved! partner discount - If you're part of an accounting firm, you get an additional discount for being our partner. [Learn more about our ExpensifyApproved! accountants program.](https://use.expensify.com/accountants-program) - Total diff --git a/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md b/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md index 464f2129d800..1cf29531f696 100644 --- a/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md +++ b/docs/articles/expensify-classic/expensify-card/Set-Up-the-Card-for-Your-Company.md @@ -58,7 +58,7 @@ Applying for or using the Expensify Card will never have any positive or negativ ## How much does the Expensify Card cost? -The Expensify Card is a free corporate card, and no fees are associated with it. In addition, if you use the Expensify Card, you can save money on your Expensify subscription. +The Expensify Card is a free corporate card, and no fees are associated with it. In addition, if you use the Expensify Card, you can save money on your Expensify subscription (based on how much of your approved spend occurs on the Expensify Card, compared with other approved spend, in each month). ## If I have staff outside the US, can they use the Expensify Card? diff --git a/docs/articles/expensify-classic/getting-started/Plan-Types.md b/docs/articles/expensify-classic/getting-started/Plan-Types.md deleted file mode 100644 index 4f8c52c2e1a1..000000000000 --- a/docs/articles/expensify-classic/getting-started/Plan-Types.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Plan Types -description: Learn which Expensify plan is the best fit for you ---- -# Overview -You can access comprehensive information about Expensify's plans and pricing by visiting www.expensify.com/pricing. Below, we provide an overview of each plan type to assist you in selecting the one that best suits your business or personal requirements. - -## Free Plan -The Free plan is suited for small businesses, offering a dedicated workspace for efficiently handling Expensify card management, expense reimbursement, invoicing, and bill payment. This plan includes unlimited receipt scanning for all users within the company and the potential to earn up to 1% cashback on card spending exceeding $25,000 per month (across all cards). - -## Collect Workspace Plan -The Collect Workspace Plan is designed with small companies in mind, providing essential features like a single layer of expense approvals, reimbursement capabilities, corporate card management, and basic integration options such as QuickBooks Online, QuickBooks Desktop, and Xero. This plan is ideal for those who require simple expense management functions. - -## Control Workspace Plan -Our most popular option, the Control Workspace plan, offers a heightened level of control and Workspace customization. With a Control Workspace, you gain access to multi-level approval workflows, comprehensive corporate card management, advanced accounting integration, tax tracking capabilities, and advanced expense rules that facilitate the enforcement of your internal expense policy. This plan provides a robust set of features for effective expense management. - -## Individual Track Plan -The Track plan is tailored for solo Expensify users who don't require expense submission to others. Individuals or sole proprietors can choose the Track plan to customize their Individual Workspace to align with their personal expense tracking requirements. - -## Individual Submit Plan -The Submit plan is designed for individuals who need to keep track of their expenses and share them with someone else, such as their boss, accountant, or even a housemate. It's specifically tailored for single users who want to both track and submit their expenses efficiently. - -{% include faq-begin.md %} - -## How can I change Individual plans? -You have the flexibility to switch between a Track and Submit plan, or vice versa, at any time by navigating to **Settings > Workspaces > Individual > *Workspace Name* > Plan**. This allows you to adapt your expense management approach as needed. - -## How can I upgrade Group plans? -You can easily upgrade from a Collect to a Control plan at any time by going to **Settings > Workspaces > Group > *Workspace Name* > Plan**. However, it's important to note that if you have an active Annual Subscription, downgrading from Control to Collect is not possible until your current commitment period expires. - -## How does pricing work if I have two types of Group Workspace plans? -If you have a Control and Collect Workspace, you will be charged at the Control Workspace rate. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md b/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md deleted file mode 100644 index 189ff671b213..000000000000 --- a/docs/articles/expensify-classic/getting-started/approved-accountants/Card-Revenue-Share-For-Expensify-Approved-Partners.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: Expensify Card revenue share for ExpensifyApproved! partners -description: Earn money when your clients adopt the Expensify Card -redirect_from: articles/other/Card-Revenue-Share-for-ExpensifyApproved!-Partners/ ---- - - -Start making more with us! We're thrilled to announce a new incentive for our US-based ExpensifyApproved! partner accountants. You can now earn additional income for your firm every time your client uses their Expensify Card. **In short, your firm gets 0.5% of your clients’ total Expensify Card spend as cash back**. The more your clients spend, the more cashback your firm receives!
-
This program is currently only available to US-based ExpensifyApproved! partner accountants. - -# How-to -To benefit from this program, all you need to do is ensure that you are listed as a domain admin on your client's Expensify account. If you're not currently a domain admin, your client can follow the instructions outlined in [our help article](https://community.expensify.com/discussion/5749/how-to-add-and-remove-domain-admins#:~:text=Domain%20Admins%20have%20total%20control,a%20member%20of%20the%20domain.) to assign you this role. -{% include faq-begin.md %} -- What if my firm is not permitted to accept revenue share from our clients?
-
We understand that different firms may have different policies. If your firm is unable to accept this revenue share, you can pass the revenue share back to your client to give them an additional 0.5% of cash back using your own internal payment tools.

-- What if my firm does not wish to participate in the program?
-
Please reach out to your assigned partner manager at new.expensify.com to inform them you would not like to accept the revenue share nor do you want to pass the revenue share to your clients. \ No newline at end of file diff --git a/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md b/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md deleted file mode 100644 index fb3cb5341f61..000000000000 --- a/docs/articles/expensify-classic/getting-started/approved-accountants/Your-Expensify-Partner-Manager.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Your Expensify Partner Manager -description: Everything you need to know about your Expensify Partner Manager -redirect_from: articles/other/Your-Expensify-Partner-Manager/ ---- - - -# What is a Partner Manager? -A Partner Manager is a dedicated point of contact to support our ExpensifyApproved! Accountants with questions about their Expensify account. Partner Managers support our accounting partners by providing recommendations for client's accounts, assisting with firm-wide training, and ensuring partners receive the full benefits of our partnership program. They will actively monitor open technical issues and be proactive with recommendations to increase efficiency and minimize time spent on expense management. - -Unlike Concierge, a Partner Manager’s support will not be real-time, 24 hours a day. A benefit of Concierge is that you get real-time support every day. Your partner manager will be super responsive when online, but anything sent when they’re offline will not be responded to until they’re online again. - -For real-time responses and simple troubleshooting issues, you can always message our general support by writing to Concierge via the in-product chat or by emailing concierge@expensify.com. - -# How do I know if I have a Partner Manager? -For your firm to be assigned a Partner Manager, you must complete the [ExpensifyApproved! University](https://use.expensify.com/accountants) training course. Every external accountant or bookkeeper who completes the training is automatically enrolled in our program and receives all the benefits, including access to the Partner Manager. So everyone at your firm must complete the training to receive the maximum benefit. - -You can check to see if you’ve completed the course and enrolled in the ExpensifyApproved! Accountants program simply by logging into your Expensify account. In the bottom left-hand corner of the website, you will see the ExpensifyApproved! logo. - -# How do I contact my Partner Manager? -You can contact your Partner Manager by: -- Signing in to new.expensify.com and searching for your Partner Manager -- Replying to or clicking the chat link on any email you get from your Partner Manager - -{% include faq-begin.md %} -## How do I know if my Partner Manager is online? -You will be able to see if they are online via their status in new.expensify.com, which will either say “online” or have their working hours. - -## What if I’m unable to reach my Partner Manager? -If you’re unable to contact your Partner Manager (i.e., they're out of office for the day) you can reach out to Concierge for assistance. Your Partner Manager will get back to you when they’re online again. - -## Can I get on a call with my Partner Manager? -Of course! You can ask your Partner Manager to schedule a call whenever you think one might be helpful. Partner Managers can discuss client onboarding strategies, firm wide training, and client setups. - -We recommend continuing to work with Concierge for **general support questions**, as this team is always online and available to help immediately. - -{% include faq-end.md %} diff --git a/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md b/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md index 30adac589dc0..b3f0ad3c6f6f 100644 --- a/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md +++ b/docs/articles/expensify-classic/workspace-and-domain-settings/Budgets.md @@ -44,7 +44,7 @@ Expensify’s Budgets feature allows you to: {% include faq-begin.md %} ## Can I import budgets as a CSV? -At this time, you cannot import budgets via CSV since we don’t import categories or tags from direct accounting integrations. +At this time, you cannot import budgets via CSV. ## When will I be notified as a budget is hit? Notifications are sent twice: diff --git a/docs/redirects.csv b/docs/redirects.csv index d3a7fdd695a3..74667e967f7f 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -24,9 +24,9 @@ https://community.expensify.com/discussion/5655/deep-dive-what-is-a-vacation-del https://community.expensify.com/discussion/5194/how-to-assign-a-vacation-delegate-for-an-employee-through-domains,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate https://community.expensify.com/discussion/5190/how-to-individually-assign-a-vacation-delegate-from-account-settings,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate https://community.expensify.com/discussion/5274/how-to-set-up-an-adp-indirect-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/integrations/HR-integrations/ADP -https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/get-paid-back/Distance-Tracking#gsc.tab=0 -https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details#gsc.tab=0 -https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details#gsc.tab=0 -https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details#gsc.tab=0 -https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details#gsc.tab=0 -https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details#gsc.tab=0 +https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/get-paid-back/Distance-Tracking +https://community.expensify.com/discussion/7385/how-to-enable-two-factor-authentication-in-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details +https://community.expensify.com/discussion/5124/how-to-add-your-name-and-photo-to-your-account,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details +https://community.expensify.com/discussion/5149/how-to-manage-your-devices-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details +https://community.expensify.com/discussion/4432/how-to-add-a-secondary-login,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details +https://community.expensify.com/discussion/6794/how-to-change-your-email-in-expensify,https://help.expensify.com/articles/expensify-classic/account-settings/Account-Details diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 34341662d137..c636ced8e7f9 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.31 + 1.4.32 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.31.2 + 1.4.32.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 38073f64d814..ef1ef0d998d5 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.31 + 1.4.32 CFBundleSignature ???? CFBundleVersion - 1.4.31.2 + 1.4.32.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 8550e23db7b1..16439b1d24d9 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -3,9 +3,9 @@ CFBundleShortVersionString - 1.4.31 + 1.4.32 CFBundleVersion - 1.4.31.2 + 1.4.32.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 776dcb544ee6..4cdf61554a6b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1967,7 +1967,7 @@ SPEC CHECKSUMS: SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 Turf: 13d1a92d969ca0311bbc26e8356cca178ce95da2 VisionCamera: 7d13aae043ffb38b224a0f725d1e23ca9c190fe7 - Yoga: e64aa65de36c0832d04e8c7bd614396c77a80047 + Yoga: 13c8ef87792450193e117976337b8527b49e8c03 PODFILE CHECKSUM: 0ccbb4f2406893c6e9f266dc1e7470dcd72885d2 diff --git a/metro.config.js b/metro.config.js index a4d0da1d85f4..2422d29aaacf 100644 --- a/metro.config.js +++ b/metro.config.js @@ -7,12 +7,6 @@ require('dotenv').config(); const defaultConfig = getDefaultConfig(__dirname); const isE2ETesting = process.env.E2E_TESTING === 'true'; - -if (isE2ETesting) { - // eslint-disable-next-line no-console - console.log('⚠️⚠️⚠️⚠️ Using mock API ⚠️⚠️⚠️⚠️'); -} - const e2eSourceExts = ['e2e.js', 'e2e.ts']; /** @@ -26,21 +20,6 @@ const config = { assetExts: [...defaultAssetExts, 'lottie'], // When we run the e2e tests we want files that have the extension e2e.js to be resolved as source files sourceExts: [...(isE2ETesting ? e2eSourceExts : []), ...defaultSourceExts, 'jsx'], - resolveRequest: (context, moduleName, platform) => { - const resolution = context.resolveRequest(context, moduleName, platform); - if (isE2ETesting && moduleName.includes('/API')) { - const originalPath = resolution.filePath; - const mockPath = originalPath.replace('src/libs/API.ts', 'src/libs/E2E/API.mock.ts').replace('/src/libs/API.ts/', 'src/libs/E2E/API.mock.ts'); - // eslint-disable-next-line no-console - console.log('⚠️⚠️⚠️⚠️ Replacing resolution path', originalPath, ' => ', mockPath); - - return { - ...resolution, - filePath: mockPath, - }; - } - return resolution; - }, }, }; diff --git a/package-lock.json b/package-lock.json index f05837e853ba..c1328d498c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.31-2", + "version": "1.4.32-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.31-2", + "version": "1.4.32-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -91,6 +91,7 @@ "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b", "react-native-key-command": "^1.0.6", + "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", @@ -4001,6 +4002,26 @@ "node": ">=8" } }, + "node_modules/@expo/config-plugins/node_modules/xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/@expo/config-plugins/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "engines": { + "node": ">=4.0" + } + }, "node_modules/@expo/config-types": { "version": "45.0.0", "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-45.0.0.tgz", @@ -22072,7 +22093,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", - "license": "BSD-3-Clause" + "deprecated": "Use your platform's native atob() and btoa() methods instead" }, "node_modules/abbrev": { "version": "1.1.1", @@ -22139,6 +22160,34 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals/node_modules/acorn-walk": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz", + "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -27521,26 +27570,7 @@ "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", - "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "license": "MIT", - "dependencies": { - "cssom": "~0.3.6" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cssstyle/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "license": "MIT" + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==" }, "node_modules/csstype": { "version": "3.1.1", @@ -27585,20 +27615,6 @@ "node": ">= 6" } }, - "node_modules/data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -28329,7 +28345,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "license": "MIT", + "deprecated": "Use your platform's native DOMException instead", "dependencies": { "webidl-conversions": "^7.0.0" }, @@ -33641,18 +33657,6 @@ "wbuf": "^1.1.0" } }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/html-entities": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", @@ -36616,6 +36620,17 @@ "@types/yargs-parser": "*" } }, + "node_modules/jest-environment-jsdom/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/jest-environment-jsdom/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -36665,6 +36680,59 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/jest-environment-jsdom/node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-environment-jsdom/node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" + }, + "node_modules/jest-environment-jsdom/node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jest-environment-jsdom/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/jest-environment-jsdom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -36674,6 +36742,72 @@ "node": ">=8" } }, + "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-jsdom/node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jest-environment-jsdom/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -36686,6 +36820,67 @@ "node": ">=8" } }, + "node_modules/jest-environment-jsdom/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-environment-jsdom/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "engines": { + "node": ">=12" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -38698,130 +38893,6 @@ "node": ">=12.0.0" } }, - "node_modules/jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "license": "MIT", - "dependencies": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/jsdom/node_modules/acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "license": "MIT", - "dependencies": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, - "node_modules/jsdom/node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/jsdom/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/jsdom/node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/jsdom/node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "license": "MIT", - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/jsdom/node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -42047,8 +42118,9 @@ } }, "node_modules/nwsapi": { - "version": "2.2.2", - "license": "MIT" + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" }, "node_modules/ob1": { "version": "0.80.3", @@ -43959,8 +44031,7 @@ "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "license": "MIT" + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" }, "node_modules/public-encrypt": { "version": "4.0.3", @@ -44014,9 +44085,9 @@ } }, "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "engines": { "node": ">=6" } @@ -44979,6 +45050,15 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, + "node_modules/react-native-launch-arguments": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/react-native-launch-arguments/-/react-native-launch-arguments-4.0.2.tgz", + "integrity": "sha512-OaXXOG9jVrmb66cTV8wPdhKxaSVivOBKuYr8wgKCM5uAHkY5B6SkvydgJ3B/x8uGoWqtr87scSYYDm4MMU4rSg==", + "peerDependencies": { + "react": ">=16.8.1", + "react-native": ">=0.60.0-rc.0 <1.0.x" + } + }, "node_modules/react-native-linear-gradient": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.1.tgz", @@ -47423,6 +47503,17 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.22.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", @@ -50464,8 +50555,9 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.2", - "license": "BSD-3-Clause", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -50480,23 +50572,10 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "license": "MIT", "engines": { "node": ">= 4.0.0" } }, - "node_modules/tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "license": "MIT", - "dependencies": { - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", @@ -51709,18 +51788,6 @@ "pbf": "^3.2.1" } }, - "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "license": "MIT", - "dependencies": { - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/wait-port": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-0.2.14.tgz", @@ -52131,7 +52198,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", "engines": { "node": ">=12" } @@ -52820,45 +52886,11 @@ "node": ">=0.8.0" } }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-fetch": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "license": "MIT", - "dependencies": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/whatwg-url-without-unicode": { "version": "8.0.0-3", "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", @@ -53192,9 +53224,9 @@ } }, "node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "engines": { "node": ">=10.0.0" }, @@ -53246,37 +53278,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "license": "Apache-2.0", - "engines": { - "node": ">=12" - } - }, - "node_modules/xml2js": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xml2js/node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, "node_modules/xmlbuilder": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", @@ -56315,6 +56316,20 @@ "requires": { "has-flag": "^4.0.0" } + }, + "xml2js": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", + "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" } } }, @@ -69434,6 +69449,27 @@ } } }, + "acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "requires": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + }, + "dependencies": { + "acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==" + }, + "acorn-walk": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.1.tgz", + "integrity": "sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==" + } + } + }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -73360,21 +73396,6 @@ "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==" }, - "cssstyle": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", - "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", - "requires": { - "cssom": "~0.3.6" - }, - "dependencies": { - "cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" - } - } - }, "csstype": { "version": "3.1.1" }, @@ -73408,16 +73429,6 @@ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==" }, - "data-urls": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", - "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", - "requires": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0" - } - }, "date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -77790,14 +77801,6 @@ "wbuf": "^1.1.0" } }, - "html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "requires": { - "whatwg-encoding": "^2.0.0" - } - }, "html-entities": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", @@ -79724,20 +79727,99 @@ } } }, - "jest-docblock": { - "version": "29.2.0", - "requires": { - "detect-newline": "^3.0.0" - } - }, - "jest-each": { + "jest-docblock": { + "version": "29.2.0", + "requires": { + "detect-newline": "^3.0.0" + } + }, + "jest-each": { + "version": "29.4.1", + "requires": { + "@jest/types": "^29.4.1", + "chalk": "^4.0.0", + "jest-get-type": "^29.2.0", + "jest-util": "^29.4.1", + "pretty-format": "^29.4.1" + }, + "dependencies": { + "@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "requires": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "17.0.24", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", + "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", + "requires": { + "@types/yargs-parser": "*" + } + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jest-environment-jsdom": { "version": "29.4.1", "requires": { + "@jest/environment": "^29.4.1", + "@jest/fake-timers": "^29.4.1", "@jest/types": "^29.4.1", - "chalk": "^4.0.0", - "jest-get-type": "^29.2.0", + "@types/jsdom": "^20.0.0", + "@types/node": "*", + "jest-mock": "^29.4.1", "jest-util": "^29.4.1", - "pretty-format": "^29.4.1" + "jsdom": "^20.0.0" }, "dependencies": { "@jest/types": { @@ -79761,6 +79843,11 @@ "@types/yargs-parser": "*" } }, + "acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==" + }, "ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -79791,11 +79878,100 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "requires": { + "cssom": "~0.3.6" + }, + "dependencies": { + "cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==" + } + } + }, + "data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "requires": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" + } + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, + "html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "requires": { + "whatwg-encoding": "^2.0.0" + } + }, + "jsdom": { + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "requires": { + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", + "cssstyle": "^2.3.0", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" + } + }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "requires": { + "entities": "^4.4.0" + } + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -79803,85 +79979,49 @@ "requires": { "has-flag": "^4.0.0" } - } - } - }, - "jest-environment-jsdom": { - "version": "29.4.1", - "requires": { - "@jest/environment": "^29.4.1", - "@jest/fake-timers": "^29.4.1", - "@jest/types": "^29.4.1", - "@types/jsdom": "^20.0.0", - "@types/node": "*", - "jest-mock": "^29.4.1", - "jest-util": "^29.4.1", - "jsdom": "^20.0.0" - }, - "dependencies": { - "@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "requires": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - } }, - "@types/yargs": { - "version": "17.0.24", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", - "integrity": "sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==", + "tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", "requires": { - "@types/yargs-parser": "*" + "punycode": "^2.1.1" } }, - "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", "requires": { - "color-convert": "^2.0.1" + "xml-name-validator": "^4.0.0" } }, - "chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", "requires": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "iconv-lite": "0.6.3" } }, - "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" + }, + "whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", "requires": { - "color-name": "~1.1.4" + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" } }, - "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "has-flag": { + "xml-name-validator": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "requires": { - "has-flag": "^4.0.0" - } + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==" } } }, @@ -81296,91 +81436,6 @@ "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", "dev": true }, - "jsdom": { - "version": "20.0.3", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", - "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", - "requires": { - "abab": "^2.0.6", - "acorn": "^8.8.1", - "acorn-globals": "^7.0.0", - "cssom": "^0.5.0", - "cssstyle": "^2.3.0", - "data-urls": "^3.0.2", - "decimal.js": "^10.4.2", - "domexception": "^4.0.0", - "escodegen": "^2.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.2", - "parse5": "^7.1.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^11.0.0", - "ws": "^8.11.0", - "xml-name-validator": "^4.0.0" - }, - "dependencies": { - "acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==" - }, - "acorn-globals": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", - "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", - "requires": { - "acorn": "^8.1.0", - "acorn-walk": "^8.0.2" - } - }, - "acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==" - }, - "entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" - }, - "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - } - }, - "parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "requires": { - "entities": "^4.4.0" - } - }, - "saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "requires": { - "xmlchars": "^2.2.0" - } - } - } - }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -83729,7 +83784,9 @@ "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==" }, "nwsapi": { - "version": "2.2.2" + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" }, "ob1": { "version": "0.80.3", @@ -85111,9 +85168,9 @@ } }, "punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, "pusher-js": { "version": "8.3.0", @@ -85884,6 +85941,12 @@ } } }, + "react-native-launch-arguments": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/react-native-launch-arguments/-/react-native-launch-arguments-4.0.2.tgz", + "integrity": "sha512-OaXXOG9jVrmb66cTV8wPdhKxaSVivOBKuYr8wgKCM5uAHkY5B6SkvydgJ3B/x8uGoWqtr87scSYYDm4MMU4rSg==", + "requires": {} + }, "react-native-linear-gradient": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.1.tgz", @@ -87502,6 +87565,14 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, + "saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "requires": { + "xmlchars": "^2.2.0" + } + }, "scheduler": { "version": "0.22.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", @@ -89753,7 +89824,9 @@ "dev": true }, "tough-cookie": { - "version": "4.1.2", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", "requires": { "psl": "^1.1.33", "punycode": "^2.1.1", @@ -89768,14 +89841,6 @@ } } }, - "tr46": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", - "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", - "requires": { - "punycode": "^2.1.1" - } - }, "traverse": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.8.tgz", @@ -90623,14 +90688,6 @@ "pbf": "^3.2.1" } }, - "w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "requires": { - "xml-name-validator": "^4.0.0" - } - }, "wait-port": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-0.2.14.tgz", @@ -91410,33 +91467,11 @@ "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", "dev": true }, - "whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "requires": { - "iconv-lite": "0.6.3" - } - }, "whatwg-fetch": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==" }, - "whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" - }, - "whatwg-url": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", - "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", - "requires": { - "tr46": "^3.0.0", - "webidl-conversions": "^7.0.0" - } - }, "whatwg-url-without-unicode": { "version": "8.0.0-3", "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", @@ -91684,9 +91719,9 @@ } }, "ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", "requires": {} }, "x-default-browser": { @@ -91714,27 +91749,6 @@ } } }, - "xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==" - }, - "xml2js": { - "version": "0.4.23", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", - "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==", - "requires": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "dependencies": { - "xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" - } - } - }, "xmlbuilder": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", diff --git a/package.json b/package.json index 2ba358b438e6..96de7fb0ab77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.31-2", + "version": "1.4.32-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.", @@ -139,6 +139,7 @@ "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#8393b7e58df6ff65fd41f60aee8ece8822c91e2b", "react-native-key-command": "^1.0.6", + "react-native-launch-arguments": "^4.0.2", "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", diff --git a/patches/react-native+0.73.2+001+NumberOfLines.patch b/patches/react-native+0.73.2+001+NumberOfLines.patch deleted file mode 100644 index c9ce92f8e1ef..000000000000 --- a/patches/react-native+0.73.2+001+NumberOfLines.patch +++ /dev/null @@ -1,968 +0,0 @@ -diff --git a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js -index 55b770d..4073836 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js -+++ b/node_modules/react-native/Libraries/Components/TextInput/AndroidTextInputNativeComponent.js -@@ -179,6 +179,13 @@ export type NativeProps = $ReadOnly<{| - */ - numberOfLines?: ?Int32, - -+ /** -+ * Sets the maximum number of lines for a `TextInput`. Use it with multiline set to -+ * `true` to be able to fill the lines. -+ * @platform android -+ */ -+ maximumNumberOfLines?: ?Int32, -+ - /** - * When `false`, if there is a small amount of space available around a text input - * (e.g. landscape orientation on a phone), the OS may choose to have the user edit -diff --git a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js -index 88d3cc8..664d37d 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js -+++ b/node_modules/react-native/Libraries/Components/TextInput/RCTTextInputViewConfig.js -@@ -144,6 +144,8 @@ const RCTTextInputViewConfig = { - placeholder: true, - autoCorrect: true, - multiline: true, -+ numberOfLines: true, -+ maximumNumberOfLines: true, - textContentType: true, - maxLength: true, - autoCapitalize: true, -diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts -index 2c0c099..5cb6bf1 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts -+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.d.ts -@@ -695,11 +695,29 @@ export interface TextInputProps - */ - maxLength?: number | undefined; - -+ /** -+ * Sets the maximum number of lines for a TextInput. -+ * Use it with multiline set to true to be able to fill the lines. -+ */ -+ maxNumberOfLines?: number | undefined; -+ - /** - * If true, the text input can be multiple lines. The default value is false. - */ - multiline?: boolean | undefined; - -+ /** -+ * Sets the number of lines for a TextInput. -+ * Use it with multiline set to true to be able to fill the lines. -+ */ -+ numberOfLines?: number | undefined; -+ -+ /** -+ * Sets the number of rows for a TextInput. -+ * Use it with multiline set to true to be able to fill the lines. -+ */ -+ rows?: number | undefined; -+ - /** - * Callback that is called when the text input is blurred - */ -diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js -index 9adbfe9..dc52051 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js -+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.flow.js -@@ -366,26 +366,12 @@ type AndroidProps = $ReadOnly<{| - */ - inlineImagePadding?: ?number, - -- /** -- * Sets the number of lines for a `TextInput`. Use it with multiline set to -- * `true` to be able to fill the lines. -- * @platform android -- */ -- numberOfLines?: ?number, -- - /** - * Sets the return key to the label. Use it instead of `returnKeyType`. - * @platform android - */ - returnKeyLabel?: ?string, - -- /** -- * Sets the number of rows for a `TextInput`. Use it with multiline set to -- * `true` to be able to fill the lines. -- * @platform android -- */ -- rows?: ?number, -- - /** - * When `false`, it will prevent the soft keyboard from showing when the field is focused. - * Defaults to `true`. -@@ -680,12 +666,24 @@ export type Props = $ReadOnly<{| - */ - maxLength?: ?number, - -+ /** -+ * Sets the maximum number of lines for a `TextInput`. Use it with multiline set to -+ * `true` to be able to fill the lines. -+ */ -+ maxNumberOfLines?: ?number, -+ - /** - * If `true`, the text input can be multiple lines. - * The default value is `false`. - */ - multiline?: ?boolean, - -+ /** -+ * Sets the number of lines for a `TextInput`. Use it with multiline set to -+ * `true` to be able to fill the lines. -+ */ -+ numberOfLines?: ?number, -+ - /** - * Callback that is called when the text input is blurred. - */ -@@ -847,6 +845,13 @@ export type Props = $ReadOnly<{| - */ - returnKeyType?: ?ReturnKeyType, - -+ /** -+ * Sets the number of rows for a `TextInput`. Use it with multiline set to -+ * `true` to be able to fill the lines. -+ */ -+ rows?: ?number, -+ -+ - /** - * If `true`, the text input obscures the text entered so that sensitive text - * like passwords stay secure. The default value is `false`. Does not work with 'multiline={true}'. -diff --git a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js -index 481938f..3ce7422 100644 ---- a/node_modules/react-native/Libraries/Components/TextInput/TextInput.js -+++ b/node_modules/react-native/Libraries/Components/TextInput/TextInput.js -@@ -413,7 +413,6 @@ type AndroidProps = $ReadOnly<{| - /** - * Sets the number of lines for a `TextInput`. Use it with multiline set to - * `true` to be able to fill the lines. -- * @platform android - */ - numberOfLines?: ?number, - -@@ -426,10 +425,15 @@ type AndroidProps = $ReadOnly<{| - /** - * Sets the number of rows for a `TextInput`. Use it with multiline set to - * `true` to be able to fill the lines. -- * @platform android - */ - rows?: ?number, - -+ /** -+ * Sets the maximum number of lines the TextInput can have. -+ */ -+ maxNumberOfLines?: ?number, -+ -+ - /** - * When `false`, it will prevent the soft keyboard from showing when the field is focused. - * Defaults to `true`. -@@ -1102,6 +1106,9 @@ function InternalTextInput(props: Props): React.Node { - accessibilityState, - id, - tabIndex, -+ rows, -+ numberOfLines, -+ maxNumberOfLines, - selection: propsSelection, - ...otherProps - } = props; -@@ -1460,6 +1467,8 @@ function InternalTextInput(props: Props): React.Node { - focusable={tabIndex !== undefined ? !tabIndex : focusable} - mostRecentEventCount={mostRecentEventCount} - nativeID={id ?? props.nativeID} -+ numberOfLines={props.rows ?? props.numberOfLines} -+ maximumNumberOfLines={maxNumberOfLines} - onBlur={_onBlur} - onKeyPressSync={props.unstable_onKeyPressSync} - onChange={_onChange} -@@ -1515,6 +1524,7 @@ function InternalTextInput(props: Props): React.Node { - mostRecentEventCount={mostRecentEventCount} - nativeID={id ?? props.nativeID} - numberOfLines={props.rows ?? props.numberOfLines} -+ maximumNumberOfLines={maxNumberOfLines} - onBlur={_onBlur} - onChange={_onChange} - onFocus={_onFocus} -diff --git a/node_modules/react-native/Libraries/Text/Text.js b/node_modules/react-native/Libraries/Text/Text.js -index d737ccc..beee7ce 100644 ---- a/node_modules/react-native/Libraries/Text/Text.js -+++ b/node_modules/react-native/Libraries/Text/Text.js -@@ -17,7 +17,11 @@ import flattenStyle from '../StyleSheet/flattenStyle'; - import processColor from '../StyleSheet/processColor'; - import Platform from '../Utilities/Platform'; - import TextAncestor from './TextAncestor'; --import {NativeText, NativeVirtualText} from './TextNativeComponent'; -+import { -+ CONTAINS_MAX_NUMBER_OF_LINES_RENAME, -+ NativeText, -+ NativeVirtualText, -+} from './TextNativeComponent'; - import * as React from 'react'; - import {useContext, useMemo, useState} from 'react'; - -@@ -56,6 +60,7 @@ const Text: React.AbstractComponent< - onStartShouldSetResponder, - pressRetentionOffset, - suppressHighlighting, -+ numberOfLines, - ...restProps - } = props; - -@@ -195,14 +200,34 @@ const Text: React.AbstractComponent< - } - } - -- let numberOfLines = restProps.numberOfLines; -+ let numberOfLinesValue = numberOfLines; - if (numberOfLines != null && !(numberOfLines >= 0)) { - console.error( - `'numberOfLines' in must be a non-negative number, received: ${numberOfLines}. The value will be set to 0.`, - ); -- numberOfLines = 0; -+ numberOfLinesValue = 0; - } - -+ const numberOfLinesProps = useMemo((): { -+ maximumNumberOfLines?: ?number, -+ numberOfLines?: ?number, -+ } => { -+ // FIXME: Current logic is breaking all Text components. -+ // if (CONTAINS_MAX_NUMBER_OF_LINES_RENAME) { -+ // return { -+ // maximumNumberOfLines: numberOfLinesValue, -+ // }; -+ // } else { -+ // return { -+ // numberOfLines: numberOfLinesValue, -+ // }; -+ // } -+ return { -+ maximumNumberOfLines: numberOfLinesValue, -+ }; -+ }, [numberOfLinesValue]); -+ -+ - const hasTextAncestor = useContext(TextAncestor); - - const _accessible = Platform.select({ -@@ -251,7 +276,6 @@ const Text: React.AbstractComponent< - isHighlighted={isHighlighted} - isPressable={isPressable} - nativeID={id ?? nativeID} -- numberOfLines={numberOfLines} - ref={forwardedRef} - selectable={_selectable} - selectionColor={selectionColor} -@@ -262,6 +286,7 @@ const Text: React.AbstractComponent< - - #import -+#import -+#import - - @implementation RCTMultilineTextInputViewManager - -@@ -17,8 +19,21 @@ - (UIView *)view - return [[RCTMultilineTextInputView alloc] initWithBridge:self.bridge]; - } - -+- (RCTShadowView *)shadowView -+{ -+ RCTBaseTextInputShadowView *shadowView = (RCTBaseTextInputShadowView *)[super shadowView]; -+ -+ shadowView.maximumNumberOfLines = 0; -+ shadowView.exactNumberOfLines = 0; -+ -+ return shadowView; -+} -+ - #pragma mark - Multiline (aka TextView) specific properties - - RCT_REMAP_VIEW_PROPERTY(dataDetectorTypes, backedTextInputView.dataDetectorTypes, UIDataDetectorTypes) - -+RCT_EXPORT_SHADOW_PROPERTY(maximumNumberOfLines, NSInteger) -+RCT_REMAP_SHADOW_PROPERTY(numberOfLines, exactNumberOfLines, NSInteger) -+ - @end -diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.h b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.h -index 8f4cf7e..6238ebc 100644 ---- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.h -+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.h -@@ -16,6 +16,7 @@ NS_ASSUME_NONNULL_BEGIN - @property (nonatomic, copy, nullable) NSString *text; - @property (nonatomic, copy, nullable) NSString *placeholder; - @property (nonatomic, assign) NSInteger maximumNumberOfLines; -+@property (nonatomic, assign) NSInteger exactNumberOfLines; - @property (nonatomic, copy, nullable) RCTDirectEventBlock onContentSizeChange; - - - (void)uiManagerWillPerformMounting; -diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.mm -index 1f06b79..48172ce 100644 ---- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.mm -+++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputShadowView.mm -@@ -218,7 +218,22 @@ - (NSAttributedString *)measurableAttributedText - - - (CGSize)sizeThatFitsMinimumSize:(CGSize)minimumSize maximumSize:(CGSize)maximumSize - { -- NSAttributedString *attributedText = [self measurableAttributedText]; -+ NSMutableAttributedString *attributedText = [[self measurableAttributedText] mutableCopy]; -+ -+ /* -+ * The block below is responsible for setting the exact height of the view in lines -+ * Unfortunatelly, iOS doesn't export any easy way to do it. So we set maximumNumberOfLines -+ * prop and then add random lines at the front. However, they are only used for layout -+ * so they are not visible on the screen. -+ */ -+ if (self.exactNumberOfLines) { -+ NSMutableString *newLines = [NSMutableString stringWithCapacity:self.exactNumberOfLines]; -+ for (NSUInteger i = 0UL; i < self.exactNumberOfLines; ++i) { -+ [newLines appendString:@"\n"]; -+ } -+ [attributedText insertAttributedString:[[NSAttributedString alloc] initWithString:newLines attributes:self.textAttributes.effectiveTextAttributes] atIndex:0]; -+ _maximumNumberOfLines = self.exactNumberOfLines; -+ } - - if (!_textStorage) { - _textContainer = [NSTextContainer new]; -diff --git a/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTSinglelineTextInputViewManager.mm b/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTSinglelineTextInputViewManager.mm -index 413ac42..56d039c 100644 ---- a/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTSinglelineTextInputViewManager.mm -+++ b/node_modules/react-native/Libraries/Text/TextInput/Singleline/RCTSinglelineTextInputViewManager.mm -@@ -19,6 +19,7 @@ - (RCTShadowView *)shadowView - RCTBaseTextInputShadowView *shadowView = (RCTBaseTextInputShadowView *)[super shadowView]; - - shadowView.maximumNumberOfLines = 1; -+ shadowView.exactNumberOfLines = 0; - - return shadowView; - } -diff --git a/node_modules/react-native/Libraries/Text/TextNativeComponent.js b/node_modules/react-native/Libraries/Text/TextNativeComponent.js -index 0d59904..3216e43 100644 ---- a/node_modules/react-native/Libraries/Text/TextNativeComponent.js -+++ b/node_modules/react-native/Libraries/Text/TextNativeComponent.js -@@ -9,6 +9,7 @@ - */ - - import {createViewConfig} from '../NativeComponent/ViewConfig'; -+import getNativeComponentAttributes from '../ReactNative/getNativeComponentAttributes'; - import UIManager from '../ReactNative/UIManager'; - import createReactNativeComponentClass from '../Renderer/shims/createReactNativeComponentClass'; - import {type HostComponent} from '../Renderer/shims/ReactNativeTypes'; -@@ -18,6 +19,7 @@ import {type TextProps} from './TextProps'; - - type NativeTextProps = $ReadOnly<{ - ...TextProps, -+ maximumNumberOfLines?: ?number, - isHighlighted?: ?boolean, - selectionColor?: ?ProcessedColorValue, - onClick?: ?(event: PressEvent) => mixed, -@@ -31,7 +33,7 @@ const textViewConfig = { - validAttributes: { - isHighlighted: true, - isPressable: true, -- numberOfLines: true, -+ maximumNumberOfLines: true, - ellipsizeMode: true, - allowFontScaling: true, - dynamicTypeRamp: true, -@@ -73,6 +75,12 @@ export const NativeText: HostComponent = - createViewConfig(textViewConfig), - ): any); - -+const jestIsDefined = typeof jest !== 'undefined'; -+export const CONTAINS_MAX_NUMBER_OF_LINES_RENAME: boolean = jestIsDefined -+ ? true -+ : getNativeComponentAttributes('RCTText')?.NativeProps -+ ?.maximumNumberOfLines === 'number'; -+ - export const NativeVirtualText: HostComponent = - !global.RN$Bridgeless && !UIManager.hasViewManagerConfig('RCTVirtualText') - ? NativeText -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewDefaults.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewDefaults.java -index 8cab407..ad5fa96 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewDefaults.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewDefaults.java -@@ -12,5 +12,6 @@ public class ViewDefaults { - - public static final float FONT_SIZE_SP = 14.0f; - public static final int LINE_HEIGHT = 0; -- public static final int NUMBER_OF_LINES = Integer.MAX_VALUE; -+ public static final int NUMBER_OF_LINES = -1; -+ public static final int MAXIMUM_NUMBER_OF_LINES = Integer.MAX_VALUE; - } -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java -index fa6eae3..f524753 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java -@@ -96,6 +96,7 @@ public class ViewProps { - public static final String LETTER_SPACING = "letterSpacing"; - public static final String NEEDS_OFFSCREEN_ALPHA_COMPOSITING = "needsOffscreenAlphaCompositing"; - public static final String NUMBER_OF_LINES = "numberOfLines"; -+ public static final String MAXIMUM_NUMBER_OF_LINES = "maximumNumberOfLines"; - public static final String ELLIPSIZE_MODE = "ellipsizeMode"; - public static final String ADJUSTS_FONT_SIZE_TO_FIT = "adjustsFontSizeToFit"; - public static final String MINIMUM_FONT_SCALE = "minimumFontScale"; -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java -index d2c2d6e..e4dec5d 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactBaseTextShadowNode.java -@@ -311,6 +311,7 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { - protected @Nullable Role mRole = null; - - protected int mNumberOfLines = UNSET; -+ protected int mMaxNumberOfLines = UNSET; - protected int mTextAlign = Gravity.NO_GRAVITY; - protected int mTextBreakStrategy = - (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) ? 0 : Layout.BREAK_STRATEGY_HIGH_QUALITY; -@@ -395,6 +396,12 @@ public abstract class ReactBaseTextShadowNode extends LayoutShadowNode { - markUpdated(); - } - -+ @ReactProp(name = ViewProps.MAXIMUM_NUMBER_OF_LINES, defaultInt = UNSET) -+ public void setMaxNumberOfLines(int numberOfLines) { -+ mMaxNumberOfLines = numberOfLines == 0 ? UNSET : numberOfLines; -+ markUpdated(); -+ } -+ - @ReactProp(name = ViewProps.LINE_HEIGHT, defaultFloat = Float.NaN) - public void setLineHeight(float lineHeight) { - mTextAttributes.setLineHeight(lineHeight); -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java -index f683c24..b5f6f7d 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextAnchorViewManager.java -@@ -49,8 +49,8 @@ public abstract class ReactTextAnchorViewManager minimumFontSize -- && (mNumberOfLines != UNSET && layout.getLineCount() > mNumberOfLines -+ && (mMaxNumberOfLines != UNSET && layout.getLineCount() > mMaxNumberOfLines - || heightMode != YogaMeasureMode.UNDEFINED && layout.getHeight() > height)) { - // TODO: We could probably use a smarter algorithm here. This will require 0(n) - // measurements -@@ -124,9 +124,9 @@ public class ReactTextShadowNode extends ReactBaseTextShadowNode { - } - - final int lineCount = -- mNumberOfLines == UNSET -+ mMaxNumberOfLines == UNSET - ? layout.getLineCount() -- : Math.min(mNumberOfLines, layout.getLineCount()); -+ : Math.min(mMaxNumberOfLines, layout.getLineCount()); - - // Instead of using `layout.getWidth()` (which may yield a significantly larger width for - // text that is wrapping), compute width using the longest line. -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java -index 4af5729..64474af 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java -@@ -90,7 +90,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie - - mReactBackgroundManager = new ReactViewBackgroundManager(this); - -- mNumberOfLines = ViewDefaults.NUMBER_OF_LINES; -+ mNumberOfLines = ViewDefaults.MAXIMUM_NUMBER_OF_LINES; - mAdjustsFontSizeToFit = false; - mLinkifyMaskType = 0; - mNotifyOnInlineViewLayout = false; -@@ -579,7 +579,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie - } - - public void setNumberOfLines(int numberOfLines) { -- mNumberOfLines = numberOfLines == 0 ? ViewDefaults.NUMBER_OF_LINES : numberOfLines; -+ mNumberOfLines = numberOfLines == 0 ? ViewDefaults.MAXIMUM_NUMBER_OF_LINES : numberOfLines; - setMaxLines(mNumberOfLines); - } - -@@ -621,7 +621,7 @@ public class ReactTextView extends AppCompatTextView implements ReactCompoundVie - public void updateView() { - @Nullable - TextUtils.TruncateAt ellipsizeLocation = -- mNumberOfLines == ViewDefaults.NUMBER_OF_LINES || mAdjustsFontSizeToFit -+ mNumberOfLines == ViewDefaults.MAXIMUM_NUMBER_OF_LINES || mAdjustsFontSizeToFit - ? null - : mEllipsizeLocation; - setEllipsize(ellipsizeLocation); -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java -index ffd5b2f..e9a8b0b 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.java -@@ -19,6 +19,7 @@ import android.text.SpannableStringBuilder; - import android.text.Spanned; - import android.text.StaticLayout; - import android.text.TextPaint; -+import android.text.TextUtils; - import android.util.LayoutDirection; - import android.util.LruCache; - import android.view.View; -@@ -68,6 +69,7 @@ public class TextLayoutManager { - private static final String TEXT_BREAK_STRATEGY_KEY = "textBreakStrategy"; - private static final String HYPHENATION_FREQUENCY_KEY = "android_hyphenationFrequency"; - private static final String MAXIMUM_NUMBER_OF_LINES_KEY = "maximumNumberOfLines"; -+ private static final String NUMBER_OF_LINES_KEY = "numberOfLines"; - private static final LruCache sSpannableCache = - new LruCache<>(spannableCacheSize); - private static final ConcurrentHashMap sTagToSpannableCache = -@@ -395,6 +397,48 @@ public class TextLayoutManager { - ? paragraphAttributes.getInt(MAXIMUM_NUMBER_OF_LINES_KEY) - : UNSET; - -+ int numberOfLines = -+ paragraphAttributes.hasKey(NUMBER_OF_LINES_KEY) -+ ? paragraphAttributes.getInt(NUMBER_OF_LINES_KEY) -+ : UNSET; -+ -+ int lines = layout.getLineCount(); -+ if (numberOfLines != UNSET && numberOfLines != 0 && numberOfLines >= lines && text.length() > 0) { -+ int numberOfEmptyLines = numberOfLines - lines; -+ SpannableStringBuilder ssb = new SpannableStringBuilder(); -+ -+ // for some reason a newline on end causes issues with computing height so we add a character -+ if (text.toString().endsWith("\n")) { -+ ssb.append("A"); -+ } -+ -+ for (int i = 0; i < numberOfEmptyLines; ++i) { -+ ssb.append("\nA"); -+ } -+ -+ Object[] spans = text.getSpans(0, 0, Object.class); -+ for (Object span : spans) { // It's possible we need to set exl-exl -+ ssb.setSpan(span, 0, ssb.length(), text.getSpanFlags(span)); -+ }; -+ -+ text = new SpannableStringBuilder(TextUtils.concat(text, ssb)); -+ boring = null; -+ layout = createLayout( -+ text, -+ boring, -+ width, -+ widthYogaMeasureMode, -+ includeFontPadding, -+ textBreakStrategy, -+ hyphenationFrequency); -+ } -+ -+ -+ if (numberOfLines != UNSET && numberOfLines != 0) { -+ maximumNumberOfLines = numberOfLines; -+ } -+ -+ - int calculatedLineCount = - maximumNumberOfLines == UNSET || maximumNumberOfLines == 0 - ? layout.getLineCount() -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java -index 8cd5764..35a2e9e 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManagerMapBuffer.java -@@ -20,6 +20,7 @@ import android.text.SpannableStringBuilder; - import android.text.Spanned; - import android.text.StaticLayout; - import android.text.TextPaint; -+import android.text.TextUtils; - import android.util.LayoutDirection; - import android.util.LruCache; - import android.view.View; -@@ -66,6 +67,7 @@ public class TextLayoutManagerMapBuffer { - public static final short PA_KEY_ADJUST_FONT_SIZE_TO_FIT = 3; - public static final short PA_KEY_INCLUDE_FONT_PADDING = 4; - public static final short PA_KEY_HYPHENATION_FREQUENCY = 5; -+ public static final short PA_KEY_NUMBER_OF_LINES = 6; - - private static final boolean ENABLE_MEASURE_LOGGING = ReactBuildConfig.DEBUG && false; - -@@ -417,6 +419,46 @@ public class TextLayoutManagerMapBuffer { - ? paragraphAttributes.getInt(PA_KEY_MAX_NUMBER_OF_LINES) - : UNSET; - -+ int numberOfLines = -+ paragraphAttributes.contains(PA_KEY_NUMBER_OF_LINES) -+ ? paragraphAttributes.getInt(PA_KEY_NUMBER_OF_LINES) -+ : UNSET; -+ -+ int lines = layout.getLineCount(); -+ if (numberOfLines != UNSET && numberOfLines != 0 && numberOfLines > lines && text.length() > 0) { -+ int numberOfEmptyLines = numberOfLines - lines; -+ SpannableStringBuilder ssb = new SpannableStringBuilder(); -+ -+ // for some reason a newline on end causes issues with computing height so we add a character -+ if (text.toString().endsWith("\n")) { -+ ssb.append("A"); -+ } -+ -+ for (int i = 0; i < numberOfEmptyLines; ++i) { -+ ssb.append("\nA"); -+ } -+ -+ Object[] spans = text.getSpans(0, 0, Object.class); -+ for (Object span : spans) { // It's possible we need to set exl-exl -+ ssb.setSpan(span, 0, ssb.length(), text.getSpanFlags(span)); -+ }; -+ -+ text = new SpannableStringBuilder(TextUtils.concat(text, ssb)); -+ boring = null; -+ layout = createLayout( -+ text, -+ boring, -+ width, -+ widthYogaMeasureMode, -+ includeFontPadding, -+ textBreakStrategy, -+ hyphenationFrequency); -+ } -+ -+ if (numberOfLines != UNSET && numberOfLines != 0) { -+ maximumNumberOfLines = numberOfLines; -+ } -+ - int calculatedLineCount = - maximumNumberOfLines == UNSET || maximumNumberOfLines == 0 - ? layout.getLineCount() -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java -index 081f2b8..0659179 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactEditText.java -@@ -546,9 +546,15 @@ public class ReactEditText extends AppCompatEditText { - * android.widget.TextView#isMultilineInputType(int)}} Source: {@Link TextView.java} - */ -- if (isMultiline()) { -- setSingleLine(false); -- } -+ if (isMultiline()) { -+ // we save max lines as setSingleLines overwrites it -+ // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/TextView.java#10671 -+ int maxLines = getMaxLines(); -+ setSingleLine(false); -+ if (maxLines != -1) { -+ setMaxLines(maxLines); -+ } -+ } - - // We override the KeyListener so that all keys on the soft input keyboard as well as hardware - // keyboards work. Some KeyListeners like DigitsKeyListener will display the keyboard but not -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java -index a850510..c59be1d 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputLocalData.java -@@ -41,9 +41,9 @@ public final class ReactTextInputLocalData { - public void apply(EditText editText) { - editText.setText(mText); - editText.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); -+ editText.setInputType(mInputType); - editText.setMinLines(mMinLines); - editText.setMaxLines(mMaxLines); -- editText.setInputType(mInputType); - editText.setHint(mPlaceholder); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - editText.setBreakStrategy(mBreakStrategy); -diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -index 8496a7d..e4d975b 100644 ---- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/textinput/ReactTextInputManager.java -@@ -736,9 +736,18 @@ public class ReactTextInputManager extends BaseViewManager= Build.VERSION_CODES.M -diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp -index f2317ba..10f342c 100644 ---- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp -+++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.cpp -@@ -16,6 +16,7 @@ namespace facebook::react { - - bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const { - return std::tie( -+ numberOfLines, - maximumNumberOfLines, - ellipsizeMode, - textBreakStrategy, -@@ -23,6 +24,7 @@ bool ParagraphAttributes::operator==(const ParagraphAttributes& rhs) const { - includeFontPadding, - android_hyphenationFrequency) == - std::tie( -+ rhs.numberOfLines, - rhs.maximumNumberOfLines, - rhs.ellipsizeMode, - rhs.textBreakStrategy, -@@ -42,6 +44,7 @@ bool ParagraphAttributes::operator!=(const ParagraphAttributes& rhs) const { - #if RN_DEBUG_STRING_CONVERTIBLE - SharedDebugStringConvertibleList ParagraphAttributes::getDebugProps() const { - return { -+ debugStringConvertibleItem("numberOfLines", numberOfLines), - debugStringConvertibleItem("maximumNumberOfLines", maximumNumberOfLines), - debugStringConvertibleItem("ellipsizeMode", ellipsizeMode), - debugStringConvertibleItem("textBreakStrategy", textBreakStrategy), -diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h -index d73f863..1f85b22 100644 ---- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h -+++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/ParagraphAttributes.h -@@ -29,6 +29,11 @@ class ParagraphAttributes : public DebugStringConvertible { - public: - #pragma mark - Fields - -+ /* -+ * Number of lines which paragraph takes. -+ */ -+ int numberOfLines{}; -+ - /* - * Maximum number of lines which paragraph can take. - * Zero value represents "no limit". -@@ -89,6 +94,7 @@ struct hash { - size_t operator()( - const facebook::react::ParagraphAttributes& attributes) const { - return facebook::react::hash_combine( -+ attributes.numberOfLines, - attributes.maximumNumberOfLines, - attributes.ellipsizeMode, - attributes.textBreakStrategy, -diff --git a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h -index 445e452..3f0bb36 100644 ---- a/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h -+++ b/node_modules/react-native/ReactCommon/react/renderer/attributedstring/conversions.h -@@ -692,10 +692,16 @@ inline ParagraphAttributes convertRawProp( - const ParagraphAttributes& defaultParagraphAttributes) { - auto paragraphAttributes = ParagraphAttributes{}; - -- paragraphAttributes.maximumNumberOfLines = convertRawProp( -+ paragraphAttributes.numberOfLines = convertRawProp( - context, - rawProps, - "numberOfLines", -+ sourceParagraphAttributes.numberOfLines, -+ defaultParagraphAttributes.numberOfLines); -+ paragraphAttributes.maximumNumberOfLines = convertRawProp( -+ context, -+ rawProps, -+ "maximumNumberOfLines", - sourceParagraphAttributes.maximumNumberOfLines, - defaultParagraphAttributes.maximumNumberOfLines); - paragraphAttributes.ellipsizeMode = convertRawProp( -@@ -770,6 +776,7 @@ inline std::string toString(const AttributedString::Range& range) { - inline folly::dynamic toDynamic( - const ParagraphAttributes& paragraphAttributes) { - auto values = folly::dynamic::object(); -+ values("numberOfLines", paragraphAttributes.numberOfLines); - values("maximumNumberOfLines", paragraphAttributes.maximumNumberOfLines); - values("ellipsizeMode", toString(paragraphAttributes.ellipsizeMode)); - values("textBreakStrategy", toString(paragraphAttributes.textBreakStrategy)); -@@ -979,6 +986,7 @@ constexpr static MapBuffer::Key PA_KEY_TEXT_BREAK_STRATEGY = 2; - constexpr static MapBuffer::Key PA_KEY_ADJUST_FONT_SIZE_TO_FIT = 3; - constexpr static MapBuffer::Key PA_KEY_INCLUDE_FONT_PADDING = 4; - constexpr static MapBuffer::Key PA_KEY_HYPHENATION_FREQUENCY = 5; -+constexpr static MapBuffer::Key PA_KEY_NUMBER_OF_LINES = 6; - - inline MapBuffer toMapBuffer(const ParagraphAttributes& paragraphAttributes) { - auto builder = MapBufferBuilder(); -@@ -996,6 +1004,8 @@ inline MapBuffer toMapBuffer(const ParagraphAttributes& paragraphAttributes) { - builder.putString( - PA_KEY_HYPHENATION_FREQUENCY, - toString(paragraphAttributes.android_hyphenationFrequency)); -+ builder.putInt( -+ PA_KEY_NUMBER_OF_LINES, paragraphAttributes.numberOfLines); - - return builder.build(); - } -diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp -index 116284f..5749c57 100644 ---- a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp -+++ b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.cpp -@@ -56,6 +56,10 @@ AndroidTextInputProps::AndroidTextInputProps( - "numberOfLines", - sourceProps.numberOfLines, - {0})), -+ maximumNumberOfLines(CoreFeatures::enablePropIteratorSetter? sourceProps.maximumNumberOfLines : convertRawProp(context, rawProps, -+ "maximumNumberOfLines", -+ sourceProps.maximumNumberOfLines, -+ {0})), - disableFullscreenUI(CoreFeatures::enablePropIteratorSetter? sourceProps.disableFullscreenUI : convertRawProp(context, rawProps, - "disableFullscreenUI", - sourceProps.disableFullscreenUI, -@@ -281,6 +285,12 @@ void AndroidTextInputProps::setProp( - value, - paragraphAttributes, - maximumNumberOfLines, -+ "maximumNumberOfLines"); -+ REBUILD_FIELD_SWITCH_CASE( -+ paDefaults, -+ value, -+ paragraphAttributes, -+ numberOfLines, - "numberOfLines"); - REBUILD_FIELD_SWITCH_CASE( - paDefaults, value, paragraphAttributes, ellipsizeMode, "ellipsizeMode"); -@@ -323,6 +333,7 @@ void AndroidTextInputProps::setProp( - } - - switch (hash) { -+ RAW_SET_PROP_SWITCH_CASE_BASIC(maximumNumberOfLines); - RAW_SET_PROP_SWITCH_CASE_BASIC(autoComplete); - RAW_SET_PROP_SWITCH_CASE_BASIC(returnKeyLabel); - RAW_SET_PROP_SWITCH_CASE_BASIC(numberOfLines); -@@ -422,6 +433,7 @@ void AndroidTextInputProps::setProp( - // TODO T53300085: support this in codegen; this was hand-written - folly::dynamic AndroidTextInputProps::getDynamic() const { - folly::dynamic props = folly::dynamic::object(); -+ props["maximumNumberOfLines"] = maximumNumberOfLines; - props["autoComplete"] = autoComplete; - props["returnKeyLabel"] = returnKeyLabel; - props["numberOfLines"] = numberOfLines; -diff --git a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h -index 43cbb68..0bf63e7 100644 ---- a/node_modules/react-native/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h -+++ b/node_modules/react-native/ReactCommon/react/renderer/components/textinput/androidtextinput/react/renderer/components/androidtextinput/AndroidTextInputProps.h -@@ -81,6 +81,7 @@ class AndroidTextInputProps final : public ViewProps, public BaseTextProps { - std::string autoComplete{}; - std::string returnKeyLabel{}; - int numberOfLines{0}; -+ int maximumNumberOfLines{0}; - bool disableFullscreenUI{false}; - std::string textBreakStrategy{}; - SharedColor underlineColorAndroid{}; -diff --git a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm -index 368c334..ef9ec17 100644 ---- a/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm -+++ b/node_modules/react-native/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm -@@ -244,26 +244,49 @@ - (void)getRectWithAttributedString:(AttributedString)attributedString - - #pragma mark - Private - --- (NSTextStorage *)_textStorageForNSAttributesString:(NSAttributedString *)attributedString -+- (NSTextStorage *)_textStorageForNSAttributesString:(NSAttributedString *)inputAttributedString - paragraphAttributes:(ParagraphAttributes)paragraphAttributes - size:(CGSize)size - { -- NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:size]; -+NSMutableAttributedString *attributedString = [ inputAttributedString mutableCopy]; -+ /* -+ * The block below is responsible for setting the exact height of the view in lines -+ * Unfortunatelly, iOS doesn't export any easy way to do it. So we set maximumNumberOfLines -+ * prop and then add random lines at the front. However, they are only used for layout -+ * so they are not visible on the screen. This method is used for drawing only for Paragraph component -+ * but we set exact height in lines only on TextInput that doesn't use it. -+ */ -+ if (paragraphAttributes.numberOfLines) { -+ paragraphAttributes.maximumNumberOfLines = paragraphAttributes.numberOfLines; -+ NSMutableString *newLines = [NSMutableString stringWithCapacity: paragraphAttributes.numberOfLines]; -+ for (NSUInteger i = 0UL; i < paragraphAttributes.numberOfLines; ++i) { -+ // K is added on purpose. New line seems to be not enough for NTtextContainer -+ [newLines appendString:@"K\n"]; -+ } -+ NSDictionary * attributesOfFirstCharacter = [inputAttributedString attributesAtIndex:0 effectiveRange:NULL]; - -- textContainer.lineFragmentPadding = 0.0; // Note, the default value is 5. -- textContainer.lineBreakMode = paragraphAttributes.maximumNumberOfLines > 0 -- ? RCTNSLineBreakModeFromEllipsizeMode(paragraphAttributes.ellipsizeMode) -- : NSLineBreakByClipping; -- textContainer.maximumNumberOfLines = paragraphAttributes.maximumNumberOfLines; -+ [attributedString insertAttributedString:[[NSAttributedString alloc] initWithString:newLines attributes:attributesOfFirstCharacter] atIndex:0]; -+ } -+ -+ NSTextContainer *textContainer = [NSTextContainer new]; - - NSLayoutManager *layoutManager = [NSLayoutManager new]; - layoutManager.usesFontLeading = NO; - [layoutManager addTextContainer:textContainer]; - -- NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:attributedString]; -+ NSTextStorage *textStorage = [NSTextStorage new]; - - [textStorage addLayoutManager:layoutManager]; - -+ textContainer.lineFragmentPadding = 0.0; // Note, the default value is 5. -+ textContainer.lineBreakMode = paragraphAttributes.maximumNumberOfLines > 0 -+ ? RCTNSLineBreakModeFromEllipsizeMode(paragraphAttributes.ellipsizeMode) -+ : NSLineBreakByClipping; -+ textContainer.size = size; -+ textContainer.maximumNumberOfLines = paragraphAttributes.maximumNumberOfLines; -+ -+ [textStorage replaceCharactersInRange:(NSRange){0, textStorage.length} withAttributedString:attributedString]; -+ - if (paragraphAttributes.adjustsFontSizeToFit) { - CGFloat minimumFontSize = !isnan(paragraphAttributes.minimumFontSize) ? paragraphAttributes.minimumFontSize : 4.0; - CGFloat maximumFontSize = !isnan(paragraphAttributes.maximumFontSize) ? paragraphAttributes.maximumFontSize : 96.0; diff --git a/src/CONST.ts b/src/CONST.ts index cea26c789799..ff3934c31943 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -724,6 +724,8 @@ const CONST = { REPORT_INITIAL_RENDER: 'report_initial_render', SWITCH_REPORT: 'switch_report', SIDEBAR_LOADED: 'sidebar_loaded', + OPEN_SEARCH: 'open_search', + LOAD_SEARCH_OPTIONS: 'load_search_options', COLD: 'cold', WARM: 'warm', REPORT_ACTION_ITEM_LAYOUT_DEBOUNCE_TIME: 1500, @@ -975,6 +977,7 @@ const CONST = { SMALL_EMOJI_PICKER_SIZE: { WIDTH: '100%', }, + MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM: 83, NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 300, NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT_WEB: 200, EMOJI_PICKER_ITEM_HEIGHT: 32, @@ -1301,6 +1304,14 @@ const CONST = { LAST_BUSINESS_DAY_OF_MONTH: 'lastBusinessDayOfMonth', LAST_DAY_OF_MONTH: 'lastDayOfMonth', }, + APPROVAL_MODE: { + OPTIONAL: 'OPTIONAL', + BASIC: 'BASIC', + ADVANCED: 'ADVANCED', + DYNAMICEXTERNAL: 'DYNAMIC_EXTERNAL', + SMARTREPORT: 'SMARTREPORT', + BILLCOM: 'BILLCOM', + }, ROOM_PREFIX: '#', CUSTOM_UNIT_RATE_BASE_OFFSET: 100, OWNER_EMAIL_FAKE: '_FAKE_', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 26424af8056c..7abf6db1769d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -173,9 +173,6 @@ const ONYXKEYS = { /** Stores information about the active reimbursement account being set up */ REIMBURSEMENT_ACCOUNT: 'reimbursementAccount', - /** Stores draft information about the active reimbursement account being set up */ - REIMBURSEMENT_ACCOUNT_DRAFT: 'reimbursementAccountDraft', - /** Store preferred skintone for emoji */ PREFERRED_EMOJI_SKIN_TONE: 'preferredEmojiSkinTone', @@ -358,13 +355,15 @@ const ONYXKEYS = { GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', POLICY_REPORT_FIELD_EDIT_FORM: 'policyReportFieldEditForm', POLICY_REPORT_FIELD_EDIT_FORM_DRAFT: 'policyReportFieldEditFormDraft', + REIMBURSEMENT_ACCOUNT_FORM: 'reimbursementAccount', + REIMBURSEMENT_ACCOUNT_FORM_DRAFT: 'reimbursementAccountDraft', }, } as const; type OnyxKeysMap = typeof ONYXKEYS; type OnyxCollectionKey = ValueOf; type OnyxKey = DeepValueOf>; -type OnyxFormKey = ValueOf | OnyxKeysMap['REIMBURSEMENT_ACCOUNT'] | OnyxKeysMap['REIMBURSEMENT_ACCOUNT_DRAFT']; +type OnyxFormKey = ValueOf; type OnyxValues = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; @@ -419,7 +418,6 @@ type OnyxValues = { [ONYXKEYS.WALLET_STATEMENT]: OnyxTypes.WalletStatement; [ONYXKEYS.PERSONAL_BANK_ACCOUNT]: OnyxTypes.PersonalBankAccount; [ONYXKEYS.REIMBURSEMENT_ACCOUNT]: OnyxTypes.ReimbursementAccount; - [ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT]: OnyxTypes.ReimbursementAccountDraft; [ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE]: string | number; [ONYXKEYS.FREQUENTLY_USED_EMOJIS]: OnyxTypes.FrequentlyUsedEmoji[]; [ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID]: string; @@ -486,8 +484,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.CLOSE_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.PROFILE_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.DISPLAY_NAME_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.DISPLAY_NAME_FORM]: OnyxTypes.DisplayNameForm; + [ONYXKEYS.FORMS.DISPLAY_NAME_FORM_DRAFT]: OnyxTypes.DisplayNameForm; [ONYXKEYS.FORMS.ROOM_NAME_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.ROOM_NAME_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.WELCOME_MESSAGE_FORM]: OnyxTypes.Form; @@ -500,8 +498,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.DATE_OF_BIRTH_FORM_DRAFT]: OnyxTypes.DateOfBirthForm; [ONYXKEYS.FORMS.HOME_ADDRESS_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.HOME_ADDRESS_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.NEW_ROOM_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.NEW_ROOM_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.NEW_ROOM_FORM]: OnyxTypes.NewRoomForm; + [ONYXKEYS.FORMS.NEW_ROOM_FORM_DRAFT]: OnyxTypes.NewRoomForm; [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.ROOM_SETTINGS_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.NEW_TASK_FORM]: OnyxTypes.Form; @@ -526,20 +524,23 @@ type OnyxValues = { [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM_DRAFT]: OnyxTypes.Form; - [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: OnyxTypes.PrivateNotesForm; + [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM_DRAFT]: OnyxTypes.PrivateNotesForm; + [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: OnyxTypes.IKnowATeacherForm; + [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM_DRAFT]: OnyxTypes.IKnowATeacherForm; + [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM]: OnyxTypes.IntroSchoolPrincipalForm; + [ONYXKEYS.FORMS.INTRO_SCHOOL_PRINCIPAL_FORM_DRAFT]: OnyxTypes.IntroSchoolPrincipalForm; [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD]: OnyxTypes.Form; [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form | undefined; + [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form | undefined; + [ONYXKEYS.FORMS.POLICY_REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; + // @ts-expect-error Different values are defined under the same key: ReimbursementAccount and ReimbursementAccountForm + [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; }; type OnyxKeyValue = OnyxEntry; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 42123aa9b4a4..deabdc0ac853 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -237,10 +237,6 @@ const ROUTES = { route: 'r/:reportID/assignee', getRoute: (reportID: string) => `r/${reportID}/assignee` as const, }, - PRIVATE_NOTES_VIEW: { - route: 'r/:reportID/notes/:accountID', - getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}` as const, - }, PRIVATE_NOTES_LIST: { route: 'r/:reportID/notes', getRoute: (reportID: string) => `r/${reportID}/notes` as const, @@ -271,10 +267,6 @@ const ROUTES = { route: ':iouType/new/participants/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}` as const, }, - MONEY_REQUEST_CONFIRMATION: { - route: ':iouType/new/confirmation/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}` as const, - }, MONEY_REQUEST_DATE: { route: ':iouType/new/date/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 960991eb277b..5a8922ee01c3 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -141,7 +141,6 @@ const SCREENS = { ROOT: 'Money_Request', AMOUNT: 'Money_Request_Amount', PARTICIPANTS: 'Money_Request_Participants', - CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', DATE: 'Money_Request_Date', DESCRIPTION: 'Money_Request_Description', @@ -183,7 +182,6 @@ const SCREENS = { }, PRIVATE_NOTES: { - VIEW: 'PrivateNotes_View', LIST: 'PrivateNotes_List', EDIT: 'PrivateNotes_Edit', }, diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 0f3416076cc0..05080fcdd21c 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {ForwardedRef} from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; @@ -34,7 +35,7 @@ type AmountTextInputProps = { function AmountTextInput( {formattedAmount, onChangeAmount, placeholder, selection, onSelectionChange, style, touchableInputWrapperStyle, onKeyPress}: AmountTextInputProps, - ref: BaseTextInputRef, + ref: ForwardedRef, ) { const styles = useThemeStyles(); return ( diff --git a/src/components/AnonymousReportFooter.tsx b/src/components/AnonymousReportFooter.tsx index 04e8a5f8d55b..4e23d9cdd895 100644 --- a/src/components/AnonymousReportFooter.tsx +++ b/src/components/AnonymousReportFooter.tsx @@ -1,11 +1,10 @@ import React from 'react'; import {View} from 'react-native'; -import type {OnyxCollection} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx/lib/types'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Session from '@userActions/Session'; -import type {PersonalDetails, Report} from '@src/types/onyx'; +import type {PersonalDetailsList, Report} from '@src/types/onyx'; import AvatarWithDisplayName from './AvatarWithDisplayName'; import Button from './Button'; import ExpensifyWordmark from './ExpensifyWordmark'; @@ -19,7 +18,7 @@ type AnonymousReportFooterProps = { isSmallSizeLayout?: boolean; /** Personal details of all the users */ - personalDetails: OnyxCollection; + personalDetails: OnyxEntry; }; function AnonymousReportFooter({isSmallSizeLayout = false, personalDetails, report}: AnonymousReportFooterProps) { diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 4da91c2e7d19..8ea8a1bb6f64 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -124,3 +124,4 @@ function Avatar({ Avatar.displayName = 'Avatar'; export default Avatar; +export {type AvatarProps}; diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.tsx similarity index 83% rename from src/components/AvatarCropModal/AvatarCropModal.js rename to src/components/AvatarCropModal/AvatarCropModal.tsx index 2da3cf88c78c..3ac2e3e3d729 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.js +++ b/src/components/AvatarCropModal/AvatarCropModal.tsx @@ -1,79 +1,71 @@ -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useState} from 'react'; import {ActivityIndicator, Image, View} from 'react-native'; +import type {LayoutChangeEvent} from 'react-native'; import {Gesture, GestureHandlerRootView} from 'react-native-gesture-handler'; +import type {GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; import {interpolate, runOnUI, useSharedValue, useWorkletCallback} from 'react-native-reanimated'; import Button from '@components/Button'; import HeaderGap from '@components/HeaderGap'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import sourcePropTypes from '@components/Image/sourcePropTypes'; import Modal from '@components/Modal'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; +import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import cropOrRotateImage from '@libs/cropOrRotateImage'; +import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; import ImageCropView from './ImageCropView'; import Slider from './Slider'; -const propTypes = { +type AvatarCropModalProps = { /** Link to image for cropping */ - imageUri: PropTypes.string, + imageUri?: string; /** Name of the image */ - imageName: PropTypes.string, + imageName?: string; /** Type of the image file */ - imageType: PropTypes.string, + imageType?: string; /** Callback to be called when user closes the modal */ - onClose: PropTypes.func, + onClose?: () => void; /** Callback to be called when user saves the image */ - onSave: PropTypes.func, + onSave?: (newImage: File | CustomRNImageManipulatorResult) => void; /** Modal visibility */ - isVisible: PropTypes.bool.isRequired, + isVisible: boolean; /** Image crop vector mask */ - maskImage: sourcePropTypes, - - ...withLocalizePropTypes, - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - imageUri: '', - imageName: '', - imageType: '', - onClose: () => {}, - onSave: () => {}, - maskImage: undefined, + maskImage?: IconAsset; }; // This component can't be written using class since reanimated API uses hooks. -function AvatarCropModal(props) { +function AvatarCropModal({imageUri = '', imageName = '', imageType = '', onClose, onSave, isVisible, maskImage}: AvatarCropModalProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const originalImageWidth = useSharedValue(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); - const originalImageHeight = useSharedValue(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); + const originalImageWidth = useSharedValue(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); + const originalImageHeight = useSharedValue(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); const translateY = useSharedValue(0); const translateX = useSharedValue(0); - const scale = useSharedValue(CONST.AVATAR_CROP_MODAL.MIN_SCALE); + const scale = useSharedValue(CONST.AVATAR_CROP_MODAL.MIN_SCALE); const rotation = useSharedValue(0); const translateSlider = useSharedValue(0); const isPressableEnabled = useSharedValue(true); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + // Check if image cropping, saving or uploading is in progress const isLoading = useSharedValue(false); @@ -82,13 +74,13 @@ function AvatarCropModal(props) { const prevMaxOffsetX = useSharedValue(0); const prevMaxOffsetY = useSharedValue(0); - const [imageContainerSize, setImageContainerSize] = useState(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); - const [sliderContainerSize, setSliderContainerSize] = useState(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); + const [imageContainerSize, setImageContainerSize] = useState(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); + const [sliderContainerSize, setSliderContainerSize] = useState(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); const [isImageContainerInitialized, setIsImageContainerInitialized] = useState(false); const [isImageInitialized, setIsImageInitialized] = useState(false); // An onLayout callback, that initializes the image container, for proper render of an image - const initializeImageContainer = useCallback((event) => { + const initializeImageContainer = useCallback((event: LayoutChangeEvent) => { setIsImageContainerInitialized(true); const {height, width} = event.nativeEvent.layout; @@ -98,7 +90,7 @@ function AvatarCropModal(props) { }, []); // An onLayout callback, that initializes the slider container size, for proper render of a slider - const initializeSliderContainer = useCallback((event) => { + const initializeSliderContainer = useCallback((event: LayoutChangeEvent) => { setSliderContainerSize(event.nativeEvent.layout.width); }, []); @@ -122,7 +114,6 @@ function AvatarCropModal(props) { // In order to calculate proper image position/size/animation, we have to know its size. // And we have to update image size if image url changes. - const imageUri = props.imageUri; useEffect(() => { if (!imageUri) { return; @@ -143,17 +134,11 @@ function AvatarCropModal(props) { /** * Validates that value is within the provided mix/max range. - * - * @param {Number} value - * @param {Array} minMax - * @returns {Number} */ - const clamp = useWorkletCallback((value, [min, max]) => interpolate(value, [min, max], [min, max], 'clamp'), []); + const clamp = useWorkletCallback((value: number, [min, max]) => interpolate(value, [min, max], [min, max], 'clamp'), []); /** * Returns current image size taking into account scale and rotation. - * - * @returns {Object} */ const getDisplayedImageSize = useWorkletCallback(() => { let height = imageContainerSize * scale.value; @@ -172,12 +157,9 @@ function AvatarCropModal(props) { /** * Validates the offset to prevent overflow, and updates the image offset. - * - * @param {Number} newX - * @param {Number} newY */ const updateImageOffset = useWorkletCallback( - (offsetX, offsetY) => { + (offsetX: number, offsetY: number) => { const {height, width} = getDisplayedImageSize(); const maxOffsetX = (width - imageContainerSize) / 2; const maxOffsetY = (height - imageContainerSize) / 2; @@ -189,12 +171,7 @@ function AvatarCropModal(props) { [imageContainerSize, scale, clamp], ); - /** - * @param {Number} newSliderValue - * @param {Number} containerSize - * @returns {Number} - */ - const newScaleValue = useWorkletCallback((newSliderValue, containerSize) => { + const newScaleValue = useWorkletCallback((newSliderValue: number, containerSize: number) => { const {MAX_SCALE, MIN_SCALE} = CONST.AVATAR_CROP_MODAL; return (newSliderValue / containerSize) * (MAX_SCALE - MIN_SCALE) + MIN_SCALE; }); @@ -244,7 +221,7 @@ function AvatarCropModal(props) { isPressableEnabled.value = false; }, - onChange: (event) => { + onChange: (event: GestureUpdateEvent) => { 'worklet'; const newSliderValue = clamp(translateSlider.value + event.changeX, [0, sliderContainerSize]); @@ -311,24 +288,35 @@ function AvatarCropModal(props) { // Svg images are converted to a png blob to preserve transparency, so we need to update the // image name and type accordingly. - const isSvg = props.imageType.includes('image/svg'); - const imageName = isSvg ? 'fileName.png' : props.imageName; - const imageType = isSvg ? 'image/png' : props.imageType; + const isSvg = imageType.includes('image/svg'); + const name = isSvg ? 'fileName.png' : imageName; + const type = isSvg ? 'image/png' : imageType; - cropOrRotateImage(props.imageUri, [{rotate: rotation.value % 360}, {crop}], {compress: 1, name: imageName, type: imageType}) + cropOrRotateImage(imageUri, [{rotate: rotation.value % 360}, {crop}], {compress: 1, name, type}) .then((newImage) => { - props.onClose(); - props.onSave(newImage); + onClose?.(); + onSave?.(newImage); }) .catch(() => { isLoading.value = false; }); - }, [originalImageHeight.value, originalImageWidth.value, scale.value, translateX.value, imageContainerSize, translateY.value, props, rotation.value, isLoading]); - - /** - * @param {Number} locationX - */ - const sliderOnPress = (locationX) => { + }, [ + imageUri, + imageName, + imageType, + onClose, + onSave, + originalImageHeight.value, + originalImageWidth.value, + scale.value, + translateX.value, + imageContainerSize, + translateY.value, + rotation.value, + isLoading, + ]); + + const sliderOnPress = (locationX: number) => { // We are using the worklet directive here and running on the UI thread to ensure the Reanimated // shared values are updated synchronously, as they update asynchronously on the JS thread. @@ -349,8 +337,8 @@ function AvatarCropModal(props) { return ( onClose?.()} + isVisible={isVisible} type={CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED} onModalHide={resetState} > @@ -360,12 +348,12 @@ function AvatarCropModal(props) { includeSafeAreaPaddingBottom={false} testID={AvatarCropModal.displayName} > - {props.isSmallScreenWidth && } + {isSmallScreenWidth && } - {props.translate('avatarCropModal.description')} + {translate('avatarCropModal.description')} @@ -432,7 +420,7 @@ function AvatarCropModal(props) { style={[styles.m5]} onPress={cropAndSaveImage} pressOnEnter - text={props.translate('common.save')} + text={translate('common.save')} /> @@ -440,6 +428,5 @@ function AvatarCropModal(props) { } AvatarCropModal.displayName = 'AvatarCropModal'; -AvatarCropModal.propTypes = propTypes; -AvatarCropModal.defaultProps = defaultProps; -export default compose(withWindowDimensions, withLocalize)(AvatarCropModal); + +export default AvatarCropModal; diff --git a/src/components/AvatarCropModal/ImageCropView.js b/src/components/AvatarCropModal/ImageCropView.tsx similarity index 69% rename from src/components/AvatarCropModal/ImageCropView.js rename to src/components/AvatarCropModal/ImageCropView.tsx index 0790ffaca8e1..c79a209376b4 100644 --- a/src/components/AvatarCropModal/ImageCropView.js +++ b/src/components/AvatarCropModal/ImageCropView.tsx @@ -1,58 +1,52 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import type {PanGesture} from 'react-native-gesture-handler'; import Animated, {interpolate, useAnimatedStyle} from 'react-native-reanimated'; +import type {SharedValue} from 'react-native-reanimated'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; +import type IconAsset from '@src/types/utils/IconAsset'; -const propTypes = { +type ImageCropViewProps = { /** Link to image for cropping */ - imageUri: PropTypes.string, + imageUri?: string; /** Size of the image container that will be rendered */ - containerSize: PropTypes.number, + containerSize?: number; /** The height of the selected image */ - originalImageHeight: PropTypes.shape({value: PropTypes.number}).isRequired, + originalImageHeight: SharedValue; /** The width of the selected image */ - originalImageWidth: PropTypes.shape({value: PropTypes.number}).isRequired, + originalImageWidth: SharedValue; /** The rotation value of the selected image */ - rotation: PropTypes.shape({value: PropTypes.number}).isRequired, + rotation: SharedValue; /** The relative image shift along X-axis */ - translateX: PropTypes.shape({value: PropTypes.number}).isRequired, + translateX: SharedValue; /** The relative image shift along Y-axis */ - translateY: PropTypes.shape({value: PropTypes.number}).isRequired, + translateY: SharedValue; /** The scale factor of the image */ - scale: PropTypes.shape({value: PropTypes.number}).isRequired, + scale: SharedValue; /** Configuration object for pan gesture for handling image panning */ - // eslint-disable-next-line react/forbid-prop-types - panGesture: PropTypes.object, + panGesture?: PanGesture; /** Image crop vector mask */ - maskImage: PropTypes.func, + maskImage?: IconAsset; }; -const defaultProps = { - imageUri: '', - containerSize: 0, - panGesture: Gesture.Pan(), - maskImage: Expensicons.ImageCropCircleMask, -}; - -function ImageCropView(props) { +function ImageCropView({imageUri = '', containerSize = 0, panGesture = Gesture.Pan(), maskImage = Expensicons.ImageCropCircleMask, ...props}: ImageCropViewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const containerStyle = StyleUtils.getWidthAndHeightStyle(props.containerSize, props.containerSize); + const containerStyle = StyleUtils.getWidthAndHeightStyle(containerSize, containerSize); const originalImageHeight = props.originalImageHeight; const originalImageWidth = props.originalImageWidth; @@ -75,23 +69,23 @@ function ImageCropView(props) { // We're preventing text selection with ControlSelection.blockElement to prevent safari // default behaviour of cursor - I-beam cursor on drag. See https://github.com/Expensify/App/issues/13688 return ( - + ControlSelection.blockElement(el as HTMLElement | null)} style={[containerStyle, styles.imageCropContainer]} > @@ -100,8 +94,6 @@ function ImageCropView(props) { } ImageCropView.displayName = 'ImageCropView'; -ImageCropView.propTypes = propTypes; -ImageCropView.defaultProps = defaultProps; // React.memo is needed here to prevent styles recompilation // which sometimes may cause glitches during rerender of the modal diff --git a/src/components/AvatarCropModal/Slider.js b/src/components/AvatarCropModal/Slider.tsx similarity index 65% rename from src/components/AvatarCropModal/Slider.js rename to src/components/AvatarCropModal/Slider.tsx index 83c8577d2e55..9a9da65befa0 100644 --- a/src/components/AvatarCropModal/Slider.js +++ b/src/components/AvatarCropModal/Slider.tsx @@ -1,43 +1,31 @@ -import PropTypes from 'prop-types'; import React, {useState} from 'react'; import {View} from 'react-native'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; +import type {GestureUpdateEvent, PanGestureChangeEventPayload, PanGestureHandlerEventPayload} from 'react-native-gesture-handler'; import Animated, {runOnJS, useAnimatedStyle} from 'react-native-reanimated'; +import type {SharedValue} from 'react-native-reanimated'; import Tooltip from '@components/Tooltip'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; -const propTypes = { - /** Callbacks for react-native-gesture-handler to be executed when the user is panning slider */ - gestureCallbacks: PropTypes.shape({onBegin: PropTypes.func, onChange: PropTypes.func, onFinalize: PropTypes.func}), +type SliderProps = { + /** React-native-reanimated lib handler which executes when the user is panning slider */ + gestureCallbacks: { + onBegin: () => void; + onChange: (event: GestureUpdateEvent) => void; + onFinalize: () => void; + }; /** X position of the slider knob */ - sliderValue: PropTypes.shape({value: PropTypes.number}), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - gestureCallbacks: { - onBegin: () => { - 'worklet'; - }, - onChange: () => { - 'worklet'; - }, - onFinalize: () => { - 'worklet'; - }, - }, - sliderValue: {}, + sliderValue: SharedValue; }; // This component can't be written using class since reanimated API uses hooks. -function Slider(props) { +function Slider({sliderValue, gestureCallbacks}: SliderProps) { const styles = useThemeStyles(); - const sliderValue = props.sliderValue; const [tooltipIsVisible, setTooltipIsVisible] = useState(true); + const {translate} = useLocalize(); // A reanimated memoized style, which tracks // a translateX shared value and updates the slider position. @@ -49,28 +37,28 @@ function Slider(props) { .minDistance(5) .onBegin(() => { runOnJS(setTooltipIsVisible)(false); - props.gestureCallbacks.onBegin(); + gestureCallbacks.onBegin(); }) .onChange((event) => { - props.gestureCallbacks.onChange(event); + gestureCallbacks.onChange(event); }) .onFinalize(() => { runOnJS(setTooltipIsVisible)(true); - props.gestureCallbacks.onFinalize(); + gestureCallbacks.onFinalize(); }); // We're preventing text selection with ControlSelection.blockElement to prevent safari // default behaviour of cursor - I-beam cursor on drag. See https://github.com/Expensify/App/issues/13688 return ( ControlSelection.blockElement(el as HTMLElement | null)} style={styles.sliderBar} > {tooltipIsVisible && ( {/* pointerEventsNone is a workaround to make sure the pan gesture works correctly on mobile safari */} @@ -84,6 +72,4 @@ function Slider(props) { } Slider.displayName = 'Slider'; -Slider.propTypes = propTypes; -Slider.defaultProps = defaultProps; -export default withLocalize(Slider); +export default Slider; diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index e9e1054427b9..d42d47caafc9 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -12,7 +12,7 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetails, Policy, Report, ReportActions} from '@src/types/onyx'; +import type {PersonalDetails, PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; import DisplayNames from './DisplayNames'; import MultipleAvatars from './MultipleAvatars'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; @@ -36,7 +36,7 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { size?: ValueOf; /** Personal details of all the users */ - personalDetails: OnyxCollection; + personalDetails: OnyxEntry; /** Whether if it's an unauthenticated user */ isAnonymous?: boolean; @@ -63,7 +63,7 @@ function AvatarWithDisplayName({ const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report); const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails), false); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails) as PersonalDetails[], false); const shouldShowSubscriptAvatar = ReportUtils.shouldReportShowSubscript(report); const isExpenseRequest = ReportUtils.isExpenseRequest(report); const avatarBorderColor = isAnonymous ? theme.highlightBG : theme.componentBG; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 5fb134648134..f4b6e8b23ecf 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -20,7 +20,7 @@ import validateSubmitShortcut from './validateSubmitShortcut'; type ButtonWithText = { /** The text for the button label */ - text: string; + text?: string; /** Boolean whether to display the right icon */ shouldShowRightIcon?: boolean; diff --git a/src/components/Composer/index.android.tsx b/src/components/Composer/index.android.tsx deleted file mode 100644 index ade1513c8613..000000000000 --- a/src/components/Composer/index.android.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import type {ForwardedRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import type {TextInput} from 'react-native'; -import {StyleSheet} from 'react-native'; -import RNTextInput from '@components/RNTextInput'; -import useResetComposerFocus from '@hooks/useResetComposerFocus'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ComposerUtils from '@libs/ComposerUtils'; -import type {ComposerProps} from './types'; - -function Composer( - { - shouldClear = false, - onClear = () => {}, - isDisabled = false, - maxLines, - isComposerFullSize = false, - setIsFullComposerAvailable = () => {}, - style, - autoFocus = false, - selection = { - start: 0, - end: 0, - }, - isFullComposerAvailable = false, - ...props - }: ComposerProps, - ref: ForwardedRef, -) { - const textInput = useRef(null); - const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); - - const styles = useThemeStyles(); - const theme = useTheme(); - - /** - * Set the TextInput Ref - */ - const setTextInputRef = useCallback((el: TextInput) => { - textInput.current = el; - if (typeof ref !== 'function' || textInput.current === null) { - return; - } - - // This callback prop is used by the parent component using the constructor to - // get a ref to the inner textInput element e.g. if we do - // this.textInput = el} /> this will not - // return a ref to the component, but rather the HTML element by default - ref(textInput.current); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (!shouldClear) { - return; - } - textInput.current?.clear(); - onClear(); - }, [shouldClear, onClear]); - - /** - * Set maximum number of lines - */ - const maxNumberOfLines = useMemo(() => { - if (isComposerFullSize) { - return 1000000; - } - return maxLines; - }, [isComposerFullSize, maxLines]); - - const composerStyles = useMemo(() => StyleSheet.flatten(style), [style]); - - return ( - ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} - rejectResponderTermination={false} - // Setting a really high number here fixes an issue with the `maxNumberOfLines` prop on TextInput, where on Android the text input would collapse to only one line, - // when it should actually expand to the container (https://github.com/Expensify/App/issues/11694#issuecomment-1560520670) - // @Szymon20000 is working on fixing this (android-only) issue in the in the upstream PR (https://github.com/facebook/react-native/pulls?q=is%3Apr+is%3Aopen+maxNumberOfLines) - // TODO: remove this comment once upstream PR is merged and available in a future release - maxNumberOfLines={maxNumberOfLines} - textAlignVertical="center" - style={[composerStyles]} - autoFocus={autoFocus} - selection={selection} - isFullComposerAvailable={isFullComposerAvailable} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...props} - readOnly={isDisabled} - onBlur={(e) => { - if (!isFocused) { - shouldResetFocus.current = true; // detect the input is blurred when the page is hidden - } - props?.onBlur?.(e); - }} - /> - ); -} - -Composer.displayName = 'Composer'; - -export default React.forwardRef(Composer); diff --git a/src/components/Composer/index.ios.tsx b/src/components/Composer/index.native.tsx similarity index 82% rename from src/components/Composer/index.ios.tsx rename to src/components/Composer/index.native.tsx index 07736e5ddcba..c7b020a5c6dd 100644 --- a/src/components/Composer/index.ios.tsx +++ b/src/components/Composer/index.native.tsx @@ -2,8 +2,10 @@ import type {ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import type {TextInput} from 'react-native'; import {StyleSheet} from 'react-native'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RNTextInput from '@components/RNTextInput'; import useResetComposerFocus from '@hooks/useResetComposerFocus'; +import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ComposerUtils from '@libs/ComposerUtils'; @@ -28,15 +30,17 @@ function Composer( }: ComposerProps, ref: ForwardedRef, ) { - const textInput = useRef(null); + const textInput = useRef(null); const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); - const styles = useThemeStyles(); const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); /** * Set the TextInput Ref + * @param {Element} el */ - const setTextInputRef = useCallback((el: TextInput) => { + const setTextInputRef = useCallback((el: AnimatedTextInputRef) => { textInput.current = el; if (typeof ref !== 'function' || textInput.current === null) { return; @@ -58,28 +62,20 @@ function Composer( onClear(); }, [shouldClear, onClear]); - /** - * Set maximum number of lines - */ - const maxNumberOfLines = useMemo(() => { - if (isComposerFullSize) { - return; - } - return maxLines; - }, [isComposerFullSize, maxLines]); - - const composerStyles = useMemo(() => StyleSheet.flatten(style), [style]); + const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); + const composerStyle = useMemo(() => StyleSheet.flatten(style), [style]); return ( ComposerUtils.updateNumberOfLines({maxLines, isComposerFullSize, isDisabled, setIsFullComposerAvailable}, e, styles)} rejectResponderTermination={false} smartInsertDelete={false} - style={[composerStyles, styles.verticalAlignMiddle]} - maxNumberOfLines={maxNumberOfLines} + textAlignVertical="center" + style={[composerStyle, maxHeightStyle]} autoFocus={autoFocus} isFullComposerAvailable={isFullComposerAvailable} /* eslint-disable-next-line react/jsx-props-no-spreading */ diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 3c2caf020ef7..50a79021437c 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -4,9 +4,10 @@ import type {BaseSyntheticEvent, ForwardedRef} from 'react'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {flushSync} from 'react-dom'; // eslint-disable-next-line no-restricted-imports -import type {DimensionValue, NativeSyntheticEvent, Text as RNText, TextInput, TextInputKeyPressEventData, TextInputProps, TextInputSelectionChangeEventData} from 'react-native'; +import type {DimensionValue, NativeSyntheticEvent, Text as RNText, TextInputKeyPressEventData, TextInputProps, TextInputSelectionChangeEventData} from 'react-native'; import {StyleSheet, View} from 'react-native'; import type {AnimatedProps} from 'react-native-reanimated'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import RNTextInput from '@components/RNTextInput'; import Text from '@components/Text'; import useIsScrollBarVisible from '@hooks/useIsScrollBarVisible'; @@ -83,7 +84,7 @@ function Composer( const {windowWidth} = useWindowDimensions(); const navigation = useNavigation(); const textRef = useRef(null); - const textInput = useRef<(HTMLTextAreaElement & TextInput) | null>(null); + const textInput = useRef(null); const [numberOfLines, setNumberOfLines] = useState(numberOfLinesProp); const [selection, setSelection] = useState< | { @@ -359,7 +360,7 @@ function Composer( autoComplete="off" autoCorrect={!Browser.isMobileSafari()} placeholderTextColor={theme.placeholderText} - ref={(el: TextInput & HTMLTextAreaElement) => (textInput.current = el)} + ref={(el) => (textInput.current = el)} selection={selection} style={inputStyleMemo} value={value} @@ -368,7 +369,7 @@ function Composer( /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} onSelectionChange={addCursorPositionToSelectionChange} - rows={numberOfLines} + numberOfLines={numberOfLines} disabled={isDisabled} onKeyPress={handleKeyPress} onFocus={(e) => { diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index e627119270dd..832715e3214c 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -11,6 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import getButtonState from '@libs/getButtonState'; import * as EmojiPickerAction from '@userActions/EmojiPickerAction'; +import CONST from '@src/CONST'; const propTypes = { /** Flag to disable the emoji picker button */ @@ -22,6 +23,9 @@ const propTypes = { /** Unique id for emoji picker */ emojiPickerID: PropTypes.string, + /** Emoji popup anchor offset shift vertical */ + shiftVertical: PropTypes.number, + ...withLocalizePropTypes, }; @@ -29,6 +33,7 @@ const defaultProps = { isDisabled: false, id: '', emojiPickerID: '', + shiftVertical: 0, }; function EmojiPickerButton(props) { @@ -49,7 +54,18 @@ function EmojiPickerButton(props) { return; } if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { - EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor, undefined, () => {}, props.emojiPickerID); + EmojiPickerAction.showEmojiPicker( + props.onModalHide, + props.onEmojiSelected, + emojiPopoverAnchor, + { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + shiftVertical: props.shiftVertical, + }, + () => {}, + props.emojiPickerID, + ); } else { EmojiPickerAction.emojiPickerRef.current.hideEmojiPicker(); } diff --git a/src/components/Form/FormContext.js b/src/components/Form/FormContext.js deleted file mode 100644 index 40edaa7cca69..000000000000 --- a/src/components/Form/FormContext.js +++ /dev/null @@ -1,4 +0,0 @@ -import {createContext} from 'react'; - -const FormContext = createContext({}); -export default FormContext; diff --git a/src/components/Form/FormContext.tsx b/src/components/Form/FormContext.tsx new file mode 100644 index 000000000000..47e0de8b497c --- /dev/null +++ b/src/components/Form/FormContext.tsx @@ -0,0 +1,12 @@ +import {createContext} from 'react'; +import type {RegisterInput} from './types'; + +type FormContext = { + registerInput: RegisterInput; +}; + +export default createContext({ + registerInput: () => { + throw new Error('Registered input should be wrapped with FormWrapper'); + }, +}); diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js deleted file mode 100644 index 4d1630dbbe06..000000000000 --- a/src/components/Form/FormProvider.js +++ /dev/null @@ -1,408 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {createRef, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import networkPropTypes from '@components/networkPropTypes'; -import {withNetwork} from '@components/OnyxProvider'; -import compose from '@libs/compose'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import Visibility from '@libs/Visibility'; -import stylePropTypes from '@styles/stylePropTypes'; -import * as FormActions from '@userActions/FormActions'; -import CONST from '@src/CONST'; -import FormContext from './FormContext'; -import FormWrapper from './FormWrapper'; - -const propTypes = { - /** A unique Onyx key identifying the form */ - formID: PropTypes.string.isRequired, - - /** Text to be displayed in the submit button */ - submitButtonText: PropTypes.string.isRequired, - - /** Controls the submit button's visibility */ - isSubmitButtonVisible: PropTypes.bool, - - /** Callback to validate the form */ - validate: PropTypes.func, - - /** Callback to submit the form */ - onSubmit: PropTypes.func.isRequired, - - /** Children to render. */ - children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, - - /* Onyx Props */ - - /** Contains the form state that must be accessed outside of the component */ - formState: PropTypes.shape({ - /** Controls the loading state of the form */ - isLoading: PropTypes.bool, - - /** Server side errors keyed by microtime */ - errors: PropTypes.objectOf(PropTypes.string), - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), - - /** Contains draft values for each input in the form */ - draftValues: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number, PropTypes.objectOf(Date)])), - - /** Should the button be enabled when offline */ - enabledWhenOffline: PropTypes.bool, - - /** Whether the form submit action is dangerous */ - isSubmitActionDangerous: PropTypes.bool, - - /** Whether ScrollWithContext should be used instead of regular ScrollView. Set to true when there's a nested Picker component in Form. */ - scrollContextEnabled: PropTypes.bool, - - /** Container styles */ - style: stylePropTypes, - - /** Custom content to display in the footer after submit button */ - footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), - - /** Information about the network */ - network: networkPropTypes.isRequired, - - /** Should validate function be called when input loose focus */ - shouldValidateOnBlur: PropTypes.bool, - - /** Should validate function be called when the value of the input is changed */ - shouldValidateOnChange: PropTypes.bool, - - /** Should fix the errors alert be displayed when there is an error in the form */ - shouldHideFixErrorsAlert: PropTypes.bool, -}; - -// In order to prevent Checkbox focus loss when the user are focusing a TextInput and proceeds to toggle a CheckBox in web and mobile web. -// 200ms delay was chosen as a result of empirical testing. -// More details: https://github.com/Expensify/App/pull/16444#issuecomment-1482983426 -const VALIDATE_DELAY = 200; - -const defaultProps = { - isSubmitButtonVisible: true, - formState: { - isLoading: false, - }, - draftValues: {}, - enabledWhenOffline: false, - isSubmitActionDangerous: false, - scrollContextEnabled: false, - footerContent: null, - style: [], - validate: () => {}, - shouldValidateOnBlur: true, - shouldValidateOnChange: true, - shouldHideFixErrorsAlert: false, -}; - -function getInitialValueByType(valueType) { - switch (valueType) { - case 'string': - return ''; - case 'boolean': - return false; - case 'date': - return new Date(); - default: - return ''; - } -} - -const FormProvider = forwardRef( - ({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, draftValues, onSubmit, ...rest}, forwardedRef) => { - const inputRefs = useRef({}); - const touchedInputs = useRef({}); - const [inputValues, setInputValues] = useState(() => ({...draftValues})); - const [errors, setErrors] = useState({}); - const hasServerError = useMemo(() => Boolean(formState) && !_.isEmpty(formState.errors), [formState]); - - const onValidate = useCallback( - (values, shouldClearServerError = true) => { - const trimmedStringValues = ValidationUtils.prepareValues(values); - - if (shouldClearServerError) { - FormActions.setErrors(formID, null); - } - FormActions.setErrorFields(formID, null); - - const validateErrors = validate(trimmedStringValues) || {}; - - // Validate the input for html tags. It should supercede any other error - _.each(trimmedStringValues, (inputValue, inputID) => { - // If the input value is empty OR is non-string, we don't need to validate it for HTML tags - if (!inputValue || !_.isString(inputValue)) { - return; - } - const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX); - const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX); - - // Return early if there are no HTML characters - if (leadingSpaceIndex === -1 && foundHtmlTagIndex === -1) { - return; - } - - const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX); - let isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(inputValue)); - // Check for any matches that the original regex (foundHtmlTagIndex) matched - if (matchedHtmlTags) { - // Check if any matched inputs does not match in WHITELISTED_TAGS list and return early if needed. - for (let i = 0; i < matchedHtmlTags.length; i++) { - const htmlTag = matchedHtmlTags[i]; - isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(htmlTag)); - if (!isMatch) { - break; - } - } - } - - if (isMatch && leadingSpaceIndex === -1) { - return; - } - - // Add a validation error here because it is a string value that contains HTML characters - validateErrors[inputID] = 'common.error.invalidCharacter'; - }); - - if (!_.isObject(validateErrors)) { - throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}'); - } - - const touchedInputErrors = _.pick(validateErrors, (inputValue, inputID) => Boolean(touchedInputs.current[inputID])); - - if (!_.isEqual(errors, touchedInputErrors)) { - setErrors(touchedInputErrors); - } - - return touchedInputErrors; - }, - [errors, formID, validate], - ); - - /** - * @param {String} inputID - The inputID of the input being touched - */ - const setTouchedInput = useCallback( - (inputID) => { - touchedInputs.current[inputID] = true; - }, - [touchedInputs], - ); - - const submit = useCallback(() => { - // Return early if the form is already submitting to avoid duplicate submission - if (formState.isLoading) { - return; - } - - // Prepare values before submitting - const trimmedStringValues = ValidationUtils.prepareValues(inputValues); - - // Touches all form inputs so we can validate the entire form - _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true)); - - // Validate form and return early if any errors are found - if (!_.isEmpty(onValidate(trimmedStringValues))) { - return; - } - - // Do not submit form if network is offline and the form is not enabled when offline - if (network.isOffline && !enabledWhenOffline) { - return; - } - - onSubmit(trimmedStringValues); - }, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]); - - /** - * Resets the form - */ - const resetForm = useCallback( - (optionalValue) => { - _.each(inputValues, (inputRef, inputID) => { - setInputValues((prevState) => { - const copyPrevState = _.clone(prevState); - - touchedInputs.current[inputID] = false; - copyPrevState[inputID] = optionalValue[inputID] || ''; - - return copyPrevState; - }); - }); - setErrors({}); - }, - [inputValues], - ); - useImperativeHandle(forwardedRef, () => ({ - resetForm, - })); - - const registerInput = useCallback( - (inputID, propsToParse = {}) => { - const newRef = inputRefs.current[inputID] || propsToParse.ref || createRef(); - if (inputRefs.current[inputID] !== newRef) { - inputRefs.current[inputID] = newRef; - } - - if (!_.isUndefined(propsToParse.value)) { - inputValues[inputID] = propsToParse.value; - } else if (propsToParse.shouldSaveDraft && !_.isUndefined(draftValues[inputID]) && _.isUndefined(inputValues[inputID])) { - inputValues[inputID] = draftValues[inputID]; - } else if (propsToParse.shouldUseDefaultValue && _.isUndefined(inputValues[inputID])) { - // We force the form to set the input value from the defaultValue props if there is a saved valid value - inputValues[inputID] = propsToParse.defaultValue; - } else if (_.isUndefined(inputValues[inputID])) { - // We want to initialize the input value if it's undefined - inputValues[inputID] = _.isUndefined(propsToParse.defaultValue) ? getInitialValueByType(propsToParse.valueType) : propsToParse.defaultValue; - } - - const errorFields = lodashGet(formState, 'errorFields', {}); - const fieldErrorMessage = - _.chain(errorFields[inputID]) - .keys() - .sortBy() - .reverse() - .map((key) => errorFields[inputID][key]) - .first() - .value() || ''; - - return { - ...propsToParse, - ref: - typeof propsToParse.ref === 'function' - ? (node) => { - propsToParse.ref(node); - newRef.current = node; - } - : newRef, - inputID, - key: propsToParse.key || inputID, - errorText: errors[inputID] || fieldErrorMessage, - value: inputValues[inputID], - // As the text input is controlled, we never set the defaultValue prop - // as this is already happening by the value prop. - defaultValue: undefined, - onTouched: (event) => { - if (!propsToParse.shouldSetTouchedOnBlurOnly) { - setTimeout(() => { - setTouchedInput(inputID); - }, VALIDATE_DELAY); - } - if (_.isFunction(propsToParse.onTouched)) { - propsToParse.onTouched(event); - } - }, - onPress: (event) => { - if (!propsToParse.shouldSetTouchedOnBlurOnly) { - setTimeout(() => { - setTouchedInput(inputID); - }, VALIDATE_DELAY); - } - if (_.isFunction(propsToParse.onPress)) { - propsToParse.onPress(event); - } - }, - onPressOut: (event) => { - // To prevent validating just pressed inputs, we need to set the touched input right after - // onValidate and to do so, we need to delays setTouchedInput of the same amount of time - // as the onValidate is delayed - if (!propsToParse.shouldSetTouchedOnBlurOnly) { - setTimeout(() => { - setTouchedInput(inputID); - }, VALIDATE_DELAY); - } - if (_.isFunction(propsToParse.onPressIn)) { - propsToParse.onPressIn(event); - } - }, - onBlur: (event) => { - // Only run validation when user proactively blurs the input. - if (Visibility.isVisible() && Visibility.hasFocus()) { - const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id'); - // We delay the validation in order to prevent Checkbox loss of focus when - // the user is focusing a TextInput and proceeds to toggle a CheckBox in - // web and mobile web platforms. - - setTimeout(() => { - if ( - relatedTargetId && - _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId) - ) { - return; - } - setTouchedInput(inputID); - if (shouldValidateOnBlur) { - onValidate(inputValues, !hasServerError); - } - }, VALIDATE_DELAY); - } - - if (_.isFunction(propsToParse.onBlur)) { - propsToParse.onBlur(event); - } - }, - onInputChange: (value, key) => { - const inputKey = key || inputID; - setInputValues((prevState) => { - const newState = { - ...prevState, - [inputKey]: value, - }; - - if (shouldValidateOnChange) { - onValidate(newState); - } - return newState; - }); - - if (propsToParse.shouldSaveDraft) { - FormActions.setDraftValues(formID, {[inputKey]: value}); - } - - if (_.isFunction(propsToParse.onValueChange)) { - propsToParse.onValueChange(value, inputKey); - } - }, - }; - }, - [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], - ); - const value = useMemo(() => ({registerInput}), [registerInput]); - - return ( - - {/* eslint-disable react/jsx-props-no-spreading */} - - {_.isFunction(children) ? children({inputValues}) : children} - - - ); - }, -); - -FormProvider.displayName = 'Form'; -FormProvider.propTypes = propTypes; -FormProvider.defaultProps = defaultProps; - -export default compose( - withNetwork(), - withOnyx({ - formState: { - key: (props) => props.formID, - }, - draftValues: { - key: (props) => `${props.formID}Draft`, - }, - }), -)(FormProvider); diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx new file mode 100644 index 000000000000..424fd989291a --- /dev/null +++ b/src/components/Form/FormProvider.tsx @@ -0,0 +1,358 @@ +import lodashIsEqual from 'lodash/isEqual'; +import type {ForwardedRef, MutableRefObject, ReactNode} from 'react'; +import React, {createRef, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import Visibility from '@libs/Visibility'; +import * as FormActions from '@userActions/FormActions'; +import CONST from '@src/CONST'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Form, Network} from '@src/types/onyx'; +import type {FormValueType} from '@src/types/onyx/Form'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import FormContext from './FormContext'; +import FormWrapper from './FormWrapper'; +import type {BaseInputProps, FormProps, InputRefs, OnyxFormKeyWithoutDraft, OnyxFormValues, OnyxFormValuesFields, RegisterInput, ValueTypeKey} from './types'; + +// In order to prevent Checkbox focus loss when the user are focusing a TextInput and proceeds to toggle a CheckBox in web and mobile web. +// 200ms delay was chosen as a result of empirical testing. +// More details: https://github.com/Expensify/App/pull/16444#issuecomment-1482983426 +const VALIDATE_DELAY = 200; + +type InitialDefaultValue = false | Date | ''; + +function getInitialValueByType(valueType?: ValueTypeKey): InitialDefaultValue { + switch (valueType) { + case 'string': + return ''; + case 'boolean': + return false; + case 'date': + return new Date(); + default: + return ''; + } +} + +type FormProviderOnyxProps = { + /** Contains the form state that must be accessed outside the component */ + formState: OnyxEntry
; + + /** Contains draft values for each input in the form */ + draftValues: OnyxEntry; + + /** Information about the network */ + network: OnyxEntry; +}; + +type FormProviderProps = FormProviderOnyxProps & + FormProps & { + /** Children to render. */ + children: ((props: {inputValues: OnyxFormValues}) => ReactNode) | ReactNode; + + /** Callback to validate the form */ + validate?: (values: OnyxFormValuesFields) => Errors; + + /** Should validate function be called when input loose focus */ + shouldValidateOnBlur?: boolean; + + /** Should validate function be called when the value of the input is changed */ + shouldValidateOnChange?: boolean; + }; + +type FormRef = { + resetForm: (optionalValue: OnyxFormValues) => void; +}; + +function FormProvider( + { + formID, + validate, + shouldValidateOnBlur = true, + shouldValidateOnChange = true, + children, + formState, + network, + enabledWhenOffline = false, + draftValues, + onSubmit, + ...rest + }: FormProviderProps, + forwardedRef: ForwardedRef, +) { + const inputRefs = useRef({}); + const touchedInputs = useRef>({}); + const [inputValues, setInputValues] = useState(() => ({...draftValues})); + const [errors, setErrors] = useState({}); + const hasServerError = useMemo(() => !!formState && !isEmptyObject(formState?.errors), [formState]); + + const onValidate = useCallback( + (values: OnyxFormValuesFields, shouldClearServerError = true) => { + const trimmedStringValues = ValidationUtils.prepareValues(values); + + if (shouldClearServerError) { + FormActions.clearErrors(formID); + } + FormActions.clearErrorFields(formID); + + const validateErrors = validate?.(trimmedStringValues) ?? {}; + + // Validate the input for html tags. It should supersede any other error + Object.entries(trimmedStringValues).forEach(([inputID, inputValue]) => { + // If the input value is empty OR is non-string, we don't need to validate it for HTML tags + if (!inputValue || typeof inputValue !== 'string') { + return; + } + const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX); + const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX); + + // Return early if there are no HTML characters + if (leadingSpaceIndex === -1 && foundHtmlTagIndex === -1) { + return; + } + + const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX); + let isMatch = CONST.WHITELISTED_TAGS.some((regex) => regex.test(inputValue)); + // Check for any matches that the original regex (foundHtmlTagIndex) matched + if (matchedHtmlTags) { + // Check if any matched inputs does not match in WHITELISTED_TAGS list and return early if needed. + for (const htmlTag of matchedHtmlTags) { + isMatch = CONST.WHITELISTED_TAGS.some((regex) => regex.test(htmlTag)); + if (!isMatch) { + break; + } + } + } + + if (isMatch && leadingSpaceIndex === -1) { + return; + } + + // Add a validation error here because it is a string value that contains HTML characters + validateErrors[inputID] = 'common.error.invalidCharacter'; + }); + + if (typeof validateErrors !== 'object') { + throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}'); + } + + const touchedInputErrors = Object.fromEntries(Object.entries(validateErrors).filter(([inputID]) => touchedInputs.current[inputID])); + + if (!lodashIsEqual(errors, touchedInputErrors)) { + setErrors(touchedInputErrors); + } + + return touchedInputErrors; + }, + [errors, formID, validate], + ); + + /** @param inputID - The inputID of the input being touched */ + const setTouchedInput = useCallback( + (inputID: keyof Form) => { + touchedInputs.current[inputID] = true; + }, + [touchedInputs], + ); + + const submit = useCallback(() => { + // Return early if the form is already submitting to avoid duplicate submission + if (formState?.isLoading) { + return; + } + + // Prepare values before submitting + const trimmedStringValues = ValidationUtils.prepareValues(inputValues); + + // Touches all form inputs, so we can validate the entire form + Object.keys(inputRefs.current).forEach((inputID) => (touchedInputs.current[inputID] = true)); + + // Validate form and return early if any errors are found + if (!isEmptyObject(onValidate(trimmedStringValues))) { + return; + } + + // Do not submit form if network is offline and the form is not enabled when offline + if (network?.isOffline && !enabledWhenOffline) { + return; + } + + onSubmit(trimmedStringValues); + }, [enabledWhenOffline, formState?.isLoading, inputValues, network?.isOffline, onSubmit, onValidate]); + + const resetForm = useCallback( + (optionalValue: OnyxFormValuesFields) => { + Object.keys(inputValues).forEach((inputID) => { + setInputValues((prevState) => { + const copyPrevState = {...prevState}; + + touchedInputs.current[inputID] = false; + copyPrevState[inputID] = optionalValue[inputID as keyof OnyxFormValuesFields] || ''; + + return copyPrevState; + }); + }); + setErrors({}); + }, + [inputValues], + ); + useImperativeHandle(forwardedRef, () => ({ + resetForm, + })); + + const registerInput = useCallback( + (inputID: keyof Form, inputProps: TInputProps): TInputProps => { + const newRef: MutableRefObject = inputRefs.current[inputID] ?? inputProps.ref ?? createRef(); + if (inputRefs.current[inputID] !== newRef) { + inputRefs.current[inputID] = newRef; + } + if (inputProps.value !== undefined) { + inputValues[inputID] = inputProps.value; + } else if (inputProps.shouldSaveDraft && draftValues?.[inputID] !== undefined && inputValues[inputID] === undefined) { + inputValues[inputID] = draftValues[inputID]; + } else if (inputProps.shouldUseDefaultValue && inputProps.defaultValue !== undefined && inputValues[inputID] === undefined) { + // We force the form to set the input value from the defaultValue props if there is a saved valid value + inputValues[inputID] = inputProps.defaultValue; + } else if (inputValues[inputID] === undefined) { + // We want to initialize the input value if it's undefined + inputValues[inputID] = inputProps.defaultValue ?? getInitialValueByType(inputProps.valueType); + } + + const errorFields = formState?.errorFields?.[inputID] ?? {}; + const fieldErrorMessage = + Object.keys(errorFields) + .sort() + .map((key) => errorFields[key]) + .at(-1) ?? ''; + + const inputRef = inputProps.ref; + + return { + ...inputProps, + ref: + typeof inputRef === 'function' + ? (node: BaseInputProps) => { + inputRef(node); + newRef.current = node; + } + : newRef, + inputID, + key: inputProps.key ?? inputID, + errorText: errors[inputID] ?? fieldErrorMessage, + value: inputValues[inputID], + // As the text input is controlled, we never set the defaultValue prop + // as this is already happening by the value prop. + defaultValue: undefined, + onTouched: (event) => { + if (!inputProps.shouldSetTouchedOnBlurOnly) { + setTimeout(() => { + setTouchedInput(inputID); + }, VALIDATE_DELAY); + } + inputProps.onTouched?.(event); + }, + onPress: (event) => { + if (!inputProps.shouldSetTouchedOnBlurOnly) { + setTimeout(() => { + setTouchedInput(inputID); + }, VALIDATE_DELAY); + } + inputProps.onPress?.(event); + }, + onPressOut: (event) => { + // To prevent validating just pressed inputs, we need to set the touched input right after + // onValidate and to do so, we need to delay setTouchedInput of the same amount of time + // as the onValidate is delayed + if (!inputProps.shouldSetTouchedOnBlurOnly) { + setTimeout(() => { + setTouchedInput(inputID); + }, VALIDATE_DELAY); + } + inputProps.onPressOut?.(event); + }, + onBlur: (event) => { + // Only run validation when user proactively blurs the input. + if (Visibility.isVisible() && Visibility.hasFocus()) { + const relatedTarget = event && 'relatedTarget' in event.nativeEvent && event?.nativeEvent?.relatedTarget; + const relatedTargetId = relatedTarget && 'id' in relatedTarget && typeof relatedTarget.id === 'string' && relatedTarget.id; + // We delay the validation in order to prevent Checkbox loss of focus when + // the user is focusing a TextInput and proceeds to toggle a CheckBox in + // web and mobile web platforms. + + setTimeout(() => { + if ( + relatedTargetId === CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID || + relatedTargetId === CONST.OVERLAY.TOP_BUTTON_NATIVE_ID || + relatedTargetId === CONST.BACK_BUTTON_NATIVE_ID + ) { + return; + } + setTouchedInput(inputID); + if (shouldValidateOnBlur) { + onValidate(inputValues, !hasServerError); + } + }, VALIDATE_DELAY); + } + inputProps.onBlur?.(event); + }, + onInputChange: (value: FormValueType, key?: string) => { + const inputKey = key ?? inputID; + setInputValues((prevState) => { + const newState = { + ...prevState, + [inputKey]: value, + }; + + if (shouldValidateOnChange) { + onValidate(newState); + } + return newState as Form; + }); + + if (inputProps.shouldSaveDraft && !formID.includes('Draft')) { + FormActions.setDraftValues(formID as OnyxFormKeyWithoutDraft, {[inputKey]: value}); + } + inputProps.onValueChange?.(value, inputKey); + }, + }; + }, + [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], + ); + const value = useMemo(() => ({registerInput}), [registerInput]); + + return ( + + {/* eslint-disable react/jsx-props-no-spreading */} + + {typeof children === 'function' ? children({inputValues}) : children} + + + ); +} + +FormProvider.displayName = 'Form'; + +export default withOnyx({ + network: { + key: ONYXKEYS.NETWORK, + }, + // withOnyx typings are not able to handle such generic cases like this one, since it's a generic component we need to cast the keys to any + formState: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any + key: ({formID}) => formID as any, + }, + draftValues: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any + key: (props) => `${props.formID}Draft` as any, + }, +})(forwardRef(FormProvider)) as (props: Omit, keyof FormProviderOnyxProps>) => ReactNode; diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js deleted file mode 100644 index f1c5d6de9071..000000000000 --- a/src/components/Form/FormWrapper.js +++ /dev/null @@ -1,223 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useCallback, useMemo, useRef} from 'react'; -import {Keyboard, ScrollView, StyleSheet} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; -import FormSubmit from '@components/FormSubmit'; -import refPropTypes from '@components/refPropTypes'; -import SafeAreaConsumer from '@components/SafeAreaConsumer'; -import ScrollViewWithContext from '@components/ScrollViewWithContext'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import stylePropTypes from '@styles/stylePropTypes'; -import errorsPropType from './errorsPropType'; - -const propTypes = { - /** A unique Onyx key identifying the form */ - formID: PropTypes.string.isRequired, - - /** Text to be displayed in the submit button */ - submitButtonText: PropTypes.string.isRequired, - - /** Controls the submit button's visibility */ - isSubmitButtonVisible: PropTypes.bool, - - /** Callback to submit the form */ - onSubmit: PropTypes.func.isRequired, - - /** Children to render. */ - children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, - - /* Onyx Props */ - - /** Contains the form state that must be accessed outside of the component */ - formState: PropTypes.shape({ - /** Controls the loading state of the form */ - isLoading: PropTypes.bool, - - /** Server side errors keyed by microtime */ - errors: errorsPropType, - - /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), - }), - - /** Should the button be enabled when offline */ - enabledWhenOffline: PropTypes.bool, - - /** Whether the form submit action is dangerous */ - isSubmitActionDangerous: PropTypes.bool, - - /** Whether ScrollWithContext should be used instead of regular ScrollView. - * Set to true when there's a nested Picker component in Form. - */ - scrollContextEnabled: PropTypes.bool, - - /** Container styles */ - style: stylePropTypes, - - /** Submit button styles */ - submitButtonStyles: stylePropTypes, - - /** Custom content to display in the footer after submit button */ - footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), - - errors: errorsPropType.isRequired, - - inputRefs: PropTypes.objectOf(refPropTypes).isRequired, - - shouldHideFixErrorsAlert: PropTypes.bool, -}; - -const defaultProps = { - isSubmitButtonVisible: true, - formState: { - isLoading: false, - }, - enabledWhenOffline: false, - isSubmitActionDangerous: false, - scrollContextEnabled: false, - footerContent: null, - style: [], - submitButtonStyles: [], - shouldHideFixErrorsAlert: false, -}; - -function FormWrapper(props) { - const styles = useThemeStyles(); - const { - onSubmit, - children, - formState, - errors, - inputRefs, - submitButtonText, - footerContent, - isSubmitButtonVisible, - style, - submitButtonStyles, - enabledWhenOffline, - isSubmitActionDangerous, - formID, - shouldHideFixErrorsAlert, - } = props; - const formRef = useRef(null); - const formContentRef = useRef(null); - const errorMessage = useMemo(() => { - const latestErrorMessage = ErrorUtils.getLatestErrorMessage(formState); - return typeof latestErrorMessage === 'string' ? latestErrorMessage : ''; - }, [formState]); - - const scrollViewContent = useCallback( - (safeAreaPaddingBottomStyle) => ( - - {children} - {isSubmitButtonVisible && ( - 0 || !_.isEmpty(formState.errorFields)) && !shouldHideFixErrorsAlert) || Boolean(errorMessage)} - isLoading={formState.isLoading} - message={_.isEmpty(formState.errorFields) ? errorMessage : null} - onSubmit={onSubmit} - footerContent={footerContent} - onFixTheErrorsLinkPressed={() => { - const errorFields = !_.isEmpty(errors) ? errors : formState.errorFields; - const focusKey = _.find(_.keys(inputRefs.current), (key) => _.keys(errorFields).includes(key)); - const focusInput = inputRefs.current[focusKey].current; - - // Dismiss the keyboard for non-text fields by checking if the component has the isFocused method, as only TextInput has this method. - if (typeof focusInput.isFocused !== 'function') { - Keyboard.dismiss(); - } - - // We subtract 10 to scroll slightly above the input - if (focusInput.measureLayout && typeof focusInput.measureLayout === 'function') { - // We measure relative to the content root, not the scroll view, as that gives - // consistent results across mobile and web - focusInput.measureLayout(formContentRef.current, (x, y) => - formRef.current.scrollTo({ - y: y - 10, - animated: false, - }), - ); - } - - // Focus the input after scrolling, as on the Web it gives a slightly better visual result - if (focusInput.focus && typeof focusInput.focus === 'function') { - focusInput.focus(); - } - }} - containerStyles={[styles.mh0, styles.mt5, styles.flex1, ...submitButtonStyles]} - enabledWhenOffline={enabledWhenOffline} - isSubmitActionDangerous={isSubmitActionDangerous} - disablePressOnEnter - shouldHideFixErrorsAlert={shouldHideFixErrorsAlert} - /> - )} - - ), - [ - children, - enabledWhenOffline, - errorMessage, - errors, - footerContent, - formID, - formState.errorFields, - formState.isLoading, - inputRefs, - isSubmitActionDangerous, - isSubmitButtonVisible, - onSubmit, - style, - styles.flex1, - styles.mh0, - styles.mt5, - submitButtonStyles, - submitButtonText, - shouldHideFixErrorsAlert, - ], - ); - - return ( - - {({safeAreaPaddingBottomStyle}) => - props.scrollContextEnabled ? ( - - {scrollViewContent(safeAreaPaddingBottomStyle)} - - ) : ( - - {scrollViewContent(safeAreaPaddingBottomStyle)} - - ) - } - - ); -} - -FormWrapper.displayName = 'FormWrapper'; -FormWrapper.propTypes = propTypes; -FormWrapper.defaultProps = defaultProps; - -export default withOnyx({ - formState: { - key: (props) => props.formID, - }, -})(FormWrapper); diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx new file mode 100644 index 000000000000..d5b47761e4c0 --- /dev/null +++ b/src/components/Form/FormWrapper.tsx @@ -0,0 +1,178 @@ +import React, {useCallback, useMemo, useRef} from 'react'; +import type {RefObject} from 'react'; +import type {StyleProp, View, ViewStyle} from 'react-native'; +import {Keyboard, ScrollView} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import FormSubmit from '@components/FormSubmit'; +import SafeAreaConsumer from '@components/SafeAreaConsumer'; +import type {SafeAreaChildrenProps} from '@components/SafeAreaConsumer/types'; +import ScrollViewWithContext from '@components/ScrollViewWithContext'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import type {Form} from '@src/types/onyx'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {FormProps, InputRefs} from './types'; + +type FormWrapperOnyxProps = { + /** Contains the form state that must be accessed outside the component */ + formState: OnyxEntry; +}; + +type FormWrapperProps = ChildrenProps & + FormWrapperOnyxProps & + FormProps & { + /** Submit button styles */ + submitButtonStyles?: StyleProp; + + /** Server side errors keyed by microtime */ + errors: Errors; + + /** Assuming refs are React refs */ + inputRefs: RefObject; + + /** Callback to submit the form */ + onSubmit: () => void; + }; + +function FormWrapper({ + onSubmit, + children, + formState, + errors, + inputRefs, + submitButtonText, + footerContent, + isSubmitButtonVisible = true, + style, + submitButtonStyles, + enabledWhenOffline, + isSubmitActionDangerous = false, + formID, + scrollContextEnabled = false, + shouldHideFixErrorsAlert = false, +}: FormWrapperProps) { + const styles = useThemeStyles(); + const formRef = useRef(null); + const formContentRef = useRef(null); + const errorMessage = useMemo(() => (formState ? ErrorUtils.getLatestErrorMessage(formState) : undefined), [formState]); + + const onFixTheErrorsLinkPressed = useCallback(() => { + const errorFields = !isEmptyObject(errors) ? errors : formState?.errorFields ?? {}; + const focusKey = Object.keys(inputRefs.current ?? {}).find((key) => Object.keys(errorFields).includes(key)); + + if (!focusKey) { + return; + } + + const focusInput = inputRefs.current?.[focusKey]?.current; + + // Dismiss the keyboard for non-text fields by checking if the component has the isFocused method, as only TextInput has this method. + if (typeof focusInput?.isFocused !== 'function') { + Keyboard.dismiss(); + } + + // We subtract 10 to scroll slightly above the input + if (formContentRef.current) { + // We measure relative to the content root, not the scroll view, as that gives + // consistent results across mobile and web + focusInput?.measureLayout?.(formContentRef.current, (X: number, y: number) => + formRef.current?.scrollTo({ + y: y - 10, + animated: false, + }), + ); + } + + // Focus the input after scrolling, as on the Web it gives a slightly better visual result + focusInput?.focus?.(); + }, [errors, formState?.errorFields, inputRefs]); + + const scrollViewContent = useCallback( + (safeAreaPaddingBottomStyle: SafeAreaChildrenProps['safeAreaPaddingBottomStyle']) => ( + + {children} + {isSubmitButtonVisible && ( + + )} + + ), + [ + children, + enabledWhenOffline, + errorMessage, + errors, + footerContent, + formID, + formState?.errorFields, + formState?.isLoading, + isSubmitActionDangerous, + isSubmitButtonVisible, + onSubmit, + style, + styles.flex1, + styles.mh0, + styles.mt5, + submitButtonStyles, + submitButtonText, + shouldHideFixErrorsAlert, + onFixTheErrorsLinkPressed, + ], + ); + + return ( + + {({safeAreaPaddingBottomStyle}) => + scrollContextEnabled ? ( + + {scrollViewContent(safeAreaPaddingBottomStyle)} + + ) : ( + + {scrollViewContent(safeAreaPaddingBottomStyle)} + + ) + } + + ); +} + +FormWrapper.displayName = 'FormWrapper'; + +export default withOnyx({ + formState: { + // withOnyx typings are not able to handle such generic cases like this one, since it's a generic component we need to cast the keys to any + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any + key: (props) => props.formID as any, + }, +})(FormWrapper); diff --git a/src/components/Form/InputWrapper.js b/src/components/Form/InputWrapper.js deleted file mode 100644 index 9a31210195c4..000000000000 --- a/src/components/Form/InputWrapper.js +++ /dev/null @@ -1,45 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {forwardRef, useContext} from 'react'; -import refPropTypes from '@components/refPropTypes'; -import TextInput from '@components/TextInput'; -import FormContext from './FormContext'; - -const propTypes = { - InputComponent: PropTypes.oneOfType([PropTypes.func, PropTypes.elementType]).isRequired, - inputID: PropTypes.string.isRequired, - valueType: PropTypes.string, - forwardedRef: refPropTypes, -}; - -const defaultProps = { - forwardedRef: undefined, - valueType: 'string', -}; - -function InputWrapper(props) { - const {InputComponent, inputID, forwardedRef, ...rest} = props; - const {registerInput} = useContext(FormContext); - // There are inputs that dont have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to - // use different methods like onPress. This introduced a problem that inputs that have the onBlur method were - // calling some methods too early or twice, so we had to add this check to prevent that side effect. - // For now this side effect happened only in `TextInput` components. - const shouldSetTouchedOnBlurOnly = InputComponent === TextInput; - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -} - -InputWrapper.propTypes = propTypes; -InputWrapper.defaultProps = defaultProps; -InputWrapper.displayName = 'InputWrapper'; - -const InputWrapperWithRef = forwardRef((props, ref) => ( - -)); - -InputWrapperWithRef.displayName = 'InputWrapperWithRef'; - -export default InputWrapperWithRef; diff --git a/src/components/Form/InputWrapper.tsx b/src/components/Form/InputWrapper.tsx new file mode 100644 index 000000000000..ae78e909753b --- /dev/null +++ b/src/components/Form/InputWrapper.tsx @@ -0,0 +1,23 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useContext} from 'react'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import TextInput from '@components/TextInput'; +import FormContext from './FormContext'; +import type {InputWrapperProps, ValidInputs} from './types'; + +function InputWrapper({InputComponent, inputID, valueType = 'string', ...rest}: InputWrapperProps, ref: ForwardedRef) { + const {registerInput} = useContext(FormContext); + // There are inputs that don't have onBlur methods, to simulate the behavior of onBlur in e.g. checkbox, we had to + // use different methods like onPress. This introduced a problem that inputs that have the onBlur method were + // calling some methods too early or twice, so we had to add this check to prevent that side effect. + // For now this side effect happened only in `TextInput` components. + const shouldSetTouchedOnBlurOnly = InputComponent === TextInput; + + // TODO: Sometimes we return too many props with register input, so we need to consider if it's better to make the returned type more general and disregard the issue, or we would like to omit the unused props somehow. + // eslint-disable-next-line react/jsx-props-no-spreading, @typescript-eslint/no-explicit-any + return ; +} + +InputWrapper.displayName = 'InputWrapper'; + +export default forwardRef(InputWrapper); diff --git a/src/components/Form/errorsPropType.js b/src/components/Form/errorsPropType.js deleted file mode 100644 index 3a02bb74e942..000000000000 --- a/src/components/Form/errorsPropType.js +++ /dev/null @@ -1,11 +0,0 @@ -import PropTypes from 'prop-types'; - -export default PropTypes.oneOfType([ - PropTypes.string, - PropTypes.objectOf( - PropTypes.oneOfType([ - PropTypes.string, - PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number]))])), - ]), - ), -]); diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts new file mode 100644 index 000000000000..447f3205ad68 --- /dev/null +++ b/src/components/Form/types.ts @@ -0,0 +1,93 @@ +import type {ComponentProps, FocusEvent, Key, MutableRefObject, ReactNode, Ref} from 'react'; +import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, ViewStyle} from 'react-native'; +import type AddressSearch from '@components/AddressSearch'; +import type AmountTextInput from '@components/AmountTextInput'; +import type CheckboxWithLabel from '@components/CheckboxWithLabel'; +import type Picker from '@components/Picker'; +import type SingleChoiceQuestion from '@components/SingleChoiceQuestion'; +import type TextInput from '@components/TextInput'; +import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; +import type Form from '@src/types/onyx/Form'; +import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; + +/** + * This type specifies all the inputs that can be used with `InputWrapper` component. Make sure to update it + * when adding new inputs or removing old ones. + * + * TODO: Add remaining inputs here once these components are migrated to Typescript: + * CountrySelector | StatePicker | DatePicker | EmojiPickerButtonDropdown | RoomNameInput | ValuePicker + */ +type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch; + +type ValueTypeKey = 'string' | 'boolean' | 'date'; + +type MeasureLayoutOnSuccessCallback = (left: number, top: number, width: number, height: number) => void; + +type BaseInputProps = { + shouldSetTouchedOnBlurOnly?: boolean; + onValueChange?: (value: unknown, key: string) => void; + onTouched?: (event: GestureResponderEvent) => void; + valueType?: ValueTypeKey; + value?: FormValueType; + defaultValue?: FormValueType; + onBlur?: (event: FocusEvent | NativeSyntheticEvent) => void; + onPressOut?: (event: GestureResponderEvent) => void; + onPress?: (event: GestureResponderEvent) => void; + shouldSaveDraft?: boolean; + shouldUseDefaultValue?: boolean; + key?: Key | null | undefined; + ref?: Ref; + isFocused?: boolean; + measureLayout?: (ref: unknown, callback: MeasureLayoutOnSuccessCallback) => void; + focus?: () => void; +}; + +type InputWrapperProps = Omit & + ComponentProps & { + InputComponent: TInput; + inputID: string; + }; + +type ExcludeDraft = T extends `${string}Draft` ? never : T; +type OnyxFormKeyWithoutDraft = ExcludeDraft; + +type OnyxFormValues = OnyxValues[TOnyxKey]; +type OnyxFormValuesFields = Omit, keyof BaseForm>; + +type FormProps = { + /** A unique Onyx key identifying the form */ + formID: TFormID; + + /** Text to be displayed in the submit button */ + submitButtonText: string; + + /** Controls the submit button's visibility */ + isSubmitButtonVisible?: boolean; + + /** Callback to submit the form */ + onSubmit: (values: OnyxFormValuesFields) => void; + + /** Should the button be enabled when offline */ + enabledWhenOffline?: boolean; + + /** Whether the form submit action is dangerous */ + isSubmitActionDangerous?: boolean; + + /** Should fix the errors alert be displayed when there is an error in the form */ + shouldHideFixErrorsAlert?: boolean; + + /** Whether ScrollWithContext should be used instead of regular ScrollView. Set to true when there's a nested Picker component in Form. */ + scrollContextEnabled?: boolean; + + /** Container styles */ + style?: StyleProp; + + /** Custom content to display in the footer after submit button */ + footerContent?: ReactNode; +}; + +type RegisterInput = (inputID: keyof Form, inputProps: TInputProps) => TInputProps; + +type InputRefs = Record>; + +export type {InputWrapperProps, FormProps, RegisterInput, ValidInputs, BaseInputProps, ValueTypeKey, OnyxFormValues, OnyxFormValuesFields, InputRefs, OnyxFormKeyWithoutDraft}; diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx index 512d2063dc0f..ae96aa6c5359 100644 --- a/src/components/FormAlertWithSubmitButton.tsx +++ b/src/components/FormAlertWithSubmitButton.tsx @@ -65,7 +65,7 @@ function FormAlertWithSubmitButton({ enabledWhenOffline = false, disablePressOnEnter = false, isSubmitActionDangerous = false, - footerContent = null, + footerContent, buttonStyles, buttonText, isAlertVisible, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js index 11ffabe4fe6a..3646d9148b3a 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js @@ -1,3 +1,4 @@ +import {cloneDeep} from 'lodash'; import lodashGet from 'lodash/get'; import React from 'react'; import {TNodeChildrenRenderer} from 'react-native-render-html'; @@ -16,6 +17,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import personalDetailsPropType from '@pages/personalDetailsPropType'; import CONST from '@src/CONST'; +import * as LoginUtils from '@src/libs/LoginUtils'; import ROUTES from '@src/ROUTES'; import htmlRendererPropTypes from './htmlRendererPropTypes'; @@ -31,21 +33,41 @@ function MentionUserRenderer(props) { const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); - const htmlAttribAccountID = lodashGet(props.tnode.attributes, 'accountid'); + const htmlAttributeAccountID = lodashGet(props.tnode.attributes, 'accountid'); const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; let accountID; let displayNameOrLogin; let navigationRoute; + const tnode = cloneDeep(props.tnode); - if (!_.isEmpty(htmlAttribAccountID)) { - const user = lodashGet(personalDetails, htmlAttribAccountID); - accountID = parseInt(htmlAttribAccountID, 10); + const getMentionDisplayText = (displayText, userAccountID, userLogin = '') => { + // If the userAccountID does not exist, this is an email-based mention so the displayText must be an email. + // If the userAccountID exists but userLogin is different from displayText, this means the displayText is either user display name, Hidden, or phone number, in which case we should return it as is. + if (userAccountID && userLogin !== displayText) { + return displayText; + } + + // If the emails are not in the same private domain, we also return the displayText + if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, props.currentUserPersonalDetails.login)) { + return displayText; + } + + // Otherwise, the emails must be of the same private domain, so we should remove the domain part + return displayText.split('@')[0]; + }; + + if (!_.isEmpty(htmlAttributeAccountID)) { + const user = lodashGet(personalDetails, htmlAttributeAccountID); + accountID = parseInt(htmlAttributeAccountID, 10); displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(lodashGet(user, 'login', '')) || lodashGet(user, 'displayName', '') || translate('common.hidden'); - navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID); - } else if (!_.isEmpty(props.tnode.data)) { + displayNameOrLogin = getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID, lodashGet(user, 'login', '')); + navigationRoute = ROUTES.PROFILE.getRoute(htmlAttributeAccountID); + } else if (!_.isEmpty(tnode.data)) { // We need to remove the LTR unicode and leading @ from data as it is not part of the login - displayNameOrLogin = props.tnode.data.replace(CONST.UNICODE.LTR, '').slice(1); + displayNameOrLogin = tnode.data.replace(CONST.UNICODE.LTR, '').slice(1); + // We need to replace tnode.data here because we will pass it to TNodeChildrenRenderer below + tnode.data = tnode.data.replace(displayNameOrLogin, getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID)); accountID = _.first(PersonalDetailsUtils.getAccountIDsByLogins([displayNameOrLogin])); navigationRoute = ROUTES.DETAILS.getRoute(displayNameOrLogin); @@ -83,7 +105,7 @@ function MentionUserRenderer(props) { // eslint-disable-next-line react/jsx-props-no-spreading {...defaultRendererProps} > - {!_.isEmpty(htmlAttribAccountID) ? `@${displayNameOrLogin}` : } + {!_.isEmpty(htmlAttributeAccountID) ? `@${displayNameOrLogin}` : } diff --git a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js deleted file mode 100644 index 2f7ac48b558b..000000000000 --- a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js +++ /dev/null @@ -1,104 +0,0 @@ -import PropTypes from 'prop-types'; -import participantPropTypes from '@components/participantPropTypes'; -import {ThreeDotsMenuItemPropTypes} from '@components/ThreeDotsMenu'; -import iouReportPropTypes from '@pages/iouReportPropTypes'; - -const propTypes = { - /** Title of the Header */ - title: PropTypes.string, - - /** Subtitle of the header */ - subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), - - /** Method to trigger when pressing download button of the header */ - onDownloadButtonPress: PropTypes.func, - - /** Method to trigger when pressing close button of the header */ - onCloseButtonPress: PropTypes.func, - - /** Method to trigger when pressing back button of the header */ - onBackButtonPress: PropTypes.func, - - /** Method to trigger when pressing more options button of the header */ - onThreeDotsButtonPress: PropTypes.func, - - /** Whether we should show a border on the bottom of the Header */ - shouldShowBorderBottom: PropTypes.bool, - - /** Whether we should show a download button */ - shouldShowDownloadButton: PropTypes.bool, - - /** Whether we should show a get assistance (question mark) button */ - shouldShowGetAssistanceButton: PropTypes.bool, - - /** Whether we should disable the get assistance button */ - shouldDisableGetAssistanceButton: PropTypes.bool, - - /** Whether we should show a pin button */ - shouldShowPinButton: PropTypes.bool, - - /** Whether we should show a more options (threedots) button */ - shouldShowThreeDotsButton: PropTypes.bool, - - /** Whether we should disable threedots button */ - shouldDisableThreeDotsButton: PropTypes.bool, - - /** List of menu items for more(three dots) menu */ - threeDotsMenuItems: ThreeDotsMenuItemPropTypes, - - /** The anchor position of the menu */ - threeDotsAnchorPosition: PropTypes.shape({ - top: PropTypes.number, - right: PropTypes.number, - bottom: PropTypes.number, - left: PropTypes.number, - }), - - /** Whether we should show a close button */ - shouldShowCloseButton: PropTypes.bool, - - /** Whether we should show a back button */ - shouldShowBackButton: PropTypes.bool, - - /** The guides call taskID to associate with the get assistance button, if we show it */ - guidesCallTaskID: PropTypes.string, - - /** Data to display a step counter in the header */ - stepCounter: PropTypes.shape({ - step: PropTypes.number, - total: PropTypes.number, - text: PropTypes.string, - }), - - /** Whether we should show an avatar */ - shouldShowAvatarWithDisplay: PropTypes.bool, - - /** Parent report, if provided it will override props.report for AvatarWithDisplay */ - parentReport: iouReportPropTypes, - - /** Report, if we're showing the details for one and using AvatarWithDisplay */ - report: iouReportPropTypes, - - /** The report's policy, if we're showing the details for a report and need info about it for AvatarWithDisplay */ - policy: PropTypes.shape({ - /** Name of the policy */ - name: PropTypes.string, - }), - - /** Policies, if we're showing the details for a report and need participant details for AvatarWithDisplay */ - personalDetails: PropTypes.objectOf(participantPropTypes), - - /** Children to wrap in Header */ - children: PropTypes.node, - - /** Single execution function to prevent concurrent navigation actions */ - singleExecution: PropTypes.func, - - /** Whether we should navigate to report page when the route have a topMostReport */ - shouldNavigateToTopMostReport: PropTypes.bool, - - /** Whether we should overlay the 3 dots menu */ - shouldOverlayDots: PropTypes.bool, -}; - -export default propTypes; diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 832351b2b70e..725d14e041a7 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -9,7 +9,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; type ThreeDotsMenuItem = { /** An icon element displayed on the left side */ - icon?: IconAsset; + icon: IconAsset; /** Text label */ text: string; diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js deleted file mode 100644 index 98349b213aa5..000000000000 --- a/src/components/ImageView/index.native.js +++ /dev/null @@ -1,52 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Lightbox from '@components/Lightbox'; -import {zoomRangeDefaultProps, zoomRangePropTypes} from '@components/MultiGestureCanvas/propTypes'; -import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; - -/** - * On the native layer, we use a image library to handle zoom functionality - */ -const propTypes = { - ...imageViewPropTypes, - ...zoomRangePropTypes, - - /** Function for handle on press */ - onPress: PropTypes.func, - - /** Additional styles to add to the component */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), -}; - -const defaultProps = { - ...imageViewDefaultProps, - ...zoomRangeDefaultProps, - - onPress: () => {}, - style: {}, -}; - -function ImageView({isAuthTokenRequired, url, onScaleChanged, onPress, style, zoomRange, onError, isUsedInCarousel, isSingleCarouselItem, carouselItemIndex, carouselActiveItemIndex}) { - const hasSiblingCarouselItems = isUsedInCarousel && !isSingleCarouselItem; - - return ( - - ); -} - -ImageView.propTypes = propTypes; -ImageView.defaultProps = defaultProps; -ImageView.displayName = 'ImageView'; - -export default ImageView; diff --git a/src/components/ImageView/index.native.tsx b/src/components/ImageView/index.native.tsx new file mode 100644 index 000000000000..e36bb39d2bed --- /dev/null +++ b/src/components/ImageView/index.native.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import Lightbox from '@components/Lightbox'; +import {zoomRangeDefaultProps} from '@components/MultiGestureCanvas/propTypes'; +import type {ImageViewProps} from './types'; + +function ImageView({ + isAuthTokenRequired = false, + url, + onScaleChanged, + onPress, + style, + zoomRange = zoomRangeDefaultProps.zoomRange, + onError, + isUsedInCarousel = false, + isSingleCarouselItem = false, + carouselItemIndex = 0, + carouselActiveItemIndex = 0, +}: ImageViewProps) { + const hasSiblingCarouselItems = isUsedInCarousel && !isSingleCarouselItem; + + return ( + + ); +} + +ImageView.displayName = 'ImageView'; + +export default ImageView; diff --git a/src/components/ImageView/index.js b/src/components/ImageView/index.tsx similarity index 79% rename from src/components/ImageView/index.js rename to src/components/ImageView/index.tsx index f16b37f328f5..ec37abf6d275 100644 --- a/src/components/ImageView/index.js +++ b/src/components/ImageView/index.tsx @@ -1,15 +1,21 @@ +import type {SyntheticEvent} from 'react'; import React, {useCallback, useEffect, useRef, useState} from 'react'; +import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent} from 'react-native'; import {View} from 'react-native'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Image from '@components/Image'; +import RESIZE_MODES from '@components/Image/resizeModes'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; -import {imageViewDefaultProps, imageViewPropTypes} from './propTypes'; +import viewRef from '@src/types/utils/viewRef'; +import type {ImageLoadNativeEventData, ImageViewProps} from './types'; -function ImageView({isAuthTokenRequired, url, fileName, onError}) { +type ZoomDelta = {offsetX: number; offsetY: number}; + +function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageViewProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [isLoading, setIsLoading] = useState(true); @@ -25,18 +31,12 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { const [imgWidth, setImgWidth] = useState(0); const [imgHeight, setImgHeight] = useState(0); const [zoomScale, setZoomScale] = useState(0); - const [zoomDelta, setZoomDelta] = useState({offsetX: 0, offsetY: 0}); + const [zoomDelta, setZoomDelta] = useState(); - const scrollableRef = useRef(null); + const scrollableRef = useRef(null); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); - /** - * @param {Number} newContainerWidth - * @param {Number} newContainerHeight - * @param {Number} newImageWidth - * @param {Number} newImageHeight - */ - const setScale = (newContainerWidth, newContainerHeight, newImageWidth, newImageHeight) => { + const setScale = (newContainerWidth: number, newContainerHeight: number, newImageWidth: number, newImageHeight: number) => { if (!newContainerWidth || !newImageWidth || !newContainerHeight || !newImageHeight) { return; } @@ -44,10 +44,7 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { setZoomScale(newZoomScale); }; - /** - * @param {SyntheticEvent} e - */ - const onContainerLayoutChanged = (e) => { + const onContainerLayoutChanged = (e: LayoutChangeEvent) => { const {width, height} = e.nativeEvent.layout; setScale(width, height, imgWidth, imgHeight); @@ -57,10 +54,8 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { /** * When open image, set image width, height. - * @param {Number} imageWidth - * @param {Number} imageHeight */ - const setImageRegion = (imageWidth, imageHeight) => { + const setImageRegion = (imageWidth: number, imageHeight: number) => { if (imageHeight <= 0) { return; } @@ -78,32 +73,29 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { setIsZoomed(false); }; - const imageLoad = ({nativeEvent}) => { + const imageLoad = ({nativeEvent}: NativeSyntheticEvent) => { setImageRegion(nativeEvent.width, nativeEvent.height); setIsLoading(false); }; - /** - * @param {SyntheticEvent} e - */ - const onContainerPressIn = (e) => { + const onContainerPressIn = (e: GestureResponderEvent) => { const {pageX, pageY} = e.nativeEvent; setIsMouseDown(true); setInitialX(pageX); setInitialY(pageY); - setInitialScrollLeft(scrollableRef.current.scrollLeft); - setInitialScrollTop(scrollableRef.current.scrollTop); + setInitialScrollLeft(scrollableRef.current?.scrollLeft ?? 0); + setInitialScrollTop(scrollableRef.current?.scrollTop ?? 0); }; /** * Convert touch point to zoomed point - * @param {Boolean} x x point when click zoom - * @param {Boolean} y y point when click zoom - * @returns {Object} converted touch point + * @param x point when click zoom + * @param y point when click zoom + * @returns converted touch point */ - const getScrollOffset = (x, y) => { - let offsetX; - let offsetY; + const getScrollOffset = (x: number, y: number) => { + let offsetX = 0; + let offsetY = 0; // Container size bigger than clicked position offset if (x <= containerWidth / 2) { @@ -121,12 +113,9 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { return {offsetX, offsetY}; }; - /** - * @param {SyntheticEvent} e - */ - const onContainerPress = (e) => { + const onContainerPress = (e?: GestureResponderEvent | KeyboardEvent | SyntheticEvent) => { if (!isZoomed && !isDragging) { - if (e.nativeEvent) { + if (e && 'nativeEvent' in e && e.nativeEvent instanceof PointerEvent) { const {offsetX, offsetY} = e.nativeEvent; // Dividing clicked positions by the zoom scale to get coordinates @@ -148,13 +137,10 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { } }; - /** - * @param {SyntheticEvent} e - */ const trackPointerPosition = useCallback( - (e) => { + (event: MouseEvent) => { // Whether the pointer is released inside the ImageView - const isInsideImageView = scrollableRef.current.contains(e.nativeEvent.target); + const isInsideImageView = scrollableRef.current?.contains(event.target as Node); if (!isInsideImageView && isZoomed && isDragging && isMouseDown) { setIsDragging(false); @@ -165,14 +151,14 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { ); const trackMovement = useCallback( - (e) => { + (event: MouseEvent) => { if (!isZoomed) { return; } - if (isDragging && isMouseDown) { - const x = e.nativeEvent.x; - const y = e.nativeEvent.y; + if (isDragging && isMouseDown && scrollableRef.current) { + const x = event.x; + const y = event.y; const moveX = initialX - x; const moveY = initialY - y; scrollableRef.current.scrollLeft = initialScrollLeft + moveX; @@ -218,7 +204,7 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { style={isLoading || zoomScale === 0 ? undefined : [styles.w100, styles.h100]} // When Image dimensions are lower than the container boundary(zoomscale <= 1), use `contain` to render the image with natural dimensions. // Both `center` and `contain` keeps the image centered on both x and y axis. - resizeMode={zoomScale > 1 ? Image.resizeMode.center : Image.resizeMode.contain} + resizeMode={zoomScale > 1 ? RESIZE_MODES.center : RESIZE_MODES.contain} onLoadStart={imageLoadingStart} onLoad={imageLoad} onError={onError} @@ -229,7 +215,7 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { } return ( @@ -249,7 +235,7 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { source={{uri: url}} isAuthTokenRequired={isAuthTokenRequired} style={[styles.h100, styles.w100]} - resizeMode={Image.resizeMode.contain} + resizeMode={RESIZE_MODES.contain} onLoadStart={imageLoadingStart} onLoad={imageLoad} onError={onError} @@ -261,8 +247,6 @@ function ImageView({isAuthTokenRequired, url, fileName, onError}) { ); } -ImageView.propTypes = imageViewPropTypes; -ImageView.defaultProps = imageViewDefaultProps; ImageView.displayName = 'ImageView'; export default ImageView; diff --git a/src/components/ImageView/propTypes.js b/src/components/ImageView/propTypes.js deleted file mode 100644 index 3809d9aed043..000000000000 --- a/src/components/ImageView/propTypes.js +++ /dev/null @@ -1,46 +0,0 @@ -import PropTypes from 'prop-types'; - -const imageViewPropTypes = { - /** Whether source url requires authentication */ - isAuthTokenRequired: PropTypes.bool, - - /** Handles scale changed event in image zoom component. Used on native only */ - // eslint-disable-next-line react/no-unused-prop-types - onScaleChanged: PropTypes.func.isRequired, - - /** URL to full-sized image */ - url: PropTypes.string.isRequired, - - /** image file name */ - fileName: PropTypes.string.isRequired, - - /** Handles errors while displaying the image */ - onError: PropTypes.func, - - /** Whether this view is the active screen */ - isFocused: PropTypes.bool, - - /** Whether this AttachmentView is shown as part of a AttachmentCarousel */ - isUsedInCarousel: PropTypes.bool, - - /** When "isUsedInCarousel" is set to true, determines whether there is only one item in the carousel */ - isSingleCarouselItem: PropTypes.bool, - - /** The index of the carousel item */ - carouselItemIndex: PropTypes.number, - - /** The index of the currently active carousel item */ - carouselActiveItemIndex: PropTypes.number, -}; - -const imageViewDefaultProps = { - isAuthTokenRequired: false, - onError: () => {}, - isFocused: true, - isUsedInCarousel: false, - isSingleCarouselItem: false, - carouselItemIndex: 0, - carouselActiveItemIndex: 0, -}; - -export {imageViewPropTypes, imageViewDefaultProps}; diff --git a/src/components/ImageView/types.ts b/src/components/ImageView/types.ts new file mode 100644 index 000000000000..bf83bc44d47b --- /dev/null +++ b/src/components/ImageView/types.ts @@ -0,0 +1,47 @@ +import type {StyleProp, ViewStyle} from 'react-native'; +import type ZoomRange from '@components/MultiGestureCanvas/types'; + +type ImageViewProps = { + /** Whether source url requires authentication */ + isAuthTokenRequired?: boolean; + + /** Handles scale changed event in image zoom component. Used on native only */ + onScaleChanged: (scale: number) => void; + + /** URL to full-sized image */ + url: string; + + /** image file name */ + fileName: string; + + /** Handles errors while displaying the image */ + onError?: () => void; + + /** Whether this AttachmentView is shown as part of a AttachmentCarousel */ + isUsedInCarousel?: boolean; + + /** When "isUsedInCarousel" is set to true, determines whether there is only one item in the carousel */ + isSingleCarouselItem?: boolean; + + /** The index of the carousel item */ + carouselItemIndex?: number; + + /** The index of the currently active carousel item */ + carouselActiveItemIndex?: number; + + /** Function for handle on press */ + onPress?: () => void; + + /** Additional styles to add to the component */ + style?: StyleProp; + + /** Range of zoom that can be applied to the content by pinching or double tapping. */ + zoomRange?: ZoomRange; +}; + +type ImageLoadNativeEventData = { + width: number; + height: number; +}; + +export type {ImageViewProps, ImageLoadNativeEventData}; diff --git a/src/components/InvertedFlatList/CellRendererComponent.tsx b/src/components/InvertedFlatList/CellRendererComponent.tsx index b95fbf42cbb4..1199fb2a594c 100644 --- a/src/components/InvertedFlatList/CellRendererComponent.tsx +++ b/src/components/InvertedFlatList/CellRendererComponent.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import type {StyleProp, ViewProps} from 'react-native'; +import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; import {View} from 'react-native'; type CellRendererComponentProps = ViewProps & { index: number; - style?: StyleProp; + style?: StyleProp; }; function CellRendererComponent(props: CellRendererComponentProps) { diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index ecf320807b48..15c12afb2609 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -32,9 +32,23 @@ function LHNOptionsList({ currentReportID = '', draftComments = {}, transactionViolations = {}, + onFirstItemRendered = () => {}, }: LHNOptionsListProps) { const styles = useThemeStyles(); const {canUseViolations} = usePermissions(); + + // When the first item renders we want to call the onFirstItemRendered callback. + // At this point in time we know that the list is actually displaying items. + const hasCalledOnLayout = React.useRef(false); + const onLayoutItem = useCallback(() => { + if (hasCalledOnLayout.current) { + return; + } + hasCalledOnLayout.current = true; + + onFirstItemRendered(); + }, [onFirstItemRendered]); + /** * Function which renders a row in the list */ @@ -48,7 +62,7 @@ function LHNOptionsList({ const transactionID = itemParentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? itemParentReportAction.originalMessage.IOUTransactionID ?? '' : ''; const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? null; const itemComment = draftComments?.[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] ?? ''; - const participants = [...ReportUtils.getParticipantsIDs(itemFullReport), itemFullReport?.ownerAccountID, itemParentReportAction?.actorAccountID]; + const participants = [...ReportUtils.getParticipantsIDs(itemFullReport), itemFullReport?.ownerAccountID, itemParentReportAction?.actorAccountID].filter(Boolean) as number[]; const participantsPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(participants, personalDetails); return ( @@ -58,7 +72,6 @@ function LHNOptionsList({ reportActions={itemReportActions} parentReportAction={itemParentReportAction} policy={itemPolicy} - // @ts-expect-error TODO: Remove this once OptionsListUtils (https://github.com/Expensify/App/issues/24921) is migrated to TypeScript. personalDetails={participantsPersonalDetails} transaction={itemTransaction} receiptTransactions={transactions} @@ -69,6 +82,7 @@ function LHNOptionsList({ comment={itemComment} transactionViolations={transactionViolations} canUseViolations={canUseViolations} + onLayout={onLayoutItem} /> ); }, @@ -86,6 +100,7 @@ function LHNOptionsList({ transactions, transactionViolations, canUseViolations, + onLayoutItem, ], ); diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 1932cf6c6b7f..ae225b3db9e9 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -28,7 +28,7 @@ import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OptionRowLHNProps} from './types'; -function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, optionItem, viewMode = 'default', style}: OptionRowLHNProps) { +function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, optionItem, viewMode = 'default', style, onLayout = () => {}}: OptionRowLHNProps) { const theme = useTheme(); const styles = useThemeStyles(); const popoverAnchor = useRef(null); @@ -57,18 +57,17 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti return null; } + const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const textStyle = isFocused ? styles.sidebarLinkActiveText : styles.sidebarLinkText; - const textUnreadStyle = optionItem?.isUnread ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; + const textUnreadStyle = optionItem?.isUnread && optionItem.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE ? [textStyle, styles.sidebarLinkTextBold] : [textStyle]; const displayNameStyle = [styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, textUnreadStyle, style]; - const alternateTextStyle = - viewMode === CONST.OPTION_MODE.COMPACT - ? [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2, style] - : [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, style]; + const alternateTextStyle = isInFocusMode + ? [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2, style] + : [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, style]; - const contentContainerStyles = - viewMode === CONST.OPTION_MODE.COMPACT ? [styles.flex1, styles.flexRow, styles.overflowHidden, StyleUtils.getCompactContentContainerStyles()] : [styles.flex1]; + const contentContainerStyles = isInFocusMode ? [styles.flex1, styles.flexRow, styles.overflowHidden, StyleUtils.getCompactContentContainerStyles()] : [styles.flex1]; const sidebarInnerRowStyle = StyleSheet.flatten( - viewMode === CONST.OPTION_MODE.COMPACT + isInFocusMode ? [styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRowCompact, styles.justifyContentCenter] : [styles.chatLinkRowPressable, styles.flexGrow1, styles.optionItemAvatarNameWrapper, styles.optionRow, styles.justifyContentCenter], ); @@ -113,7 +112,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti const report = ReportUtils.getReport(optionItem.reportID ?? ''); const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(!isEmptyObject(report) ? report : null); - const isGroupChat = optionItem.type === CONST.REPORT.TYPE.CHAT && optionItem.chatType && !optionItem.isThread && (optionItem.displayNamesWithTooltips?.length ?? 0) > 2; + const isGroupChat = optionItem.type === CONST.REPORT.TYPE.CHAT && !optionItem.chatType && !optionItem.isThread && (optionItem.displayNamesWithTooltips?.length ?? 0) > 2; const fullTitle = isGroupChat ? getGroupChatName(!isEmptyObject(report) ? report : null) : optionItem.text; const subscriptAvatarBorderColor = isFocused ? focusedBackgroundColor : theme.sidebar; @@ -165,6 +164,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti ]} role={CONST.ROLE.BUTTON} accessibilityLabel={translate('accessibilityHints.navigatesToChat')} + onLayout={onLayout} needsOffscreenAlphaCompositing={(optionItem?.icons?.length ?? 0) >= 2} > @@ -175,13 +175,13 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti backgroundColor={hovered && !isFocused ? hoveredBackgroundColor : subscriptAvatarBorderColor} mainAvatar={optionItem.icons?.[0]} secondaryAvatar={optionItem.icons?.[1]} - size={viewMode === CONST.OPTION_MODE.COMPACT ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT} + size={isInFocusMode ? CONST.AVATAR_SIZE.SMALL : CONST.AVATAR_SIZE.DEFAULT} /> ) : ( void; + onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; /** Toggle between compact and default view of the option */ optionMode: OptionMode; /** Whether to allow option focus or not */ shouldDisableFocusOptions?: boolean; + + /** Callback to fire when the list is laid out */ + onFirstItemRendered: () => void; }; type LHNOptionsListProps = CustomLHNOptionsListProps & CurrentReportIDContextValue & LHNOptionsListOnyxProps; @@ -97,6 +100,15 @@ type OptionRowLHNDataProps = { /** Whether the user can use violations */ canUseViolations: boolean | undefined; + + /** Toggle between compact and default view */ + viewMode?: OptionMode; + + /** A function that is called when an option is selected. Selected option is passed as a param */ + onSelectRow?: (optionItem: OptionData, popoverAnchor: RefObject) => void; + + /** Callback to execute when the OptionList lays out */ + onLayout?: (event: LayoutChangeEvent) => void; }; type OptionRowLHNProps = { @@ -117,6 +129,8 @@ type OptionRowLHNProps = { /** The item that should be rendered */ optionItem?: OptionData; + + onLayout?: (event: LayoutChangeEvent) => void; }; type RenderItemProps = {item: string}; diff --git a/src/components/Lightbox.js b/src/components/Lightbox.js index 45326edb4610..8b7d68befafd 100644 --- a/src/components/Lightbox.js +++ b/src/components/Lightbox.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, PixelRatio, StyleSheet, View} from 'react-native'; import useStyleUtils from '@hooks/useStyleUtils'; +import stylePropTypes from '@styles/stylePropTypes'; import * as AttachmentsPropTypes from './Attachments/propTypes'; import Image from './Image'; import MultiGestureCanvas from './MultiGestureCanvas'; @@ -44,7 +45,7 @@ const propTypes = { activeIndex: PropTypes.number, /** Additional styles to add to the component */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + style: stylePropTypes, }; const defaultProps = { diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx index a250e21c0021..590d7c45df15 100644 --- a/src/components/Modal/BaseModal.tsx +++ b/src/components/Modal/BaseModal.tsx @@ -177,7 +177,7 @@ function BaseModal( onBackdropPress={handleBackdropPress} // Note: Escape key on web/desktop will trigger onBackButtonPress callback // eslint-disable-next-line react/jsx-props-no-multi-spaces - onBackButtonPress={onClose} + onBackButtonPress={Modal.closeTop} onModalShow={handleShowModal} propagateSwipe={propagateSwipe} onModalHide={hideModal} diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 590154b48bca..d967d04ab94b 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -758,7 +758,7 @@ function MoneyRequestConfirmationList(props) { {shouldShowTags && ( { diff --git a/src/components/MultiGestureCanvas/types.ts b/src/components/MultiGestureCanvas/types.ts new file mode 100644 index 000000000000..0242f045feef --- /dev/null +++ b/src/components/MultiGestureCanvas/types.ts @@ -0,0 +1,6 @@ +type ZoomRange = { + min: number; + max: number; +}; + +export default ZoomRange; diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index dd8cd115e13f..97e85cacf42d 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -195,7 +195,7 @@ function OptionRow({ shouldHaveOptionSeparator && styles.borderTop, !onSelectRow && !isOptionDisabled ? styles.cursorDefault : null, ]} - accessibilityLabel={option.text} + accessibilityLabel={option.text ?? ''} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} hoverStyle={!optionIsFocused ? hoverStyle ?? styles.sidebarLinkHover : undefined} diff --git a/src/components/OptionsList/BaseOptionsList.tsx b/src/components/OptionsList/BaseOptionsList.tsx index c1e4562a0c2d..8cac059436b5 100644 --- a/src/components/OptionsList/BaseOptionsList.tsx +++ b/src/components/OptionsList/BaseOptionsList.tsx @@ -9,6 +9,8 @@ import SectionList from '@components/SectionList'; import Text from '@components/Text'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; +import Timing from '@libs/actions/Timing'; +import Performance from '@libs/Performance'; import type {OptionData} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; import variables from '@styles/variables'; @@ -108,6 +110,16 @@ function BaseOptionsList( flattenedData.current = buildFlatSectionArray(); }); + useEffect(() => { + if (isLoading) { + return; + } + + // Mark the end of the search page load time. This data is collected only for Search page. + Timing.end(CONST.TIMING.OPEN_SEARCH); + Performance.markEnd(CONST.TIMING.OPEN_SEARCH); + }, [isLoading]); + const onViewableItemsChanged = () => { if (didLayout.current || !onLayout) { return; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 412aeedcf965..bbcce6fff9a6 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -91,7 +91,7 @@ class BaseOptionsSelector extends Component { allOptions, focusedIndex, shouldDisableRowSelection: false, - shouldShowReferralModal: false, + shouldShowReferralModal: this.props.shouldShowReferralCTA, errorMessage: '', paginationPage: 1, disableEnterShortCut: false, @@ -660,9 +660,12 @@ class BaseOptionsSelector extends Component { )} - {this.props.shouldShowReferralCTA && ( + {this.props.shouldShowReferralCTA && this.state.shouldShowReferralModal && ( - + )} diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 17b1a119671a..ff7d0fdfb8e5 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -177,3 +177,4 @@ function PopoverMenu({ PopoverMenu.displayName = 'PopoverMenu'; export default React.memo(PopoverMenu); +export type {PopoverMenuItem}; diff --git a/src/components/Pressable/GenericPressable/types.ts b/src/components/Pressable/GenericPressable/types.ts index dc04b6fcf329..2dd2e17e0454 100644 --- a/src/components/Pressable/GenericPressable/types.ts +++ b/src/components/Pressable/GenericPressable/types.ts @@ -40,7 +40,7 @@ type PressableProps = RNPressableProps & /** * onPress callback */ - onPress: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise; + onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void | Promise; /** * Specifies keyboard shortcut to trigger onPressHandler diff --git a/src/components/Pressable/PressableWithDelayToggle.tsx b/src/components/Pressable/PressableWithDelayToggle.tsx index ab1fa95efeb5..86f6c9d8aff8 100644 --- a/src/components/Pressable/PressableWithDelayToggle.tsx +++ b/src/components/Pressable/PressableWithDelayToggle.tsx @@ -78,7 +78,7 @@ function PressableWithDelayToggle( return; } temporarilyDisableInteractions(); - onPress(); + onPress?.(); }; // Due to limitations in RN regarding the vertical text alignment of non-Text elements, diff --git a/src/components/Pressable/PressableWithoutFocus.tsx b/src/components/Pressable/PressableWithoutFocus.tsx index f887b0ea9b7d..240ef4a9873a 100644 --- a/src/components/Pressable/PressableWithoutFocus.tsx +++ b/src/components/Pressable/PressableWithoutFocus.tsx @@ -15,7 +15,7 @@ function PressableWithoutFocus({children, onPress, onLongPress, ...rest}: Pressa const pressAndBlur = () => { ref?.current?.blur(); - onPress(); + onPress?.(); }; return ( diff --git a/src/components/RNTextInput.tsx b/src/components/RNTextInput.tsx index f7917a852704..e21219e99730 100644 --- a/src/components/RNTextInput.tsx +++ b/src/components/RNTextInput.tsx @@ -1,17 +1,17 @@ -import type {Component, ForwardedRef} from 'react'; +import type {ForwardedRef} from 'react'; import React from 'react'; // eslint-disable-next-line no-restricted-imports import type {TextInputProps} from 'react-native'; import {TextInput} from 'react-native'; -import type {AnimatedProps} from 'react-native-reanimated'; import Animated from 'react-native-reanimated'; import useTheme from '@hooks/useTheme'; -type AnimatedTextInputRef = Component>; // Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); -function RNTextInputWithRef(props: TextInputProps, ref: ForwardedRef>>) { +type AnimatedTextInputRef = typeof AnimatedTextInput & TextInput & HTMLInputElement; + +function RNTextInputWithRef(props: TextInputProps, ref: ForwardedRef) { const theme = useTheme(); return ( @@ -23,7 +23,7 @@ function RNTextInputWithRef(props: TextInputProps, ref: ForwardedRef void; }; -function ReferralProgramCTA({referralContentType}: ReferralProgramCTAProps) { +function ReferralProgramCTA({referralContentType, onCloseButtonPress = () => {}}: ReferralProgramCTAProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); @@ -28,7 +31,7 @@ function ReferralProgramCTA({referralContentType}: ReferralProgramCTAProps) { onPress={() => { Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(referralContentType)); }} - style={[styles.p5, styles.w100, styles.br2, styles.highlightBG, styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, {gap: 10}]} + style={[styles.w100, styles.br2, styles.highlightBG, styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, {gap: 10, padding: 10}, styles.pl5]} accessibilityLabel="referral" role={CONST.ACCESSIBILITY_ROLE.BUTTON} > @@ -41,12 +44,22 @@ function ReferralProgramCTA({referralContentType}: ReferralProgramCTAProps) { {translate(`referralProgram.${referralContentType}.buttonText2`)} - + { + e.preventDefault(); + }} + style={[styles.touchableButtonImage]} + role={CONST.ACCESSIBILITY_ROLE.BUTTON} + accessibilityLabel={translate('common.close')} + > + + ); } diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 3d1710de1432..ed7c05b828a9 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -1,6 +1,8 @@ import React, {useMemo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -18,10 +20,11 @@ import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import variables from '@styles/variables'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PolicyReportField, Report} from '@src/types/onyx'; +import type {Policy, PolicyReportField, Report} from '@src/types/onyx'; -type MoneyReportViewProps = { +type MoneyReportViewComponentProps = { /** The report currently being looked at */ report: Report; @@ -32,7 +35,14 @@ type MoneyReportViewProps = { shouldShowHorizontalRule: boolean; }; -function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule}: MoneyReportViewProps) { +type MoneyReportViewOnyxProps = { + /** Policies that the user is part of */ + policies: OnyxCollection; +}; + +type MoneyReportViewProps = MoneyReportViewComponentProps & MoneyReportViewOnyxProps; + +function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule, policies}: MoneyReportViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -59,7 +69,7 @@ function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule}: () => policyReportFields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight), [policyReportFields], ); - + const isAdmin = ReportUtils.isPolicyAdmin(report.policyID ?? '', policies); return ( @@ -67,6 +77,7 @@ function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule}: {canUseReportFields && sortedPolicyReportFields.map((reportField) => { const title = ReportUtils.getReportFieldTitle(report, reportField); + const isDisabled = !isAdmin || isSettled || ReportUtils.isReportFieldOfTypeTitle(reportField); return ( Navigation.navigate(ROUTES.EDIT_REPORT_FIELD_REQUEST.getRoute(report.reportID, report.policyID ?? '', reportField.fieldID))} shouldShowRightIcon - disabled={ReportUtils.isReportFieldOfTypeTitle(reportField)} + disabled={isDisabled} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} shouldGreyOutWhenDisabled={false} numberOfLinesTitle={0} @@ -167,4 +178,8 @@ function MoneyReportView({report, policyReportFields, shouldShowHorizontalRule}: MoneyReportView.displayName = 'MoneyReportView'; -export default MoneyReportView; +export default withOnyx({ + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, +})(MoneyReportView); diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index b337c3581213..7c7364cc58f0 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -36,11 +36,7 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP const isDefault = !(isChatRoom || isPolicyExpenseChat); const participantAccountIDs = report?.participantAccountIDs ?? []; const isMultipleParticipant = participantAccountIDs.length > 1; - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( - // @ts-expect-error TODO: Remove this once OptionsListUtils (https://github.com/Expensify/App/issues/24921) is migrated to TypeScript. - OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), - isMultipleParticipant, - ); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(report, isUserPolicyAdmin); const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs); diff --git a/src/components/SafeAreaConsumer/types.ts b/src/components/SafeAreaConsumer/types.ts index 432bf3f25ca1..d7b115983434 100644 --- a/src/components/SafeAreaConsumer/types.ts +++ b/src/components/SafeAreaConsumer/types.ts @@ -1,7 +1,7 @@ import type {DimensionValue} from 'react-native'; import type {EdgeInsets} from 'react-native-safe-area-context'; -type ChildrenProps = { +type SafeAreaChildrenProps = { paddingTop?: DimensionValue; paddingBottom?: DimensionValue; insets?: EdgeInsets; @@ -11,7 +11,9 @@ type ChildrenProps = { }; type SafeAreaConsumerProps = { - children: React.FC; + children: React.FC; }; export default SafeAreaConsumerProps; + +export type {SafeAreaChildrenProps}; diff --git a/src/components/ScrollViewWithContext.tsx b/src/components/ScrollViewWithContext.tsx index 4d465ed64a74..1ac53651a542 100644 --- a/src/components/ScrollViewWithContext.tsx +++ b/src/components/ScrollViewWithContext.tsx @@ -1,5 +1,5 @@ -import type {ForwardedRef} from 'react'; -import React, {useMemo, useRef, useState} from 'react'; +import type {ForwardedRef, ReactNode} from 'react'; +import React, {createContext, forwardRef, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native'; import {ScrollView} from 'react-native'; @@ -10,16 +10,16 @@ type ScrollContextValue = { scrollViewRef: ForwardedRef; }; -const ScrollContext = React.createContext({ +const ScrollContext = createContext({ contentOffsetY: 0, scrollViewRef: null, }); -type ScrollViewWithContextProps = { +type ScrollViewWithContextProps = Partial & { onScroll?: (event: NativeSyntheticEvent) => void; - children?: React.ReactNode; + children?: ReactNode; scrollEventThrottle?: number; -} & Partial; +}; /* * is a wrapper around that provides a ref to the . @@ -28,7 +28,7 @@ type ScrollViewWithContextProps = { * Using this wrapper will automatically handle scrolling to the picker's * when the picker modal is opened */ -function ScrollViewWithContextWithRef({onScroll, scrollEventThrottle, children, ...restProps}: ScrollViewWithContextProps, ref: ForwardedRef) { +function ScrollViewWithContext({onScroll, scrollEventThrottle, children, ...restProps}: ScrollViewWithContextProps, ref: ForwardedRef) { const [contentOffsetY, setContentOffsetY] = useState(0); const defaultScrollViewRef = useRef(null); const scrollViewRef = ref ?? defaultScrollViewRef; @@ -54,15 +54,17 @@ function ScrollViewWithContextWithRef({onScroll, scrollEventThrottle, children, {...restProps} ref={scrollViewRef} onScroll={setContextScrollPosition} - scrollEventThrottle={scrollEventThrottle ?? MIN_SMOOTH_SCROLL_EVENT_THROTTLE} + // It's possible for scrollEventThrottle to be 0, so we must use "||" to fallback to MIN_SMOOTH_SCROLL_EVENT_THROTTLE. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + scrollEventThrottle={scrollEventThrottle || MIN_SMOOTH_SCROLL_EVENT_THROTTLE} > {children} ); } -ScrollViewWithContextWithRef.displayName = 'ScrollViewWithContextWithRef'; +ScrollViewWithContext.displayName = 'ScrollViewWithContext'; -export default React.forwardRef(ScrollViewWithContextWithRef); +export default forwardRef(ScrollViewWithContext); export {ScrollContext}; export type {ScrollContextValue}; diff --git a/src/components/StatePicker/StateSelectorModal.js b/src/components/StatePicker/StateSelectorModal.tsx similarity index 73% rename from src/components/StatePicker/StateSelectorModal.js rename to src/components/StatePicker/StateSelectorModal.tsx index 003211478529..798d3be7a698 100644 --- a/src/components/StatePicker/StateSelectorModal.js +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -1,7 +1,5 @@ import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; -import PropTypes from 'prop-types'; import React, {useEffect, useMemo} from 'react'; -import _ from 'underscore'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -9,40 +7,36 @@ import SelectionList from '@components/SelectionList'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchCountryOptions from '@libs/searchCountryOptions'; +import type {CountryData} from '@libs/searchCountryOptions'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; -const propTypes = { +type State = keyof typeof COMMON_CONST.STATES; + +type StateSelectorModalProps = { /** Whether the modal is visible */ - isVisible: PropTypes.bool.isRequired, + isVisible: boolean; /** State value selected */ - currentState: PropTypes.string, + currentState?: State; /** Function to call when the user selects a State */ - onStateSelected: PropTypes.func, + onStateSelected?: (state: CountryData) => void; /** Function to call when the user closes the State modal */ - onClose: PropTypes.func, + onClose?: () => void; /** The search value from the selection list */ - searchValue: PropTypes.string.isRequired, + searchValue: string; /** Function to call when the user types in the search input */ - setSearchValue: PropTypes.func.isRequired, + setSearchValue: (value: string) => void; /** Label to display on field */ - label: PropTypes.string, -}; - -const defaultProps = { - currentState: '', - onClose: () => {}, - onStateSelected: () => {}, - label: undefined, + label?: string; }; -function StateSelectorModal({currentState, isVisible, onClose, onStateSelected, searchValue, setSearchValue, label}) { +function StateSelectorModal({currentState, isVisible, onClose = () => {}, onStateSelected = () => {}, searchValue, setSearchValue, label}: StateSelectorModalProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -53,11 +47,11 @@ function StateSelectorModal({currentState, isVisible, onClose, onStateSelected, setSearchValue(''); }, [isVisible, setSearchValue]); - const countryStates = useMemo( + const countryStates: CountryData[] = useMemo( () => - _.map(_.keys(COMMON_CONST.STATES), (state) => { - const stateName = translate(`allStates.${state}.stateName`); - const stateISO = translate(`allStates.${state}.stateISO`); + Object.keys(COMMON_CONST.STATES).map((state) => { + const stateName = translate(`allStates.${state as State}.stateName`); + const stateISO = translate(`allStates.${state as State}.stateISO`); return { value: stateISO, keyForList: stateISO, @@ -88,12 +82,16 @@ function StateSelectorModal({currentState, isVisible, onClose, onStateSelected, testID={StateSelectorModal.displayName} > void; /** Label to display on field */ - label: PropTypes.string, + label?: string; /** Callback to call when the picker modal is dismissed */ - onBlur: PropTypes.func, -}; - -const defaultProps = { - value: undefined, - forwardedRef: undefined, - errorText: '', - onInputChange: () => {}, - label: undefined, - onBlur: () => {}, + onBlur?: () => void; }; -function StatePicker({value, errorText, onInputChange, forwardedRef, label, onBlur}) { +function StatePicker({value, onInputChange, label, onBlur, errorText = ''}: StatePickerProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [isPickerVisible, setIsPickerVisible] = useState(false); @@ -51,29 +39,31 @@ function StatePicker({value, errorText, onInputChange, forwardedRef, label, onBl const hidePickerModal = (shouldBlur = true) => { if (shouldBlur) { - onBlur(); + onBlur?.(); } setIsPickerVisible(false); }; - const updateStateInput = (state) => { + const updateStateInput = (state: CountryData) => { if (state.value !== value) { - onInputChange(state.value); + onInputChange?.(state.value); } // If the user selects any state, call the hidePickerModal function with shouldBlur = false // to prevent the onBlur function from being called. hidePickerModal(false); }; - const title = value && _.keys(COMMON_CONST.STATES).includes(value) ? translate(`allStates.${value}.stateName`) : ''; + const title = value && Object.keys(COMMON_CONST.STATES).includes(value) ? translate(`allStates.${value}.stateName`) : ''; const descStyle = title.length === 0 ? styles.textNormal : null; return ( ( - -)); - -StatePickerWithRef.displayName = 'StatePickerWithRef'; - -export default StatePickerWithRef; +export default React.forwardRef(StatePicker); diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index 99b3e98588ac..c26ab8700bc0 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -1,4 +1,5 @@ import Str from 'expensify-common/lib/str'; +import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useRef, useState} from 'react'; import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInput, TextInputFocusEventData, ViewStyle} from 'react-native'; @@ -58,7 +59,7 @@ function BaseTextInput( inputID, ...props }: BaseTextInputProps, - ref: BaseTextInputRef, + ref: ForwardedRef, ) { const inputProps = {shouldSaveDraft: false, shouldUseDefaultValue: false, ...props}; const theme = useTheme(); diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 9c3899979aaa..49b203b37567 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -1,4 +1,5 @@ import Str from 'expensify-common/lib/str'; +import type {ForwardedRef} from 'react'; import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, Animated, StyleSheet, View} from 'react-native'; import type {GestureResponderEvent, LayoutChangeEvent, NativeSyntheticEvent, StyleProp, TextInput, TextInputFocusEventData, ViewStyle} from 'react-native'; @@ -58,7 +59,7 @@ function BaseTextInput( inputID, ...inputProps }: BaseTextInputProps, - ref: BaseTextInputRef, + ref: ForwardedRef, ) { const theme = useTheme(); const styles = useThemeStyles(); @@ -435,8 +436,9 @@ function BaseTextInput( */} {(!!autoGrow || autoGrowHeight) && ( // Add +2 to width on Safari browsers so that text is not cut off due to the cursor or when changing the value - // https://github.com/Expensify/App/issues/8158 - // https://github.com/Expensify/App/issues/26628 + // Reference: https://github.com/Expensify/App/issues/8158, https://github.com/Expensify/App/issues/26628 + // For mobile Chrome, ensure proper display of the text selection handle (blue bubble down). + // Reference: https://github.com/Expensify/App/issues/34921 { let additionalWidth = 0; - if (Browser.isMobileSafari() || Browser.isSafari()) { + if (Browser.isMobileSafari() || Browser.isSafari() || Browser.isMobileChrome()) { additionalWidth = 2; } setTextInputWidth(e.nativeEvent.layout.width + additionalWidth); diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 21875d4dcc64..0f912ef8f2a2 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -1,6 +1,5 @@ -import type {Component, ForwardedRef} from 'react'; import type {GestureResponderEvent, StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native'; -import type {AnimatedProps} from 'react-native-reanimated'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import type {MaybePhraseKey} from '@libs/Localize'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -111,7 +110,7 @@ type CustomBaseTextInputProps = { autoCompleteType?: string; }; -type BaseTextInputRef = ForwardedRef>>; +type BaseTextInputRef = HTMLFormElement | AnimatedTextInputRef; type BaseTextInputProps = CustomBaseTextInputProps & TextInputProps; diff --git a/src/components/TextInput/index.native.tsx b/src/components/TextInput/index.native.tsx index 656f0657dd26..acc40295d575 100644 --- a/src/components/TextInput/index.native.tsx +++ b/src/components/TextInput/index.native.tsx @@ -1,10 +1,11 @@ +import type {ForwardedRef} from 'react'; import React, {forwardRef, useEffect} from 'react'; import {AppState, Keyboard} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import BaseTextInput from './BaseTextInput'; import type {BaseTextInputProps, BaseTextInputRef} from './BaseTextInput/types'; -function TextInput(props: BaseTextInputProps, ref: BaseTextInputRef) { +function TextInput(props: BaseTextInputProps, ref: ForwardedRef) { const styles = useThemeStyles(); useEffect(() => { diff --git a/src/components/TextInput/index.tsx b/src/components/TextInput/index.tsx index 3043edbd26a5..75c4d52e0f86 100644 --- a/src/components/TextInput/index.tsx +++ b/src/components/TextInput/index.tsx @@ -1,3 +1,4 @@ +import type {ForwardedRef} from 'react'; import React, {useEffect, useRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,9 +11,9 @@ import * as styleConst from './styleConst'; type RemoveVisibilityListener = () => void; -function TextInput(props: BaseTextInputProps, ref: BaseTextInputRef) { +function TextInput(props: BaseTextInputProps, ref: ForwardedRef) { const styles = useThemeStyles(); - const textInputRef = useRef(null); + const textInputRef = useRef(null); const removeVisibilityListenerRef = useRef(null); useEffect(() => { @@ -57,7 +58,7 @@ function TextInput(props: BaseTextInputProps, ref: BaseTextInputRef) { // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={(element) => { - textInputRef.current = element as HTMLElement; + textInputRef.current = element as HTMLFormElement; if (!ref) { return; diff --git a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.js b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.js index ee7abde8c554..bb1bb9c9bfa1 100644 --- a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.js +++ b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.js @@ -2,6 +2,7 @@ import React from 'react'; import AmountTextInput from '@components/AmountTextInput'; import CurrencySymbolButton from '@components/CurrencySymbolButton'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import * as textInputWithCurrencySymbolPropTypes from './textInputWithCurrencySymbolPropTypes'; @@ -10,6 +11,7 @@ function BaseTextInputWithCurrencySymbol(props) { const {fromLocaleDigit} = useLocalize(); const currencySymbol = CurrencyUtils.getLocalizedCurrencySymbol(props.selectedCurrencyCode); const isCurrencySymbolLTR = CurrencyUtils.isCurrencySymbolLTR(props.selectedCurrencyCode); + const styles = useThemeStyles(); const currencySymbolButton = ( ); diff --git a/src/components/ThreeDotsMenu/ThreeDotsMenuItemPropTypes.js b/src/components/ThreeDotsMenu/ThreeDotsMenuItemPropTypes.js deleted file mode 100644 index 9f09eabbc7f7..000000000000 --- a/src/components/ThreeDotsMenu/ThreeDotsMenuItemPropTypes.js +++ /dev/null @@ -1,12 +0,0 @@ -import PropTypes from 'prop-types'; -import sourcePropTypes from '@components/Image/sourcePropTypes'; - -const menuItemProps = PropTypes.arrayOf( - PropTypes.shape({ - icon: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]), - text: PropTypes.string, - onPress: PropTypes.func, - }), -); - -export default menuItemProps; diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.tsx similarity index 66% rename from src/components/ThreeDotsMenu/index.js rename to src/components/ThreeDotsMenu/index.tsx index 150487b2aa57..7384874a2746 100644 --- a/src/components/ThreeDotsMenu/index.js +++ b/src/components/ThreeDotsMenu/index.tsx @@ -1,10 +1,10 @@ -import PropTypes from 'prop-types'; import React, {useRef, useState} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import _ from 'underscore'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import sourcePropTypes from '@components/Image/sourcePropTypes'; +import type {AnchorAlignment} from '@components/Popover/types'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; @@ -13,72 +13,65 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; import CONST from '@src/CONST'; -import ThreeDotsMenuItemPropTypes from './ThreeDotsMenuItemPropTypes'; +import type {TranslationPaths} from '@src/languages/types'; +import type {AnchorPosition} from '@src/styles'; +import type IconAsset from '@src/types/utils/IconAsset'; -const propTypes = { +type ThreeDotsMenuProps = { /** Tooltip for the popup icon */ - iconTooltip: PropTypes.string, + iconTooltip?: TranslationPaths; /** icon for the popup trigger */ - icon: PropTypes.oneOfType([PropTypes.string, sourcePropTypes]), + icon?: IconAsset; /** Any additional styles to pass to the icon container. */ - // eslint-disable-next-line react/forbid-prop-types - iconStyles: PropTypes.arrayOf(PropTypes.object), + iconStyles?: StyleProp; /** The fill color to pass into the icon. */ - iconFill: PropTypes.string, + iconFill?: string; /** Function to call on icon press */ - onIconPress: PropTypes.func, + onIconPress?: () => void; /** menuItems that'll show up on toggle of the popup menu */ - menuItems: ThreeDotsMenuItemPropTypes.isRequired, + menuItems: PopoverMenuItem[]; /** The anchor position of the menu */ - anchorPosition: PropTypes.shape({ - top: PropTypes.number, - right: PropTypes.number, - bottom: PropTypes.number, - left: PropTypes.number, - }).isRequired, + anchorPosition: AnchorPosition; /** The anchor alignment of the menu */ - anchorAlignment: PropTypes.shape({ - horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), - vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), - }), + anchorAlignment?: AnchorAlignment; /** Whether the popover menu should overlay the current view */ - shouldOverlay: PropTypes.bool, + shouldOverlay?: boolean; /** Whether the menu is disabled */ - disabled: PropTypes.bool, + disabled?: boolean; /** Should we announce the Modal visibility changes? */ - shouldSetModalVisibility: PropTypes.bool, + shouldSetModalVisibility?: boolean; }; -const defaultProps = { - iconTooltip: 'common.more', - disabled: false, - iconFill: undefined, - iconStyles: [], - icon: Expensicons.ThreeDots, - onIconPress: () => {}, - anchorAlignment: { +function ThreeDotsMenu({ + iconTooltip = 'common.more', + icon = Expensicons.ThreeDots, + iconFill, + iconStyles, + onIconPress = () => {}, + menuItems, + anchorPosition, + anchorAlignment = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP }, - shouldOverlay: false, - shouldSetModalVisibility: true, -}; - -function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, shouldOverlay, shouldSetModalVisibility, disabled}) { + shouldOverlay = false, + shouldSetModalVisibility = true, + disabled = false, +}: ThreeDotsMenuProps) { const theme = useTheme(); const styles = useThemeStyles(); const [isPopupMenuVisible, setPopupMenuVisible] = useState(false); - const buttonRef = useRef(null); + const buttonRef = useRef(null); const {translate} = useLocalize(); const showPopoverMenu = () => { @@ -99,6 +92,7 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me hidePopoverMenu(); return; } + buttonRef.current?.blur(); showPopoverMenu(); if (onIconPress) { onIconPress(); @@ -113,13 +107,13 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me e.preventDefault(); }} ref={buttonRef} - style={[styles.touchableButtonImage, ...iconStyles]} + style={[styles.touchableButtonImage, iconStyles]} role={CONST.ROLE.BUTTON} accessibilityLabel={translate(iconTooltip)} > @@ -139,10 +133,6 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me ); } -ThreeDotsMenu.propTypes = propTypes; -ThreeDotsMenu.defaultProps = defaultProps; ThreeDotsMenu.displayName = 'ThreeDotsMenu'; export default ThreeDotsMenu; - -export {ThreeDotsMenuItemPropTypes}; diff --git a/src/hooks/useResponsiveLayout.ts b/src/hooks/useResponsiveLayout.ts index dd782a9dbba5..f00890116d47 100644 --- a/src/hooks/useResponsiveLayout.ts +++ b/src/hooks/useResponsiveLayout.ts @@ -1,25 +1,21 @@ -import type {ParamListBase, RouteProp} from '@react-navigation/native'; -import {useRoute} from '@react-navigation/native'; +import {navigationRef} from '@libs/Navigation/Navigation'; +import NAVIGATORS from '@src/NAVIGATORS'; import useWindowDimensions from './useWindowDimensions'; -type RouteParams = ParamListBase & { - params: {isInRHP?: boolean}; -}; type ResponsiveLayoutResult = { shouldUseNarrowLayout: boolean; + isSmallScreenWidth: boolean; + isInModal: boolean; }; /** - * Hook to determine if we are on mobile devices or in the RHP + * Hook to determine if we are on mobile devices or in the Modal Navigator */ export default function useResponsiveLayout(): ResponsiveLayoutResult { const {isSmallScreenWidth} = useWindowDimensions(); - try { - // eslint-disable-next-line react-hooks/rules-of-hooks - const {params} = useRoute>(); - return {shouldUseNarrowLayout: isSmallScreenWidth || (params?.isInRHP ?? false)}; - } catch (error) { - return { - shouldUseNarrowLayout: isSmallScreenWidth, - }; - } + const state = navigationRef?.getRootState(); + const lastRoute = state?.routes?.at(-1); + const lastRouteName = lastRoute?.name; + const isInModal = lastRouteName === NAVIGATORS.LEFT_MODAL_NAVIGATOR || lastRouteName === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; + const shouldUseNarrowLayout = isSmallScreenWidth || isInModal; + return {shouldUseNarrowLayout, isSmallScreenWidth, isInModal}; } diff --git a/src/languages/en.ts b/src/languages/en.ts index bb22c7a7856a..fc426002809a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -20,6 +20,7 @@ import type { DeleteConfirmationParams, DidSplitAmountMessageParams, EditActionParams, + ElectronicFundsParams, EnterMagicCodeParams, FormattedMaxLengthParams, GoBackMessageParams, @@ -67,6 +68,7 @@ import type { StepCounterParams, TagSelectionParams, TaskCreatedActionParams, + TermsParams, ThreadRequestReportNameParams, ThreadSentMoneyReportNameParams, ToValidateLoginParams, @@ -581,7 +583,7 @@ export default { canceled: 'Canceled', posted: 'Posted', deleteReceipt: 'Delete receipt', - receiptScanning: 'Receipt scan in progress…', + receiptScanning: 'Scan in progress…', receiptMissingDetails: 'Receipt missing details', receiptStatusTitle: 'Scanning…', receiptStatusText: "Only you can see this receipt when it's scanning. Check back later or enter the details now.", @@ -1358,10 +1360,8 @@ export default { agreeToThe: 'I agree to the', walletAgreement: 'Wallet agreement', enablePayments: 'Enable payments', - feeAmountZero: '$0', monthlyFee: 'Monthly fee', inactivity: 'Inactivity', - electronicFundsInstantFee: '1.5%', noOverdraftOrCredit: 'No overdraft/credit feature.', electronicFundsWithdrawal: 'Electronic funds withdrawal', standard: 'Standard', @@ -1383,7 +1383,7 @@ export default { conditionsDetails: 'Find details and conditions for all fees and services by visiting', conditionsPhone: 'or calling +1 833-400-0904.', instant: '(instant)', - electronicFundsInstantFeeMin: '(min $0.25)', + electronicFundsInstantFeeMin: ({amount}: TermsParams) => `(min ${amount})`, }, longTermsForm: { listOfAllFees: 'A list of all Expensify Wallet fees', @@ -1402,14 +1402,14 @@ export default { 'There is no fee to transfer funds from your Expensify Wallet ' + 'to your bank account using the standard option. This transfer usually completes within 1-3 business' + ' days.', - electronicFundsInstantDetails: + electronicFundsInstantDetails: ({percentage, amount}: ElectronicFundsParams) => 'There is a fee to transfer funds from your Expensify Wallet to ' + 'your linked debit card using the instant transfer option. This transfer usually completes within ' + - 'several minutes. The fee is 1.5% of the transfer amount (with a minimum fee of $0.25).', - fdicInsuranceBancorp: + `several minutes. The fee is ${percentage}% of the transfer amount (with a minimum fee of ${amount}).`, + fdicInsuranceBancorp: ({amount}: TermsParams) => 'Your funds are eligible for FDIC insurance. Your funds will be held at or ' + `transferred to ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, an FDIC-insured institution. Once there, your funds are insured up ` + - `to $250,000 by the FDIC in the event ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} fails. See`, + `to ${amount} by the FDIC in the event ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} fails. See`, fdicInsuranceBancorp2: 'for details.', contactExpensifyPayments: `Contact ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} by calling +1 833-400-0904, by email at`, contactExpensifyPayments2: 'or sign in at', @@ -1419,7 +1419,7 @@ export default { automated: 'Automated', liveAgent: 'Live Agent', instant: 'Instant', - electronicFundsInstantFeeMin: 'Min $0.25', + electronicFundsInstantFeeMin: ({amount}: TermsParams) => `Min ${amount}`, }, }, activateStep: { @@ -2000,7 +2000,7 @@ export default { }, cardTransactions: { notActivated: 'Not activated', - outOfPocket: 'Out of pocket', + outOfPocket: 'Out-of-pocket spend', companySpend: 'Company spend', }, distance: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 858fe29a8faf..5fb65ab42d50 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -18,6 +18,7 @@ import type { DeleteConfirmationParams, DidSplitAmountMessageParams, EditActionParams, + ElectronicFundsParams, EnglishTranslation, EnterMagicCodeParams, FormattedMaxLengthParams, @@ -66,6 +67,7 @@ import type { StepCounterParams, TagSelectionParams, TaskCreatedActionParams, + TermsParams, ThreadRequestReportNameParams, ThreadSentMoneyReportNameParams, ToValidateLoginParams, @@ -441,10 +443,10 @@ export default { copyEmailToClipboard: 'Copiar email al portapapeles', markAsUnread: 'Marcar como no leído', markAsRead: 'Marcar como leído', - editAction: ({action}: EditActionParams) => `Edit ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'pedido' : 'comentario'}`, - deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'pedido' : 'comentario'}`, + editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`, + deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`, deleteConfirmation: ({action}: DeleteConfirmationParams) => - `¿Estás seguro de que quieres eliminar este ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'pedido' : 'comentario'}`, + `¿Estás seguro de que quieres eliminar esta ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`, onlyVisible: 'Visible sólo para', replyInThread: 'Responder en el hilo', joinThread: 'Unirse al hilo', @@ -459,16 +461,16 @@ export default { reportActionsView: { beginningOfArchivedRoomPartOne: 'Te perdiste la fiesta en ', beginningOfArchivedRoomPartTwo: ', no hay nada que ver aquí.', - beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `Colabora aquí con todos los participantes de ${domainRoom}! 🎉\nUtiliza `, + beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `¡Colabora aquí con todos los participantes de ${domainRoom}! 🎉\nUtiliza `, beginningOfChatHistoryDomainRoomPartTwo: ' para chatear con compañeros, compartir consejos o hacer una pregunta.', beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => - `Este es el lugar para que los administradores de ${workspaceName} colaboren! 🎉\nUsa `, + `¡Este es el lugar para que los administradores de ${workspaceName} colaboren! 🎉\nUsa `, beginningOfChatHistoryAdminRoomPartTwo: ' para chatear sobre temas como la configuración del espacio de trabajo y mas.', beginningOfChatHistoryAdminOnlyPostingRoom: 'Solo los administradores pueden enviar mensajes en esta sala.', beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => - `Este es el lugar para que todos los miembros de ${workspaceName} colaboren! 🎉\nUsa `, + `¡Este es el lugar para que todos los miembros de ${workspaceName} colaboren! 🎉\nUsa `, beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` para chatear sobre cualquier cosa relacionada con ${workspaceName}.`, - beginningOfChatHistoryUserRoomPartOne: 'Este es el lugar para colaborar! 🎉\nUsa este espacio para chatear sobre cualquier cosa relacionada con ', + beginningOfChatHistoryUserRoomPartOne: '¡Este es el lugar para colaborar! 🎉\nUsa este espacio para chatear sobre cualquier cosa relacionada con ', beginningOfChatHistoryUserRoomPartTwo: '.', beginningOfChatHistory: 'Aquí comienzan tus conversaciones con ', beginningOfChatHistoryPolicyExpenseChatPartOne: '¡La colaboración entre ', @@ -573,7 +575,7 @@ export default { canceled: 'Canceló', posted: 'Contabilizado', deleteReceipt: 'Eliminar recibo', - receiptScanning: 'Escaneo de recibo en curso…', + receiptScanning: 'Escaneo en curso…', receiptMissingDetails: 'Recibo con campos vacíos', receiptStatusTitle: 'Escaneando…', receiptStatusText: 'Solo tú puedes ver este recibo cuando se está escaneando. Vuelve más tarde o introduce los detalles ahora.', @@ -581,8 +583,8 @@ export default { transactionPendingText: 'La transacción tarda unos días en contabilizarse desde la fecha en que se utilizó la tarjeta.', requestCount: ({count, scanningReceipts = 0}: RequestCountParams) => `${count} ${Str.pluralize('solicitude', 'solicitudes', count)}${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}`, - deleteRequest: 'Eliminar pedido', - deleteConfirmation: '¿Estás seguro de que quieres eliminar este pedido?', + deleteRequest: 'Eliminar solicitud', + deleteConfirmation: '¿Estás seguro de que quieres eliminar esta solicitud?', settledExpensify: 'Pagado', settledElsewhere: 'Pagado de otra forma', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`), @@ -627,22 +629,22 @@ export default { tagSelection: ({tagName}: TagSelectionParams) => `Seleccione una ${tagName} para organizar mejor tu dinero.`, categorySelection: 'Seleccione una categoría para organizar mejor tu dinero.', error: { - invalidCategoryLength: 'El largo de la categoría escogida excede el máximo permitido (255). Por favor escoge otra categoría o acorta la categoría primero.', - invalidAmount: 'Por favor ingresa un monto válido antes de continuar.', + invalidCategoryLength: 'El largo de la categoría escogida excede el máximo permitido (255). Por favor, escoge otra categoría o acorta la categoría primero.', + invalidAmount: 'Por favor, ingresa un importe válido antes de continuar.', invalidTaxAmount: ({amount}: RequestAmountParams) => `El importe máximo del impuesto es ${amount}`, - invalidSplit: 'La suma de las partes no equivale al monto total', + invalidSplit: 'La suma de las partes no equivale al importe total', other: 'Error inesperado, por favor inténtalo más tarde', - genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde', + genericCreateFailureMessage: 'Error inesperado solicitando dinero. Por favor, inténtalo más tarde', receiptFailureMessage: 'El recibo no se subió. ', saveFileMessage: 'Guarda el archivo ', loseFileMessage: 'o descarta este error y piérdelo', genericDeleteFailureMessage: 'Error inesperado eliminando la solicitud de dinero. Por favor, inténtalo más tarde', genericEditFailureMessage: 'Error inesperado al guardar la solicitud de dinero. Por favor, inténtalo más tarde', genericSmartscanFailureMessage: 'La transacción tiene campos vacíos', - duplicateWaypointsErrorMessage: 'Por favor elimina los puntos de ruta duplicados', - atLeastTwoDifferentWaypoints: 'Por favor introduce al menos dos direcciones diferentes', - splitBillMultipleParticipantsErrorMessage: 'Solo puedes dividir una cuenta entre un único espacio de trabajo o con usuarios individuales. Por favor actualiza tu selección.', - invalidMerchant: 'Por favor ingrese un comerciante correcto.', + duplicateWaypointsErrorMessage: 'Por favor, elimina los puntos de ruta duplicados', + atLeastTwoDifferentWaypoints: 'Por favor, introduce al menos dos direcciones diferentes', + splitBillMultipleParticipantsErrorMessage: 'Solo puedes dividir una cuenta entre un único espacio de trabajo o con usuarios individuales. Por favor, actualiza tu selección.', + invalidMerchant: 'Por favor, introduce un comerciante correcto.', }, waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`, enableWallet: 'Habilitar Billetera', @@ -865,7 +867,7 @@ export default { }, }, passwordConfirmationScreen: { - passwordUpdated: 'Contraseña actualizada!', + passwordUpdated: '¡Contraseña actualizada!', allSet: 'Todo está listo. Guarda tu contraseña en un lugar seguro.', }, privateNotes: { @@ -922,7 +924,7 @@ export default { enableWallet: 'Habilitar Billetera', bankAccounts: 'Cuentas bancarias', addBankAccountToSendAndReceive: 'Añade una cuenta bancaria para enviar y recibir pagos directamente en la aplicación.', - addBankAccount: 'Agregar cuenta bancaria', + addBankAccount: 'Añadir cuenta bancaria', assignedCards: 'Tarjetas asignadas', assignedCardsDescription: 'Son tarjetas asignadas por un administrador del Espacio de Trabajo para gestionar los gastos de la empresa.', expensifyCard: 'Tarjeta Expensify', @@ -1211,7 +1213,7 @@ export default { }, statusPage: { status: 'Estado', - statusExplanation: 'Agrega un emoji para que tus colegas y amigos puedan saber fácilmente qué está pasando. ¡También puedes agregar un mensaje opcionalmente!', + statusExplanation: 'Añade un emoji para que tus colegas y amigos puedan saber fácilmente qué está pasando. ¡También puedes añadir un mensaje opcionalmente!', today: 'Hoy', clearStatus: 'Borrar estado', save: 'Guardar', @@ -1375,35 +1377,33 @@ export default { headerTitle: 'Condiciones y tarifas', haveReadAndAgree: 'He leído y acepto recibir ', electronicDisclosures: 'divulgaciones electrónicas', - agreeToThe: 'Estoy de acuerdo con la ', - walletAgreement: 'Acuerdo de billetera', + agreeToThe: 'Estoy de acuerdo con el ', + walletAgreement: 'Acuerdo de la billetera', enablePayments: 'Habilitar pagos', - feeAmountZero: '$0', monthlyFee: 'Cuota mensual', inactivity: 'Inactividad', - electronicFundsInstantFee: '1.5%', - noOverdraftOrCredit: 'Sin función de sobregiro / crédito', + noOverdraftOrCredit: 'Sin función de sobregiro/crédito', electronicFundsWithdrawal: 'Retiro electrónico de fondos', standard: 'Estándar', shortTermsForm: { expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `La billetera Expensify es emitida por ${walletProgram}.`, perPurchase: 'Por compra', - atmWithdrawal: 'Retiro de cajero automático', + atmWithdrawal: 'Retiro en cajeros automáticos', cashReload: 'Recarga de efectivo', inNetwork: 'en la red', outOfNetwork: 'fuera de la red', - atmBalanceInquiry: 'Consulta de saldo de cajero automático', + atmBalanceInquiry: 'Consulta de saldo en cajeros automáticos', inOrOutOfNetwork: '(dentro o fuera de la red)', customerService: 'Servicio al cliente', automatedOrLive: '(agente automatizado o en vivo)', afterTwelveMonths: '(después de 12 meses sin transacciones)', weChargeOneFee: 'Cobramos un tipo de tarifa.', - fdicInsurance: 'Sus fondos son elegibles para el seguro de la FDIC.', - generalInfo: 'Para obtener información general sobre cuentas prepagas, visite', + fdicInsurance: 'Tus fondos pueden acogerse al seguro de la FDIC.', + generalInfo: 'Para obtener información general sobre cuentas de prepago, visite', conditionsDetails: 'Encuentra detalles y condiciones para todas las tarifas y servicios visitando', conditionsPhone: 'o llamando al +1 833-400-0904.', instant: '(instantáneo)', - electronicFundsInstantFeeMin: '(mínimo $0.25)', + electronicFundsInstantFeeMin: ({amount}: TermsParams) => `(mínimo ${amount})`, }, longTermsForm: { listOfAllFees: 'Una lista de todas las tarifas de la billetera Expensify', @@ -1417,30 +1417,30 @@ export default { customerServiceDetails: 'No hay tarifas de servicio al cliente.', inactivityDetails: 'No hay tarifa de inactividad.', sendingFundsTitle: 'Enviar fondos a otro titular de cuenta', - sendingFundsDetails: 'No se aplica ningún cargo por enviar fondos a otro titular de cuenta utilizando su saldo cuenta bancaria o tarjeta de débito', + sendingFundsDetails: 'No se aplica ningún cargo por enviar fondos a otro titular de cuenta utilizando tu saldo cuenta bancaria o tarjeta de débito', electronicFundsStandardDetails: - 'No hay cargo por transferir fondos desde su billetera Expensify ' + - 'a su cuenta bancaria utilizando la opción estándar. Esta transferencia generalmente se completa en' + - '1-3 negocios días.', - electronicFundsInstantDetails: - 'Hay una tarifa para transferir fondos desde su billetera Expensify a ' + - 'su tarjeta de débito vinculada utilizando la opción de transferencia instantánea. Esta transferencia ' + - 'generalmente se completa dentro de varios minutos. La tarifa es el 1.5% del monto de la ' + - 'transferencia (con una tarifa mínima de $ 0.25). ', - fdicInsuranceBancorp: - 'Sus fondos son elegibles para el seguro de la FDIC. Sus fondos se mantendrán en o ' + - `transferido a ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, una institución asegurada por la FDIC. Una vez allí, sus fondos ` + - `están asegurados a $ 250,000 por la FDIC en caso de que ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} quiebre. Ver`, - fdicInsuranceBancorp2: 'para detalles.', - contactExpensifyPayments: `Comuníquese con ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} llamando al + 1833-400-0904, por correoelectrónico a`, + 'No hay cargo por transferir fondos desde tu billetera Expensify ' + + 'a tu cuenta bancaria utilizando la opción estándar. Esta transferencia generalmente se completa en' + + '1-3 días laborables.', + electronicFundsInstantDetails: ({percentage, amount}: ElectronicFundsParams) => + 'Hay una tarifa para transferir fondos desde tu billetera Expensify a ' + + 'la tarjeta de débito vinculada utilizando la opción de transferencia instantánea. Esta transferencia ' + + `generalmente se completa dentro de varios minutos. La tarifa es el ${percentage}% del importe de la ` + + `transferencia (con una tarifa mínima de ${amount}). `, + fdicInsuranceBancorp: ({amount}: TermsParams) => + 'Tus fondos pueden acogerse al seguro de la FDIC. Tus fondos se mantendrán o serán ' + + `transferidos a ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, una institución asegurada por la FDIC. Una vez allí, tus fondos ` + + `están asegurados hasta ${amount} por la FDIC en caso de que ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} quiebre. Ver`, + fdicInsuranceBancorp2: 'para más detalles.', + contactExpensifyPayments: `Comunícate con ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} llamando al + 1833-400-0904, o por correo electrónico a`, contactExpensifyPayments2: 'o inicie sesión en', - generalInformation: 'Para obtener información general sobre cuentas prepagas, visite', - generalInformation2: 'Si tiene una queja sobre una cuenta prepaga, llame al Consumer Financial Oficina de Protección al 1-855-411-2372 o visite', + generalInformation: 'Para obtener información general sobre cuentas de prepago, visite', + generalInformation2: 'Si tienes alguna queja sobre una cuenta de prepago, llama al Consumer Financial Oficina de Protección al 1-855-411-2372 o visita', printerFriendlyView: 'Ver versión para imprimir', automated: 'Automatizado', liveAgent: 'Agente en vivo', instant: 'Instantáneo', - electronicFundsInstantFeeMin: 'Mínimo $0.25', + electronicFundsInstantFeeMin: ({amount}: TermsParams) => `Mínimo ${amount}`, }, }, activateStep: { @@ -1448,7 +1448,7 @@ export default { activatedTitle: '¡Billetera activada!', activatedMessage: 'Felicidades, tu Billetera está configurada y lista para hacer pagos.', checkBackLaterTitle: 'Un momento...', - checkBackLaterMessage: 'Todavía estamos revisando tu información. Por favor, vuelva más tarde.', + checkBackLaterMessage: 'Todavía estamos revisando tu información. Por favor, vuelve más tarde.', continueToPayment: 'Continuar al pago', continueToTransfer: 'Continuar a la transferencia', }, @@ -1813,7 +1813,7 @@ export default { resultsAreLimited: 'Los resultados de búsqueda están limitados.', }, genericErrorPage: { - title: '¡Uh-oh, algo salió mal!', + title: '¡Oh-oh, algo salió mal!', body: { helpTextMobile: 'Intenta cerrar y volver a abrir la aplicación o cambiar a la', helpTextWeb: 'web.', @@ -1896,12 +1896,12 @@ export default { }, notAvailable: { title: 'Actualización no disponible', - message: 'No existe ninguna actualización disponible! Inténtalo de nuevo más tarde.', + message: '¡No existe ninguna actualización disponible! Inténtalo de nuevo más tarde.', okay: 'Vale', }, error: { title: 'Comprobación fallida', - message: 'No hemos podido comprobar si existe una actualización. Inténtalo de nuevo más tarde!', + message: 'No hemos podido comprobar si existe una actualización. ¡Inténtalo de nuevo más tarde!', }, }, report: { @@ -2419,7 +2419,7 @@ export default { }, parentReportAction: { deletedMessage: '[Mensaje eliminado]', - deletedRequest: '[Pedido eliminado]', + deletedRequest: '[Solicitud eliminada]', reversedTransaction: '[Transacción anulada]', deletedTask: '[Tarea eliminada]', hiddenMessage: '[Mensaje oculto]', @@ -2443,13 +2443,13 @@ export default { flagDescription: 'Todos los mensajes marcados se enviarán a un moderador para su revisión.', chooseAReason: 'Elige abajo un motivo para reportarlo:', spam: 'Spam', - spamDescription: 'Promoción fuera de tema no solicitada', + spamDescription: 'Publicidad no solicitada', inconsiderate: 'Desconsiderado', inconsiderateDescription: 'Frase insultante o irrespetuosa, con intenciones cuestionables', intimidation: 'Intimidación', intimidationDescription: 'Persigue agresivamente una agenda sobre objeciones válidas', bullying: 'Bullying', - bullyingDescription: 'Apunta a un individuo para obtener obediencia', + bullyingDescription: 'Se dirige a un individuo para obtener obediencia', harassment: 'Acoso', harassmentDescription: 'Comportamiento racista, misógino u otro comportamiento discriminatorio', assault: 'Agresion', @@ -2457,8 +2457,8 @@ export default { flaggedContent: 'Este mensaje ha sido marcado por violar las reglas de nuestra comunidad y el contenido se ha ocultado.', hideMessage: 'Ocultar mensaje', revealMessage: 'Revelar mensaje', - levelOneResult: 'Envia una advertencia anónima y el mensaje es reportado para revisión.', - levelTwoResult: 'Mensaje ocultado del canal, más advertencia anónima y mensaje reportado para revisión.', + levelOneResult: 'Envía una advertencia anónima y el mensaje es reportado para revisión.', + levelTwoResult: 'Mensaje ocultado en el canal, más advertencia anónima y mensaje reportado para revisión.', levelThreeResult: 'Mensaje eliminado del canal, más advertencia anónima y mensaje reportado para revisión.', }, teachersUnitePage: { @@ -2487,11 +2487,11 @@ export default { }, cardTransactions: { notActivated: 'No activado', - outOfPocket: 'Por cuenta propia', + outOfPocket: 'Gastos por cuenta propia', companySpend: 'Gastos de empresa', }, distance: { - addStop: 'Agregar parada', + addStop: 'Añadir parada', deleteWaypoint: 'Eliminar punto de ruta', deleteWaypointConfirmation: '¿Estás seguro de que quieres eliminar este punto de ruta?', address: 'Dirección', @@ -2573,21 +2573,21 @@ export default { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, billableExpense: 'La opción facturable ya no es válida', - cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para montos mayores a ${amount}`, + cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${amount}`, categoryOutOfPolicy: 'La categoría ya no es válida', conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `${surcharge}% de recargo aplicado`, - customUnitOutOfPolicy: 'Unidad ya no es válida', - duplicatedTransaction: 'Potencial duplicado', + customUnitOutOfPolicy: 'La unidad ya no es válida', + duplicatedTransaction: 'Posible duplicado', fieldRequired: 'Los campos del informe son obligatorios', futureDate: 'Fecha futura no permitida', invoiceMarkup: ({invoiceMarkup}: ViolationsInvoiceMarkupParams) => `Incrementado un ${invoiceMarkup}%`, maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', - missingComment: 'Descripción obligatoria para categoría seleccionada', + missingComment: 'Descripción obligatoria para la categoría seleccionada', missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName}`, modifiedAmount: 'Importe superior al del recibo escaneado', modifiedDate: 'Fecha difiere del recibo escaneado', - nonExpensiworksExpense: 'Gasto no es de Expensiworks', + nonExpensiworksExpense: 'Gasto no proviene de Expensiworks', overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Importe supera el límite de aprobación automática de ${formattedLimitAmount}`, overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría de ${categoryLimit}/persona`, overLimit: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, @@ -2598,22 +2598,22 @@ export default { rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { return isAdmin - ? `No se puede adjuntar recibo debido a una conexión con su banco que ${email} necesita arreglar` - : 'No se puede adjuntar recibo debido a una conexión con su banco que necesitas arreglar'; + ? `No se puede adjuntar recibo debido a un problema con la conexión a su banco que ${email} necesita arreglar` + : 'No se puede adjuntar recibo debido a un problema con la conexión a su banco que necesitas arreglar'; } if (!isTransactionOlderThan7Days) { return isAdmin - ? `Pídele a ${member} que marque la transacción como efectivo o espera 7 días e intenta de nuevo` - : 'Esperando adjuntar automáticamente a transacción de tarjeta de crédito'; + ? `Pide a ${member} que marque la transacción como efectivo o espera 7 días e inténtalo de nuevo` + : 'Esperando a adjuntar automáticamente la transacción de tarjeta de crédito'; } return ''; }, smartscanFailed: 'No se pudo escanear el recibo. Introduce los datos manualmente', someTagLevelsRequired: 'Falta etiqueta', - tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `Le etiqueta ${tagName} ya no es válida`, + tagOutOfPolicy: ({tagName}: ViolationsTagOutOfPolicyParams) => `La etiqueta ${tagName} ya no es válida`, taxAmountChanged: 'El importe del impuesto fue modificado', taxOutOfPolicy: ({taxName}: ViolationsTaxOutOfPolicyParams) => `${taxName} ya no es válido`, taxRateChanged: 'La tasa de impuesto fue modificada', - taxRequired: 'Falta tasa de impuesto', + taxRequired: 'Falta la tasa de impuesto', }, } satisfies EnglishTranslation; diff --git a/src/languages/types.ts b/src/languages/types.ts index 3185b7a8f6f1..11adf01ac252 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -287,6 +287,10 @@ type TranslationFlatObject = { [TKey in TranslationPaths]: TranslateType; }; +type TermsParams = {amount: string}; + +type ElectronicFundsParams = {percentage: string; amount: string}; + export type { ApprovedAmountParams, AddressLineParams, @@ -305,6 +309,7 @@ export type { DeleteConfirmationParams, DidSplitAmountMessageParams, EditActionParams, + ElectronicFundsParams, EnglishTranslation, EnterMagicCodeParams, FormattedMaxLengthParams, @@ -353,6 +358,7 @@ export type { StepCounterParams, TagSelectionParams, TaskCreatedActionParams, + TermsParams, ThreadRequestReportNameParams, ThreadSentMoneyReportNameParams, ToValidateLoginParams, diff --git a/src/libs/ComposerFocusManager.js b/src/libs/ComposerFocusManager.ts similarity index 78% rename from src/libs/ComposerFocusManager.js rename to src/libs/ComposerFocusManager.ts index 569e165da962..b66bbe92599e 100644 --- a/src/libs/ComposerFocusManager.js +++ b/src/libs/ComposerFocusManager.ts @@ -1,18 +1,20 @@ let isReadyToFocusPromise = Promise.resolve(); -let resolveIsReadyToFocus; +let resolveIsReadyToFocus: (value: void | PromiseLike) => void; function resetReadyToFocus() { isReadyToFocusPromise = new Promise((resolve) => { resolveIsReadyToFocus = resolve; }); } + function setReadyToFocus() { if (!resolveIsReadyToFocus) { return; } resolveIsReadyToFocus(); } -function isReadyToFocus() { + +function isReadyToFocus(): Promise { return isReadyToFocusPromise; } diff --git a/src/libs/ControlSelection/index.ts b/src/libs/ControlSelection/index.ts index ab11e66bc369..44787dc77dbe 100644 --- a/src/libs/ControlSelection/index.ts +++ b/src/libs/ControlSelection/index.ts @@ -1,4 +1,3 @@ -import type CustomRefObject from '@src/types/utils/CustomRefObject'; import type ControlSelectionModule from './types'; /** @@ -20,25 +19,25 @@ function unblock() { /** * Block selection on particular element */ -function blockElement(ref?: CustomRefObject | null) { - if (!ref) { +function blockElement(element?: HTMLElement | null) { + if (!element) { return; } // eslint-disable-next-line no-param-reassign - ref.onselectstart = () => false; + element.onselectstart = () => false; } /** * Unblock selection on particular element */ -function unblockElement(ref?: CustomRefObject | null) { - if (!ref) { +function unblockElement(element?: HTMLElement | null) { + if (!element) { return; } // eslint-disable-next-line no-param-reassign - ref.onselectstart = () => true; + element.onselectstart = () => true; } const ControlSelection: ControlSelectionModule = { diff --git a/src/libs/ControlSelection/types.ts b/src/libs/ControlSelection/types.ts index fc0b488577ec..c4ca4b713b9b 100644 --- a/src/libs/ControlSelection/types.ts +++ b/src/libs/ControlSelection/types.ts @@ -1,10 +1,8 @@ -import type CustomRefObject from '@src/types/utils/CustomRefObject'; - type ControlSelectionModule = { block: () => void; unblock: () => void; - blockElement: (ref?: CustomRefObject | null) => void; - unblockElement: (ref?: CustomRefObject | null) => void; + blockElement: (element?: HTMLElement | null) => void; + unblockElement: (element?: HTMLElement | null) => void; }; export default ControlSelectionModule; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index 1a10eb03a00e..526769723531 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -5,9 +5,12 @@ import { eachDayOfInterval, eachMonthOfInterval, endOfDay, + endOfMonth, endOfWeek, format, formatDistanceToNow, + getDate, + getDay, getDayOfYear, isAfter, isBefore, @@ -730,6 +733,25 @@ function formatToSupportedTimezone(timezoneInput: Timezone): Timezone { }; } +/** + * Returns the last business day of given date month + * + * param {Date} inputDate + * returns {number} + */ +function getLastBusinessDayOfMonth(inputDate: Date): number { + let currentDate = endOfMonth(inputDate); + const dayOfWeek = getDay(currentDate); + + if (dayOfWeek === 0) { + currentDate = subDays(currentDate, 2); + } else if (dayOfWeek === 6) { + currentDate = subDays(currentDate, 1); + } + + return getDate(currentDate); +} + const DateUtils = { formatToDayOfWeek, formatToLongDateWithWeekday, @@ -774,6 +796,7 @@ const DateUtils = { getWeekEndsOn, isTimeAtLeastOneMinuteInFuture, formatToSupportedTimezone, + getLastBusinessDayOfMonth, }; export default DateUtils; diff --git a/src/libs/E2E/API.mock.ts b/src/libs/E2E/API.mock.ts deleted file mode 100644 index 83b7cb218977..000000000000 --- a/src/libs/E2E/API.mock.ts +++ /dev/null @@ -1,82 +0,0 @@ -import Onyx from 'react-native-onyx'; -import Log from '@libs/Log'; -import type Response from '@src/types/onyx/Response'; -// mock functions -import mockAuthenticatePusher from './apiMocks/authenticatePusher'; -import mockBeginSignin from './apiMocks/beginSignin'; -import mockOpenApp from './apiMocks/openApp'; -import mockOpenReport from './apiMocks/openReport'; -import mockReadNewestAction from './apiMocks/readNewestAction'; -import mockSigninUser from './apiMocks/signinUser'; - -type ApiCommandParameters = Record; - -type Mocks = Record Response>; - -/** - * A dictionary which has the name of a API command as key, and a function which - * receives the api command parameters as value and is expected to return a response - * object. - */ -const mocks: Mocks = { - BeginSignIn: mockBeginSignin, - SigninUser: mockSigninUser, - OpenApp: mockOpenApp, - ReconnectApp: mockOpenApp, - OpenReport: mockOpenReport, - ReconnectToReport: mockOpenReport, - AuthenticatePusher: mockAuthenticatePusher, - ReadNewestAction: mockReadNewestAction, -}; - -function mockCall(command: string, apiCommandParameters: ApiCommandParameters, tag: string): Promise | Promise | undefined { - const mockResponse = mocks[command]?.(apiCommandParameters); - if (!mockResponse) { - Log.warn(`[${tag}] for command ${command} is not mocked yet! ⚠️`); - return; - } - - if (Array.isArray(mockResponse.onyxData)) { - return Onyx.update(mockResponse.onyxData); - } - - return Promise.resolve(mockResponse); -} - -/** - * All calls to API.write() will be persisted to disk as JSON with the params, successData, and failureData. - * This is so that if the network is unavailable or the app is closed, we can send the WRITE request later. - * - * @param command - Name of API command to call. - * @param apiCommandParameters - Parameters to send to the API. - */ -function write(command: string, apiCommandParameters: ApiCommandParameters = {}): Promise | Promise | undefined { - return mockCall(command, apiCommandParameters, 'API.write'); -} - -/** - * For commands where the network response must be accessed directly or when there is functionality that can only - * happen once the request is finished (eg. calling third-party services like Onfido and Plaid, redirecting a user - * depending on the response data, etc.). - * It works just like API.read(), except that it will return a promise. - * Using this method is discouraged and will throw an ESLint error. Use it sparingly and only when all other alternatives have been exhausted. - * It is best to discuss it in Slack anytime you are tempted to use this method. - * - * @param command - Name of API command to call. - * @param apiCommandParameters - Parameters to send to the API. - */ -function makeRequestWithSideEffects(command: string, apiCommandParameters: ApiCommandParameters = {}): Promise | Promise | undefined { - return mockCall(command, apiCommandParameters, 'API.makeRequestWithSideEffects'); -} - -/** - * Requests made with this method are not be persisted to disk. If there is no network connectivity, the request is ignored and discarded. - * - * @param command - Name of API command to call. - * @param apiCommandParameters - Parameters to send to the API. - */ -function read(command: string, apiCommandParameters: ApiCommandParameters): Promise | Promise | undefined { - return mockCall(command, apiCommandParameters, 'API.read'); -} - -export {write, makeRequestWithSideEffects, read}; diff --git a/src/libs/E2E/actions/e2eLogin.ts b/src/libs/E2E/actions/e2eLogin.ts index 6a25705df755..f98eab5005e1 100644 --- a/src/libs/E2E/actions/e2eLogin.ts +++ b/src/libs/E2E/actions/e2eLogin.ts @@ -1,8 +1,20 @@ +/* eslint-disable rulesdir/prefer-actions-set-data */ + /* eslint-disable rulesdir/prefer-onyx-connect-in-libs */ import Onyx from 'react-native-onyx'; -import * as Session from '@userActions/Session'; +import {Authenticate} from '@libs/Authentication'; +import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; +import CONFIG from '@src/CONFIG'; import ONYXKEYS from '@src/ONYXKEYS'; +const e2eUserCredentials = { + email: getConfigValueOrThrow('EXPENSIFY_PARTNER_PASSWORD_EMAIL'), + partnerUserID: getConfigValueOrThrow('EXPENSIFY_PARTNER_USER_ID'), + partnerUserSecret: getConfigValueOrThrow('EXPENSIFY_PARTNER_USER_SECRET'), + partnerName: CONFIG.EXPENSIFY.PARTNER_NAME, + partnerPassword: CONFIG.EXPENSIFY.PARTNER_PASSWORD, +}; + /** * Command for e2e test to automatically sign in a user. * If the user is already logged in the function will simply @@ -10,7 +22,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; * * @return Resolved true when the user was actually signed in. Returns false if the user was already logged in. */ -export default function (email = 'fake@email.com', password = 'Password123'): Promise { +export default function (): Promise { const waitForBeginSignInToFinish = (): Promise => new Promise((resolve) => { const id = Onyx.connect({ @@ -30,7 +42,7 @@ export default function (email = 'fake@email.com', password = 'Password123'): Pr let neededLogin = false; // Subscribe to auth token, to check if we are authenticated - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const connectionId = Onyx.connect({ key: ONYXKEYS.SESSION, callback: (session) => { @@ -38,15 +50,24 @@ export default function (email = 'fake@email.com', password = 'Password123'): Pr neededLogin = true; // authenticate with a predefined user - Session.beginSignIn(email); - waitForBeginSignInToFinish().then(() => { - Session.signIn(password); - }); - } else { - // signal that auth was completed - resolve(neededLogin); - Onyx.disconnect(connectionId); + console.debug('[E2E] Signing in…'); + Authenticate(e2eUserCredentials) + .then((response) => { + Onyx.merge(ONYXKEYS.SESSION, { + authToken: response.authToken, + email: e2eUserCredentials.email, + }); + console.debug('[E2E] Signed in finished!'); + return waitForBeginSignInToFinish(); + }) + .catch((error) => { + console.error('[E2E] Error while signing in', error); + reject(error); + }); } + // signal that auth was completed + resolve(neededLogin); + Onyx.disconnect(connectionId); }, }); }); diff --git a/src/libs/E2E/actions/waitForAppLoaded.ts b/src/libs/E2E/actions/waitForAppLoaded.ts new file mode 100644 index 000000000000..bea739a1b4c7 --- /dev/null +++ b/src/libs/E2E/actions/waitForAppLoaded.ts @@ -0,0 +1,19 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +// Once we get the sidebar loaded end mark we know that the app is ready to be used: +export default function waitForAppLoaded(): Promise { + return new Promise((resolve) => { + const connectionId = Onyx.connect({ + key: ONYXKEYS.IS_SIDEBAR_LOADED, + callback: (isSidebarLoaded) => { + if (!isSidebarLoaded) { + return; + } + + resolve(); + Onyx.disconnect(connectionId); + }, + }); + }); +} diff --git a/src/libs/E2E/apiMocks/authenticatePusher.ts b/src/libs/E2E/apiMocks/authenticatePusher.ts deleted file mode 100644 index 28f9ebbbee88..000000000000 --- a/src/libs/E2E/apiMocks/authenticatePusher.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type Response from '@src/types/onyx/Response'; - -const authenticatePusher = (): Response => ({ - auth: 'auth', - // eslint-disable-next-line @typescript-eslint/naming-convention - shared_secret: 'secret', - jsonCode: 200, - requestID: '783ef7fc3991969a-SJC', -}); - -export default authenticatePusher; diff --git a/src/libs/E2E/apiMocks/beginSignin.ts b/src/libs/E2E/apiMocks/beginSignin.ts deleted file mode 100644 index a578f935c2aa..000000000000 --- a/src/libs/E2E/apiMocks/beginSignin.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type {SigninParams} from '@libs/E2E/types'; -import type Response from '@src/types/onyx/Response'; - -const beginSignin = ({email}: SigninParams): Response => ({ - onyxData: [ - { - onyxMethod: 'merge', - key: 'credentials', - value: { - login: email, - }, - }, - { - onyxMethod: 'merge', - key: 'account', - value: { - validated: true, - }, - }, - ], - jsonCode: 200, - requestID: '783e54ef4b38cff5-SJC', -}); - -export default beginSignin; diff --git a/src/libs/E2E/apiMocks/openApp.ts b/src/libs/E2E/apiMocks/openApp.ts deleted file mode 100644 index d6dd4a8f8003..000000000000 --- a/src/libs/E2E/apiMocks/openApp.ts +++ /dev/null @@ -1,2069 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import type Response from '@src/types/onyx/Response'; - -const openApp = (): Response => ({ - onyxData: [ - { - onyxMethod: 'merge', - key: 'user', - value: { - isFromPublicDomain: false, - }, - }, - { - onyxMethod: 'merge', - key: 'currencyList', - value: { - AED: { - symbol: 'Dhs', - name: 'UAE Dirham', - ISO4217: '784', - }, - AFN: { - symbol: 'Af', - name: 'Afghan Afghani', - ISO4217: '971', - }, - ALL: { - symbol: 'ALL', - name: 'Albanian Lek', - ISO4217: '008', - }, - AMD: { - symbol: '\u0564\u0580', - name: 'Armenian Dram', - ISO4217: '051', - }, - ANG: { - symbol: 'NA\u0192', - name: 'Neth Antilles Guilder', - ISO4217: '532', - }, - AOA: { - symbol: 'Kz', - name: 'Angolan Kwanza', - ISO4217: '973', - }, - ARS: { - symbol: 'AR$', - name: 'Argentine Peso', - ISO4217: '032', - }, - AUD: { - symbol: 'A$', - name: 'Australian Dollar', - ISO4217: '036', - }, - AWG: { - symbol: '\u0192', - name: 'Aruba Florin', - ISO4217: '533', - }, - AZN: { - symbol: 'man', - name: 'Azerbaijani Manat', - ISO4217: '944', - }, - BAM: { - symbol: 'KM', - name: 'Bosnia And Herzegovina Convertible Mark', - ISO4217: '977', - }, - BBD: { - symbol: 'Bds$', - name: 'Barbados Dollar', - ISO4217: '052', - }, - BDT: { - symbol: 'Tk', - name: 'Bangladesh Taka', - ISO4217: '050', - }, - BGN: { - symbol: '\u043b\u0432', - name: 'Bulgarian Lev', - ISO4217: '975', - }, - BHD: { - symbol: 'BHD', - name: 'Bahraini Dinar', - ISO4217: '048', - }, - BIF: { - symbol: 'FBu', - name: 'Burundi Franc', - decimals: 0, - ISO4217: '108', - }, - BMD: { - symbol: 'BD$', - name: 'Bermuda Dollar', - ISO4217: '060', - }, - BND: { - symbol: 'BN$', - name: 'Brunei Dollar', - ISO4217: '096', - }, - BOB: { - symbol: 'Bs', - name: 'Bolivian Boliviano', - ISO4217: '068', - }, - BRL: { - symbol: 'R$', - name: 'Brazilian Real', - ISO4217: '986', - }, - BSD: { - symbol: 'BS$', - name: 'Bahamian Dollar', - ISO4217: '044', - }, - BTN: { - symbol: 'Nu.', - name: 'Bhutan Ngultrum', - ISO4217: '064', - }, - BWP: { - symbol: 'P', - name: 'Botswana Pula', - ISO4217: '072', - }, - BYN: { - symbol: 'BR', - name: 'Belarus Ruble', - ISO4217: '933', - }, - BYR: { - symbol: 'BR', - name: 'Belarus Ruble', - retired: true, - retirementDate: '2016-07-01', - ISO4217: '974', - }, - BZD: { - symbol: 'BZ$', - name: 'Belize Dollar', - ISO4217: '084', - }, - CAD: { - symbol: 'C$', - name: 'Canadian Dollar', - ISO4217: '124', - }, - CDF: { - symbol: 'CDF', - name: 'Congolese Franc', - ISO4217: '976', - }, - CHF: { - symbol: 'CHF', - name: 'Swiss Franc', - ISO4217: '756', - }, - CLP: { - symbol: 'Ch$', - name: 'Chilean Peso', - decimals: 0, - ISO4217: '152', - }, - CNY: { - symbol: '\u00a5', - name: 'Chinese Yuan', - ISO4217: '156', - }, - COP: { - symbol: 'Col$', - name: 'Colombian Peso', - decimals: 0, - ISO4217: '170', - }, - CRC: { - symbol: 'CR\u20a1', - name: 'Costa Rica Colon', - ISO4217: '188', - }, - CUC: { - symbol: 'CUC', - name: 'Cuban Convertible Peso', - ISO4217: '931', - }, - CUP: { - symbol: '$MN', - name: 'Cuban Peso', - ISO4217: '192', - }, - CVE: { - symbol: 'Esc', - name: 'Cape Verde Escudo', - ISO4217: '132', - }, - CZK: { - symbol: 'K\u010d', - name: 'Czech Koruna', - ISO4217: '203', - }, - DJF: { - symbol: 'Fdj', - name: 'Dijibouti Franc', - decimals: 0, - ISO4217: '262', - }, - DKK: { - symbol: 'Dkr', - name: 'Danish Krone', - ISO4217: '208', - }, - DOP: { - symbol: 'RD$', - name: 'Dominican Peso', - ISO4217: '214', - }, - DZD: { - symbol: 'DZD', - name: 'Algerian Dinar', - ISO4217: '012', - }, - EEK: { - symbol: 'KR', - name: 'Estonian Kroon', - ISO4217: '', - retired: true, - }, - EGP: { - symbol: 'EGP', - name: 'Egyptian Pound', - ISO4217: '818', - }, - ERN: { - symbol: 'Nfk', - name: 'Eritrea Nakfa', - ISO4217: '232', - }, - ETB: { - symbol: 'Br', - name: 'Ethiopian Birr', - ISO4217: '230', - }, - EUR: { - symbol: '\u20ac', - name: 'Euro', - ISO4217: '978', - }, - FJD: { - symbol: 'FJ$', - name: 'Fiji Dollar', - ISO4217: '242', - }, - FKP: { - symbol: 'FK\u00a3', - name: 'Falkland Islands Pound', - ISO4217: '238', - }, - GBP: { - symbol: '\u00a3', - name: 'British Pound', - ISO4217: '826', - }, - GEL: { - symbol: '\u10da', - name: 'Georgian Lari', - ISO4217: '981', - }, - GHS: { - symbol: '\u20b5', - name: 'Ghanaian Cedi', - ISO4217: '936', - }, - GIP: { - symbol: '\u00a3G', - name: 'Gibraltar Pound', - ISO4217: '292', - }, - GMD: { - symbol: 'D', - name: 'Gambian Dalasi', - ISO4217: '270', - }, - GNF: { - symbol: 'FG', - name: 'Guinea Franc', - decimals: 0, - ISO4217: '324', - }, - GTQ: { - symbol: 'Q', - name: 'Guatemala Quetzal', - ISO4217: '320', - }, - GYD: { - symbol: 'GY$', - name: 'Guyana Dollar', - ISO4217: '328', - }, - HKD: { - symbol: 'HK$', - name: 'Hong Kong Dollar', - ISO4217: '344', - }, - HNL: { - symbol: 'HNL', - name: 'Honduras Lempira', - ISO4217: '340', - }, - HRK: { - symbol: 'kn', - name: 'Croatian Kuna', - ISO4217: '191', - }, - HTG: { - symbol: 'G', - name: 'Haiti Gourde', - ISO4217: '332', - }, - HUF: { - symbol: 'Ft', - name: 'Hungarian Forint', - ISO4217: '348', - }, - IDR: { - symbol: 'Rp', - name: 'Indonesian Rupiah', - ISO4217: '360', - }, - ILS: { - symbol: '\u20aa', - name: 'Israeli Shekel', - ISO4217: '376', - }, - INR: { - symbol: '\u20b9', - name: 'Indian Rupee', - ISO4217: '356', - }, - IQD: { - symbol: 'IQD', - name: 'Iraqi Dinar', - ISO4217: '368', - }, - IRR: { - symbol: '\ufdfc', - name: 'Iran Rial', - ISO4217: '364', - }, - ISK: { - symbol: 'kr', - name: 'Iceland Krona', - decimals: 0, - ISO4217: '352', - }, - JMD: { - symbol: 'J$', - name: 'Jamaican Dollar', - ISO4217: '388', - }, - JOD: { - symbol: 'JOD', - name: 'Jordanian Dinar', - ISO4217: '400', - }, - JPY: { - symbol: '\u00a5', - name: 'Japanese Yen', - decimals: 0, - ISO4217: '392', - }, - KES: { - symbol: 'KSh', - name: 'Kenyan Shilling', - ISO4217: '404', - }, - KGS: { - symbol: 'KGS', - name: 'Kyrgyzstani Som', - ISO4217: '417', - }, - KHR: { - symbol: 'KHR', - name: 'Cambodia Riel', - ISO4217: '116', - }, - KMF: { - symbol: 'CF', - name: 'Comoros Franc', - ISO4217: '174', - }, - KPW: { - symbol: 'KP\u20a9', - name: 'North Korean Won', - ISO4217: '408', - }, - KRW: { - symbol: '\u20a9', - name: 'Korean Won', - ISO4217: '410', - }, - KWD: { - symbol: 'KWD', - name: 'Kuwaiti Dinar', - ISO4217: '414', - }, - KYD: { - symbol: 'CI$', - name: 'Cayman Islands Dollar', - ISO4217: '136', - }, - KZT: { - symbol: '\u3012', - name: 'Kazakhstan Tenge', - ISO4217: '398', - }, - LAK: { - symbol: '\u20ad', - name: 'Lao Kip', - ISO4217: '418', - }, - LBP: { - symbol: 'LBP', - name: 'Lebanese Pound', - ISO4217: '422', - }, - LKR: { - symbol: 'SL\u20a8', - name: 'Sri Lanka Rupee', - ISO4217: '144', - }, - LRD: { - symbol: 'L$', - name: 'Liberian Dollar', - ISO4217: '430', - }, - LSL: { - symbol: 'M', - name: 'Lesotho Loti', - ISO4217: '426', - }, - LTL: { - symbol: 'Lt', - name: 'Lithuanian Lita', - retirementDate: '2015-08-22', - retired: true, - ISO4217: '440', - }, - LVL: { - symbol: 'Ls', - name: 'Latvian Lat', - ISO4217: '428', - retired: true, - }, - LYD: { - symbol: 'LYD', - name: 'Libyan Dinar', - ISO4217: '434', - }, - MAD: { - symbol: 'MAD', - name: 'Moroccan Dirham', - ISO4217: '504', - }, - MDL: { - symbol: 'MDL', - name: 'Moldovan Leu', - ISO4217: '498', - }, - MGA: { - symbol: 'MGA', - name: 'Malagasy Ariary', - ISO4217: '969', - }, - MKD: { - symbol: '\u0434\u0435\u043d', - name: 'Macedonian Denar', - ISO4217: '807', - }, - MMK: { - symbol: 'Ks', - name: 'Myanmar Kyat', - ISO4217: '104', - }, - MNT: { - symbol: '\u20ae', - name: 'Mongolian Tugrik', - ISO4217: '496', - }, - MOP: { - symbol: 'MOP$', - name: 'Macau Pataca', - ISO4217: '446', - }, - MRO: { - symbol: 'UM', - name: 'Mauritania Ougulya', - decimals: 0, - retired: true, - retirementDate: '2018-07-11', - ISO4217: '478', - }, - MRU: { - symbol: 'UM', - name: 'Mauritania Ougulya', - decimals: 0, - ISO4217: '', - }, - MUR: { - symbol: 'Rs', - name: 'Mauritius Rupee', - ISO4217: '480', - }, - MVR: { - symbol: 'Rf', - name: 'Maldives Rufiyaa', - ISO4217: '462', - }, - MWK: { - symbol: 'MK', - name: 'Malawi Kwacha', - ISO4217: '454', - }, - MXN: { - symbol: 'Mex$', - name: 'Mexican Peso', - ISO4217: '484', - }, - MYR: { - symbol: 'RM', - name: 'Malaysian Ringgit', - ISO4217: '458', - }, - MZN: { - symbol: 'MTn', - name: 'Mozambican Metical', - ISO4217: '943', - }, - NAD: { - symbol: 'N$', - name: 'Namibian Dollar', - ISO4217: '516', - }, - NGN: { - symbol: '\u20a6', - name: 'Nigerian Naira', - ISO4217: '566', - }, - NIO: { - symbol: 'NIO', - name: 'Nicaragua Cordoba', - ISO4217: '558', - }, - NOK: { - symbol: 'Nkr', - name: 'Norwegian Krone', - ISO4217: '578', - }, - NPR: { - symbol: '\u20a8', - name: 'Nepalese Rupee', - ISO4217: '524', - }, - NZD: { - symbol: 'NZ$', - name: 'New Zealand Dollar', - ISO4217: '554', - }, - OMR: { - symbol: 'OMR', - name: 'Omani Rial', - ISO4217: '512', - }, - PAB: { - symbol: 'B', - name: 'Panama Balboa', - ISO4217: '590', - }, - PEN: { - symbol: 'S/.', - name: 'Peruvian Nuevo Sol', - ISO4217: '604', - }, - PGK: { - symbol: 'K', - name: 'Papua New Guinea Kina', - ISO4217: '598', - }, - PHP: { - symbol: '\u20b1', - name: 'Philippine Peso', - ISO4217: '608', - }, - PKR: { - symbol: 'Rs', - name: 'Pakistani Rupee', - ISO4217: '586', - }, - PLN: { - symbol: 'z\u0142', - name: 'Polish Zloty', - ISO4217: '985', - }, - PYG: { - symbol: '\u20b2', - name: 'Paraguayan Guarani', - ISO4217: '600', - }, - QAR: { - symbol: 'QAR', - name: 'Qatar Rial', - ISO4217: '634', - }, - RON: { - symbol: 'RON', - name: 'Romanian New Leu', - ISO4217: '946', - }, - RSD: { - symbol: '\u0420\u0421\u0414', - name: 'Serbian Dinar', - ISO4217: '941', - }, - RUB: { - symbol: '\u20bd', - name: 'Russian Rouble', - ISO4217: '643', - }, - RWF: { - symbol: 'RF', - name: 'Rwanda Franc', - decimals: 0, - ISO4217: '646', - }, - SAR: { - symbol: 'SAR', - name: 'Saudi Arabian Riyal', - ISO4217: '682', - }, - SBD: { - symbol: 'SI$', - name: 'Solomon Islands Dollar', - ISO4217: '090', - }, - SCR: { - symbol: 'SR', - name: 'Seychelles Rupee', - ISO4217: '690', - }, - SDG: { - symbol: 'SDG', - name: 'Sudanese Pound', - ISO4217: '938', - }, - SEK: { - symbol: 'Skr', - name: 'Swedish Krona', - ISO4217: '752', - }, - SGD: { - symbol: 'S$', - name: 'Singapore Dollar', - ISO4217: '702', - }, - SHP: { - symbol: '\u00a3S', - name: 'St Helena Pound', - ISO4217: '654', - }, - SLL: { - symbol: 'Le', - name: 'Sierra Leone Leone', - ISO4217: '694', - }, - SOS: { - symbol: 'So.', - name: 'Somali Shilling', - ISO4217: '706', - }, - SRD: { - symbol: 'SRD', - name: 'Surinamese Dollar', - ISO4217: '968', - }, - STD: { - symbol: 'Db', - name: 'Sao Tome Dobra', - retired: true, - retirementDate: '2018-07-11', - ISO4217: '678', - }, - STN: { - symbol: 'Db', - name: 'Sao Tome Dobra', - ISO4217: '', - }, - SVC: { - symbol: 'SVC', - name: 'El Salvador Colon', - ISO4217: '222', - }, - SYP: { - symbol: 'SYP', - name: 'Syrian Pound', - ISO4217: '760', - }, - SZL: { - symbol: 'E', - name: 'Swaziland Lilageni', - ISO4217: '748', - }, - THB: { - symbol: '\u0e3f', - name: 'Thai Baht', - ISO4217: '764', - }, - TJS: { - symbol: 'TJS', - name: 'Tajikistani Somoni', - ISO4217: '972', - }, - TMT: { - symbol: 'm', - name: 'Turkmenistani Manat', - ISO4217: '934', - }, - TND: { - symbol: 'TND', - name: 'Tunisian Dinar', - ISO4217: '788', - }, - TOP: { - symbol: 'T$', - name: "Tonga Pa'ang", - ISO4217: '776', - }, - TRY: { - symbol: 'TL', - name: 'Turkish Lira', - ISO4217: '949', - }, - TTD: { - symbol: 'TT$', - name: 'Trinidad & Tobago Dollar', - ISO4217: '780', - }, - TWD: { - symbol: 'NT$', - name: 'Taiwan Dollar', - ISO4217: '901', - }, - TZS: { - symbol: 'TZS', - name: 'Tanzanian Shilling', - ISO4217: '834', - }, - UAH: { - symbol: '\u20b4', - name: 'Ukraine Hryvnia', - ISO4217: '980', - }, - UGX: { - symbol: 'USh', - name: 'Ugandan Shilling', - decimals: 0, - ISO4217: '800', - }, - USD: { - symbol: '$', - name: 'United States Dollar', - ISO4217: '840', - }, - UYU: { - symbol: '$U', - name: 'Uruguayan New Peso', - ISO4217: '858', - }, - UZS: { - symbol: 'UZS', - name: 'Uzbekistani Som', - ISO4217: '860', - }, - VEB: { - symbol: 'Bs.', - name: 'Venezuelan Bolivar', - retired: true, - retirementDate: '2008-02-01', - ISO4217: '', - }, - VEF: { - symbol: 'Bs.F', - name: 'Venezuelan Bolivar Fuerte', - retired: true, - retirementDate: '2018-08-20', - ISO4217: '937', - }, - VES: { - symbol: 'Bs.S', - name: 'Venezuelan Bolivar Soberano', - ISO4217: '928', - }, - VND: { - symbol: '\u20ab', - name: 'Vietnam Dong', - decimals: 0, - ISO4217: '704', - }, - VUV: { - symbol: 'Vt', - name: 'Vanuatu Vatu', - ISO4217: '548', - }, - WST: { - symbol: 'WS$', - name: 'Samoa Tala', - ISO4217: '882', - }, - XAF: { - symbol: 'FCFA', - name: 'CFA Franc (BEAC)', - decimals: 0, - ISO4217: '950', - }, - XCD: { - symbol: 'EC$', - name: 'East Caribbean Dollar', - ISO4217: '951', - }, - XOF: { - symbol: 'CFA', - name: 'CFA Franc (BCEAO)', - decimals: 0, - ISO4217: '952', - }, - XPF: { - symbol: 'XPF', - name: 'Pacific Franc', - decimals: 0, - ISO4217: '953', - }, - YER: { - symbol: 'YER', - name: 'Yemen Riyal', - ISO4217: '886', - }, - ZAR: { - symbol: 'R', - name: 'South African Rand', - ISO4217: '710', - }, - ZMK: { - symbol: 'ZK', - name: 'Zambian Kwacha', - retired: true, - retirementDate: '2013-01-01', - ISO4217: '894', - }, - ZMW: { - symbol: 'ZMW', - name: 'Zambian Kwacha', - cacheBurst: 1, - ISO4217: '967', - }, - }, - }, - { - onyxMethod: 'merge', - key: 'nvp_priorityMode', - value: 'default', - }, - { - onyxMethod: 'merge', - key: 'isFirstTimeNewExpensifyUser', - value: false, - }, - { - onyxMethod: 'merge', - key: 'preferredLocale', - value: 'en', - }, - { - onyxMethod: 'merge', - key: 'preferredEmojiSkinTone', - value: -1, - }, - { - onyxMethod: 'set', - key: 'frequentlyUsedEmojis', - value: [ - { - code: '\ud83e\udd11', - count: 155, - keywords: ['rich', 'money_mouth_face', 'face', 'money', 'mouth'], - lastUpdatedAt: 1669657594, - name: 'money_mouth_face', - }, - { - code: '\ud83e\udd17', - count: 91, - keywords: ['hugs', 'face', 'hug', 'hugging'], - lastUpdatedAt: 1669660894, - name: 'hugs', - }, - { - code: '\ud83d\ude0d', - count: 68, - keywords: ['love', 'crush', 'heart_eyes', 'eye', 'face', 'heart', 'smile'], - lastUpdatedAt: 1669659126, - name: 'heart_eyes', - }, - { - code: '\ud83e\udd14', - count: 56, - keywords: ['thinking', 'face'], - lastUpdatedAt: 1669661008, - name: 'thinking', - }, - { - code: '\ud83d\ude02', - count: 55, - keywords: ['tears', 'joy', 'face', 'laugh', 'tear'], - lastUpdatedAt: 1670346435, - name: 'joy', - }, - { - code: '\ud83d\ude05', - count: 41, - keywords: ['hot', 'sweat_smile', 'cold', 'face', 'open', 'smile', 'sweat'], - lastUpdatedAt: 1670346845, - name: 'sweat_smile', - }, - { - code: '\ud83d\ude04', - count: 37, - keywords: ['happy', 'joy', 'laugh', 'pleased', 'smile', 'eye', 'face', 'mouth', 'open'], - lastUpdatedAt: 1669659306, - name: 'smile', - }, - { - code: '\ud83d\ude18', - count: 27, - keywords: ['face', 'heart', 'kiss'], - lastUpdatedAt: 1670346848, - name: 'kissing_heart', - }, - { - code: '\ud83e\udd23', - count: 25, - keywords: ['lol', 'laughing', 'rofl', 'face', 'floor', 'laugh', 'rolling'], - lastUpdatedAt: 1669659311, - name: 'rofl', - }, - { - code: '\ud83d\ude0b', - count: 18, - keywords: ['tongue', 'lick', 'yum', 'delicious', 'face', 'savouring', 'smile', 'um'], - lastUpdatedAt: 1669658204, - name: 'yum', - }, - { - code: '\ud83d\ude0a', - count: 17, - keywords: ['proud', 'blush', 'eye', 'face', 'smile'], - lastUpdatedAt: 1669661018, - name: 'blush', - }, - { - code: '\ud83d\ude06', - count: 17, - keywords: ['happy', 'haha', 'laughing', 'satisfied', 'face', 'laugh', 'mouth', 'open', 'smile'], - lastUpdatedAt: 1669659070, - name: 'laughing', - }, - { - code: '\ud83d\ude10', - count: 17, - keywords: ['deadpan', 'face', 'neutral'], - lastUpdatedAt: 1669658922, - name: 'neutral_face', - }, - { - code: '\ud83d\ude03', - count: 17, - keywords: ['happy', 'joy', 'haha', 'smiley', 'face', 'mouth', 'open', 'smile'], - lastUpdatedAt: 1669636981, - name: 'smiley', - }, - { - code: '\ud83d\ude17', - count: 15, - keywords: ['face', 'kiss'], - lastUpdatedAt: 1669639079, - name: 'kissing', - }, - { - code: '\ud83d\ude1a', - count: 14, - keywords: ['kissing_closed_eyes', 'closed', 'eye', 'face', 'kiss'], - lastUpdatedAt: 1669660248, - name: 'kissing_closed_eyes', - }, - { - code: '\ud83d\ude19', - count: 12, - keywords: ['kissing_smiling_eyes', 'eye', 'face', 'kiss', 'smile'], - lastUpdatedAt: 1669658208, - name: 'kissing_smiling_eyes', - }, - { - code: '\ud83e\udd10', - count: 11, - keywords: ['face', 'mouth', 'zipper'], - lastUpdatedAt: 1670346432, - name: 'zipper_mouth_face', - }, - { - code: '\ud83d\ude25', - count: 11, - keywords: ['disappointed', 'face', 'relieved', 'whew'], - lastUpdatedAt: 1669660257, - name: 'disappointed_relieved', - }, - { - code: '\ud83d\ude0e', - count: 11, - keywords: ['bright', 'cool', 'eye', 'eyewear', 'face', 'glasses', 'smile', 'sun', 'sunglasses', 'weather'], - lastUpdatedAt: 1669660252, - name: 'sunglasses', - }, - { - code: '\ud83d\ude36', - count: 11, - keywords: ['face', 'mouth', 'quiet', 'silent'], - lastUpdatedAt: 1669659075, - name: 'no_mouth', - }, - { - code: '\ud83d\ude11', - count: 11, - keywords: ['expressionless', 'face', 'inexpressive', 'unexpressive'], - lastUpdatedAt: 1669640332, - name: 'expressionless', - }, - { - code: '\ud83d\ude0f', - count: 11, - keywords: ['face', 'smirk'], - lastUpdatedAt: 1666207075, - name: 'smirk', - }, - { - code: '\ud83e\udd70', - count: 1, - keywords: ['love', 'smiling_face_with_three_hearts'], - lastUpdatedAt: 1670581230, - name: 'smiling_face_with_three_hearts', - }, - ], - }, - { - onyxMethod: 'merge', - key: 'private_blockedFromConcierge', - value: {}, - }, - { - onyxMethod: 'merge', - key: 'user', - value: { - isSubscribedToNewsletter: true, - validated: true, - isUsingExpensifyCard: true, - }, - }, - { - onyxMethod: 'set', - key: 'loginList', - value: { - 'applausetester+perf2@applause.expensifail.com': { - partnerName: 'expensify.com', - partnerUserID: 'applausetester+perf2@applause.expensifail.com', - validatedDate: '2022-08-01 05:00:48', - }, - }, - }, - { - onyxMethod: 'merge', - key: 'personalDetailsList', - value: { - 1: { - accountID: 1, - login: 'fake2@gmail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/7a1fd3cdd41564cf04f4305140372b59d1dcd495_128.jpeg', - displayName: 'fake2@gmail.com', - pronouns: '__predefined_zeHirHirs', - timezone: { - automatic: false, - selected: 'Europe/Monaco', - }, - firstName: '', - lastName: '', - phoneNumber: '', - validated: true, - }, - 2: { - accountID: 2, - login: 'fake1@gmail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/d76dfb6912a0095cbfd2a02f64f4d9d2d9c33c29_128.jpeg', - displayName: '"Chat N Laz"', - pronouns: '__predefined_theyThemTheirs', - timezone: { - automatic: true, - selected: 'Europe/Athens', - }, - firstName: '"Chat N', - lastName: 'Laz"', - phoneNumber: '', - validated: true, - }, - 3: { - accountID: 3, - login: 'fake4@gmail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/e769e0edf5fd0bc11cfa7c39ec2605c5310d26de_128.jpeg', - displayName: 'fake4@gmail.com', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Kyiv', - }, - firstName: '', - lastName: '', - phoneNumber: '', - validated: true, - }, - 4: { - accountID: 4, - login: 'fake3@gmail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/301e37631eca9e3127d6b668822e3a53771551f6_128.jpeg', - displayName: '123 Ios', - pronouns: '__predefined_perPers', - timezone: { - automatic: false, - selected: 'Europe/Helsinki', - }, - firstName: '123', - lastName: 'Ios', - phoneNumber: '', - validated: true, - }, - 5: { - accountID: 5, - login: 'fake5@gmail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/2810a38b66d9a60fe41a9cf39c9fd6ecbe2cb35f_128.jpeg', - displayName: 'Qqq Qqq', - pronouns: '__predefined_sheHerHers', - timezone: { - automatic: false, - selected: 'Europe/Lisbon', - }, - firstName: 'Qqq', - lastName: 'Qqq', - phoneNumber: '', - validated: true, - }, - 6: { - accountID: 6, - login: 'andreylazutkinutest@gmail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/2af13161ffcc95fc807769bb22c013c32280f338_128.jpeg', - displayName: 'Main Ios🏴󠁧󠁢󠁳󠁣󠁴󠁿ios', - pronouns: '__predefined_heHimHis', - timezone: { - automatic: false, - selected: 'Europe/London', - }, - firstName: 'Main', - lastName: 'Ios🏴󠁧󠁢󠁳󠁣󠁴󠁿ios', - phoneNumber: '', - validated: true, - }, - 7: { - accountID: 7, - login: 'applausetester+0604lsn@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/ad20184a011ed54383d69e4fe68658522583cbb8_128.jpeg', - displayName: '0604 Lsn', - pronouns: '__predefined_zeHirHirs', - timezone: { - automatic: false, - selected: 'America/Costa_Rica', - }, - firstName: '0604', - lastName: 'Lsn', - phoneNumber: '', - validated: true, - }, - 8: { - accountID: 8, - login: 'applausetester+0704sveta@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/63cc4a392cc64ba1c8f6a1b90d5f1441a23270d1_128.jpeg', - displayName: '07 04 0704 Lsn lsn', - pronouns: '__predefined_callMeByMyName', - timezone: { - automatic: false, - selected: 'Africa/Freetown', - }, - firstName: '07 04 0704', - lastName: 'Lsn lsn', - phoneNumber: '', - validated: true, - }, - 9: { - accountID: 9, - login: 'applausetester+0707abb@applause.expensifail.com', - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_5.png', - displayName: 'Katya Becciv', - pronouns: '__predefined_sheHerHers', - timezone: { - automatic: false, - selected: 'America/New_York', - }, - firstName: 'Katya', - lastName: 'Becciv', - phoneNumber: '', - validated: true, - }, - 10: { - accountID: 10, - login: 'applausetester+0901abb@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/0f6e999ba61695599f092b7652c1e159aee62c65_128.jpeg', - displayName: 'Katie Becciv', - pronouns: '__predefined_faeFaer', - timezone: { - automatic: false, - selected: 'Africa/Accra', - }, - firstName: 'Katie', - lastName: 'Becciv', - phoneNumber: '', - validated: true, - }, - 11: { - accountID: 11, - login: 'applausetester+1904lsn@applause.expensifail.com', - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_5.png', - displayName: '11 11', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Athens', - }, - firstName: '11', - lastName: '11', - phoneNumber: '', - validated: true, - }, - 12: { - accountID: 12, - login: 'applausetester+42222abb@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/d166c112f300a6e30bc70752cd394c3fde099e4f_128.jpeg', - displayName: '"First"', - pronouns: '', - timezone: { - automatic: true, - selected: 'America/New_York', - }, - firstName: '"First"', - lastName: '', - phoneNumber: '', - validated: true, - }, - 13: { - accountID: 13, - login: 'applausetester+bernardo@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/803733b7038bbd5e543315fa9c6c0118eda227af_128.jpeg', - displayName: 'bernardo utest', - pronouns: '', - timezone: { - automatic: true, - selected: 'America/Los_Angeles', - }, - firstName: 'bernardo', - lastName: 'utest', - phoneNumber: '', - validated: false, - }, - 14: { - accountID: 14, - login: 'applausetester+ihchat4@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/1008dcaadc12badbddf4720dcb7ad99b7384c613_128.jpeg', - displayName: 'Chat HT', - pronouns: '__predefined_callMeByMyName', - timezone: { - automatic: true, - selected: 'Europe/Kyiv', - }, - firstName: 'Chat', - lastName: 'HT', - phoneNumber: '', - validated: true, - }, - 15: { - accountID: 15, - login: 'applausetester+pd1005@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/86c9b7dce35aea83b69c6e825a4b3d00a87389b7_128.jpeg', - displayName: 'applausetester+pd1005@applause.expensifail.com', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Lisbon', - }, - firstName: '', - lastName: '', - phoneNumber: '', - validated: true, - }, - 16: { - accountID: 16, - login: 'fake6@gmail.com', - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_7.png', - displayName: 'fake6@gmail.com', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Warsaw', - }, - firstName: '', - lastName: '', - phoneNumber: '', - validated: true, - }, - 17: { - accountID: 17, - login: 'applausetester+perf2@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/1486f9cc6367d8c399ee453ad5b686d157bb4dda_128.jpeg', - displayName: 'applausetester+perf2@applause.expensifail.com', - pronouns: '', - timezone: { - automatic: true, - selected: 'America/Los_Angeles', - }, - firstName: '', - lastName: '', - phoneNumber: '', - validated: true, - localCurrencyCode: 'USD', - }, - 18: { - accountID: 18, - login: 'fake7@gmail.com', - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_2.png', - displayName: 'fake7@gmail.com', - pronouns: '', - timezone: { - automatic: true, - selected: 'America/Toronto', - }, - firstName: '', - lastName: '', - phoneNumber: '', - validated: true, - }, - 19: { - accountID: 19, - login: 'fake8@gmail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/7b0a9cf9c93987053be9d6cc707cb1f091a1ef46_128.jpeg', - displayName: 'fake8@gmail.com', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Paris', - }, - firstName: '', - lastName: '', - phoneNumber: '', - validated: true, - }, - 20: { - accountID: 20, - login: 'applausetester@applause.expensifail.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/8ddbb1a4675883ea12b3021f698a8b2dcfc18d42_128.jpeg', - displayName: 'Applause Main Account', - pronouns: '__predefined_coCos', - timezone: { - automatic: true, - selected: 'Europe/Kyiv', - }, - firstName: 'Applause', - lastName: 'Main Account', - phoneNumber: '', - validated: true, - }, - 21: { - accountID: 21, - login: 'christoph+hightraffic@margelo.io', - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_1.png', - displayName: 'Christoph Pader', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Vienna', - }, - firstName: 'Christoph', - lastName: 'Pader', - phoneNumber: '', - validated: true, - }, - 22: { - accountID: 22, - login: 'concierge@expensify.com', - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/icons/concierge_2022.png', - displayName: 'Concierge', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Moscow', - }, - firstName: 'Concierge', - lastName: '', - phoneNumber: '', - validated: true, - }, - 23: { - accountID: 23, - login: 'svetlanalazutkinautest+0211@gmail.com', - avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_6.png', - displayName: 'Chat S', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Kyiv', - }, - firstName: 'Chat S', - lastName: '', - phoneNumber: '', - validated: true, - }, - 24: { - accountID: 24, - login: 'tayla.lay@team.expensify.com', - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/d3196c27ed6bdb2df741af29a3ccfdb0f9919c41_128.jpeg', - displayName: 'Tayla Simmons', - pronouns: '__predefined_sheHerHers', - timezone: { - automatic: true, - selected: 'America/Chicago', - }, - firstName: 'Tayla', - lastName: 'Simmons', - phoneNumber: '', - validated: true, - }, - }, - }, - { - onyxMethod: 'set', - key: 'betas', - value: ['all'], - }, - { - onyxMethod: 'merge', - key: 'countryCode', - value: 1, - }, - { - onyxMethod: 'merge', - key: 'account', - value: { - requiresTwoFactorAuth: false, - }, - }, - { - onyxMethod: 'mergecollection', - key: 'policy_', - value: { - policy_28493C792FA01DAE: { - isFromFullPolicy: false, - id: '28493C792FA01DAE', - name: "applausetester+perf2's Expenses", - role: 'admin', - type: 'personal', - owner: 'applausetester+perf2@applause.expensifail.com', - outputCurrency: 'USD', - avatar: '', - employeeList: [], - }, - policy_A6511FF8D2EE7661: { - isFromFullPolicy: false, - id: 'A6511FF8D2EE7661', - name: "Applause's Workspace", - role: 'admin', - type: 'free', - owner: 'applausetester+perf2@applause.expensifail.com', - outputCurrency: 'INR', - avatar: '', - employeeList: [], - }, - }, - }, - { - onyxMethod: 'mergecollection', - key: 'report_', - value: { - report_98258097: { - reportID: '98258097', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [22], - isPinned: true, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-08-03 06:45:00', - lastMessageTimestamp: 1659509100000, - lastMessageText: 'You can easily track, approve, and pay bills in Expensify with your custom compa', - lastActorAccountID: 22, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: - 'You can easily track, approve, and pay bills in Expensify with your custom company bill pay email address: ' + - 'applause.expensifail.com@expensify.cash. Learn more ' + - 'here.' + - ' For questions, just reply to this message.', - }, - report_98258458: { - reportID: '98258458', - reportName: '', - chatType: 'policyExpenseChat', - ownerAccountID: 17, - policyID: 'C28C2634DD7226B8', - participantAccountIDs: [20, 17], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-11-03 20:30:55.599', - lastMessageTimestamp: 1667507455599, - lastMessageText: '', - lastActorAccountID: 20, - notificationPreference: 'always', - stateNum: 2, - statusNum: 2, - oldPolicyName: 'Crowded Policy - Definitive Edition', - visibility: null, - isOwnPolicyExpenseChat: true, - lastMessageHtml: '', - }, - report_98344717: { - reportID: '98344717', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [14], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-08-02 20:03:42', - lastMessageTimestamp: 1659470622000, - lastMessageText: 'Requested \u20b41.67 from applausetester+perf2@applause.expensifail.com', - lastActorAccountID: 14, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Requested \u20b41.67 from applausetester+perf2@applause.expensifail.com', - }, - report_98345050: { - reportID: '98345050', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [4], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-11-04 21:18:00.038', - lastMessageTimestamp: 1667596680038, - lastMessageText: 'Cancelled the \u20b440.00 request', - lastActorAccountID: 4, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Cancelled the \u20b440.00 request', - }, - report_98345315: { - reportID: '98345315', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [4, 16, 18, 19], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-08-01 20:48:16', - lastMessageTimestamp: 1659386896000, - lastMessageText: 'applausetester+perf2@applause.expensifail.com', - lastActorAccountID: 4, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'applausetester+perf2@applause.expensifail.com', - }, - report_98345625: { - reportID: '98345625', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [2, 1, 4, 3, 5, 16, 18, 19], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-08-01 20:49:11', - lastMessageTimestamp: 1659386951000, - lastMessageText: 'Say hello\ud83d\ude10', - lastActorAccountID: 4, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Say hello\ud83d\ude10', - }, - report_98345679: { - reportID: '98345679', - reportName: '', - chatType: 'policyExpenseChat', - ownerAccountID: 17, - policyID: '1CE001C4B9F3CA54', - participantAccountIDs: [4, 17], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-08-16 12:30:57', - lastMessageTimestamp: 1660653057000, - lastMessageText: '', - lastActorAccountID: 4, - notificationPreference: 'always', - stateNum: 2, - statusNum: 2, - oldPolicyName: "Andreylazutkinutest+123's workspace", - visibility: null, - isOwnPolicyExpenseChat: true, - lastMessageHtml: '', - }, - report_98414813: { - reportID: '98414813', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [14, 16], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-08-02 20:03:41', - lastMessageTimestamp: 1659470621000, - lastMessageText: 'Split \u20b45.00 with applausetester+perf2@applause.expensifail.com and applauseteste', - lastActorAccountID: 14, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Split \u20b45.00 with applausetester+perf2@applause.expensifail.com and fake6@gmail.com', - }, - report_98817646: { - reportID: '98817646', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [16], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-12-09 10:17:18.362', - lastMessageTimestamp: 1670581038362, - lastMessageText: 'RR', - lastActorAccountID: 17, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'RR', - iouReportID: '2543745284790730', - }, - report_358751490033727: { - reportID: '358751490033727', - reportName: '#digimobileroom', - chatType: 'policyRoom', - ownerAccountID: 0, - policyID: 'C28C2634DD7226B8', - participantAccountIDs: [15], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-10-12 17:47:45.228', - lastMessageTimestamp: 1665596865228, - lastMessageText: 'STAGING_CHAT_MESSAGE_A2C534B7-3509-416E-A0AD-8463831C29DD', - lastActorAccountID: 25, - notificationPreference: 'daily', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: 'restricted', - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'STAGING_CHAT_MESSAGE_A2C534B7-3509-416E-A0AD-8463831C29DD', - }, - report_663424408122117: { - reportID: '663424408122117', - reportName: '#announce', - chatType: 'policyAnnounce', - ownerAccountID: 0, - policyID: 'A6511FF8D2EE7661', - participantAccountIDs: [17], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '', - lastMessageTimestamp: 0, - lastMessageText: '', - lastActorAccountID: 0, - notificationPreference: 'daily', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: '', - }, - report_944123936554214: { - reportID: '944123936554214', - reportName: '', - chatType: 'policyExpenseChat', - ownerAccountID: 17, - policyID: 'A6511FF8D2EE7661', - participantAccountIDs: [17], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '', - lastMessageTimestamp: 0, - lastMessageText: '', - lastActorAccountID: 0, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: true, - lastMessageHtml: '', - }, - report_2242399088152511: { - reportID: '2242399088152511', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [22, 10, 6, 8, 4], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-11-03 20:48:58.815', - lastMessageTimestamp: 1667508538815, - lastMessageText: 'Hi there, thanks for reaching out! How may I help?', - lastActorAccountID: 22, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: '

Hi there, thanks for reaching out! How may I help?

', - }, - report_2576922422943214: { - reportID: '2576922422943214', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [12], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-12-01 08:05:11.009', - lastMessageTimestamp: 1669881911009, - lastMessageText: 'Test', - lastActorAccountID: 17, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Test', - }, - report_2752461403207161: { - reportID: '2752461403207161', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [2], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '', - lastMessageTimestamp: 0, - lastMessageText: '', - lastActorAccountID: 0, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: '', - }, - report_3785654888638968: { - reportID: '3785654888638968', - reportName: '#jack', - chatType: 'policyRoom', - ownerAccountID: 0, - policyID: 'C28C2634DD7226B8', - participantAccountIDs: [15], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-10-12 12:20:00.668', - lastMessageTimestamp: 1665577200668, - lastMessageText: 'Room renamed to #jack', - lastActorAccountID: 15, - notificationPreference: 'daily', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: 'restricted', - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Room renamed to #jack', - }, - report_4867098979334014: { - reportID: '4867098979334014', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [21], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-12-16 18:14:00.208', - lastMessageTimestamp: 1671214440208, - lastMessageText: 'Requested \u20ac200.00 from Christoph for Essen mit Kunden', - lastActorAccountID: 17, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Requested \u20ac200.00 from Christoph for Essen mit Kunden', - iouReportID: '4249286573496381', - }, - report_5277760851229035: { - reportID: '5277760851229035', - reportName: '#kasper_tha_cat', - chatType: 'policyRoom', - ownerAccountID: 0, - policyID: 'C28C2634DD7226B8', - participantAccountIDs: [15], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-11-29 12:38:15.985', - lastMessageTimestamp: 1669725495985, - lastMessageText: 'fff', - lastActorAccountID: 16, - notificationPreference: 'daily', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: 'restricted', - isOwnPolicyExpenseChat: false, - lastMessageHtml: - 'fff
f
f
f
f
f
f
f
f

f
f
f
f

f
' + - 'f
f
f
f
f

f
f
f
f
f
ff', - }, - report_5324367938904284: { - reportID: '5324367938904284', - reportName: '#applause.expensifail.com', - chatType: 'domainAll', - ownerAccountID: 99, - policyID: '_FAKE_', - participantAccountIDs: [13], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-11-29 21:08:00.793', - lastMessageTimestamp: 1669756080793, - lastMessageText: 'Iviviviv8b', - lastActorAccountID: 10, - notificationPreference: 'daily', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Iviviviv8b', - }, - report_5654270288238256: { - reportID: '5654270288238256', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [6, 2, 9, 4, 5, 7, 100, 11], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '', - lastMessageTimestamp: 0, - lastMessageText: '', - lastActorAccountID: 0, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: '', - }, - report_6194900075541844: { - reportID: '6194900075541844', - reportName: '#admins', - chatType: 'policyAdmins', - ownerAccountID: 0, - policyID: 'A6511FF8D2EE7661', - participantAccountIDs: [17], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '', - lastMessageTimestamp: 0, - lastMessageText: '', - lastActorAccountID: 0, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: '', - }, - report_6801643744224146: { - reportID: '6801643744224146', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [22, 6, 2, 23, 9, 4, 5, 7], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-09-15 12:57:59.526', - lastMessageTimestamp: 1663246679526, - lastMessageText: "\ud83d\udc4b Welcome to Expensify! I'm Concierge. Is there anything I can help with? Click ", - lastActorAccountID: 22, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: - "\ud83d\udc4b Welcome to Expensify! I'm Concierge. Is there anything I can help with? Click the + icon on the homescreen to explore the features you can use.", - }, - report_7658708888047100: { - reportID: '7658708888047100', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [22, 6, 4, 5, 24, 101], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-09-16 11:12:46.739', - lastMessageTimestamp: 1663326766739, - lastMessageText: 'Hi there! How can I help?\u00a0', - lastActorAccountID: 22, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Hi there! How can I help?\u00a0', - }, - report_7756405299640824: { - reportID: '7756405299640824', - reportName: '#jackd23', - chatType: 'policyRoom', - ownerAccountID: 0, - policyID: 'C28C2634DD7226B8', - participantAccountIDs: [15], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '2022-10-12 12:46:43.577', - lastMessageTimestamp: 1665578803577, - lastMessageText: 'Room renamed to #jackd23', - lastActorAccountID: 15, - notificationPreference: 'daily', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: 'restricted', - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'Room renamed to #jackd23', - }, - report_7819732651025410: { - reportID: '7819732651025410', - reportName: 'Chat Report', - chatType: null, - ownerAccountID: 0, - policyID: '_FAKE_', - participantAccountIDs: [5], - isPinned: false, - lastReadCreated: '1980-01-01 00:00:00.000', - lastVisibleActionCreated: '', - lastMessageTimestamp: 0, - lastMessageText: '', - lastActorAccountID: 0, - notificationPreference: 'always', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: '', - }, - report_2543745284790730: { - reportID: '2543745284790730', - ownerAccountID: 17, - managerID: 16, - currency: 'USD', - chatReportID: '98817646', - cachedTotal: '($1,473.11)', - total: 147311, - stateNum: 1, - statusNum: 1, - }, - report_4249286573496381: { - reportID: '4249286573496381', - ownerAccountID: 17, - managerID: 21, - currency: 'USD', - chatReportID: '4867098979334014', - cachedTotal: '($212.78)', - total: 21278, - stateNum: 1, - statusNum: 1, - }, - }, - }, - ], - jsonCode: 200, - requestID: '783ef7fac81f969a-SJC', -}); - -export default openApp; diff --git a/src/libs/E2E/apiMocks/openReport.ts b/src/libs/E2E/apiMocks/openReport.ts deleted file mode 100644 index 49d44605592d..000000000000 --- a/src/libs/E2E/apiMocks/openReport.ts +++ /dev/null @@ -1,1972 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import type Response from '@src/types/onyx/Response'; - -export default (): Response => ({ - onyxData: [ - { - onyxMethod: 'merge', - key: 'report_98345625', - value: { - reportID: '98345625', - reportName: 'Chat Report', - type: 'chat', - chatType: null, - ownerAccountID: 0, - managerID: 0, - policyID: '_FAKE_', - participantAccountIDs: [14567013], - isPinned: false, - lastReadTime: '2023-09-14 11:50:21.768', - lastMentionedTime: '2023-07-27 07:37:43.100', - lastReadSequenceNumber: 0, - lastVisibleActionCreated: '2023-08-29 12:38:16.070', - lastVisibleActionLastModified: '2023-08-29 12:38:16.070', - lastMessageText: 'terry+hightraffic@margelo.io owes \u20ac12.00', - lastActorAccountID: 14567013, - notificationPreference: 'always', - welcomeMessage: '', - stateNum: 0, - statusNum: 0, - oldPolicyName: '', - visibility: null, - isOwnPolicyExpenseChat: false, - lastMessageHtml: 'terry+hightraffic@margelo.io owes \u20ac12.00', - iouReportID: '206636935813547', - hasOutstandingChildRequest: false, - policyName: null, - hasParentAccess: null, - parentReportID: null, - parentReportActionID: null, - writeCapability: 'all', - description: null, - isDeletedParentAction: null, - total: 0, - currency: 'USD', - chatReportID: null, - isWaitingOnBankAccount: false, - }, - }, - { - onyxMethod: 'mergecollection', - key: 'transactions_', - value: { - transactions_5509240412000765850: { - amount: 1200, - billable: false, - cardID: 15467728, - category: '', - comment: { - comment: '', - }, - created: '2023-08-29', - currency: 'EUR', - filename: '', - merchant: 'Request', - modifiedAmount: 0, - modifiedCreated: '', - modifiedCurrency: '', - modifiedMerchant: '', - originalAmount: 0, - originalCurrency: '', - parentTransactionID: '', - receipt: {}, - reimbursable: true, - reportID: '206636935813547', - status: 'Pending', - tag: '', - transactionID: '5509240412000765850', - hasEReceipt: false, - }, - }, - }, - { - onyxMethod: 'merge', - key: 'reportActions_98345625', - value: { - '885570376575240776': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: '', - text: '', - isEdited: true, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - edits: [], - html: '', - lastModified: '2023-09-01 07:43:29.374', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-31 07:23:52.892', - timestamp: 1693466632, - reportActionTimestamp: 1693466632892, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '885570376575240776', - previousReportActionID: '6576518341807837187', - lastModified: '2023-09-01 07:43:29.374', - whisperedToAccountIDs: [], - }, - '6576518341807837187': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'terry+hightraffic@margelo.io owes \u20ac12.00', - text: 'terry+hightraffic@margelo.io owes \u20ac12.00', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - lastModified: '2023-08-29 12:38:16.070', - linkedReportID: '206636935813547', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-08-29 12:38:16.070', - timestamp: 1693312696, - reportActionTimestamp: 1693312696070, - automatic: false, - actionName: 'REPORTPREVIEW', - shouldShow: true, - reportActionID: '6576518341807837187', - previousReportActionID: '2658221912430757962', - lastModified: '2023-08-29 12:38:16.070', - childReportID: '206636935813547', - childType: 'iou', - childStatusNum: 1, - childStateNum: 1, - childMoneyRequestCount: 1, - whisperedToAccountIDs: [], - }, - '2658221912430757962': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'Hshshdhdhejje
Cuududdke

F
D
R
D
R
Jfj c
D

D
D
R
D
R', - text: 'Hshshdhdhejje\nCuududdke\n\nF\nD\nR\nD\nR\nJfj c\nD\n\nD\nD\nR\nD\nR', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [ - { - emoji: 'heart', - users: [ - { - accountID: 12883048, - skinTone: -1, - }, - ], - }, - ], - }, - ], - originalMessage: { - html: 'Hshshdhdhejje
Cuududdke

F
D
R
D
R
Jfj c
D

D
D
R
D
R', - lastModified: '2023-08-25 12:39:48.121', - reactions: [ - { - emoji: 'heart', - users: [ - { - accountID: 12883048, - skinTone: -1, - }, - ], - }, - ], - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-25 08:54:06.972', - timestamp: 1692953646, - reportActionTimestamp: 1692953646972, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '2658221912430757962', - previousReportActionID: '6551789403725495383', - lastModified: '2023-08-25 12:39:48.121', - childReportID: '1411015346900020', - childType: 'chat', - childOldestFourAccountIDs: '12883048', - childCommenterCount: 1, - childLastVisibleActionCreated: '2023-08-29 06:08:59.247', - childVisibleActionCount: 1, - whisperedToAccountIDs: [], - }, - '6551789403725495383': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'Typing with the composer is now also reasonably fast again', - text: 'Typing with the composer is now also reasonably fast again', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'Typing with the composer is now also reasonably fast again', - lastModified: '2023-08-25 08:53:57.490', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-25 08:53:57.490', - timestamp: 1692953637, - reportActionTimestamp: 1692953637490, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '6551789403725495383', - previousReportActionID: '6184477005811241106', - lastModified: '2023-08-25 08:53:57.490', - whisperedToAccountIDs: [], - }, - '6184477005811241106': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: '\ud83d\ude3a', - text: '\ud83d\ude3a', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: '\ud83d\ude3a', - lastModified: '2023-08-25 08:53:41.689', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-25 08:53:41.689', - timestamp: 1692953621, - reportActionTimestamp: 1692953621689, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '6184477005811241106', - previousReportActionID: '7473953427765241164', - lastModified: '2023-08-25 08:53:41.689', - whisperedToAccountIDs: [], - }, - '7473953427765241164': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'Skkkkkkrrrrrrrr', - text: 'Skkkkkkrrrrrrrr', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'Skkkkkkrrrrrrrr', - lastModified: '2023-08-25 08:53:31.900', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-25 08:53:31.900', - timestamp: 1692953611, - reportActionTimestamp: 1692953611900, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '7473953427765241164', - previousReportActionID: '872421684593496491', - lastModified: '2023-08-25 08:53:31.900', - whisperedToAccountIDs: [], - }, - '872421684593496491': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', - text: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'hello this is a new test will my version sync though? i doubt it lolasdasdasdaoe f t asdasd okay und das ging jetzt eh oder? ja schaut ganz gut aus okay geht das immer noch ? schaut gut aus ja true ghw test test 2 test 4 tse 3 oida', - lastModified: '2023-08-11 13:35:03.962', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-11 13:35:03.962', - timestamp: 1691760903, - reportActionTimestamp: 1691760903962, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '872421684593496491', - previousReportActionID: '175680146540578558', - lastModified: '2023-08-11 13:35:03.962', - whisperedToAccountIDs: [], - }, - '175680146540578558': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: '', - text: '[Attachment]', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: '', - lastModified: '2023-08-10 06:59:21.381', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-10 06:59:21.381', - timestamp: 1691650761, - reportActionTimestamp: 1691650761381, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '175680146540578558', - previousReportActionID: '1264289784533901723', - lastModified: '2023-08-10 06:59:21.381', - whisperedToAccountIDs: [], - }, - '1264289784533901723': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: '', - text: '[Attachment]', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: '', - lastModified: '2023-08-10 06:59:16.922', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-10 06:59:16.922', - timestamp: 1691650756, - reportActionTimestamp: 1691650756922, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '1264289784533901723', - previousReportActionID: '4870277010164688289', - lastModified: '2023-08-10 06:59:16.922', - whisperedToAccountIDs: [], - }, - '4870277010164688289': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'send test', - text: 'send test', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'send test', - lastModified: '2023-08-09 06:43:25.209', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-09 06:43:25.209', - timestamp: 1691563405, - reportActionTimestamp: 1691563405209, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '4870277010164688289', - previousReportActionID: '7931783095143103530', - lastModified: '2023-08-09 06:43:25.209', - whisperedToAccountIDs: [], - }, - '7931783095143103530': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', - text: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'hello terry \ud83d\ude04 this is a test @terry+hightraffic@margelo.io', - lastModified: '2023-08-08 14:38:45.035', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-08 14:38:45.035', - timestamp: 1691505525, - reportActionTimestamp: 1691505525035, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '7931783095143103530', - previousReportActionID: '4598496324774172433', - lastModified: '2023-08-08 14:38:45.035', - whisperedToAccountIDs: [], - }, - '4598496324774172433': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: '\ud83d\uddff', - text: '\ud83d\uddff', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: '\ud83d\uddff', - lastModified: '2023-08-08 13:21:42.102', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-08 13:21:42.102', - timestamp: 1691500902, - reportActionTimestamp: 1691500902102, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '4598496324774172433', - previousReportActionID: '3324110555952451144', - lastModified: '2023-08-08 13:21:42.102', - whisperedToAccountIDs: [], - }, - '3324110555952451144': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'test \ud83d\uddff', - text: 'test \ud83d\uddff', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test \ud83d\uddff', - lastModified: '2023-08-08 13:21:32.101', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-08 13:21:32.101', - timestamp: 1691500892, - reportActionTimestamp: 1691500892101, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '3324110555952451144', - previousReportActionID: '5389364980227777980', - lastModified: '2023-08-08 13:21:32.101', - whisperedToAccountIDs: [], - }, - '5389364980227777980': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'okay now it will work again y \ud83d\udc42', - text: 'okay now it will work again y \ud83d\udc42', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'okay now it will work again y \ud83d\udc42', - lastModified: '2023-08-07 10:54:38.141', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-08-07 10:54:38.141', - timestamp: 1691405678, - reportActionTimestamp: 1691405678141, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '5389364980227777980', - previousReportActionID: '4717622390560689493', - lastModified: '2023-08-07 10:54:38.141', - whisperedToAccountIDs: [], - }, - '4717622390560689493': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'hmmmm', - text: 'hmmmm', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'hmmmm', - lastModified: '2023-07-27 18:13:45.322', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-27 18:13:45.322', - timestamp: 1690481625, - reportActionTimestamp: 1690481625322, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '4717622390560689493', - previousReportActionID: '745721424446883075', - lastModified: '2023-07-27 18:13:45.322', - whisperedToAccountIDs: [], - }, - '745721424446883075': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'test', - text: 'test', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test', - lastModified: '2023-07-27 18:13:32.595', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-27 18:13:32.595', - timestamp: 1690481612, - reportActionTimestamp: 1690481612595, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '745721424446883075', - previousReportActionID: '3986429677777110818', - lastModified: '2023-07-27 18:13:32.595', - whisperedToAccountIDs: [], - }, - '3986429677777110818': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'I will', - text: 'I will', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'I will', - lastModified: '2023-07-27 17:03:11.250', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 17:03:11.250', - timestamp: 1690477391, - reportActionTimestamp: 1690477391250, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '3986429677777110818', - previousReportActionID: '7317910228472011573', - lastModified: '2023-07-27 17:03:11.250', - childReportID: '3338245207149134', - childType: 'chat', - whisperedToAccountIDs: [], - }, - '7317910228472011573': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'will you>', - text: 'will you>', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'will you>', - lastModified: '2023-07-27 16:46:58.988', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-27 16:46:58.988', - timestamp: 1690476418, - reportActionTimestamp: 1690476418988, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '7317910228472011573', - previousReportActionID: '6779343397958390319', - lastModified: '2023-07-27 16:46:58.988', - whisperedToAccountIDs: [], - }, - '6779343397958390319': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'i will always send :#', - text: 'i will always send :#', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'i will always send :#', - lastModified: '2023-07-27 07:55:33.468', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:55:33.468', - timestamp: 1690444533, - reportActionTimestamp: 1690444533468, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '6779343397958390319', - previousReportActionID: '5084145419388195535', - lastModified: '2023-07-27 07:55:33.468', - whisperedToAccountIDs: [], - }, - '5084145419388195535': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'new test', - text: 'new test', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'new test', - lastModified: '2023-07-27 07:55:22.309', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:55:22.309', - timestamp: 1690444522, - reportActionTimestamp: 1690444522309, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '5084145419388195535', - previousReportActionID: '6742067600980190659', - lastModified: '2023-07-27 07:55:22.309', - whisperedToAccountIDs: [], - }, - '6742067600980190659': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'okay good', - text: 'okay good', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'okay good', - lastModified: '2023-07-27 07:55:15.362', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:55:15.362', - timestamp: 1690444515, - reportActionTimestamp: 1690444515362, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '6742067600980190659', - previousReportActionID: '7811212427986810247', - lastModified: '2023-07-27 07:55:15.362', - whisperedToAccountIDs: [], - }, - '7811212427986810247': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'test 2', - text: 'test 2', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test 2', - lastModified: '2023-07-27 07:55:10.629', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:55:10.629', - timestamp: 1690444510, - reportActionTimestamp: 1690444510629, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '7811212427986810247', - previousReportActionID: '4544757211729131829', - lastModified: '2023-07-27 07:55:10.629', - whisperedToAccountIDs: [], - }, - '4544757211729131829': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'new test', - text: 'new test', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'new test', - lastModified: '2023-07-27 07:53:41.960', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:53:41.960', - timestamp: 1690444421, - reportActionTimestamp: 1690444421960, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '4544757211729131829', - previousReportActionID: '8290114634148431001', - lastModified: '2023-07-27 07:53:41.960', - whisperedToAccountIDs: [], - }, - '8290114634148431001': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'something was real', - text: 'something was real', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'something was real', - lastModified: '2023-07-27 07:53:27.836', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:53:27.836', - timestamp: 1690444407, - reportActionTimestamp: 1690444407836, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '8290114634148431001', - previousReportActionID: '5597494166918965742', - lastModified: '2023-07-27 07:53:27.836', - whisperedToAccountIDs: [], - }, - '5597494166918965742': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'oida', - text: 'oida', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'oida', - lastModified: '2023-07-27 07:53:20.783', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:53:20.783', - timestamp: 1690444400, - reportActionTimestamp: 1690444400783, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '5597494166918965742', - previousReportActionID: '7445709165354739065', - lastModified: '2023-07-27 07:53:20.783', - whisperedToAccountIDs: [], - }, - '7445709165354739065': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'test 12', - text: 'test 12', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test 12', - lastModified: '2023-07-27 07:53:17.393', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:53:17.393', - timestamp: 1690444397, - reportActionTimestamp: 1690444397393, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '7445709165354739065', - previousReportActionID: '1985264407541504554', - lastModified: '2023-07-27 07:53:17.393', - whisperedToAccountIDs: [], - }, - '1985264407541504554': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'new test', - text: 'new test', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'new test', - lastModified: '2023-07-27 07:53:07.894', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:53:07.894', - timestamp: 1690444387, - reportActionTimestamp: 1690444387894, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '1985264407541504554', - previousReportActionID: '6101278009725036288', - lastModified: '2023-07-27 07:53:07.894', - whisperedToAccountIDs: [], - }, - '6101278009725036288': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'grrr', - text: 'grrr', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'grrr', - lastModified: '2023-07-27 07:52:56.421', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:52:56.421', - timestamp: 1690444376, - reportActionTimestamp: 1690444376421, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '6101278009725036288', - previousReportActionID: '6913024396112106680', - lastModified: '2023-07-27 07:52:56.421', - whisperedToAccountIDs: [], - }, - '6913024396112106680': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'ne w test', - text: 'ne w test', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'ne w test', - lastModified: '2023-07-27 07:52:53.352', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:52:53.352', - timestamp: 1690444373, - reportActionTimestamp: 1690444373352, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '6913024396112106680', - previousReportActionID: '3663318486255461038', - lastModified: '2023-07-27 07:52:53.352', - whisperedToAccountIDs: [], - }, - '3663318486255461038': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'well', - text: 'well', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'well', - lastModified: '2023-07-27 07:52:47.044', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:52:47.044', - timestamp: 1690444367, - reportActionTimestamp: 1690444367044, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '3663318486255461038', - previousReportActionID: '6652909175804277965', - lastModified: '2023-07-27 07:52:47.044', - whisperedToAccountIDs: [], - }, - '6652909175804277965': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'hu', - text: 'hu', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'hu', - lastModified: '2023-07-27 07:52:43.489', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:52:43.489', - timestamp: 1690444363, - reportActionTimestamp: 1690444363489, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '6652909175804277965', - previousReportActionID: '4738491624635492834', - lastModified: '2023-07-27 07:52:43.489', - whisperedToAccountIDs: [], - }, - '4738491624635492834': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'test', - text: 'test', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test', - lastModified: '2023-07-27 07:52:40.145', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:52:40.145', - timestamp: 1690444360, - reportActionTimestamp: 1690444360145, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '4738491624635492834', - previousReportActionID: '1621235410433805703', - lastModified: '2023-07-27 07:52:40.145', - whisperedToAccountIDs: [], - }, - '1621235410433805703': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'test 4', - text: 'test 4', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test 4', - lastModified: '2023-07-27 07:48:36.809', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-27 07:48:36.809', - timestamp: 1690444116, - reportActionTimestamp: 1690444116809, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '1621235410433805703', - previousReportActionID: '1024550225871474566', - lastModified: '2023-07-27 07:48:36.809', - whisperedToAccountIDs: [], - }, - '1024550225871474566': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'test 3', - text: 'test 3', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test 3', - lastModified: '2023-07-27 07:48:24.183', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:48:24.183', - timestamp: 1690444104, - reportActionTimestamp: 1690444104183, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '1024550225871474566', - previousReportActionID: '5598482410513625723', - lastModified: '2023-07-27 07:48:24.183', - whisperedToAccountIDs: [], - }, - '5598482410513625723': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'test2', - text: 'test2', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test2', - lastModified: '2023-07-27 07:42:25.340', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:42:25.340', - timestamp: 1690443745, - reportActionTimestamp: 1690443745340, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '5598482410513625723', - previousReportActionID: '115121137377026405', - lastModified: '2023-07-27 07:42:25.340', - whisperedToAccountIDs: [], - }, - '115121137377026405': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'test', - text: 'test', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'test', - lastModified: '2023-07-27 07:42:22.583', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-27 07:42:22.583', - timestamp: 1690443742, - reportActionTimestamp: 1690443742583, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '115121137377026405', - previousReportActionID: '2167420855737359171', - lastModified: '2023-07-27 07:42:22.583', - whisperedToAccountIDs: [], - }, - '2167420855737359171': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'new message', - text: 'new message', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'new message', - lastModified: '2023-07-27 07:42:09.177', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:42:09.177', - timestamp: 1690443729, - reportActionTimestamp: 1690443729177, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '2167420855737359171', - previousReportActionID: '6106926938128802897', - lastModified: '2023-07-27 07:42:09.177', - whisperedToAccountIDs: [], - }, - '6106926938128802897': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'oh', - text: 'oh', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'oh', - lastModified: '2023-07-27 07:42:03.902', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:42:03.902', - timestamp: 1690443723, - reportActionTimestamp: 1690443723902, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '6106926938128802897', - previousReportActionID: '4366704007455141347', - lastModified: '2023-07-27 07:42:03.902', - whisperedToAccountIDs: [], - }, - '4366704007455141347': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'hm lol', - text: 'hm lol', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'hm lol', - lastModified: '2023-07-27 07:42:00.734', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:42:00.734', - timestamp: 1690443720, - reportActionTimestamp: 1690443720734, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '4366704007455141347', - previousReportActionID: '2078794664797360607', - lastModified: '2023-07-27 07:42:00.734', - whisperedToAccountIDs: [], - }, - '2078794664797360607': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'hi?', - text: 'hi?', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'hi?', - lastModified: '2023-07-27 07:41:49.724', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:41:49.724', - timestamp: 1690443709, - reportActionTimestamp: 1690443709724, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '2078794664797360607', - previousReportActionID: '2030060194258527427', - lastModified: '2023-07-27 07:41:49.724', - whisperedToAccountIDs: [], - }, - '2030060194258527427': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'lets have a thread about it, will ya?', - text: 'lets have a thread about it, will ya?', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'lets have a thread about it, will ya?', - lastModified: '2023-07-27 07:40:49.146', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-27 07:40:49.146', - timestamp: 1690443649, - reportActionTimestamp: 1690443649146, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '2030060194258527427', - previousReportActionID: '5540483153987237906', - lastModified: '2023-07-27 07:40:49.146', - childReportID: '5860710623453234', - childType: 'chat', - childOldestFourAccountIDs: '14567013,12883048', - childCommenterCount: 2, - childLastVisibleActionCreated: '2023-07-27 07:41:03.550', - childVisibleActionCount: 2, - whisperedToAccountIDs: [], - }, - '5540483153987237906': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: '@hanno@margelo.io i mention you lasagna :)', - text: '@hanno@margelo.io i mention you lasagna :)', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: '@hanno@margelo.io i mention you lasagna :)', - lastModified: '2023-07-27 07:37:43.100', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:37:43.100', - timestamp: 1690443463, - reportActionTimestamp: 1690443463100, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '5540483153987237906', - previousReportActionID: '8050559753491913991', - lastModified: '2023-07-27 07:37:43.100', - whisperedToAccountIDs: [], - }, - '8050559753491913991': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: '@terry+hightraffic@margelo.io', - text: '@terry+hightraffic@margelo.io', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: '@terry+hightraffic@margelo.io', - lastModified: '2023-07-27 07:36:41.708', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:36:41.708', - timestamp: 1690443401, - reportActionTimestamp: 1690443401708, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '8050559753491913991', - previousReportActionID: '881015235172878574', - lastModified: '2023-07-27 07:36:41.708', - whisperedToAccountIDs: [], - }, - '881015235172878574': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'yeah lets see', - text: 'yeah lets see', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'yeah lets see', - lastModified: '2023-07-27 07:25:15.997', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-27 07:25:15.997', - timestamp: 1690442715, - reportActionTimestamp: 1690442715997, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '881015235172878574', - previousReportActionID: '4800357767877651330', - lastModified: '2023-07-27 07:25:15.997', - whisperedToAccountIDs: [], - }, - '4800357767877651330': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'asdasdasd', - text: 'asdasdasd', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'asdasdasd', - lastModified: '2023-07-27 07:25:03.093', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-27 07:25:03.093', - timestamp: 1690442703, - reportActionTimestamp: 1690442703093, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '4800357767877651330', - previousReportActionID: '9012557872554910346', - lastModified: '2023-07-27 07:25:03.093', - whisperedToAccountIDs: [], - }, - '9012557872554910346': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'yeah', - text: 'yeah', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'yeah', - lastModified: '2023-07-26 19:49:40.471', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-26 19:49:40.471', - timestamp: 1690400980, - reportActionTimestamp: 1690400980471, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '9012557872554910346', - previousReportActionID: '8440677969068645500', - lastModified: '2023-07-26 19:49:40.471', - whisperedToAccountIDs: [], - }, - '8440677969068645500': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'hello motor', - text: 'hello motor', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'hello motor', - lastModified: '2023-07-26 19:49:36.262', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-26 19:49:36.262', - timestamp: 1690400976, - reportActionTimestamp: 1690400976262, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '8440677969068645500', - previousReportActionID: '306887996337608775', - lastModified: '2023-07-26 19:49:36.262', - whisperedToAccountIDs: [], - }, - '306887996337608775': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'a new messagfe', - text: 'a new messagfe', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'a new messagfe', - lastModified: '2023-07-26 19:49:29.512', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-26 19:49:29.512', - timestamp: 1690400969, - reportActionTimestamp: 1690400969512, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '306887996337608775', - previousReportActionID: '587892433077506227', - lastModified: '2023-07-26 19:49:29.512', - whisperedToAccountIDs: [], - }, - '587892433077506227': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Hanno J. G\u00f6decke', - }, - ], - actorAccountID: 12883048, - message: [ - { - type: 'COMMENT', - html: 'good', - text: 'good', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'good', - lastModified: '2023-07-26 19:49:20.473', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/fc1b8880216a5a76c8fd9998aaa33c080dacda5d_128.jpeg', - created: '2023-07-26 19:49:20.473', - timestamp: 1690400960, - reportActionTimestamp: 1690400960473, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '587892433077506227', - previousReportActionID: '1433103421804347060', - lastModified: '2023-07-26 19:49:20.473', - whisperedToAccountIDs: [], - }, - '1433103421804347060': { - person: [ - { - type: 'TEXT', - style: 'strong', - text: 'Terry Hightraffic1337', - }, - ], - actorAccountID: 14567013, - message: [ - { - type: 'COMMENT', - html: 'ah', - text: 'ah', - isEdited: false, - whisperedTo: [], - isDeletedParentAction: false, - reactions: [], - }, - ], - originalMessage: { - html: 'ah', - lastModified: '2023-07-26 19:49:12.762', - }, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - created: '2023-07-26 19:49:12.762', - timestamp: 1690400952, - reportActionTimestamp: 1690400952762, - automatic: false, - actionName: 'ADDCOMMENT', - shouldShow: true, - reportActionID: '1433103421804347060', - previousReportActionID: '8774157052628183778', - lastModified: '2023-07-26 19:49:12.762', - whisperedToAccountIDs: [], - }, - }, - }, - { - onyxMethod: 'mergecollection', - key: 'reportActionsReactions_', - value: { - reportActionsReactions_2658221912430757962: { - heart: { - createdAt: '2023-08-25 12:37:45', - users: { - 12883048: { - skinTones: { - '-1': '2023-08-25 12:37:45', - }, - }, - }, - }, - }, - }, - }, - { - onyxMethod: 'merge', - key: 'personalDetailsList', - value: { - 14567013: { - accountID: 14567013, - avatar: 'https://d1wpcgnaa73g0y.cloudfront.net/49a4c96c366f9a32905b30462f91ea39e5eee5e8_128.jpeg', - displayName: 'Terry Hightraffic1337', - firstName: 'Terry', - lastName: 'Hightraffic1337', - status: null, - login: 'terry+hightraffic@margelo.io', - pronouns: '', - timezone: { - automatic: true, - selected: 'Europe/Kyiv', - }, - phoneNumber: '', - validated: true, - }, - }, - }, - ], - jsonCode: 200, - requestID: '81b8b8509a7f5b54-VIE', -}); diff --git a/src/libs/E2E/apiMocks/readNewestAction.ts b/src/libs/E2E/apiMocks/readNewestAction.ts deleted file mode 100644 index eb3800a98b81..000000000000 --- a/src/libs/E2E/apiMocks/readNewestAction.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type Response from '@src/types/onyx/Response'; - -export default (): Response => ({ - jsonCode: 200, - requestID: '81b8c48e3bfe5a84-VIE', - onyxData: [ - { - onyxMethod: 'merge', - key: 'report_98345625', - value: { - lastReadTime: '2023-10-25 07:32:48.915', - }, - }, - ], -}); diff --git a/src/libs/E2E/apiMocks/signinUser.ts b/src/libs/E2E/apiMocks/signinUser.ts deleted file mode 100644 index 7063e56f94be..000000000000 --- a/src/libs/E2E/apiMocks/signinUser.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type {SigninParams} from '@libs/E2E/types'; -import type Response from '@src/types/onyx/Response'; - -const signinUser = ({email}: SigninParams): Response => ({ - onyxData: [ - { - onyxMethod: 'merge', - key: 'session', - value: { - authToken: 'fakeAuthToken', - accountID: 12313081, - email, - encryptedAuthToken: 'fakeEncryptedAuthToken', - }, - }, - { - onyxMethod: 'set', - key: 'shouldShowComposeInput', - value: true, - }, - { - onyxMethod: 'merge', - key: 'credentials', - value: { - autoGeneratedLogin: 'fake', - autoGeneratedPassword: 'fake', - }, - }, - { - onyxMethod: 'merge', - key: 'user', - value: { - isUsingExpensifyCard: false, - }, - }, - { - onyxMethod: 'set', - key: 'betas', - value: ['all'], - }, - { - onyxMethod: 'merge', - key: 'account', - value: { - requiresTwoFactorAuth: false, - }, - }, - ], - jsonCode: 200, - requestID: '783e5f3cadfbcfc0-SJC', -}); - -export default signinUser; diff --git a/src/libs/E2E/client.ts b/src/libs/E2E/client.ts index 472567cc6c1d..265c55c4a230 100644 --- a/src/libs/E2E/client.ts +++ b/src/libs/E2E/client.ts @@ -1,5 +1,6 @@ import Config from '../../../tests/e2e/config'; import Routes from '../../../tests/e2e/server/routes'; +import type {NetworkCacheMap, TestConfig} from './types'; type TestResult = { name: string; @@ -9,10 +10,6 @@ type TestResult = { renderCount?: number; }; -type TestConfig = { - name: string; -}; - type NativeCommandPayload = { text: string; }; @@ -24,26 +21,31 @@ type NativeCommand = { const SERVER_ADDRESS = `http://localhost:${Config.SERVER_PORT}`; -/** - * Submits a test result to the server. - * Note: a test can have multiple test results. - */ -const submitTestResults = (testResult: TestResult): Promise => { - console.debug(`[E2E] Submitting test result '${testResult.name}'…`); - return fetch(`${SERVER_ADDRESS}${Routes.testResults}`, { +const defaultHeaders = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'X-E2E-Server-Request': 'true', +}; + +const defaultRequestInit: RequestInit = { + headers: defaultHeaders, +}; + +const sendRequest = (url: string, data: Record): Promise => + fetch(url, { method: 'POST', headers: { // eslint-disable-next-line @typescript-eslint/naming-convention 'Content-Type': 'application/json', + ...defaultHeaders, }, - body: JSON.stringify(testResult), + body: JSON.stringify(data), }).then((res) => { if (res.status === 200) { - console.debug(`[E2E] Test result '${testResult.name}' submitted successfully`); - return; + return res; } - const errorMsg = `Test result submission failed with status code ${res.status}`; - res.json() + const errorMsg = `[E2E] Client failed to send request to "${url}". Returned status: ${res.status}`; + return res + .json() .then((responseText) => { throw new Error(`${errorMsg}: ${responseText}`); }) @@ -51,14 +53,24 @@ const submitTestResults = (testResult: TestResult): Promise => { throw new Error(errorMsg); }); }); + +/** + * Submits a test result to the server. + * Note: a test can have multiple test results. + */ +const submitTestResults = (testResult: TestResult): Promise => { + console.debug(`[E2E] Submitting test result '${testResult.name}'…`); + return sendRequest(`${SERVER_ADDRESS}${Routes.testResults}`, testResult).then(() => { + console.debug(`[E2E] Test result '${testResult.name}' submitted successfully`); + }); }; -const submitTestDone = () => fetch(`${SERVER_ADDRESS}${Routes.testDone}`); +const submitTestDone = () => fetch(`${SERVER_ADDRESS}${Routes.testDone}`, defaultRequestInit); let currentActiveTestConfig: TestConfig | null = null; const getTestConfig = (): Promise => - fetch(`${SERVER_ADDRESS}${Routes.testConfig}`) + fetch(`${SERVER_ADDRESS}${Routes.testConfig}`, defaultRequestInit) .then((res: Response): Promise => res.json()) .then((config: TestConfig) => { currentActiveTestConfig = config; @@ -67,27 +79,30 @@ const getTestConfig = (): Promise => const getCurrentActiveTestConfig = () => currentActiveTestConfig; -const sendNativeCommand = (payload: NativeCommand) => - fetch(`${SERVER_ADDRESS}${Routes.testNativeCommand}`, { - method: 'POST', - headers: { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }).then((res) => { - if (res.status === 200) { - return true; - } - const errorMsg = `Sending native command failed with status code ${res.status}`; - res.json() - .then((responseText) => { - throw new Error(`${errorMsg}: ${responseText}`); - }) - .catch(() => { - throw new Error(errorMsg); - }); +const sendNativeCommand = (payload: NativeCommand) => { + console.debug(`[E2E] Sending native command '${payload.actionName}'…`); + return sendRequest(`${SERVER_ADDRESS}${Routes.testNativeCommand}`, payload).then(() => { + console.debug(`[E2E] Native command '${payload.actionName}' sent successfully`); + }); +}; + +const updateNetworkCache = (appInstanceId: string, networkCache: NetworkCacheMap) => { + console.debug('[E2E] Updating network cache…'); + return sendRequest(`${SERVER_ADDRESS}${Routes.testUpdateNetworkCache}`, { + appInstanceId, + cache: networkCache, + }).then(() => { + console.debug('[E2E] Network cache updated successfully'); }); +}; + +const getNetworkCache = (appInstanceId: string): Promise => + sendRequest(`${SERVER_ADDRESS}${Routes.testGetNetworkCache}`, {appInstanceId}) + .then((res): Promise => res.json()) + .then((networkCache: NetworkCacheMap) => { + console.debug('[E2E] Network cache fetched successfully'); + return networkCache; + }); export default { submitTestResults, @@ -95,4 +110,6 @@ export default { getTestConfig, getCurrentActiveTestConfig, sendNativeCommand, + updateNetworkCache, + getNetworkCache, }; diff --git a/src/libs/E2E/reactNativeLaunchingTest.ts b/src/libs/E2E/reactNativeLaunchingTest.ts index cbd63270e736..79276e7a5d75 100644 --- a/src/libs/E2E/reactNativeLaunchingTest.ts +++ b/src/libs/E2E/reactNativeLaunchingTest.ts @@ -8,10 +8,14 @@ import type {ValueOf} from 'type-fest'; import * as Metrics from '@libs/Metrics'; import Performance from '@libs/Performance'; +import Config from 'react-native-config'; import E2EConfig from '../../../tests/e2e/config'; import E2EClient from './client'; +import installNetworkInterceptor from './utils/NetworkInterceptor'; +import LaunchArgs from './utils/LaunchArgs'; +import type { TestConfig } from './types'; -type Tests = Record, () => void>; +type Tests = Record, (config: TestConfig) => void>; console.debug('=========================='); console.debug('==== Running e2e test ===='); @@ -22,6 +26,12 @@ if (!Metrics.canCapturePerformanceMetrics()) { throw new Error('Performance module not available! Please set CAPTURE_METRICS=true in your environment file!'); } +const appInstanceId = Config.E2E_BRANCH +if (!appInstanceId) { + throw new Error('E2E_BRANCH not set in environment file!'); +} + + // import your test here, define its name and config first in e2e/config.js const tests: Tests = { [E2EConfig.TEST_NAMES.AppStartTime]: require('./tests/appStartTimeTest.e2e').default, @@ -41,10 +51,18 @@ const appReady = new Promise((resolve) => { }); }); +// Install the network interceptor +installNetworkInterceptor( + () => E2EClient.getNetworkCache(appInstanceId), + (networkCache) => E2EClient.updateNetworkCache(appInstanceId, networkCache), + LaunchArgs.mockNetwork ?? false +) + E2EClient.getTestConfig() .then((config): Promise | undefined => { const test = tests[config.name]; if (!test) { + console.error(`[E2E] Test '${config.name}' not found`); // instead of throwing, report the error to the server, which is better for DX return E2EClient.submitTestResults({ name: config.name, @@ -57,7 +75,7 @@ E2EClient.getTestConfig() .then(() => { console.debug('[E2E] App is ready, running test…'); Performance.measureFailSafe('appStartedToReady', 'regularAppStart'); - test(); + test(config); }) .catch((error) => { console.error('[E2E] Error while waiting for app to become ready', error); diff --git a/src/libs/E2E/tests/appStartTimeTest.e2e.ts b/src/libs/E2E/tests/appStartTimeTest.e2e.ts index 6589e594dac6..5720af8b3641 100644 --- a/src/libs/E2E/tests/appStartTimeTest.e2e.ts +++ b/src/libs/E2E/tests/appStartTimeTest.e2e.ts @@ -1,6 +1,7 @@ import Config from 'react-native-config'; import type {PerformanceEntry} from 'react-native-performance'; import E2ELogin from '@libs/E2E/actions/e2eLogin'; +import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import E2EClient from '@libs/E2E/client'; import Performance from '@libs/Performance'; @@ -8,8 +9,10 @@ const test = () => { // check for login (if already logged in the action will simply resolve) E2ELogin().then((neededLogin) => { if (neededLogin) { - // we don't want to submit the first login to the results - return E2EClient.submitTestDone(); + return waitForAppLoaded().then(() => + // we don't want to submit the first login to the results + E2EClient.submitTestDone(), + ); } console.debug('[E2E] Logged in, getting metrics and submitting them…'); diff --git a/src/libs/E2E/tests/chatOpeningTest.e2e.ts b/src/libs/E2E/tests/chatOpeningTest.e2e.ts index ff948c298b4a..ef380f847c3f 100644 --- a/src/libs/E2E/tests/chatOpeningTest.e2e.ts +++ b/src/libs/E2E/tests/chatOpeningTest.e2e.ts @@ -1,34 +1,25 @@ import E2ELogin from '@libs/E2E/actions/e2eLogin'; -import mockReport from '@libs/E2E/apiMocks/openReport'; +import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import E2EClient from '@libs/E2E/client'; +import type {TestConfig} from '@libs/E2E/types'; +import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -type ReportValue = { - reportID: string; -}; - -type OnyxData = { - value: ReportValue; -}; - -type MockReportResponse = { - onyxData: OnyxData[]; -}; - -const test = () => { +const test = (config: TestConfig) => { // check for login (if already logged in the action will simply resolve) console.debug('[E2E] Logging in for chat opening'); - const report = mockReport() as MockReportResponse; - const {reportID} = report.onyxData[0].value; + const reportID = getConfigValueOrThrow('reportID', config); E2ELogin().then((neededLogin) => { if (neededLogin) { - // we don't want to submit the first login to the results - return E2EClient.submitTestDone(); + return waitForAppLoaded().then(() => + // we don't want to submit the first login to the results + E2EClient.submitTestDone(), + ); } console.debug('[E2E] Logged in, getting chat opening metrics and submitting them…'); diff --git a/src/libs/E2E/tests/openSearchPageTest.e2e.ts b/src/libs/E2E/tests/openSearchPageTest.e2e.ts index c68553d6de8a..86da851396f6 100644 --- a/src/libs/E2E/tests/openSearchPageTest.e2e.ts +++ b/src/libs/E2E/tests/openSearchPageTest.e2e.ts @@ -1,5 +1,6 @@ import Config from 'react-native-config'; import E2ELogin from '@libs/E2E/actions/e2eLogin'; +import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import E2EClient from '@libs/E2E/client'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; @@ -12,8 +13,10 @@ const test = () => { E2ELogin().then((neededLogin: boolean): Promise | undefined => { if (neededLogin) { - // we don't want to submit the first login to the results - return E2EClient.submitTestDone(); + return waitForAppLoaded().then(() => + // we don't want to submit the first login to the results + E2EClient.submitTestDone(), + ); } console.debug('[E2E] Logged in, getting search metrics and submitting them…'); diff --git a/src/libs/E2E/tests/reportTypingTest.e2e.ts b/src/libs/E2E/tests/reportTypingTest.e2e.ts index 90d0dc9e0bb6..4e0678aeb020 100644 --- a/src/libs/E2E/tests/reportTypingTest.e2e.ts +++ b/src/libs/E2E/tests/reportTypingTest.e2e.ts @@ -1,7 +1,10 @@ import Config from 'react-native-config'; import E2ELogin from '@libs/E2E/actions/e2eLogin'; +import waitForAppLoaded from '@libs/E2E/actions/waitForAppLoaded'; import waitForKeyboard from '@libs/E2E/actions/waitForKeyboard'; import E2EClient from '@libs/E2E/client'; +import type {TestConfig} from '@libs/E2E/types'; +import getConfigValueOrThrow from '@libs/E2E/utils/getConfigValueOrThrow'; import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getRerenderCount, resetRerenderCount} from '@pages/home/report/ReportActionCompose/ComposerWithSuggestions/index.e2e'; @@ -9,14 +12,18 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import * as NativeCommands from '../../../../tests/e2e/nativeCommands/NativeCommandsAction'; -const test = () => { +const test = (config: TestConfig) => { // check for login (if already logged in the action will simply resolve) console.debug('[E2E] Logging in for typing'); + const reportID = getConfigValueOrThrow('reportID', config); + E2ELogin().then((neededLogin) => { if (neededLogin) { - // we don't want to submit the first login to the results - return E2EClient.submitTestDone(); + return waitForAppLoaded().then(() => + // we don't want to submit the first login to the results + E2EClient.submitTestDone(), + ); } console.debug('[E2E] Logged in, getting typing metrics and submitting them…'); @@ -27,7 +34,8 @@ const test = () => { } console.debug(`[E2E] Sidebar loaded, navigating to a report…`); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute('98345625')); + // Crowded Policy (Do Not Delete) Report, has a input bar available: + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); // Wait until keyboard is visible (so we are focused on the input): waitForKeyboard().then(() => { diff --git a/src/libs/E2E/types.ts b/src/libs/E2E/types.ts index fcdfa01d7132..2d48813fa115 100644 --- a/src/libs/E2E/types.ts +++ b/src/libs/E2E/types.ts @@ -4,4 +4,23 @@ type SigninParams = { type IsE2ETestSession = () => boolean; -export type {SigninParams, IsE2ETestSession}; +type NetworkCacheEntry = { + url: string; + options: RequestInit; + status: number; + statusText: string; + headers: Record; + body: string; +}; + +type NetworkCacheMap = Record< + string, // hash + NetworkCacheEntry +>; + +type TestConfig = { + name: string; + [key: string]: string; +}; + +export type {SigninParams, IsE2ETestSession, NetworkCacheMap, NetworkCacheEntry, TestConfig}; diff --git a/src/libs/E2E/utils/LaunchArgs.ts b/src/libs/E2E/utils/LaunchArgs.ts new file mode 100644 index 000000000000..4e452d766eff --- /dev/null +++ b/src/libs/E2E/utils/LaunchArgs.ts @@ -0,0 +1,8 @@ +import {LaunchArguments} from 'react-native-launch-arguments'; + +type ExpectedArgs = { + mockNetwork?: boolean; +}; +const LaunchArgs = LaunchArguments.value(); + +export default LaunchArgs; diff --git a/src/libs/E2E/utils/NetworkInterceptor.ts b/src/libs/E2E/utils/NetworkInterceptor.ts new file mode 100644 index 000000000000..361e14d9fdb7 --- /dev/null +++ b/src/libs/E2E/utils/NetworkInterceptor.ts @@ -0,0 +1,172 @@ +/* eslint-disable @lwc/lwc/no-async-await */ +import type {NetworkCacheEntry, NetworkCacheMap} from '@libs/E2E/types'; + +const LOG_TAG = `[E2E][NetworkInterceptor]`; +// Requests with these headers will be ignored: +const IGNORE_REQUEST_HEADERS = ['X-E2E-Server-Request']; + +let globalResolveIsNetworkInterceptorInstalled: () => void; +let globalRejectIsNetworkInterceptorInstalled: (error: Error) => void; +const globalIsNetworkInterceptorInstalledPromise = new Promise((resolve, reject) => { + globalResolveIsNetworkInterceptorInstalled = resolve; + globalRejectIsNetworkInterceptorInstalled = reject; +}); +let networkCache: NetworkCacheMap | null = null; + +/** + * The headers of a fetch request can be passed as an array of tuples or as an object. + * This function converts the headers to an object. + */ +function getFetchRequestHeadersAsObject(fetchRequest: RequestInit): Record { + const headers: Record = {}; + if (Array.isArray(fetchRequest.headers)) { + fetchRequest.headers.forEach(([key, value]) => { + headers[key] = value; + }); + } else if (typeof fetchRequest.headers === 'object') { + Object.entries(fetchRequest.headers).forEach(([key, value]) => { + headers[key] = value; + }); + } + return headers; +} + +/** + * This function extracts the RequestInit from the arguments of fetch. + * It is needed because the arguments can be passed in different ways. + */ +function fetchArgsGetRequestInit(args: Parameters): RequestInit { + const [firstArg, secondArg] = args; + if (typeof firstArg === 'string' || (typeof firstArg === 'object' && firstArg instanceof URL)) { + if (secondArg == null) { + return {}; + } + return secondArg; + } + return firstArg; +} + +/** + * This function extracts the url from the arguments of fetch. + */ +function fetchArgsGetUrl(args: Parameters): string { + const [firstArg] = args; + if (typeof firstArg === 'string') { + return firstArg; + } + if (typeof firstArg === 'object' && firstArg instanceof URL) { + return firstArg.href; + } + if (typeof firstArg === 'object' && firstArg instanceof Request) { + return firstArg.url; + } + throw new Error('Could not get url from fetch args'); +} + +/** + * This function transforms a NetworkCacheEntry (internal representation) to a (fetch) Response. + */ +function networkCacheEntryToResponse({headers, status, statusText, body}: NetworkCacheEntry): Response { + // Transform headers to Headers object: + const newHeaders = new Headers(); + Object.entries(headers).forEach(([key, value]) => { + newHeaders.append(key, value); + }); + + return new Response(body, { + status, + statusText, + headers: newHeaders, + }); +} + +/** + * This function hashes the arguments of fetch. + */ +function hashFetchArgs(args: Parameters) { + const url = fetchArgsGetUrl(args); + const options = fetchArgsGetRequestInit(args); + const headers = getFetchRequestHeadersAsObject(options); + // Note: earlier we were using the body value as well, however + // the body for the same request might be different due to including + // times or app versions. + return `${url}${JSON.stringify(headers)}`; +} + +/** + * Install a network interceptor by overwriting the global fetch function: + * - Overwrites fetch globally with a custom implementation + * - For each fetch request we cache the request and the response + * - The cache is send to the test runner server to persist the network cache in between sessions + * - On e2e test start the network cache is requested and loaded + * - If a fetch request is already in the NetworkInterceptors cache instead of making a real API request the value from the cache is used. + */ +export default function installNetworkInterceptor( + getNetworkCache: () => Promise, + updateNetworkCache: (networkCache: NetworkCacheMap) => Promise, + shouldReturnRecordedResponse: boolean, +) { + console.debug(LOG_TAG, 'installing with shouldReturnRecordedResponse:', shouldReturnRecordedResponse); + const originalFetch = global.fetch; + + if (networkCache == null && shouldReturnRecordedResponse) { + console.debug(LOG_TAG, 'fetching network cache …'); + getNetworkCache() + .then((newCache) => { + networkCache = newCache; + globalResolveIsNetworkInterceptorInstalled(); + console.debug(LOG_TAG, 'network cache fetched!'); + }, globalRejectIsNetworkInterceptorInstalled) + .catch(globalRejectIsNetworkInterceptorInstalled); + } else { + networkCache = {}; + globalResolveIsNetworkInterceptorInstalled(); + } + + // @ts-expect-error Fetch global types weirdly include URL + global.fetch = async (...args: Parameters) => { + const options = fetchArgsGetRequestInit(args); + const headers = getFetchRequestHeadersAsObject(options); + const url = fetchArgsGetUrl(args); + // Check if headers contain any of the ignored headers, or if react native metro server: + if (IGNORE_REQUEST_HEADERS.some((header) => headers[header] != null) || url.includes('8081')) { + return originalFetch(...args); + } + + await globalIsNetworkInterceptorInstalledPromise; + + const hash = hashFetchArgs(args); + const cachedResponse = networkCache?.[hash]; + if (shouldReturnRecordedResponse && cachedResponse != null) { + const response = networkCacheEntryToResponse(cachedResponse); + console.debug(LOG_TAG, 'Returning recorded response for url:', url); + return Promise.resolve(response); + } + if (shouldReturnRecordedResponse) { + console.debug('!!! Missed cache hit for url:', url); + } + + return originalFetch(...args) + .then(async (res) => { + if (networkCache != null) { + const body = await res.clone().text(); + networkCache[hash] = { + url, + options, + body, + headers: getFetchRequestHeadersAsObject(options), + status: res.status, + statusText: res.statusText, + }; + console.debug(LOG_TAG, 'Updating network cache for url:', url); + // Send the network cache to the test server: + return updateNetworkCache(networkCache).then(() => res); + } + return res; + }) + .then((res) => { + console.debug(LOG_TAG, 'Network cache updated!'); + return res; + }); + }; +} diff --git a/src/libs/E2E/utils/getConfigValueOrThrow.ts b/src/libs/E2E/utils/getConfigValueOrThrow.ts new file mode 100644 index 000000000000..a694d6709ed6 --- /dev/null +++ b/src/libs/E2E/utils/getConfigValueOrThrow.ts @@ -0,0 +1,12 @@ +import Config from 'react-native-config'; + +/** + * Gets a config value or throws an error if the value is not defined. + */ +export default function getConfigValueOrThrow(key: string, config = Config): string { + const value = config[key]; + if (value == null) { + throw new Error(`Missing config value for ${key}`); + } + return value; +} diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 159a5817189b..2466a262b4b9 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -38,7 +38,7 @@ function getAuthenticateErrorMessage(response: Response): keyof TranslationFlatO * Method used to get an error object with microsecond as the key. * @param error - error key or message to be saved */ -function getMicroSecondOnyxError(error: string): Record { +function getMicroSecondOnyxError(error: string): Errors { return {[DateUtils.getMicroseconds()]: error}; } @@ -51,7 +51,7 @@ function getMicroSecondOnyxErrorObject(error: Record): Record(onyxData: TOnyxData): string { @@ -98,7 +98,7 @@ type ErrorsList = Record; /** * Method used to generate error message for given inputID - * @param errorList - An object containing current errors in the form + * @param errors - An object containing current errors in the form * @param message - Message to assign to the inputID errors */ function addErrorMessage(errors: ErrorsList, inputID?: string, message?: TKey) { diff --git a/src/libs/FormUtils.ts b/src/libs/FormUtils.ts index 5366e149728e..37241df49af7 100644 --- a/src/libs/FormUtils.ts +++ b/src/libs/FormUtils.ts @@ -1,7 +1,4 @@ -import type {OnyxFormKey} from '@src/ONYXKEYS'; - -type ExcludeDraft = T extends `${string}Draft` ? never : T; -type OnyxFormKeyWithoutDraft = ExcludeDraft; +import type {OnyxFormKeyWithoutDraft} from '@components/Form/types'; function getDraftKey(formID: OnyxFormKeyWithoutDraft): `${OnyxFormKeyWithoutDraft}Draft` { return `${formID}Draft`; diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts index dca84b9b11e0..3781890013eb 100644 --- a/src/libs/LoginUtils.ts +++ b/src/libs/LoginUtils.ts @@ -59,4 +59,14 @@ function getPhoneLogin(partnerUserID: string): string { return appendCountryCode(getPhoneNumberWithoutSpecialChars(partnerUserID)); } -export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin}; +/** + * Check whether 2 emails have the same private domain + */ +function areEmailsFromSamePrivateDomain(email1: string, email2: string): boolean { + if (isEmailPublicDomain(email1) || isEmailPublicDomain(email2)) { + return false; + } + return Str.extractEmailDomain(email1).toLowerCase() === Str.extractEmailDomain(email2).toLowerCase(); +} + +export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain}; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 548751cbd8d1..c0a97c4fd02c 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyTags, ReportAction} from '@src/types/onyx'; @@ -95,12 +96,12 @@ function getForDistanceRequest(newDistance: string, oldDistance: string, newAmou * ModifiedExpense::getNewDotComment in Web-Expensify should match this. * If we change this function be sure to update the backend as well. */ -function getForReportAction(reportAction: ReportAction): string { - if (reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) { +function getForReportAction(reportAction: OnyxEntry): string { + if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) { return ''; } - const reportActionOriginalMessage = reportAction.originalMessage as ExpenseOriginalMessage | undefined; - const policyID = ReportUtils.getReportPolicyID(reportAction.reportID) ?? ''; + const reportActionOriginalMessage = reportAction?.originalMessage as ExpenseOriginalMessage | undefined; + const policyID = ReportUtils.getReportPolicyID(reportAction?.reportID) ?? ''; const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`] ?? {}; const policyTagListName = PolicyUtils.getTagListName(policyTags) || Localize.translateLocal('common.tag'); diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index cf5eed232212..3a843e400409 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -93,7 +93,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/MoneyRequestSelectorPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.AMOUNT]: () => require('../../../pages/iou/steps/NewRequestAmountPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.PARTICIPANTS]: () => require('../../../pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.CONFIRMATION]: () => require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, [SCREENS.MONEY_REQUEST.DATE]: () => require('../../../pages/iou/MoneyRequestDatePage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.DESCRIPTION]: () => require('../../../pages/iou/MoneyRequestDescriptionPage').default as React.ComponentType, @@ -270,7 +269,6 @@ const EditRequestStackNavigator = createModalStackNavigator({ - [SCREENS.PRIVATE_NOTES.VIEW]: () => require('../../../pages/PrivateNotes/PrivateNotesViewPage').default as React.ComponentType, [SCREENS.PRIVATE_NOTES.LIST]: () => require('../../../pages/PrivateNotes/PrivateNotesListPage').default as React.ComponentType, [SCREENS.PRIVATE_NOTES.EDIT]: () => require('../../../pages/PrivateNotes/PrivateNotesEditPage').default as React.ComponentType, }); diff --git a/src/libs/Navigation/linkingConfig.ts b/src/libs/Navigation/linkingConfig.ts index 0951b41d78b5..d4e04d5402e2 100644 --- a/src/libs/Navigation/linkingConfig.ts +++ b/src/libs/Navigation/linkingConfig.ts @@ -281,7 +281,6 @@ const linkingConfig: LinkingOptions = { }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { screens: { - [SCREENS.PRIVATE_NOTES.VIEW]: ROUTES.PRIVATE_NOTES_VIEW.route, [SCREENS.PRIVATE_NOTES.LIST]: ROUTES.PRIVATE_NOTES_LIST.route, [SCREENS.PRIVATE_NOTES.EDIT]: ROUTES.PRIVATE_NOTES_EDIT.route, }, @@ -429,7 +428,6 @@ const linkingConfig: LinkingOptions = { [SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.route, [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route, [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, - [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, [SCREENS.MONEY_REQUEST.DESCRIPTION]: ROUTES.MONEY_REQUEST_DESCRIPTION.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index dd5a7720f00d..b4a77f96cc74 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -185,10 +185,6 @@ type MoneyRequestNavigatorParamList = { iouType: string; reportID: string; }; - [SCREENS.MONEY_REQUEST.CONFIRMATION]: { - iouType: string; - reportID: string; - }; [SCREENS.MONEY_REQUEST.CURRENCY]: { iouType: string; reportID: string; @@ -333,14 +329,7 @@ type ProcessMoneyRequestHoldNavigatorParamList = { }; type PrivateNotesNavigatorParamList = { - [SCREENS.PRIVATE_NOTES.VIEW]: { - reportID: string; - accountID: string; - }; - [SCREENS.PRIVATE_NOTES.LIST]: { - reportID: string; - accountID: string; - }; + [SCREENS.PRIVATE_NOTES.LIST]: undefined; [SCREENS.PRIVATE_NOTES.EDIT]: { reportID: string; accountID: string; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.ts similarity index 61% rename from src/libs/OptionsListUtils.js rename to src/libs/OptionsListUtils.ts index 2973228af51f..2621e4d7f12b 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.ts @@ -1,12 +1,24 @@ /* eslint-disable no-continue */ import Str from 'expensify-common/lib/str'; +// eslint-disable-next-line you-dont-need-lodash-underscore/get import lodashGet from 'lodash/get'; import lodashOrderBy from 'lodash/orderBy'; import lodashSet from 'lodash/set'; +import lodashSortBy from 'lodash/sortBy'; import Onyx from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Beta, Login, PersonalDetails, PersonalDetailsList, Policy, PolicyCategories, Report, ReportAction, ReportActions, Transaction, TransactionViolation} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import times from '@src/utils/times'; +import Timing from './actions/Timing'; import * as CollectionUtils from './CollectionUtils'; import * as ErrorUtils from './ErrorUtils'; import * as LocalePhoneNumber from './LocalePhoneNumber'; @@ -14,6 +26,7 @@ import * as Localize from './Localize'; import * as LoginUtils from './LoginUtils'; import ModifiedExpenseMessage from './ModifiedExpenseMessage'; import Navigation from './Navigation/Navigation'; +import Performance from './Performance'; import Permissions from './Permissions'; import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import * as PhoneNumber from './PhoneNumber'; @@ -24,41 +37,139 @@ import * as TaskUtils from './TaskUtils'; import * as TransactionUtils from './TransactionUtils'; import * as UserUtils from './UserUtils'; +type Tag = { + enabled: boolean; + name: string; + accountID: number | null; +}; + +type Option = Partial; + +type PayeePersonalDetails = { + text: string; + alternateText: string; + icons: OnyxCommon.Icon[]; + descriptiveText: string; + login: string; + accountID: number; +}; + +type CategorySection = { + title: string | undefined; + shouldShow: boolean; + indexOffset: number; + data: Option[]; +}; + +type Category = { + name: string; + enabled: boolean; +}; + +type Hierarchy = Record; + +type GetOptionsConfig = { + reportActions?: ReportActions; + betas?: Beta[]; + selectedOptions?: Option[]; + maxRecentReportsToShow?: number; + excludeLogins?: string[]; + includeMultipleParticipantReports?: boolean; + includePersonalDetails?: boolean; + includeRecentReports?: boolean; + sortByReportTypeInSearch?: boolean; + searchInputValue?: string; + showChatPreviewLine?: boolean; + sortPersonalDetailsByAlphaAsc?: boolean; + forcePolicyNamePreview?: boolean; + includeOwnedWorkspaceChats?: boolean; + includeThreads?: boolean; + includeTasks?: boolean; + includeMoneyRequests?: boolean; + excludeUnknownUsers?: boolean; + includeP2P?: boolean; + includeCategories?: boolean; + categories?: PolicyCategories; + recentlyUsedCategories?: string[]; + includeTags?: boolean; + tags?: Record; + recentlyUsedTags?: string[]; + canInviteUser?: boolean; + includeSelectedOptions?: boolean; + includePolicyTaxRates?: boolean; + policyTaxRates?: PolicyTaxRateWithDefault; + transactionViolations?: OnyxCollection; +}; + +type MemberForList = { + text: string; + alternateText: string | null; + keyForList: string | null; + isSelected: boolean; + isDisabled: boolean | null; + accountID?: number | null; + login: string | null; + rightElement: React.ReactNode | null; + icons?: OnyxCommon.Icon[]; + pendingAction?: OnyxCommon.PendingAction; +}; + +type SectionForSearchTerm = { + section: CategorySection; + newIndexOffset: number; +}; + +type GetOptions = { + recentReports: ReportUtils.OptionData[]; + personalDetails: ReportUtils.OptionData[]; + userToInvite: ReportUtils.OptionData | null; + currentUserOption: ReportUtils.OptionData | null | undefined; + categoryOptions: CategorySection[]; + tagOptions: CategorySection[]; + policyTaxRatesOptions: CategorySection[]; +}; + +type PreviewConfig = {showChatPreviewLine?: boolean; forcePolicyNamePreview?: boolean}; + /** * OptionsListUtils is used to build a list options passed to the OptionsList component. Several different UI views can * be configured to display different results based on the options passed to the private getOptions() method. Public * methods should be named for the views they build options for and then exported for use in a component. */ - -let currentUserLogin; -let currentUserAccountID; +let currentUserLogin: string | undefined; +let currentUserAccountID: number | undefined; Onyx.connect({ key: ONYXKEYS.SESSION, - callback: (val) => { - currentUserLogin = val && val.email; - currentUserAccountID = val && val.accountID; + callback: (value) => { + currentUserLogin = value?.email; + currentUserAccountID = value?.accountID; }, }); -let loginList; +let loginList: OnyxEntry; Onyx.connect({ key: ONYXKEYS.LOGIN_LIST, - callback: (val) => (loginList = _.isEmpty(val) ? {} : val), + callback: (value) => (loginList = isEmptyObject(value) ? {} : value), }); -let allPersonalDetails; +let allPersonalDetails: OnyxEntry; Onyx.connect({ key: ONYXKEYS.PERSONAL_DETAILS_LIST, - callback: (val) => (allPersonalDetails = _.isEmpty(val) ? {} : val), + callback: (value) => (allPersonalDetails = isEmptyObject(value) ? {} : value), }); -let preferredLocale; +let preferredLocale: DeepValueOf = CONST.LOCALES.DEFAULT; Onyx.connect({ key: ONYXKEYS.NVP_PREFERRED_LOCALE, - callback: (val) => (preferredLocale = val || CONST.LOCALES.DEFAULT), + callback: (value) => { + if (!value) { + return; + } + preferredLocale = value; + }, }); -const policies = {}; +const policies: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, callback: (policy, key) => { @@ -70,10 +181,10 @@ Onyx.connect({ }, }); -const lastReportActions = {}; -const allSortedReportActions = {}; -const allReportActions = {}; -const visibleReportActionItems = {}; +const lastReportActions: ReportActions = {}; +const allSortedReportActions: Record = {}; +const allReportActions: Record = {}; +const visibleReportActionItems: ReportActions = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, callback: (actions, key) => { @@ -82,14 +193,13 @@ Onyx.connect({ } const reportID = CollectionUtils.extractCollectionItemID(key); allReportActions[reportID] = actions; - const sortedReportActions = ReportActionUtils.getSortedReportActions(_.toArray(actions), true); + const sortedReportActions = ReportActionUtils.getSortedReportActions(Object.values(actions), true); allSortedReportActions[reportID] = sortedReportActions; - lastReportActions[reportID] = _.first(sortedReportActions); + lastReportActions[reportID] = sortedReportActions[0]; // The report is only visible if it is the last action not deleted that // does not match a closed or created state. - const reportActionsForDisplay = _.filter( - sortedReportActions, + const reportActionsForDisplay = sortedReportActions.filter( (reportAction, actionKey) => ReportActionUtils.shouldReportActionBeVisible(reportAction, actionKey) && !ReportActionUtils.isWhisperAction(reportAction) && @@ -100,7 +210,7 @@ Onyx.connect({ }, }); -const policyExpenseReports = {}; +const policyExpenseReports: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, callback: (report, key) => { @@ -111,81 +221,80 @@ Onyx.connect({ }, }); -let allTransactions = {}; +let allTransactions: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, - callback: (val) => { - if (!val) { + callback: (value) => { + if (!value) { return; } - allTransactions = _.pick(val, (transaction) => transaction); + + allTransactions = Object.keys(value) + .filter((key) => !!value[key]) + .reduce((result: OnyxCollection, key) => { + if (result) { + // eslint-disable-next-line no-param-reassign + result[key] = value[key]; + } + return result; + }, {}); }, }); /** * Adds expensify SMS domain (@expensify.sms) if login is a phone number and if it's not included yet - * - * @param {String} login - * @return {String} */ -function addSMSDomainIfPhoneNumber(login) { +function addSMSDomainIfPhoneNumber(login: string): string { const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(login); if (parsedPhoneNumber.possible && !Str.isValidEmail(login)) { - return parsedPhoneNumber.number.e164 + CONST.SMS.DOMAIN; + return parsedPhoneNumber.number?.e164 + CONST.SMS.DOMAIN; } return login; } /** - * Returns avatar data for a list of user accountIDs - * - * @param {Array} accountIDs - * @param {Object} personalDetails - * @param {Object} defaultValues {login: accountID} In workspace invite page, when new user is added we pass available data to opt in - * @returns {Object} + * @param defaultValues {login: accountID} In workspace invite page, when new user is added we pass available data to opt in + * @returns Returns avatar data for a list of user accountIDs */ -function getAvatarsForAccountIDs(accountIDs, personalDetails, defaultValues = {}) { - const reversedDefaultValues = {}; - _.map(Object.entries(defaultValues), (item) => { +function getAvatarsForAccountIDs(accountIDs: number[], personalDetails: OnyxEntry, defaultValues: Record = {}): OnyxCommon.Icon[] { + const reversedDefaultValues: Record = {}; + + Object.entries(defaultValues).forEach((item) => { reversedDefaultValues[item[1]] = item[0]; }); - - return _.map(accountIDs, (accountID) => { - const login = lodashGet(reversedDefaultValues, accountID, ''); - const userPersonalDetail = lodashGet(personalDetails, accountID, {login, accountID, avatar: ''}); + return accountIDs.map((accountID) => { + const login = reversedDefaultValues[accountID] ?? ''; + const userPersonalDetail = personalDetails?.[accountID] ?? {login, accountID, avatar: ''}; return { id: accountID, source: UserUtils.getAvatar(userPersonalDetail.avatar, userPersonalDetail.accountID), type: CONST.ICON_TYPE_AVATAR, - name: userPersonalDetail.login, + name: userPersonalDetail.login ?? '', }; }); } /** * Returns the personal details for an array of accountIDs - * - * @param {Array} accountIDs - * @param {Object | null} personalDetails - * @returns {Object} – keys of the object are emails, values are PersonalDetails objects. + * @returns keys of the object are emails, values are PersonalDetails objects. */ -function getPersonalDetailsForAccountIDs(accountIDs, personalDetails) { - const personalDetailsForAccountIDs = {}; +function getPersonalDetailsForAccountIDs(accountIDs: number[] | undefined, personalDetails: OnyxEntry): PersonalDetailsList { + const personalDetailsForAccountIDs: PersonalDetailsList = {}; if (!personalDetails) { return personalDetailsForAccountIDs; } - _.each(accountIDs, (accountID) => { + accountIDs?.forEach((accountID) => { const cleanAccountID = Number(accountID); if (!cleanAccountID) { return; } - let personalDetail = personalDetails[accountID]; + let personalDetail: OnyxEntry = personalDetails[accountID]; if (!personalDetail) { personalDetail = { avatar: UserUtils.getDefaultAvatar(cleanAccountID), - }; + } as PersonalDetails; } if (cleanAccountID === CONST.ACCOUNT_ID.CONCIERGE) { @@ -200,58 +309,52 @@ function getPersonalDetailsForAccountIDs(accountIDs, personalDetails) { /** * Return true if personal details data is ready, i.e. report list options can be created. - * @param {Object} personalDetails - * @returns {Boolean} */ -function isPersonalDetailsReady(personalDetails) { - return !_.isEmpty(personalDetails) && _.some(_.keys(personalDetails), (key) => personalDetails[key].accountID); +function isPersonalDetailsReady(personalDetails: OnyxEntry): boolean { + const personalDetailsKeys = Object.keys(personalDetails ?? {}); + return personalDetailsKeys.some((key) => personalDetails?.[key]?.accountID); } /** * Get the participant option for a report. - * @param {Object} participant - * @param {Array} personalDetails - * @returns {Object} */ -function getParticipantsOption(participant, personalDetails) { - const detail = getPersonalDetailsForAccountIDs([participant.accountID], personalDetails)[participant.accountID]; - const login = detail.login || participant.login; +function getParticipantsOption(participant: ReportUtils.OptionData, personalDetails: OnyxEntry): 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 = PersonalDetailsUtils.getDisplayNameOrDefault(detail, LocalePhoneNumber.formatPhoneNumber(login)); return { - keyForList: String(detail.accountID), + keyForList: String(detail?.accountID), login, - accountID: detail.accountID, + accountID: detail?.accountID ?? -1, text: displayName, - firstName: lodashGet(detail, 'firstName', ''), - lastName: lodashGet(detail, 'lastName', ''), + firstName: detail?.firstName ?? '', + lastName: detail?.lastName ?? '', alternateText: LocalePhoneNumber.formatPhoneNumber(login) || displayName, icons: [ { - source: UserUtils.getAvatar(detail.avatar, detail.accountID), + source: UserUtils.getAvatar(detail?.avatar ?? '', detail?.accountID ?? -1), name: login, type: CONST.ICON_TYPE_AVATAR, - id: detail.accountID, + id: detail?.accountID, }, ], - phoneNumber: lodashGet(detail, 'phoneNumber', ''), + phoneNumber: detail?.phoneNumber ?? '', selected: participant.selected, isSelected: participant.selected, - searchText: participant.searchText, + searchText: participant.searchText ?? undefined, }; } /** * Constructs a Set with all possible names (displayName, firstName, lastName, email) for all participants in a report, * to be used in isSearchStringMatch. - * - * @param {Array} personalDetailList - * @return {Set} */ -function getParticipantNames(personalDetailList) { +function getParticipantNames(personalDetailList?: Array> | null): Set { // We use a Set because `Set.has(value)` on a Set of with n entries is up to n (or log(n)) times faster than // `_.contains(Array, value)` for an Array with n members. - const participantNames = new Set(); - _.each(personalDetailList, (participant) => { + const participantNames = new Set(); + personalDetailList?.forEach((participant) => { if (participant.login) { participantNames.add(participant.login.toLowerCase()); } @@ -271,21 +374,19 @@ function getParticipantNames(personalDetailList) { /** * A very optimized method to remove duplicates from an array. * Taken from https://stackoverflow.com/a/9229821/9114791 - * - * @param {Array} items - * @returns {Array} */ -function uniqFast(items) { - const seenItems = {}; - const result = []; +function uniqFast(items: string[]): string[] { + const seenItems: Record = {}; + const result: string[] = []; let j = 0; - for (let i = 0; i < items.length; i++) { - const item = items[i]; + + for (const item of items) { if (seenItems[item] !== 1) { seenItems[item] = 1; result[j++] = item; } } + return result; } @@ -296,21 +397,19 @@ function uniqFast(items) { * This method must be incredibly performant. It was found to be a big performance bottleneck * when dealing with accounts that have thousands of reports. For loops are more efficient than _.each * Array.prototype.push.apply is faster than using the spread operator, and concat() is faster than push(). - * - * @param {Object} report - * @param {String} reportName - * @param {Array} personalDetailList - * @param {Boolean} isChatRoomOrPolicyExpenseChat - * @param {Boolean} isThread - * @return {String} + */ -function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolicyExpenseChat, isThread) { - let searchTerms = []; +function getSearchText( + report: OnyxEntry, + reportName: string, + personalDetailList: Array>, + isChatRoomOrPolicyExpenseChat: boolean, + isThread: boolean, +): string { + let searchTerms: string[] = []; if (!isChatRoomOrPolicyExpenseChat) { - for (let i = 0; i < personalDetailList.length; i++) { - const personalDetail = personalDetailList[i]; - + for (const personalDetail of personalDetailList) { if (personalDetail.login) { // The regex below is used to remove dots only from the local part of the user email (local-part@domain) // so that we can match emails that have dots without explicitly writing the dots (e.g: fistlast@domain will match first.last@domain) @@ -331,18 +430,19 @@ function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolic const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(report); Array.prototype.push.apply(searchTerms, title.split(/[,\s]/)); - Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split(/[,\s]/)); + Array.prototype.push.apply(searchTerms, chatRoomSubtitle?.split(/[,\s]/) ?? ['']); } else if (isChatRoomOrPolicyExpenseChat) { const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(report); - Array.prototype.push.apply(searchTerms, chatRoomSubtitle.split(/[,\s]/)); + Array.prototype.push.apply(searchTerms, chatRoomSubtitle?.split(/[,\s]/) ?? ['']); } else { - const visibleChatMemberAccountIDs = report.visibleChatMemberAccountIDs || []; - for (let i = 0; i < visibleChatMemberAccountIDs.length; i++) { - const accountID = visibleChatMemberAccountIDs[i]; - - if (allPersonalDetails[accountID] && allPersonalDetails[accountID].login) { - searchTerms = searchTerms.concat(allPersonalDetails[accountID].login); + const visibleChatMemberAccountIDs = report.visibleChatMemberAccountIDs ?? []; + if (allPersonalDetails) { + for (const accountID of visibleChatMemberAccountIDs) { + const login = allPersonalDetails[accountID]?.login; + if (login) { + searchTerms = searchTerms.concat(login); + } } } } @@ -353,79 +453,77 @@ function getSearchText(report, reportName, personalDetailList, isChatRoomOrPolic /** * Get an object of error messages keyed by microtime by combining all error objects related to the report. - * @param {Object} report - * @param {Object} reportActions - * @returns {Object} */ -function getAllReportErrors(report, reportActions) { - const reportErrors = report.errors || {}; - const reportErrorFields = report.errorFields || {}; - const reportActionErrors = _.reduce( - reportActions, - (prevReportActionErrors, action) => (!action || _.isEmpty(action.errors) ? prevReportActionErrors : _.extend(prevReportActionErrors, action.errors)), +function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): OnyxCommon.Errors { + const reportErrors = report?.errors ?? {}; + const reportErrorFields = report?.errorFields ?? {}; + const reportActionErrors: OnyxCommon.ErrorFields = Object.values(reportActions ?? {}).reduce( + (prevReportActionErrors, action) => (!action || isEmptyObject(action.errors) ? prevReportActionErrors : {...prevReportActionErrors, ...action.errors}), {}, ); - - const parentReportAction = !report.parentReportID || !report.parentReportActionID ? {} : lodashGet(allReportActions, [report.parentReportID, report.parentReportActionID], {}); - - if (parentReportAction.actorAccountID === currentUserAccountID && ReportActionUtils.isTransactionThread(parentReportAction)) { - const transactionID = lodashGet(parentReportAction, ['originalMessage', 'IOUTransactionID'], ''); - const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] || {}; - if (TransactionUtils.hasMissingSmartscanFields(transaction) && !ReportUtils.isSettled(transaction.reportID)) { - _.extend(reportActionErrors, {smartscan: ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage')}); + const parentReportAction: OnyxEntry = + !report?.parentReportID || !report?.parentReportActionID ? null : allReportActions?.[report.parentReportID ?? '']?.[report.parentReportActionID ?? ''] ?? null; + + if (parentReportAction?.actorAccountID === currentUserAccountID && ReportActionUtils.isTransactionThread(parentReportAction)) { + const transactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction?.originalMessage?.IOUTransactionID : null; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (TransactionUtils.hasMissingSmartscanFields(transaction ?? null) && !ReportUtils.isSettled(transaction?.reportID)) { + reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage'); } - } else if ((ReportUtils.isIOUReport(report) || ReportUtils.isExpenseReport(report)) && report.ownerAccountID === currentUserAccountID) { - if (ReportUtils.hasMissingSmartscanFields(report.reportID) && !ReportUtils.isSettled(report.reportID)) { - _.extend(reportActionErrors, {smartscan: ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage')}); + } else if ((ReportUtils.isIOUReport(report) || ReportUtils.isExpenseReport(report)) && report?.ownerAccountID === currentUserAccountID) { + if (ReportUtils.hasMissingSmartscanFields(report?.reportID ?? '') && !ReportUtils.isSettled(report?.reportID)) { + reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage'); } - } else if (ReportUtils.hasSmartscanError(_.values(reportActions))) { - _.extend(reportActionErrors, {smartscan: ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage')}); + } else if (ReportUtils.hasSmartscanError(Object.values(reportActions ?? {}))) { + reportActionErrors.smartscan = ErrorUtils.getMicroSecondOnyxError('report.genericSmartscanFailureMessage'); } - // All error objects related to the report. Each object in the sources contains error messages keyed by microtime const errorSources = { reportErrors, ...reportErrorFields, - reportActionErrors, + ...reportActionErrors, }; - // Combine all error messages keyed by microtime into one object - const allReportErrors = _.reduce(errorSources, (prevReportErrors, errors) => (_.isEmpty(errors) ? prevReportErrors : _.extend(prevReportErrors, errors)), {}); + const allReportErrors = Object.values(errorSources)?.reduce((prevReportErrors, errors) => (isEmptyObject(errors) ? prevReportErrors : {...prevReportErrors, ...errors}), {}); return allReportErrors; } /** * Get the last message text from the report directly or from other sources for special cases. - * @param {Object} report - * @returns {String} */ -function getLastMessageTextForReport(report) { - const lastReportAction = _.find(allSortedReportActions[report.reportID], (reportAction) => ReportActionUtils.shouldReportActionBeVisibleAsLastAction(reportAction)); +function getLastMessageTextForReport(report: OnyxEntry): string { + const lastReportAction = allSortedReportActions[report?.reportID ?? '']?.find((reportAction) => ReportActionUtils.shouldReportActionBeVisibleAsLastAction(reportAction)) ?? null; let lastMessageTextFromReport = ''; - const lastActionName = lodashGet(lastReportAction, 'actionName', ''); + const lastActionName = lastReportAction?.actionName ?? ''; if (ReportActionUtils.isMoneyRequestAction(lastReportAction)) { const properSchemaForMoneyRequestMessage = ReportUtils.getReportPreviewMessage(report, lastReportAction, true, false, null, true); lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForMoneyRequestMessage); } else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) { const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction)); - const lastIOUMoneyReportAction = _.find( - allSortedReportActions[iouReport.reportID], + const lastIOUMoneyReportAction = allSortedReportActions[iouReport?.reportID ?? '']?.find( (reportAction, key) => ReportActionUtils.shouldReportActionBeVisible(reportAction, key) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && ReportActionUtils.isMoneyRequestAction(reportAction), ); - lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastIOUMoneyReportAction, true, ReportUtils.isChatReport(report), null, true); + lastMessageTextFromReport = ReportUtils.getReportPreviewMessage( + !isEmptyObject(iouReport) ? iouReport : null, + lastIOUMoneyReportAction, + true, + ReportUtils.isChatReport(report), + null, + true, + ); } else if (ReportActionUtils.isReimbursementQueuedAction(lastReportAction)) { lastMessageTextFromReport = ReportUtils.getReimbursementQueuedActionMessage(lastReportAction, report); } else if (ReportActionUtils.isReimbursementDeQueuedAction(lastReportAction)) { lastMessageTextFromReport = ReportUtils.getReimbursementDeQueuedActionMessage(report); } else if (ReportActionUtils.isDeletedParentAction(lastReportAction) && ReportUtils.isChatReport(report)) { lastMessageTextFromReport = ReportUtils.getDeletedParentActionMessageForChatReport(lastReportAction); - } else if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml, translationKey: report.lastMessageTranslationKey})) { - lastMessageTextFromReport = `[${Localize.translateLocal(report.lastMessageTranslationKey || 'common.attachment')}]`; + } else if (ReportUtils.isReportMessageAttachment({text: report?.lastMessageText ?? '', html: report?.lastMessageHtml, translationKey: report?.lastMessageTranslationKey, type: ''})) { + lastMessageTextFromReport = `[${Localize.translateLocal((report?.lastMessageTranslationKey ?? 'common.attachment') as TranslationPaths)}]`; } else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) { const properSchemaForModifiedExpenseMessage = ModifiedExpenseMessage.getForReportAction(lastReportAction); lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true); @@ -434,41 +532,38 @@ function getLastMessageTextForReport(report) { lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED || lastActionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELLED ) { - lastMessageTextFromReport = lodashGet(lastReportAction, 'message[0].text', ''); + lastMessageTextFromReport = lastReportAction?.message?.[0].text ?? ''; } else if (ReportActionUtils.isCreatedTaskReportAction(lastReportAction)) { lastMessageTextFromReport = TaskUtils.getTaskCreatedMessage(lastReportAction); } - return lastMessageTextFromReport || lodashGet(report, 'lastMessageText', ''); + return lastMessageTextFromReport || (report?.lastMessageText ?? ''); } /** * Creates a report list option - * - * @param {Array} accountIDs - * @param {Object} personalDetails - * @param {Object} report - * @param {Object} reportActions - * @param {Object} options - * @param {Boolean} [options.showChatPreviewLine] - * @param {Boolean} [options.forcePolicyNamePreview] - * @returns {Object} */ -function createOption(accountIDs, personalDetails, report, reportActions = {}, {showChatPreviewLine = false, forcePolicyNamePreview = false}) { - const result = { - text: null, +function createOption( + accountIDs: number[], + personalDetails: OnyxEntry, + report: OnyxEntry, + reportActions: ReportActions, + {showChatPreviewLine = false, forcePolicyNamePreview = false}: PreviewConfig, +): ReportUtils.OptionData { + const result: ReportUtils.OptionData = { + text: undefined, alternateText: null, - pendingAction: null, - allReportErrors: null, + pendingAction: undefined, + allReportErrors: undefined, brickRoadIndicator: null, - icons: null, + icons: undefined, tooltipText: null, - ownerAccountID: null, + ownerAccountID: undefined, subtitle: null, - participantsList: null, + participantsList: undefined, accountID: 0, login: null, - reportID: null, + reportID: '', phoneNumber: null, hasDraftComment: false, keyForList: null, @@ -476,7 +571,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { isDefaultRoom: false, isPinned: false, isWaitingOnBankAccount: false, - iouReportID: null, + iouReportID: undefined, isIOUReportOwner: null, iouReportAmount: 0, isChatRoom: false, @@ -485,19 +580,19 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { isPolicyExpenseChat: false, isOwnPolicyExpenseChat: false, isExpenseReport: false, - policyID: null, + policyID: undefined, isOptimisticPersonalDetail: false, }; const personalDetailMap = getPersonalDetailsForAccountIDs(accountIDs, personalDetails); - const personalDetailList = _.values(personalDetailMap); - const personalDetail = personalDetailList[0] || {}; + const personalDetailList = Object.values(personalDetailMap).filter((details): details is PersonalDetails => !!details); + const personalDetail = personalDetailList[0]; let hasMultipleParticipants = personalDetailList.length > 1; let subtitle; let reportName; result.participantsList = personalDetailList; - result.isOptimisticPersonalDetail = personalDetail.isOptimisticPersonalDetail; + result.isOptimisticPersonalDetail = personalDetail?.isOptimisticPersonalDetail; if (report) { result.isChatRoom = ReportUtils.isChatRoom(report); @@ -509,10 +604,10 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { result.isTaskReport = ReportUtils.isTaskReport(report); result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report); result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); - result.isOwnPolicyExpenseChat = report.isOwnPolicyExpenseChat || false; + result.isOwnPolicyExpenseChat = report.isOwnPolicyExpenseChat ?? false; result.allReportErrors = getAllReportErrors(report, reportActions); - result.brickRoadIndicator = !_.isEmpty(result.allReportErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; - result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom || report.pendingFields.createChat : null; + result.brickRoadIndicator = !isEmptyObject(result.allReportErrors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; + result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom ?? report.pendingFields.createChat : undefined; result.ownerAccountID = report.ownerAccountID; result.reportID = report.reportID; result.isUnread = ReportUtils.isUnread(report); @@ -520,29 +615,32 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { result.isPinned = report.isPinned; result.iouReportID = report.iouReportID; result.keyForList = String(report.reportID); - result.tooltipText = ReportUtils.getReportParticipantsTitle(report.visibleChatMemberAccountIDs || []); + result.tooltipText = ReportUtils.getReportParticipantsTitle(report.visibleChatMemberAccountIDs ?? []); result.isWaitingOnBankAccount = report.isWaitingOnBankAccount; result.policyID = report.policyID; - hasMultipleParticipants = personalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat; subtitle = ReportUtils.getChatRoomSubtitle(report); const lastMessageTextFromReport = getLastMessageTextForReport(report); - const lastActorDetails = personalDetailMap[report.lastActorAccountID] || null; + const lastActorDetails = personalDetailMap[report.lastActorAccountID ?? 0] ?? null; const lastActorDisplayName = hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID !== currentUserAccountID - ? lastActorDetails.firstName || PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails) + ? // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + lastActorDetails.firstName || PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails) : ''; - let lastMessageText = lastMessageTextFromReport; - if (result.isArchivedRoom) { - const archiveReason = - (lastReportActions[report.reportID] && lastReportActions[report.reportID].originalMessage && lastReportActions[report.reportID].originalMessage.reason) || - CONST.REPORT.ARCHIVE_REASON.DEFAULT; - lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { - displayName: archiveReason.displayName || PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails), - policyName: ReportUtils.getPolicyName(report), - }); + let lastMessageText = lastMessageTextFromReport; + const lastReportAction = lastReportActions[report.reportID ?? '']; + if (result.isArchivedRoom && lastReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED) { + const archiveReason = lastReportAction.originalMessage?.reason || CONST.REPORT.ARCHIVE_REASON.DEFAULT; + if (archiveReason === CONST.REPORT.ARCHIVE_REASON.DEFAULT || archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) { + lastMessageText = Localize.translate(preferredLocale, 'reportArchiveReasons.default'); + } else { + lastMessageText = Localize.translate(preferredLocale, `reportArchiveReasons.${archiveReason}`, { + displayName: PersonalDetailsUtils.getDisplayNameOrDefault(lastActorDetails), + policyName: ReportUtils.getPolicyName(report), + }); + } } const lastAction = visibleReportActionItems[report.reportID]; @@ -559,27 +657,37 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { } else if (result.isTaskReport) { result.alternateText = showChatPreviewLine && lastMessageText ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } else { - result.alternateText = showChatPreviewLine && lastMessageText ? lastMessageText : LocalePhoneNumber.formatPhoneNumber(personalDetail.login); + result.alternateText = showChatPreviewLine && lastMessageText ? lastMessageText : LocalePhoneNumber.formatPhoneNumber(personalDetail.login ?? ''); } reportName = ReportUtils.getReportName(report); } else { - reportName = ReportUtils.getDisplayNameForParticipant(accountIDs[0]) || LocalePhoneNumber.formatPhoneNumber(personalDetail.login); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + reportName = ReportUtils.getDisplayNameForParticipant(accountIDs[0]) || LocalePhoneNumber.formatPhoneNumber(personalDetail.login ?? ''); result.keyForList = String(accountIDs[0]); - result.alternateText = LocalePhoneNumber.formatPhoneNumber(lodashGet(personalDetails, [accountIDs[0], 'login'], '')); + + result.alternateText = LocalePhoneNumber.formatPhoneNumber(personalDetails?.[accountIDs[0]]?.login ?? ''); } result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result); result.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(result); if (!hasMultipleParticipants) { - result.login = personalDetail.login; - result.accountID = Number(personalDetail.accountID); - result.phoneNumber = personalDetail.phoneNumber; + result.login = personalDetail?.login; + result.accountID = Number(personalDetail?.accountID); + result.phoneNumber = personalDetail?.phoneNumber; } result.text = reportName; - result.searchText = getSearchText(report, reportName, personalDetailList, result.isChatRoom || result.isPolicyExpenseChat, result.isThread); - result.icons = ReportUtils.getIcons(report, personalDetails, UserUtils.getAvatar(personalDetail.avatar, personalDetail.accountID), personalDetail.login, personalDetail.accountID); + // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + result.searchText = getSearchText(report, reportName, personalDetailList, !!result.isChatRoom || !!result.isPolicyExpenseChat, !!result.isThread); + result.icons = ReportUtils.getIcons( + report, + personalDetails, + UserUtils.getAvatar(personalDetail?.avatar ?? '', personalDetail?.accountID), + personalDetail?.login, + personalDetail?.accountID, + ); result.subtitle = subtitle; return result; @@ -587,16 +695,14 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { /** * Get the option for a policy expense report. - * @param {Object} report - * @returns {Object} */ -function getPolicyExpenseReportOption(report) { - const expenseReport = policyExpenseReports[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]; +function getPolicyExpenseReportOption(report: Report): ReportUtils.OptionData { + const expenseReport = policyExpenseReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`]; const option = createOption( - expenseReport.visibleChatMemberAccountIDs, - allPersonalDetails, - expenseReport, + expenseReport?.visibleChatMemberAccountIDs ?? [], + allPersonalDetails ?? {}, + expenseReport ?? null, {}, { showChatPreviewLine: false, @@ -614,16 +720,10 @@ function getPolicyExpenseReportOption(report) { /** * Searches for a match when provided with a value - * - * @param {String} searchValue - * @param {String} searchText - * @param {Set} [participantNames] - * @param {Boolean} isChatRoom - * @returns {Boolean} */ -function isSearchStringMatch(searchValue, searchText, participantNames = new Set(), isChatRoom = false) { +function isSearchStringMatch(searchValue: string, searchText?: string | null, participantNames = new Set(), isChatRoom = false): boolean { const searchWords = new Set(searchValue.replace(/,/g, ' ').split(' ')); - const valueToSearch = searchText && searchText.replace(new RegExp(/ /g), ''); + const valueToSearch = searchText?.replace(new RegExp(/ /g), ''); let matching = true; searchWords.forEach((word) => { // if one of the word is not matching, we don't need to check further @@ -631,7 +731,7 @@ function isSearchStringMatch(searchValue, searchText, participantNames = new Set return; } const matchRegex = new RegExp(Str.escapeForRegExp(word), 'i'); - matching = matchRegex.test(valueToSearch) || (!isChatRoom && participantNames.has(word)); + matching = matchRegex.test(valueToSearch ?? '') || (!isChatRoom && participantNames.has(word)); }); return matching; } @@ -640,68 +740,48 @@ function isSearchStringMatch(searchValue, searchText, participantNames = new Set * Checks if the given userDetails is currentUser or not. * Note: We can't migrate this off of using logins because this is used to check if you're trying to start a chat with * yourself or a different user, and people won't be starting new chats via accountID usually. - * - * @param {Object} userDetails - * @returns {Boolean} */ -function isCurrentUser(userDetails) { +function isCurrentUser(userDetails: PersonalDetails): boolean { if (!userDetails) { return false; } // If user login is a mobile number, append sms domain if not appended already. - const userDetailsLogin = addSMSDomainIfPhoneNumber(userDetails.login); + const userDetailsLogin = addSMSDomainIfPhoneNumber(userDetails.login ?? ''); - if (currentUserLogin.toLowerCase() === userDetailsLogin.toLowerCase()) { + if (currentUserLogin?.toLowerCase() === userDetailsLogin.toLowerCase()) { return true; } // Check if userDetails login exists in loginList - return _.some(_.keys(loginList), (login) => login.toLowerCase() === userDetailsLogin.toLowerCase()); + return Object.keys(loginList ?? {}).some((login) => login.toLowerCase() === userDetailsLogin.toLowerCase()); } /** * Calculates count of all enabled options - * - * @param {Object[]} options - an initial strings array - * @param {Boolean} options[].enabled - a flag to enable/disable option in a list - * @param {String} options[].name - a name of an option - * @returns {Number} */ -function getEnabledCategoriesCount(options) { - return _.filter(options, (option) => option.enabled).length; +function getEnabledCategoriesCount(options: PolicyCategories): number { + return Object.values(options).filter((option) => option.enabled).length; } /** * Verifies that there is at least one enabled option - * - * @param {Object[]} options - an initial strings array - * @param {Boolean} options[].enabled - a flag to enable/disable option in a list - * @param {String} options[].name - a name of an option - * @returns {Boolean} */ -function hasEnabledOptions(options) { - return _.some(options, (option) => option.enabled); +function hasEnabledOptions(options: PolicyCategories): boolean { + return Object.values(options).some((option) => option.enabled); } /** * Sorts categories using a simple object. * It builds an hierarchy (based on an object), where each category has a name and other keys as subcategories. * Via the hierarchy we avoid duplicating and sort categories one by one. Subcategories are being sorted alphabetically. - * - * @param {Object} categories - * @returns {Array} */ -function sortCategories(categories) { +function sortCategories(categories: Record): Category[] { // Sorts categories alphabetically by name. - const sortedCategories = _.chain(categories) - .values() - .sortBy((category) => category.name) - .value(); + const sortedCategories = Object.values(categories).sort((a, b) => a.name.localeCompare(b.name)); // An object that respects nesting of categories. Also, can contain only uniq categories. const hierarchy = {}; - /** * Iterates over all categories to set each category in a proper place in hierarchy * It gets a path based on a category name e.g. "Parent: Child: Subcategory" -> "Parent.Child.Subcategory". @@ -717,10 +797,9 @@ function sortCategories(categories) { * } * } */ - _.each(sortedCategories, (category) => { + sortedCategories.forEach((category) => { const path = category.name.split(CONST.PARENT_CHILD_SEPARATOR); const existedValue = lodashGet(hierarchy, path, {}); - lodashSet(hierarchy, path, { ...existedValue, name: category.name, @@ -731,50 +810,42 @@ function sortCategories(categories) { * A recursive function to convert hierarchy into an array of category objects. * The category object contains base 2 properties: "name" and "enabled". * It iterates each key one by one. When a category has subcategories, goes deeper into them. Also, sorts subcategories alphabetically. - * - * @param {Object} initialHierarchy - * @returns {Array} */ - const flatHierarchy = (initialHierarchy) => - _.reduce( - initialHierarchy, - (acc, category) => { - const {name, ...subcategories} = category; - - if (!_.isEmpty(name)) { - const categoryObject = { - name, - enabled: lodashGet(categories, [name, 'enabled'], false), - }; - - acc.push(categoryObject); - } + const flatHierarchy = (initialHierarchy: Hierarchy) => + Object.values(initialHierarchy).reduce((acc: Category[], category) => { + const {name, ...subcategories} = category; + if (name) { + const categoryObject: Category = { + name, + enabled: categories[name].enabled ?? false, + }; + + acc.push(categoryObject); + } - if (!_.isEmpty(subcategories)) { - const nestedCategories = flatHierarchy(subcategories); + if (!isEmptyObject(subcategories)) { + const nestedCategories = flatHierarchy(subcategories); - acc.push(..._.sortBy(nestedCategories, 'name')); - } + acc.push(...nestedCategories.sort((a, b) => a.name.localeCompare(b.name))); + } - return acc; - }, - [], - ); + return acc; + }, []); return flatHierarchy(hierarchy); } /** * Sorts tags alphabetically by name. - * - * @param {Object} tags - * @returns {Array} */ -function sortTags(tags) { - const sortedTags = _.chain(tags) - .values() - .sortBy((tag) => tag.name) - .value(); +function sortTags(tags: Record | Tag[]) { + let sortedTags; + + if (Array.isArray(tags)) { + sortedTags = tags.sort((a, b) => a.name.localeCompare(b.name)); + } else { + sortedTags = Object.values(tags).sort((a, b) => a.name.localeCompare(b.name)); + } return sortedTags; } @@ -782,16 +853,14 @@ function sortTags(tags) { /** * Builds the options for the category tree hierarchy via indents * - * @param {Object[]} options - an initial object array - * @param {Boolean} options[].enabled - a flag to enable/disable option in a list - * @param {String} options[].name - a name of an option - * @param {Boolean} [isOneLine] - a flag to determine if text should be one line - * @returns {Array} + * @param options - an initial object array + * @param options[].enabled - a flag to enable/disable option in a list + * @param options[].name - a name of an option + * @param [isOneLine] - a flag to determine if text should be one line */ -function getCategoryOptionTree(options, isOneLine = false) { - const optionCollection = new Map(); - - _.each(options, (option) => { +function getCategoryOptionTree(options: Record | Category[], isOneLine = false): Option[] { + const optionCollection = new Map(); + Object.values(options).forEach((option) => { if (isOneLine) { if (optionCollection.has(option.name)) { return; @@ -809,7 +878,7 @@ function getCategoryOptionTree(options, isOneLine = false) { } option.name.split(CONST.PARENT_CHILD_SEPARATOR).forEach((optionName, index, array) => { - const indents = _.times(index, () => CONST.INDENTS).join(''); + const indents = times(index, () => CONST.INDENTS).join(''); const isChild = array.length - 1 === index; const searchText = array.slice(0, index + 1).join(CONST.PARENT_CHILD_SEPARATOR); @@ -832,21 +901,19 @@ function getCategoryOptionTree(options, isOneLine = false) { /** * Builds the section list for categories - * - * @param {Object} categories - * @param {String[]} recentlyUsedCategories - * @param {Object[]} selectedOptions - * @param {String} selectedOptions[].name - * @param {String} searchInputValue - * @param {Number} maxRecentReportsToShow - * @returns {Array} */ -function getCategoryListSections(categories, recentlyUsedCategories, selectedOptions, searchInputValue, maxRecentReportsToShow) { +function getCategoryListSections( + categories: PolicyCategories, + recentlyUsedCategories: string[], + selectedOptions: Category[], + searchInputValue: string, + maxRecentReportsToShow: number, +): CategorySection[] { const sortedCategories = sortCategories(categories); - const enabledCategories = _.filter(sortedCategories, (category) => category.enabled); + const enabledCategories = Object.values(sortedCategories).filter((category) => category.enabled); - const categorySections = []; - const numberOfCategories = _.size(enabledCategories); + const categorySections: CategorySection[] = []; + const numberOfCategories = enabledCategories.length; let indexOffset = 0; @@ -862,8 +929,8 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt return categorySections; } - if (!_.isEmpty(searchInputValue)) { - const searchCategories = _.filter(enabledCategories, (category) => category.name.toLowerCase().includes(searchInputValue.toLowerCase())); + if (searchInputValue) { + const searchCategories = enabledCategories.filter((category) => category.name.toLowerCase().includes(searchInputValue.toLowerCase())); categorySections.push({ // "Search" section @@ -876,7 +943,7 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt return categorySections; } - if (!_.isEmpty(selectedOptions)) { + if (selectedOptions.length > 0) { categorySections.push({ // "Selected" section title: '', @@ -888,8 +955,8 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt indexOffset += selectedOptions.length; } - const selectedOptionNames = _.map(selectedOptions, (selectedOption) => selectedOption.name); - const filteredCategories = _.filter(enabledCategories, (category) => !_.includes(selectedOptionNames, category.name)); + const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); + const filteredCategories = enabledCategories.filter((category) => !selectedOptionNames.includes(category.name)); const numberOfVisibleCategories = selectedOptions.length + filteredCategories.length; if (numberOfVisibleCategories < CONST.CATEGORY_LIST_THRESHOLD) { @@ -904,15 +971,14 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt return categorySections; } - const filteredRecentlyUsedCategories = _.chain(recentlyUsedCategories) - .filter((categoryName) => !_.includes(selectedOptionNames, categoryName) && lodashGet(categories, [categoryName, 'enabled'], false)) + const filteredRecentlyUsedCategories = recentlyUsedCategories + .filter((categoryName) => !selectedOptionNames.includes(categoryName) && categories[categoryName].enabled) .map((categoryName) => ({ name: categoryName, - enabled: lodashGet(categories, [categoryName, 'enabled'], false), - })) - .value(); + enabled: categories[categoryName].enabled ?? false, + })); - if (!_.isEmpty(filteredRecentlyUsedCategories)) { + if (filteredRecentlyUsedCategories.length > 0) { const cutRecentlyUsedCategories = filteredRecentlyUsedCategories.slice(0, maxRecentReportsToShow); categorySections.push({ @@ -938,15 +1004,12 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt } /** - * Transforms the provided tags into objects with a specific structure. + * Transforms the provided tags into option objects. * - * @param {Object[]} tags - an initial tag array - * @param {Boolean} tags[].enabled - a flag to enable/disable option in a list - * @param {String} tags[].name - a name of an option - * @returns {Array} + * @param tags - an initial tag array */ -function getTagsOptions(tags) { - return _.map(tags, (tag) => { +function getTagsOptions(tags: Category[]): Option[] { + return tags.map((tag) => { // This is to remove unnecessary escaping backslash in tag name sent from backend. const cleanedName = PolicyUtils.getCleanedTagName(tag.name); return { @@ -961,27 +1024,17 @@ function getTagsOptions(tags) { /** * Build the section list for tags - * - * @param {Object[]} tags - * @param {String} tags[].name - * @param {Boolean} tags[].enabled - * @param {String[]} recentlyUsedTags - * @param {Object[]} selectedOptions - * @param {String} selectedOptions[].name - * @param {String} searchInputValue - * @param {Number} maxRecentReportsToShow - * @returns {Array} */ -function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInputValue, maxRecentReportsToShow) { +function getTagListSections(tags: Tag[], recentlyUsedTags: string[], selectedOptions: Category[], searchInputValue: string, maxRecentReportsToShow: number) { const tagSections = []; const sortedTags = sortTags(tags); - const enabledTags = _.filter(sortedTags, (tag) => tag.enabled); - const numberOfTags = _.size(enabledTags); + const enabledTags = sortedTags.filter((tag) => tag.enabled); + const numberOfTags = enabledTags.length; let indexOffset = 0; // If all tags are disabled but there's a previously selected tag, show only the selected tag if (numberOfTags === 0 && selectedOptions.length > 0) { - const selectedTagOptions = _.map(selectedOptions, (option) => ({ + const selectedTagOptions = selectedOptions.map((option) => ({ name: option.name, // Should be marked as enabled to be able to be de-selected enabled: true, @@ -997,8 +1050,8 @@ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInput return tagSections; } - if (!_.isEmpty(searchInputValue)) { - const searchTags = _.filter(enabledTags, (tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchInputValue.toLowerCase())); + if (searchInputValue) { + const searchTags = enabledTags.filter((tag) => PolicyUtils.getCleanedTagName(tag.name.toLowerCase()).includes(searchInputValue.toLowerCase())); tagSections.push({ // "Search" section @@ -1023,22 +1076,21 @@ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInput return tagSections; } - const selectedOptionNames = _.map(selectedOptions, (selectedOption) => selectedOption.name); - const filteredRecentlyUsedTags = _.map( - _.filter(recentlyUsedTags, (recentlyUsedTag) => { - const tagObject = _.find(tags, (tag) => tag.name === recentlyUsedTag); - return Boolean(tagObject && tagObject.enabled) && !_.includes(selectedOptionNames, recentlyUsedTag); - }), - (tag) => ({name: tag, enabled: true}), - ); - const filteredTags = _.filter(enabledTags, (tag) => !_.includes(selectedOptionNames, tag.name)); - - if (!_.isEmpty(selectedOptions)) { - const selectedTagOptions = _.map(selectedOptions, (option) => { - const tagObject = _.find(tags, (tag) => tag.name === option.name); + const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); + const filteredRecentlyUsedTags = recentlyUsedTags + .filter((recentlyUsedTag) => { + const tagObject = tags.find((tag) => tag.name === recentlyUsedTag); + return !!tagObject?.enabled && !selectedOptionNames.includes(recentlyUsedTag); + }) + .map((tag) => ({name: tag, enabled: true})); + const filteredTags = enabledTags.filter((tag) => !selectedOptionNames.includes(tag.name)); + + if (selectedOptions) { + const selectedTagOptions = selectedOptions.map((option) => { + const tagObject = tags.find((tag) => tag.name === option.name); return { name: option.name, - enabled: Boolean(tagObject && tagObject.enabled), + enabled: !!tagObject?.enabled, }; }); @@ -1053,7 +1105,7 @@ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInput indexOffset += selectedOptions.length; } - if (!_.isEmpty(filteredRecentlyUsedTags)) { + if (filteredRecentlyUsedTags.length > 0) { const cutRecentlyUsedTags = filteredRecentlyUsedTags.slice(0, maxRecentReportsToShow); tagSections.push({ @@ -1078,52 +1130,40 @@ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInput return tagSections; } -/** - * Represents the data for a single tax rate. - * - * @property {string} name - The name of the tax rate. - * @property {string} value - The value of the tax rate. - * @property {string} code - The code associated with the tax rate. - * @property {string} modifiedName - This contains the tax name and tax value as one name - * @property {boolean} [isDisabled] - Indicates if the tax rate is disabled. - */ +type PolicyTaxRateWithDefault = { + name: string; + defaultExternalID: string; + defaultValue: string; + foreignTaxDefault: string; + taxes: PolicyTaxRates; +}; /** * Transforms tax rates to a new object format - to add codes and new name with concatenated name and value. * - * @param {Object} policyTaxRates - The original tax rates object. - * @returns {Object.>} The transformed tax rates object. + * @param policyTaxRates - The original tax rates object. + * @returns The transformed tax rates object.g */ -function transformedTaxRates(policyTaxRates) { - const defaultTaxKey = policyTaxRates.defaultExternalID; - const getModifiedName = (data, code) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`; - const taxes = Object.fromEntries(_.map(Object.entries(policyTaxRates.taxes), ([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}])); +function transformedTaxRates(policyTaxRates: PolicyTaxRateWithDefault | undefined): Record { + const defaultTaxKey = policyTaxRates?.defaultExternalID; + const getModifiedName = (data: PolicyTaxRate, code: string) => `${data.name} (${data.value})${defaultTaxKey === code ? ` • ${Localize.translateLocal('common.default')}` : ''}`; + const taxes = Object.fromEntries(Object.entries(policyTaxRates?.taxes ?? {}).map(([code, data]) => [code, {...data, code, modifiedName: getModifiedName(data, code), name: data.name}])); return taxes; } /** * Sorts tax rates alphabetically by name. - * - * @param {Object} taxRates - * @returns {Array} */ -function sortTaxRates(taxRates) { - const sortedtaxRates = _.chain(taxRates) - .values() - .sortBy((taxRate) => taxRate.name) - .value(); - +function sortTaxRates(taxRates: PolicyTaxRates): PolicyTaxRate[] { + const sortedtaxRates = lodashSortBy(taxRates, (taxRate) => taxRate.name); return sortedtaxRates; } /** * Builds the options for taxRates - * - * @param {Object[]} taxRates - an initial object array - * @returns {Array} */ -function getTaxRatesOptions(taxRates) { - return _.map(taxRates, (taxRate) => ({ +function getTaxRatesOptions(taxRates: Array>): Option[] { + return taxRates.map((taxRate) => ({ text: taxRate.modifiedName, keyForList: taxRate.code, searchText: taxRate.modifiedName, @@ -1135,32 +1175,27 @@ function getTaxRatesOptions(taxRates) { /** * Builds the section list for tax rates - * - * @param {Object} policyTaxRates - * @param {Object[]} selectedOptions - * @param {String} searchInputValue - * @returns {Array} */ -function getTaxRatesSection(policyTaxRates, selectedOptions, searchInputValue) { +function getTaxRatesSection(policyTaxRates: PolicyTaxRateWithDefault | undefined, selectedOptions: Category[], searchInputValue: string): CategorySection[] { const policyRatesSections = []; const taxes = transformedTaxRates(policyTaxRates); const sortedTaxRates = sortTaxRates(taxes); - const enabledTaxRates = _.filter(sortedTaxRates, (taxRate) => !taxRate.isDisabled); - const numberOfTaxRates = _.size(enabledTaxRates); + const enabledTaxRates = sortedTaxRates.filter((taxRate) => !taxRate.isDisabled); + const numberOfTaxRates = enabledTaxRates.length; let indexOffset = 0; // If all tax are disabled but there's a previously selected tag, show only the selected tag if (numberOfTaxRates === 0 && selectedOptions.length > 0) { - const selectedTaxRateOptions = _.map(selectedOptions, (option) => ({ + const selectedTaxRateOptions = selectedOptions.map((option) => ({ modifiedName: option.name, // Should be marked as enabled to be able to be de-selected isDisabled: false, })); policyRatesSections.push({ - // "Selected" section + // "Selected" sectiong title: '', shouldShow: false, indexOffset, @@ -1170,8 +1205,8 @@ function getTaxRatesSection(policyTaxRates, selectedOptions, searchInputValue) { return policyRatesSections; } - if (!_.isEmpty(searchInputValue)) { - const searchTaxRates = _.filter(enabledTaxRates, (taxRate) => taxRate.modifiedName.toLowerCase().includes(searchInputValue.toLowerCase())); + if (searchInputValue) { + const searchTaxRates = enabledTaxRates.filter((taxRate) => taxRate.modifiedName.toLowerCase().includes(searchInputValue.toLowerCase())); policyRatesSections.push({ // "Search" section @@ -1196,16 +1231,16 @@ function getTaxRatesSection(policyTaxRates, selectedOptions, searchInputValue) { return policyRatesSections; } - const selectedOptionNames = _.map(selectedOptions, (selectedOption) => selectedOption.name); - const filteredTaxRates = _.filter(enabledTaxRates, (taxRate) => !_.includes(selectedOptionNames, taxRate.modifiedName)); + const selectedOptionNames = selectedOptions.map((selectedOption) => selectedOption.name); + const filteredTaxRates = enabledTaxRates.filter((taxRate) => !selectedOptionNames.includes(taxRate.modifiedName)); - if (!_.isEmpty(selectedOptions)) { - const selectedTaxRatesOptions = _.map(selectedOptions, (option) => { - const taxRateObject = _.find(taxes, (taxRate) => taxRate.modifiedName === option.name); + if (selectedOptions.length > 0) { + const selectedTaxRatesOptions = selectedOptions.map((option) => { + const taxRateObject = Object.values(taxes).find((taxRate) => taxRate.modifiedName === option.name); return { modifiedName: option.name, - isDisabled: Boolean(taxRateObject && taxRateObject.isDisabled), + isDisabled: !!taxRateObject?.isDisabled, }; }); @@ -1234,34 +1269,25 @@ function getTaxRatesSection(policyTaxRates, selectedOptions, searchInputValue) { /** * Checks if a report option is selected based on matching accountID or reportID. * - * @param {Object} reportOption - The report option to be checked. - * @param {Object[]} selectedOptions - Array of selected options to compare with. - * @param {number} reportOption.accountID - The account ID of the report option. - * @param {number} reportOption.reportID - The report ID of the report option. - * @param {number} [selectedOptions[].accountID] - The account ID in the selected options. - * @param {number} [selectedOptions[].reportID] - The report ID in the selected options. - * @returns {boolean} True if the report option matches any of the selected options by accountID or reportID, false otherwise. + * @param reportOption - The report option to be checked. + * @param selectedOptions - Array of selected options to compare with. + * @returns true if the report option matches any of the selected options by accountID or reportID, false otherwise. */ -function isReportSelected(reportOption, selectedOptions) { +function isReportSelected(reportOption: ReportUtils.OptionData, selectedOptions: Array>) { if (!selectedOptions || selectedOptions.length === 0) { return false; } - return _.some(selectedOptions, (option) => (option.accountID && option.accountID === reportOption.accountID) || (option.reportID && option.reportID === reportOption.reportID)); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return selectedOptions.some((option) => (option.accountID && option.accountID === reportOption.accountID) || (option.reportID && option.reportID === reportOption.reportID)); } /** * Build the options - * - * @param {Object} reports - * @param {Object} personalDetails - * @param {Object} options - * @returns {Object} - * @private */ function getOptions( - reports, - personalDetails, + reports: OnyxCollection, + personalDetails: OnyxEntry, { reportActions = {}, betas = [], @@ -1294,10 +1320,10 @@ function getOptions( transactionViolations = {}, includePolicyTaxRates, policyTaxRates, - }, -) { + }: GetOptionsConfig, +): GetOptions { if (includeCategories) { - const categoryOptions = getCategoryListSections(categories, recentlyUsedCategories, selectedOptions, searchInputValue, maxRecentReportsToShow); + const categoryOptions = getCategoryListSections(categories, recentlyUsedCategories, selectedOptions as Category[], searchInputValue, maxRecentReportsToShow); return { recentReports: [], @@ -1311,7 +1337,7 @@ function getOptions( } if (includeTags) { - const tagOptions = getTagListSections(_.values(tags), recentlyUsedTags, selectedOptions, searchInputValue, maxRecentReportsToShow); + const tagOptions = getTagListSections(Object.values(tags), recentlyUsedTags, selectedOptions as Category[], searchInputValue, maxRecentReportsToShow); return { recentReports: [], @@ -1325,7 +1351,7 @@ function getOptions( } if (includePolicyTaxRates) { - const policyTaxRatesOptions = getTaxRatesSection(policyTaxRates, selectedOptions, searchInputValue); + const policyTaxRatesOptions = getTaxRatesSection(policyTaxRates, selectedOptions as Category[], searchInputValue); return { recentReports: [], @@ -1351,25 +1377,26 @@ function getOptions( } let recentReportOptions = []; - let personalDetailsOptions = []; - const reportMapForAccountIDs = {}; + let personalDetailsOptions: ReportUtils.OptionData[] = []; + const reportMapForAccountIDs: Record = {}; const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue))); - const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : searchInputValue.toLowerCase(); + const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 : searchInputValue.toLowerCase(); // Filter out all the reports that shouldn't be displayed - const filteredReports = _.filter(reports, (report) => { - const {parentReportID, parentReportActionID} = report || {}; + const filteredReports = Object.values(reports ?? {}).filter((report) => { + const {parentReportID, parentReportActionID} = report ?? {}; const canGetParentReport = parentReportID && parentReportActionID && allReportActions; - const parentReportAction = canGetParentReport ? lodashGet(allReportActions, [parentReportID, parentReportActionID], {}) : {}; + const parentReportAction = canGetParentReport ? allReportActions[parentReportID][parentReportActionID] : null; const doesReportHaveViolations = betas.includes(CONST.BETAS.VIOLATIONS) && ReportUtils.doesTransactionThreadHaveViolations(report, transactionViolations, parentReportAction); return ReportUtils.shouldReportBeInOptionList({ report, - currentReportId: Navigation.getTopmostReportId(), + currentReportId: Navigation.getTopmostReportId() ?? '', betas, policies, doesReportHaveViolations, isInGSDMode: false, + excludeEmptyChats: false, }); }); @@ -1377,17 +1404,17 @@ function getOptions( // Sorting the reports works like this: // - Order everything by the last message timestamp (descending) // - All archived reports should remain at the bottom - const orderedReports = _.sortBy(filteredReports, (report) => { + const orderedReports = lodashSortBy(filteredReports, (report) => { if (ReportUtils.isArchivedRoom(report)) { return CONST.DATE.UNIX_EPOCH; } - return report.lastVisibleActionCreated; + return report?.lastVisibleActionCreated; }); orderedReports.reverse(); - const allReportOptions = []; - _.each(orderedReports, (report) => { + const allReportOptions: ReportUtils.OptionData[] = []; + orderedReports.forEach((report) => { if (!report) { return; } @@ -1397,7 +1424,7 @@ function getOptions( const isTaskReport = ReportUtils.isTaskReport(report); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); - const accountIDs = report.visibleChatMemberAccountIDs || []; + const accountIDs = report.visibleChatMemberAccountIDs ?? []; if (isPolicyExpenseChat && report.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { return; @@ -1444,16 +1471,15 @@ function getOptions( }), ); }); - - /* - We're only picking personal details that have logins and accountIDs set (sometimes the __fake__ account with `ID = 0` is present in the personal details collection) - This is a temporary fix for all the logic that's been breaking because of the new privacy changes - See https://github.com/Expensify/Expensify/issues/293465, https://github.com/Expensify/App/issues/33415 for more context - Moreover, we should not override the personalDetails object, otherwise the createOption util won't work properly, it returns incorrect tooltipText - */ - const havingLoginPersonalDetails = !includeP2P ? {} : _.pick(personalDetails, (detail) => !!detail.login && !!detail.accountID && !detail.isOptimisticPersonalDetail); - let allPersonalDetailsOptions = _.map(havingLoginPersonalDetails, (personalDetail) => - createOption([personalDetail.accountID], personalDetails, reportMapForAccountIDs[personalDetail.accountID], reportActions, { + // We're only picking personal details that have logins set + // This is a temporary fix for all the logic that's been breaking because of the new privacy changes + // See https://github.com/Expensify/Expensify/issues/293465 for more context + // Moreover, we should not override the personalDetails object, otherwise the createOption util won't work properly, it returns incorrect tooltipText + const havingLoginPersonalDetails = !includeP2P + ? {} + : Object.fromEntries(Object.entries(personalDetails ?? {}).filter(([, detail]) => !!detail?.login && !!detail.accountID && !detail?.isOptimisticPersonalDetail)); + let allPersonalDetailsOptions = Object.values(havingLoginPersonalDetails).map((personalDetail) => + createOption([personalDetail?.accountID ?? -1], personalDetails, reportMapForAccountIDs[personalDetail?.accountID ?? -1], reportActions, { showChatPreviewLine, forcePolicyNamePreview, }), @@ -1461,11 +1487,11 @@ function getOptions( if (sortPersonalDetailsByAlphaAsc) { // PersonalDetails should be ordered Alphabetically by default - https://github.com/Expensify/App/issues/8220#issuecomment-1104009435 - allPersonalDetailsOptions = lodashOrderBy(allPersonalDetailsOptions, [(personalDetail) => personalDetail.text && personalDetail.text.toLowerCase()], 'asc'); + allPersonalDetailsOptions = lodashOrderBy(allPersonalDetailsOptions, [(personalDetail) => personalDetail.text?.toLowerCase()], 'asc'); } // Exclude the current user from the personal details list - const optionsToExclude = [{login: currentUserLogin}, {login: CONST.EMAIL.NOTIFICATIONS}]; + const optionsToExclude: Option[] = [{login: currentUserLogin}, {login: CONST.EMAIL.NOTIFICATIONS}]; // If we're including selected options from the search results, we only want to exclude them if the search input is empty // This is because on certain pages, we show the selected options at the top when the search input is empty @@ -1474,14 +1500,12 @@ function getOptions( optionsToExclude.push(...selectedOptions); } - _.each(excludeLogins, (login) => { + excludeLogins.forEach((login) => { optionsToExclude.push({login}); }); if (includeRecentReports) { - for (let i = 0; i < allReportOptions.length; i++) { - const reportOption = allReportOptions[i]; - + for (const reportOption of allReportOptions) { // Stop adding options to the recentReports array when we reach the maxRecentReportsToShow value if (recentReportOptions.length > 0 && recentReportOptions.length === maxRecentReportsToShow) { break; @@ -1503,8 +1527,8 @@ function getOptions( // If we're excluding threads, check the report to see if it has a single participant and if the participant is already selected if ( !includeThreads && - (reportOption.login || reportOption.reportID) && - _.some(optionsToExclude, (option) => (option.login && option.login === reportOption.login) || (option.reportID && option.reportID === reportOption.reportID)) + (!!reportOption.login || reportOption.reportID) && + optionsToExclude.some((option) => option.login === reportOption.login || option.reportID === reportOption.reportID) ) { continue; } @@ -1515,7 +1539,7 @@ function getOptions( if (searchValue) { // Determine if the search is happening within a chat room and starts with the report ID - const isReportIdSearch = isChatRoom && Str.startsWith(reportOption.reportID, searchValue); + const isReportIdSearch = isChatRoom && Str.startsWith(reportOption.reportID ?? '', searchValue); // Check if the search string matches the search text or participant names considering the type of the room const isSearchMatch = isSearchStringMatch(searchValue, searchText, participantNames, isChatRoom); @@ -1538,8 +1562,8 @@ function getOptions( if (includePersonalDetails) { // Next loop over all personal details removing any that are selectedUsers or recentChats - _.each(allPersonalDetailsOptions, (personalDetailOption) => { - if (_.some(optionsToExclude, (optionToExclude) => optionToExclude.login === personalDetailOption.login)) { + allPersonalDetailsOptions.forEach((personalDetailOption) => { + if (optionsToExclude.some((optionToExclude) => optionToExclude.login === personalDetailOption.login)) { return; } const {searchText, participantsList, isChatRoom} = personalDetailOption; @@ -1552,26 +1576,25 @@ function getOptions( }); } - let currentUserOption = _.find(allPersonalDetailsOptions, (personalDetailsOption) => personalDetailsOption.login === currentUserLogin); + let currentUserOption = allPersonalDetailsOptions.find((personalDetailsOption) => personalDetailsOption.login === currentUserLogin); if (searchValue && currentUserOption && !isSearchStringMatch(searchValue, currentUserOption.searchText)) { - currentUserOption = null; + currentUserOption = undefined; } - let userToInvite = null; + let userToInvite: ReportUtils.OptionData | null = null; const noOptions = recentReportOptions.length + personalDetailsOptions.length === 0 && !currentUserOption; - const noOptionsMatchExactly = !_.find( - personalDetailsOptions.concat(recentReportOptions), - (option) => option.login === addSMSDomainIfPhoneNumber(searchValue).toLowerCase() || option.login === searchValue.toLowerCase(), - ); + const noOptionsMatchExactly = !personalDetailsOptions + .concat(recentReportOptions) + .find((option) => option.login === addSMSDomainIfPhoneNumber(searchValue ?? '').toLowerCase() || option.login === searchValue?.toLowerCase()); if ( searchValue && (noOptions || noOptionsMatchExactly) && - !isCurrentUser({login: searchValue}) && - _.every(selectedOptions, (option) => option.login !== searchValue) && + !isCurrentUser({login: searchValue} as PersonalDetails) && + selectedOptions.every((option) => 'login' in option && option.login !== searchValue) && ((Str.isValidEmail(searchValue) && !Str.isDomainEmail(searchValue) && !Str.endsWith(searchValue, CONST.SMS.DOMAIN)) || - (parsedPhoneNumber.possible && Str.isValidPhone(LoginUtils.getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number.input)))) && - !_.find(optionsToExclude, (optionToExclude) => optionToExclude.login === addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) && + (parsedPhoneNumber.possible && Str.isValidPhone(LoginUtils.getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')))) && + !optionsToExclude.find((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) && (searchValue !== CONST.EMAIL.CHRONOS || Permissions.canUseChronos(betas)) && !excludeUnknownUsers ) { @@ -1590,7 +1613,9 @@ function getOptions( }); userToInvite.isOptimisticAccount = true; userToInvite.login = searchValue; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing userToInvite.text = userToInvite.text || searchValue; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing userToInvite.alternateText = userToInvite.alternateText || searchValue; // If user doesn't exist, use a default avatar @@ -1612,13 +1637,13 @@ function getOptions( recentReportOptions, [ (option) => { - if (option.isChatRoom || option.isArchivedRoom) { + if (!!option.isChatRoom || option.isArchivedRoom) { return 3; } if (!option.login) { return 2; } - if (option.login.toLowerCase() !== searchValue.toLowerCase()) { + if (option.login.toLowerCase() !== searchValue?.toLowerCase()) { return 1; } @@ -1643,15 +1668,11 @@ function getOptions( /** * Build the options for the Search view - * - * @param {Object} reports - * @param {Object} personalDetails - * @param {String} searchValue - * @param {Array} betas - * @returns {Object} */ -function getSearchOptions(reports, personalDetails, searchValue = '', betas) { - return getOptions(reports, personalDetails, { +function getSearchOptions(reports: Record, personalDetails: OnyxEntry, searchValue = '', betas: Beta[] = []): GetOptions { + Timing.start(CONST.TIMING.LOAD_SEARCH_OPTIONS); + Performance.markStart(CONST.TIMING.LOAD_SEARCH_OPTIONS); + const options = getOptions(reports, personalDetails, { betas, searchInputValue: searchValue.trim(), includeRecentReports: true, @@ -1666,43 +1687,39 @@ function getSearchOptions(reports, personalDetails, searchValue = '', betas) { includeMoneyRequests: true, includeTasks: true, }); + Timing.end(CONST.TIMING.LOAD_SEARCH_OPTIONS); + Performance.markEnd(CONST.TIMING.LOAD_SEARCH_OPTIONS); + + return options; } /** * Build the IOUConfirmation options for showing the payee personalDetail - * - * @param {Object} personalDetail - * @param {String} amountText - * @returns {Object} */ -function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail, amountText) { - const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetail.login); +function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: PersonalDetails, amountText: string): PayeePersonalDetails { + const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetail.login ?? ''); return { text: PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail, formattedLogin), alternateText: formattedLogin || PersonalDetailsUtils.getDisplayNameOrDefault(personalDetail, '', false), icons: [ { source: UserUtils.getAvatar(personalDetail.avatar, personalDetail.accountID), - name: personalDetail.login, + name: personalDetail.login ?? '', type: CONST.ICON_TYPE_AVATAR, id: personalDetail.accountID, }, ], descriptiveText: amountText, - login: personalDetail.login, + login: personalDetail.login ?? '', accountID: personalDetail.accountID, }; } /** * Build the IOUConfirmationOptions for showing participants - * - * @param {Array} participants - * @param {String} amountText - * @returns {Array} */ -function getIOUConfirmationOptionsFromParticipants(participants, amountText) { - return _.map(participants, (participant) => ({ +function getIOUConfirmationOptionsFromParticipants(participants: Participant[], amountText: string): Participant[] { + return participants.map((participant) => ({ ...participant, descriptiveText: amountText, })); @@ -1710,46 +1727,26 @@ function getIOUConfirmationOptionsFromParticipants(participants, amountText) { /** * Build the options for the New Group view - * - * @param {Object} reports - * @param {Object} personalDetails - * @param {Array} [betas] - * @param {String} [searchValue] - * @param {Array} [selectedOptions] - * @param {Array} [excludeLogins] - * @param {Boolean} [includeOwnedWorkspaceChats] - * @param {boolean} [includeP2P] - * @param {boolean} [includeCategories] - * @param {Object} [categories] - * @param {Array} [recentlyUsedCategories] - * @param {boolean} [includeTags] - * @param {Object} [tags] - * @param {Array} [recentlyUsedTags] - * @param {boolean} [canInviteUser] - * @param {boolean} [includeSelectedOptions] - * @param {boolean} [includePolicyTaxRates] - * @param {Object} [policyTaxRates] - * @returns {Object} */ function getFilteredOptions( - reports, - personalDetails, - betas = [], + reports: Record, + personalDetails: OnyxEntry, + betas: Beta[] = [], searchValue = '', - selectedOptions = [], - excludeLogins = [], + selectedOptions: Array> = [], + excludeLogins: string[] = [], includeOwnedWorkspaceChats = false, includeP2P = true, includeCategories = false, - categories = {}, - recentlyUsedCategories = [], + categories: PolicyCategories = {}, + recentlyUsedCategories: string[] = [], includeTags = false, - tags = {}, - recentlyUsedTags = [], + tags: Record = {}, + recentlyUsedTags: string[] = [], canInviteUser = true, includeSelectedOptions = false, includePolicyTaxRates = false, - policyTaxRates = {}, + policyTaxRates: PolicyTaxRateWithDefault = {} as PolicyTaxRateWithDefault, ) { return getOptions(reports, personalDetails, { betas, @@ -1776,25 +1773,15 @@ function getFilteredOptions( /** * Build the options for the Share Destination for a Task - * * - * @param {Object} reports - * @param {Object} personalDetails - * @param {Array} [betas] - * @param {String} [searchValue] - * @param {Array} [selectedOptions] - * @param {Array} [excludeLogins] - * @param {Boolean} [includeOwnedWorkspaceChats] - * @returns {Object} - * */ function getShareDestinationOptions( - reports, - personalDetails, - betas = [], + reports: Record, + personalDetails: OnyxEntry, + betas: Beta[] = [], searchValue = '', - selectedOptions = [], - excludeLogins = [], + selectedOptions: Array> = [], + excludeLogins: string[] = [], includeOwnedWorkspaceChats = true, excludeUnknownUsers = true, ) { @@ -1820,44 +1807,45 @@ function getShareDestinationOptions( /** * Format personalDetails or userToInvite to be shown in the list * - * @param {Object} member - personalDetails or userToInvite - * @param {Object} config - keys to overwrite the default values - * @returns {Object} + * @param member - personalDetails or userToInvite + * @param config - keys to overwrite the default values */ -function formatMemberForList(member, config = {}) { +function formatMemberForList(member: ReportUtils.OptionData, config: ReportUtils.OptionData | EmptyObject = {}): MemberForList | undefined { if (!member) { return undefined; } - const accountID = lodashGet(member, 'accountID', ''); + const accountID = member.accountID; return { - text: lodashGet(member, 'text', '') || lodashGet(member, 'displayName', ''), - alternateText: lodashGet(member, 'alternateText', '') || lodashGet(member, 'login', ''), - keyForList: lodashGet(member, 'keyForList', '') || String(accountID), + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + text: member.text || member.displayName || '', + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + alternateText: member.alternateText || member.login || '', + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + keyForList: member.keyForList || String(accountID ?? 0) || '', isSelected: false, isDisabled: false, accountID, - login: lodashGet(member, 'login', ''), + login: member.login ?? '', rightElement: null, - icons: lodashGet(member, 'icons'), - pendingAction: lodashGet(member, 'pendingAction'), + icons: member.icons, + pendingAction: member.pendingAction, ...config, }; } /** * Build the options for the Workspace Member Invite view - * - * @param {Object} personalDetails - * @param {Array} betas - * @param {String} searchValue - * @param {Array} excludeLogins - * @param {Boolean} includeSelectedOptions - * @returns {Object} */ -function getMemberInviteOptions(personalDetails, betas = [], searchValue = '', excludeLogins = [], includeSelectedOptions = false) { - return getOptions([], personalDetails, { +function getMemberInviteOptions( + personalDetails: OnyxEntry, + betas: Beta[] = [], + searchValue = '', + excludeLogins: string[] = [], + includeSelectedOptions = false, +): GetOptions { + return getOptions({}, personalDetails, { betas, searchInputValue: searchValue.trim(), includePersonalDetails: true, @@ -1869,15 +1857,8 @@ function getMemberInviteOptions(personalDetails, betas = [], searchValue = '', e /** * Helper method that returns the text to be used for the header's message and title (if any) - * - * @param {Boolean} hasSelectableOptions - * @param {Boolean} hasUserToInvite - * @param {String} searchValue - * @param {Boolean} [maxParticipantsReached] - * @param {Boolean} [hasMatchedParticipant] - * @return {String} */ -function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, maxParticipantsReached = false, hasMatchedParticipant = false) { +function getHeaderMessage(hasSelectableOptions: boolean, hasUserToInvite: boolean, searchValue: string, maxParticipantsReached = false, hasMatchedParticipant = false): string { if (maxParticipantsReached) { return Localize.translate(preferredLocale, 'common.maxParticipantsReached', {count: CONST.REPORT.MAXIMUM_PARTICIPANTS}); } @@ -1910,12 +1891,8 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma /** * Helper method for non-user lists (eg. categories and tags) that returns the text to be used for the header's message and title (if any) - * - * @param {Boolean} hasSelectableOptions - * @param {String} searchValue - * @return {String} */ -function getHeaderMessageForNonUserList(hasSelectableOptions, searchValue) { +function getHeaderMessageForNonUserList(hasSelectableOptions: boolean, searchValue: string): string { if (searchValue && !hasSelectableOptions) { return Localize.translate(preferredLocale, 'common.noResultsFound'); } @@ -1924,25 +1901,23 @@ function getHeaderMessageForNonUserList(hasSelectableOptions, searchValue) { /** * Helper method to check whether an option can show tooltip or not - * @param {Object} option - * @returns {Boolean} */ -function shouldOptionShowTooltip(option) { - return (!option.isChatRoom || option.isThread) && !option.isArchivedRoom; +function shouldOptionShowTooltip(option: ReportUtils.OptionData): boolean { + return (!option.isChatRoom || !!option.isThread) && !option.isArchivedRoom; } /** * Handles the logic for displaying selected participants from the search term - * @param {String} searchTerm - * @param {Array} selectedOptions - * @param {Array} filteredRecentReports - * @param {Array} filteredPersonalDetails - * @param {Object} personalDetails - * @param {Boolean} shouldGetOptionDetails - * @param {Number} indexOffset - * @returns {Object} */ -function formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecentReports, filteredPersonalDetails, personalDetails = {}, shouldGetOptionDetails = false, indexOffset) { +function formatSectionsFromSearchTerm( + searchTerm: string, + selectedOptions: ReportUtils.OptionData[], + filteredRecentReports: ReportUtils.OptionData[], + filteredPersonalDetails: PersonalDetails[], + personalDetails: OnyxEntry = {}, + shouldGetOptionDetails = false, + indexOffset = 0, +): SectionForSearchTerm { // We show the selected participants at the top of the list when there is no search term // However, if there is a search term we remove the selected participants from the top of the list unless they are part of the search results // This clears up space on mobile views, where if you create a group with 4+ people you can't see the selected participants and the search results at the same time @@ -1951,12 +1926,12 @@ function formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecen section: { title: undefined, data: shouldGetOptionDetails - ? _.map(selectedOptions, (participant) => { - const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); + ? selectedOptions.map((participant) => { + const isPolicyExpenseChat = participant.isPolicyExpenseChat ?? false; return isPolicyExpenseChat ? getPolicyExpenseReportOption(participant) : getParticipantsOption(participant, personalDetails); }) : selectedOptions, - shouldShow: !_.isEmpty(selectedOptions), + shouldShow: selectedOptions.length > 0, indexOffset, }, newIndexOffset: indexOffset + selectedOptions.length, @@ -1965,11 +1940,11 @@ function formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecen // If you select a new user you don't have a contact for, they won't get returned as part of a recent report or personal details // This will add them to the list of options, deduping them if they already exist in the other lists - const selectedParticipantsWithoutDetails = _.filter(selectedOptions, (participant) => { - const accountID = lodashGet(participant, 'accountID', null); - const isPartOfSearchTerm = participant.searchText.toLowerCase().includes(searchTerm.trim().toLowerCase()); - const isReportInRecentReports = _.some(filteredRecentReports, (report) => report.accountID === accountID); - const isReportInPersonalDetails = _.some(filteredPersonalDetails, (personalDetail) => personalDetail.accountID === accountID); + const selectedParticipantsWithoutDetails = selectedOptions.filter((participant) => { + const accountID = participant.accountID ?? null; + const isPartOfSearchTerm = participant.searchText?.toLowerCase().includes(searchTerm.trim().toLowerCase()); + const isReportInRecentReports = filteredRecentReports.some((report) => report.accountID === accountID); + const isReportInPersonalDetails = filteredPersonalDetails.some((personalDetail) => personalDetail.accountID === accountID); return isPartOfSearchTerm && !isReportInRecentReports && !isReportInPersonalDetails; }); @@ -1977,12 +1952,12 @@ function formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecen section: { title: undefined, data: shouldGetOptionDetails - ? _.map(selectedParticipantsWithoutDetails, (participant) => { - const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); + ? selectedParticipantsWithoutDetails.map((participant) => { + const isPolicyExpenseChat = participant.isPolicyExpenseChat ?? false; return isPolicyExpenseChat ? getPolicyExpenseReportOption(participant) : getParticipantsOption(participant, personalDetails); }) : selectedParticipantsWithoutDetails, - shouldShow: !_.isEmpty(selectedParticipantsWithoutDetails), + shouldShow: selectedParticipantsWithoutDetails.length > 0, indexOffset, }, newIndexOffset: indexOffset + selectedParticipantsWithoutDetails.length, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 33290b5046f0..2f18f7c3733c 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -28,6 +28,7 @@ import type { Transaction, TransactionViolation, } from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ChangeLog, IOUMessage, OriginalMessageActionName, OriginalMessageCreated} from '@src/types/onyx/OriginalMessage'; import type {Status} from '@src/types/onyx/PersonalDetails'; @@ -78,20 +79,6 @@ type ExpenseOriginalMessage = { oldBillable?: string; }; -type Participant = { - accountID: number; - alternateText: string; - firstName: string; - icons: Icon[]; - keyForList: string; - lastName: string; - login: string; - phoneNumber: string; - searchText: string; - selected: boolean; - text: string; -}; - type SpendBreakdown = { nonReimbursableSpend: number; reimbursableSpend: number; @@ -366,7 +353,7 @@ type CustomIcon = { }; type OptionData = { - text: string; + text?: string; alternateText?: string | null; allReportErrors?: Errors; brickRoadIndicator?: typeof CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR | '' | null; @@ -396,7 +383,14 @@ type OptionData = { isTaskReport?: boolean | null; parentReportAction?: OnyxEntry; displayNamesWithTooltips?: DisplayNameWithTooltips | null; + isDefaultRoom?: boolean; + isExpenseReport?: boolean; + isOptimisticPersonalDetail?: boolean; + selected?: boolean; + isOptimisticAccount?: boolean; + isSelected?: boolean; descriptiveText?: string; + notificationPreference?: NotificationPreference | null; isDisabled?: boolean | null; name?: string | null; } & Report; @@ -1459,84 +1453,6 @@ function getWorkspaceIcon(report: OnyxEntry, policy: OnyxEntry = return workspaceIcon; } -/** - * Checks if a report is a group chat. - * - * A report is a group chat if it meets the following conditions: - * - Not a chat thread. - * - Not a task report. - * - Not a money request / IOU report. - * - Not an archived room. - * - Not a public / admin / announce chat room (chat type doesn't match any of the specified types). - * - More than 1 participant (note that participantAccountIDs excludes the current user). - * - */ -function isGroupChat(report: OnyxEntry): boolean { - return Boolean( - report && - !isChatThread(report) && - !isTaskReport(report) && - !isMoneyRequestReport(report) && - !isArchivedRoom(report) && - !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) && - (report.participantAccountIDs?.length ?? 0) > 1, - ); -} - -function getGroupChatParticipantIDs(participants: number[]): number[] { - return [...new Set([...participants, ...(currentUserAccountID ? [currentUserAccountID] : [])])]; -} - -/** - * Returns an array of the participants Ids of a report - * - * @deprecated Use getVisibleMemberIDs instead - */ -function getParticipantsIDs(report: OnyxEntry): number[] { - if (!report) { - return []; - } - - const participants = report.participantAccountIDs ?? []; - - // Build participants list for IOU/expense reports - if (isMoneyRequestReport(report)) { - const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...participants].filter(Boolean) as number[]; - const onlyUnique = [...new Set([...onlyTruthyValues])]; - return onlyUnique; - } - - if (isGroupChat(report)) { - return getGroupChatParticipantIDs(participants); - } - - return participants; -} - -/** - * Returns an array of the visible member accountIDs for a report - */ -function getVisibleMemberIDs(report: OnyxEntry): number[] { - if (!report) { - return []; - } - - const visibleChatMemberAccountIDs = report.visibleChatMemberAccountIDs ?? []; - - // Build participants list for IOU/expense reports - if (isMoneyRequestReport(report)) { - const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...visibleChatMemberAccountIDs].filter(Boolean) as number[]; - const onlyUnique = [...new Set([...onlyTruthyValues])]; - return onlyUnique; - } - - if (isGroupChat(report)) { - return getGroupChatParticipantIDs(visibleChatMemberAccountIDs); - } - - return visibleChatMemberAccountIDs; -} - /** * Returns the appropriate icons for the given chat report using the stored personalDetails. * The Avatar sources can be URLs or Icon components according to the chat type. @@ -1653,10 +1569,6 @@ function getIcons( return isPayer ? [managerIcon, ownerIcon] : [ownerIcon, managerIcon]; } - if (isGroupChat(report)) { - return getIconsForParticipants(getVisibleMemberIDs(report), personalDetails); - } - return getIconsForParticipants(report?.participantAccountIDs ?? [], personalDetails); } @@ -1698,7 +1610,7 @@ function getDisplayNameForParticipant(accountID?: number, shouldUseShortForm = f // This is to check if account is an invite/optimistically created one // and prevent from falling back to 'Hidden', so a correct value is shown // when searching for a new user - if (personalDetails.isOptimisticPersonalDetail === true && formattedLogin) { + if (personalDetails.isOptimisticPersonalDetail === true) { return formattedLogin; } @@ -2417,12 +2329,18 @@ function isChangeLogObject(originalMessage?: ChangeLog): ChangeLog | undefined { */ function getAdminRoomInvitedParticipants(parentReportAction: ReportAction | Record, parentReportActionMessage: string) { if (!parentReportAction?.originalMessage) { - return ''; + return parentReportActionMessage || Localize.translateLocal('parentReportAction.deletedMessage'); } const originalMessage = isChangeLogObject(parentReportAction.originalMessage); const participantAccountIDs = originalMessage?.targetAccountIDs ?? []; - const participants = participantAccountIDs.map((id) => getDisplayNameForParticipant(id)); + const participants = participantAccountIDs.map((id) => { + const name = getDisplayNameForParticipant(id); + if (name && name?.length > 0) { + return name; + } + return Localize.translateLocal('common.hidden'); + }); const users = participants.length > 1 ? participants.join(` ${Localize.translateLocal('common.and')} `) : participants[0]; if (!users) { return parentReportActionMessage; @@ -3789,7 +3707,7 @@ function shouldReportBeInOptionList({ // All unread chats (even archived ones) in GSD mode will be shown. This is because GSD mode is specifically for focusing the user on the most relevant chats, primarily, the unread ones if (isInGSDMode) { - return isUnread(report); + return isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE; } // Archived reports should always be shown when in default (most recent) mode. This is because you should still be able to access and search for the chats to find them. @@ -4448,6 +4366,46 @@ function getTaskAssigneeChatOnyxData( }; } +/** + * Returns an array of the participants Ids of a report + * + * @deprecated Use getVisibleMemberIDs instead + */ +function getParticipantsIDs(report: OnyxEntry): number[] { + if (!report) { + return []; + } + + const participants = report.participantAccountIDs ?? []; + + // Build participants list for IOU/expense reports + if (isMoneyRequestReport(report)) { + const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...participants].filter(Boolean) as number[]; + const onlyUnique = [...new Set([...onlyTruthyValues])]; + return onlyUnique; + } + return participants; +} + +/** + * Returns an array of the visible member accountIDs for a report* + */ +function getVisibleMemberIDs(report: OnyxEntry): number[] { + if (!report) { + return []; + } + + const visibleChatMemberAccountIDs = report.visibleChatMemberAccountIDs ?? []; + + // Build participants list for IOU/expense reports + if (isMoneyRequestReport(report)) { + const onlyTruthyValues = [report.managerID, report.ownerAccountID, ...visibleChatMemberAccountIDs].filter(Boolean) as number[]; + const onlyUnique = [...new Set([...onlyTruthyValues])]; + return onlyUnique; + } + return visibleChatMemberAccountIDs; +} + /** * Return iou report action display message */ @@ -4504,6 +4462,30 @@ function getIOUReportActionDisplayMessage(reportAction: OnyxEntry) }); } +/** + * Checks if a report is a group chat. + * + * A report is a group chat if it meets the following conditions: + * - Not a chat thread. + * - Not a task report. + * - Not a money request / IOU report. + * - Not an archived room. + * - Not a public / admin / announce chat room (chat type doesn't match any of the specified types). + * - More than 2 participants. + * + */ +function isGroupChat(report: OnyxEntry): boolean { + return Boolean( + report && + !isChatThread(report) && + !isTaskReport(report) && + !isMoneyRequestReport(report) && + !isArchivedRoom(report) && + !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) && + (report.participantAccountIDs?.length ?? 0) > 2, + ); +} + function shouldUseFullTitleToDisplay(report: OnyxEntry): boolean { return isMoneyRequestReport(report) || isPolicyExpenseChat(report) || isChatRoom(report) || isChatThread(report) || isTaskReport(report); } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 4a2c4a2da22a..1dd405a7571e 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -147,6 +147,7 @@ function getOrderedReportIDs( const isInGSDMode = priorityMode === CONST.PRIORITY_MODE.GSD; const isInDefaultMode = !isInGSDMode; const allReportsDictValues = Object.values(allReports); + // Filter out all the reports that shouldn't be displayed let reportsToDisplay = allReportsDictValues.filter((report) => { const parentReportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.parentReportID}`; @@ -303,7 +304,7 @@ function getOptionData({ isDeletedParentAction: false, }; - const participantPersonalDetailList: PersonalDetails[] = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(report.participantAccountIDs ?? [], personalDetails)); + const participantPersonalDetailList = Object.values(OptionsListUtils.getPersonalDetailsForAccountIDs(report.participantAccountIDs ?? [], personalDetails)) as PersonalDetails[]; const personalDetail = participantPersonalDetailList[0] ?? {}; const hasErrors = Object.keys(result.allReportErrors ?? {}).length !== 0; @@ -317,7 +318,6 @@ function getOptionData({ result.isMoneyRequestReport = ReportUtils.isMoneyRequestReport(report); result.shouldShowSubscript = ReportUtils.shouldReportShowSubscript(report); result.pendingAction = report.pendingFields ? report.pendingFields.addWorkspaceRoom || report.pendingFields.createChat : undefined; - // @ts-expect-error TODO: Remove this once OptionsListUtils (https://github.com/Expensify/App/issues/24921) is migrated to TypeScript. result.allReportErrors = OptionsListUtils.getAllReportErrors(report, reportActions); result.brickRoadIndicator = hasErrors || hasViolations ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; result.ownerAccountID = report.ownerAccountID; @@ -462,9 +462,9 @@ function getOptionData({ result.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(result as Report); if (!hasMultipleParticipants) { - result.accountID = personalDetail.accountID; - result.login = personalDetail.login; - result.phoneNumber = personalDetail.phoneNumber; + result.accountID = personalDetail?.accountID; + result.login = personalDetail?.login; + result.phoneNumber = personalDetail?.phoneNumber; } const reportName = ReportUtils.getReportName(report, policy); @@ -473,7 +473,7 @@ function getOptionData({ result.subtitle = subtitle; result.participantsList = participantPersonalDetailList; - result.icons = ReportUtils.getIcons(report, personalDetails, UserUtils.getAvatar(personalDetail.avatar, personalDetail.accountID), '', -1, policy); + result.icons = ReportUtils.getIcons(report, personalDetails, UserUtils.getAvatar(personalDetail?.avatar ?? {}, personalDetail?.accountID), '', -1, policy); result.searchText = OptionsListUtils.getSearchText(report, reportName, participantPersonalDetailList, result.isChatRoom || result.isPolicyExpenseChat, result.isThread); result.displayNamesWithTooltips = displayNamesWithTooltips; diff --git a/src/libs/SuggestionUtils.js b/src/libs/SuggestionUtils.ts similarity index 70% rename from src/libs/SuggestionUtils.js rename to src/libs/SuggestionUtils.ts index 338f3b455431..96379ce49ef3 100644 --- a/src/libs/SuggestionUtils.js +++ b/src/libs/SuggestionUtils.ts @@ -2,21 +2,14 @@ import CONST from '@src/CONST'; /** * Trims first character of the string if it is a space - * @param {String} str - * @returns {String} */ -function trimLeadingSpace(str) { - return str.slice(0, 1) === ' ' ? str.slice(1) : str; +function trimLeadingSpace(str: string): string { + return str.startsWith(' ') ? str.slice(1) : str; } - /** * Checks if space is available to render large suggestion menu - * @param {Number} listHeight - * @param {Number} composerHeight - * @param {Number} totalSuggestions - * @returns {Boolean} */ -function hasEnoughSpaceForLargeSuggestionMenu(listHeight, composerHeight, totalSuggestions) { +function hasEnoughSpaceForLargeSuggestionMenu(listHeight: number, composerHeight: number, totalSuggestions: number): boolean { const maxSuggestions = CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER; const chatFooterHeight = CONST.CHAT_FOOTER_SECONDARY_ROW_HEIGHT + 2 * CONST.CHAT_FOOTER_SECONDARY_ROW_PADDING; const availableHeight = listHeight - composerHeight - chatFooterHeight; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 29c692b86709..b1a900675949 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -5,8 +5,7 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {RecentWaypoint, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; -import type {PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates'; -import type PolicyTaxRate from '@src/types/onyx/PolicyTaxRates'; +import type {PolicyTaxRate, PolicyTaxRates} from '@src/types/onyx/PolicyTaxRates'; import type {Comment, Receipt, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isCorporateCard, isExpensifyCard} from './CardUtils'; diff --git a/src/libs/UnreadIndicatorUpdater/index.ts b/src/libs/UnreadIndicatorUpdater/index.ts index 2a7019686308..b4f3cd34a8c4 100644 --- a/src/libs/UnreadIndicatorUpdater/index.ts +++ b/src/libs/UnreadIndicatorUpdater/index.ts @@ -26,8 +26,12 @@ export default function getUnreadReportsForUnreadIndicator(reports: OnyxCollecti * Chats with hidden preference remain invisible in the LHN and are not considered "unread." * They are excluded from the LHN rendering, but not filtered from the "option list." * This ensures they appear in Search, but not in the LHN or unread count. + * + * 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. */ - report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN && + report?.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE, ); } diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 9ba11fb16d6a..7eff51c354df 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -392,12 +392,16 @@ function isValidAccountRoute(accountID: number): boolean { return CONST.REGEX.NUMBER.test(String(accountID)) && accountID > 0; } +type DateTimeValidationErrorKeys = { + dateValidationErrorKey: string; + timeValidationErrorKey: string; +}; /** * Validates that the date and time are at least one minute in the future. * data - A date and time string in 'YYYY-MM-DD HH:mm:ss.sssZ' format * returns an object containing the error messages for the date and time */ -const validateDateTimeIsAtLeastOneMinuteInFuture = (data: string): {dateValidationErrorKey: string; timeValidationErrorKey: string} => { +const validateDateTimeIsAtLeastOneMinuteInFuture = (data: string): DateTimeValidationErrorKeys => { if (!data) { return { dateValidationErrorKey: '', @@ -413,6 +417,7 @@ const validateDateTimeIsAtLeastOneMinuteInFuture = (data: string): {dateValidati timeValidationErrorKey, }; }; + type ValuesType = Record; /** diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index 6b73636e6d82..00ad3652c665 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -1,34 +1,36 @@ import Onyx from 'react-native-onyx'; import type {KeyValueMapping, NullishDeep} from 'react-native-onyx/lib/types'; +import type {OnyxFormKeyWithoutDraft} from '@components/Form/types'; import FormUtils from '@libs/FormUtils'; import type {OnyxFormKey} from '@src/ONYXKEYS'; -import type {Form} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; -type ExcludeDraft = T extends `${string}Draft` ? never : T; -type OnyxFormKeyWithoutDraft = ExcludeDraft; - function setIsLoading(formID: OnyxFormKey, isLoading: boolean) { - Onyx.merge(formID, {isLoading} satisfies Form); + Onyx.merge(formID, {isLoading}); } function setErrors(formID: OnyxFormKey, errors: OnyxCommon.Errors) { - Onyx.merge(formID, {errors} satisfies Form); + Onyx.merge(formID, {errors}); } function setErrorFields(formID: OnyxFormKey, errorFields: OnyxCommon.ErrorFields) { - Onyx.merge(formID, {errorFields} satisfies Form); + Onyx.merge(formID, {errorFields}); +} + +function clearErrors(formID: OnyxFormKey) { + Onyx.merge(formID, {errors: null}); +} + +function clearErrorFields(formID: OnyxFormKey) { + Onyx.merge(formID, {errorFields: null}); } function setDraftValues(formID: OnyxFormKeyWithoutDraft, draftValues: NullishDeep) { Onyx.merge(FormUtils.getDraftKey(formID), draftValues); } -/** - * @param formID - */ function clearDraftValues(formID: OnyxFormKeyWithoutDraft) { - Onyx.merge(FormUtils.getDraftKey(formID), undefined); + Onyx.set(FormUtils.getDraftKey(formID), {}); } -export {setDraftValues, setErrorFields, setErrors, setIsLoading, clearDraftValues}; +export {setDraftValues, setErrorFields, setErrors, clearErrors, clearErrorFields, setIsLoading, clearDraftValues}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index d258b5419103..eb9541edcad2 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1169,7 +1169,7 @@ function updateMoneyRequestMerchant(transactionID, transactionThreadReportID, va } /** - * Updates the created date of a money request + * Updates the tag of a money request * * @param {String} transactionID * @param {Number} transactionThreadReportID @@ -1183,6 +1183,21 @@ function updateMoneyRequestTag(transactionID, transactionThreadReportID, tag) { API.write('UpdateMoneyRequestTag', params, onyxData); } +/** + * Updates the waypoints of a distance money request + * + * @param {String} transactionID + * @param {Number} transactionThreadReportID + * @param {Object} waypoints + */ +function updateMoneyRequestDistance(transactionID, transactionThreadReportID, waypoints) { + const transactionChanges = { + waypoints, + }; + const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, true); + API.write('UpdateMoneyRequestDistance', params, onyxData); +} + /** * Updates the category of a money request * @@ -3734,6 +3749,7 @@ export { updateMoneyRequestBillable, updateMoneyRequestMerchant, updateMoneyRequestTag, + updateMoneyRequestDistance, updateMoneyRequestCategory, updateMoneyRequestAmountAndCurrency, updateMoneyRequestDescription, diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts index e1e73d425281..71ba850e721f 100644 --- a/src/libs/actions/Modal.ts +++ b/src/libs/actions/Modal.ts @@ -1,9 +1,10 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; -const closeModals: Array<(isNavigating: boolean) => void> = []; +const closeModals: Array<(isNavigating?: boolean) => void> = []; let onModalClose: null | (() => void); +let isNavigate: undefined | boolean; /** * Allows other parts of the app to call modal close function @@ -21,6 +22,20 @@ function setCloseModal(onClose: () => void) { }; } +/** + * Close topmost modal + */ +function closeTop() { + if (closeModals.length === 0) { + return; + } + if (onModalClose) { + closeModals[closeModals.length - 1](isNavigate); + return; + } + closeModals[closeModals.length - 1](); +} + /** * Close modal in other parts of the app */ @@ -30,17 +45,21 @@ function close(onModalCloseCallback: () => void, isNavigating = true) { return; } onModalClose = onModalCloseCallback; - [...closeModals].reverse().forEach((onClose) => { - onClose(isNavigating); - }); + isNavigate = isNavigating; + closeTop(); } function onModalDidClose() { if (!onModalClose) { return; } + if (closeModals.length) { + closeTop(); + return; + } onModalClose(); onModalClose = null; + isNavigate = undefined; } /** @@ -58,4 +77,4 @@ function willAlertModalBecomeVisible(isVisible: boolean) { Onyx.merge(ONYXKEYS.MODAL, {willAlertModalBecomeVisible: isVisible}); } -export {setCloseModal, close, onModalDidClose, setModalVisibility, willAlertModalBecomeVisible}; +export {setCloseModal, close, onModalDidClose, setModalVisibility, willAlertModalBecomeVisible, closeTop}; diff --git a/src/libs/actions/Plaid.ts b/src/libs/actions/Plaid.ts index ab828eefeece..8c35c391790a 100644 --- a/src/libs/actions/Plaid.ts +++ b/src/libs/actions/Plaid.ts @@ -28,7 +28,7 @@ function openPlaidBankLogin(allowDebit: boolean, bankAccountID: number) { }, { onyxMethod: Onyx.METHOD.MERGE, - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, + key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, value: { plaidAccountID: '', }, diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index cbbc00dd42fc..b47891e64350 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -200,6 +200,7 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { + avatar: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null, }, diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js index f963640bc74e..217cacf921a6 100644 --- a/src/libs/actions/ReimbursementAccount/index.js +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -31,7 +31,7 @@ function setWorkspaceIDForReimbursementAccount(workspaceID) { * @param {Object} bankAccountData */ function updateReimbursementAccountDraft(bankAccountData) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, bankAccountData); + Onyx.merge(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, bankAccountData); Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {draftStep: undefined}); } diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js index 14c988033689..3110c059d2fc 100644 --- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -60,7 +60,7 @@ function resetFreePlanBankAccount(bankAccountID, session) { }, { onyxMethod: Onyx.METHOD.SET, - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, + key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, value: {}, }, ], diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 228b88d194ba..e084a99df0c7 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -754,7 +754,7 @@ function navigateToAndOpenChildReport(childReportID = '0', parentReportAction: P parentReport?.policyID ?? CONST.POLICY.OWNER_EMAIL_FAKE, CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, false, - '', + parentReport?.policyName ?? '', undefined, undefined, ReportUtils.getChildReportNotificationPreference(parentReportAction), @@ -934,7 +934,7 @@ function expandURLPreview(reportID: string, reportActionID: string) { } /** Marks the new report actions as read */ -function readNewestAction(reportID: string) { +function readNewestAction(reportID: string, shouldEmitEvent = true) { const lastReadTime = DateUtils.getDBTime(); const optimisticData: OnyxUpdate[] = [ @@ -958,6 +958,11 @@ function readNewestAction(reportID: string) { }; API.write('ReadNewestAction', parameters, {optimisticData}); + + if (!shouldEmitEvent) { + return; + } + DeviceEventEmitter.emit(`readNewestAction_${reportID}`, lastReadTime); } @@ -2702,7 +2707,8 @@ function updateLastVisitTime(reportID: string) { function clearNewRoomFormError() { Onyx.set(ONYXKEYS.FORMS.NEW_ROOM_FORM, { isLoading: false, - errorFields: {}, + errorFields: null, + errors: null, }); } diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index c03fa15fe1ae..b2f6b57f390a 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -718,11 +718,7 @@ function getShareDestination(reportID: string, reports: OnyxCollection 1; - const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips( - // @ts-expect-error TODO: Remove this once OptionsListUtils (https://github.com/Expensify/App/issues/24921) is migrated to TypeScript. - OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), - isMultipleParticipant, - ); + const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); let subtitle = ''; if (ReportUtils.isChatReport(report) && ReportUtils.isDM(report) && ReportUtils.hasSingleParticipant(report)) { diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index df5709ac68e2..aaba02ecec1e 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -5,6 +5,7 @@ import type {OnyxEntry} from 'react-native-onyx/lib/types'; import type {ValueOf} from 'type-fest'; import * as API from '@libs/API'; import * as ErrorUtils from '@libs/ErrorUtils'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import * as Pusher from '@libs/Pusher/pusher'; @@ -490,6 +491,7 @@ function subscribeToUserEvents() { // - The data is an object, containing updateIDs from the server and an array of onyx updates (this array is the same format as the original format above) // Example: {lastUpdateID: 1, previousUpdateID: 0, updates: [{onyxMethod: 'whatever', key: 'foo', value: 'bar'}]} if (Array.isArray(pushJSON)) { + Log.warn('Received pusher event with array format'); pushJSON.forEach((multipleEvent) => { PusherUtils.triggerMultiEventHandler(multipleEvent.eventType, multipleEvent.data); }); diff --git a/src/libs/calculateAnchorPosition.ts b/src/libs/calculateAnchorPosition.ts index 66966b7b504c..3b6617aa3ed0 100644 --- a/src/libs/calculateAnchorPosition.ts +++ b/src/libs/calculateAnchorPosition.ts @@ -22,7 +22,7 @@ export default function calculateAnchorPosition(anchorComponent: View, anchorOri if (anchorOrigin?.vertical === CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP && anchorOrigin?.horizontal === CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT) { return resolve({horizontal: x, vertical: y + height + (anchorOrigin?.shiftVertical ?? 0)}); } - return resolve({horizontal: x + width, vertical: y}); + return resolve({horizontal: x + width, vertical: y + (anchorOrigin?.shiftVertical ?? 0)}); }); }); } diff --git a/src/libs/searchCountryOptions.ts b/src/libs/searchCountryOptions.ts index 8fb1cc9c37f3..1fc5d343f556 100644 --- a/src/libs/searchCountryOptions.ts +++ b/src/libs/searchCountryOptions.ts @@ -37,3 +37,4 @@ function searchCountryOptions(searchValue: string, countriesData: CountryData[]) } export default searchCountryOptions; +export type {CountryData}; diff --git a/src/libs/updateMultilineInputRange/types.ts b/src/libs/updateMultilineInputRange/types.ts index d1b134b09a99..ce8f553c51f8 100644 --- a/src/libs/updateMultilineInputRange/types.ts +++ b/src/libs/updateMultilineInputRange/types.ts @@ -1,5 +1,5 @@ import type {TextInput} from 'react-native'; -type UpdateMultilineInputRange = (input: HTMLInputElement | TextInput, shouldAutoFocus?: boolean) => void; +type UpdateMultilineInputRange = (input: HTMLInputElement | TextInput | null, shouldAutoFocus?: boolean) => void; export default UpdateMultilineInputRange; diff --git a/src/pages/EditReportFieldDatePage.tsx b/src/pages/EditReportFieldDatePage.tsx index 5ee86b2bf8e6..6faa84ef8b43 100644 --- a/src/pages/EditReportFieldDatePage.tsx +++ b/src/pages/EditReportFieldDatePage.tsx @@ -3,12 +3,15 @@ import {View} from 'react-native'; import DatePicker from '@components/DatePicker'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {OnyxFormValuesFields} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; type EditReportFieldDatePageProps = { /** Value of the policy report field */ @@ -27,12 +30,13 @@ type EditReportFieldDatePageProps = { function EditReportFieldDatePage({fieldName, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const inputRef = useRef(null); + const inputRef = useRef(null); const validate = useCallback( - (value: Record) => { - const errors: Record = {}; - if (value[fieldID].trim() === '') { + (values: OnyxFormValuesFields) => { + const errors: Errors = {}; + const value = values[fieldID]; + if (typeof value === 'string' && value.trim() === '') { errors[fieldID] = 'common.error.fieldRequired'; } return errors; @@ -48,7 +52,6 @@ function EditReportFieldDatePage({fieldName, onSubmit, fieldValue, fieldID}: Edi testID={EditReportFieldDatePage.displayName} > - {/* @ts-expect-error TODO: TS migration */} - InputComponent={DatePicker} inputID={fieldID} name={fieldID} diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldTextPage.tsx index b468861e9a27..733bfd6e5fee 100644 --- a/src/pages/EditReportFieldTextPage.tsx +++ b/src/pages/EditReportFieldTextPage.tsx @@ -2,13 +2,16 @@ import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {OnyxFormValuesFields} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import TextInput from '@components/TextInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; type EditReportFieldTextPageProps = { /** Value of the policy report field */ @@ -27,12 +30,13 @@ type EditReportFieldTextPageProps = { function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, fieldID}: EditReportFieldTextPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const inputRef = useRef(null); + const inputRef = useRef(null); const validate = useCallback( - (value: Record) => { - const errors: Record = {}; - if (value[fieldID].trim() === '') { + (values: OnyxFormValuesFields) => { + const errors: Errors = {}; + const value = values[fieldID]; + if (typeof value === 'string' && value.trim() === '') { errors[fieldID] = 'common.error.fieldRequired'; } return errors; @@ -48,7 +52,6 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, fieldID}: Edi testID={EditReportFieldTextPage.displayName} > - {/* @ts-expect-error TODO: TS migration */} - _.map(termsData, (section, index) => ( - // eslint-disable-next-line react/no-array-index-key - - - - {section.title} - {Boolean(section.subTitle) && {section.subTitle}} - - - {section.rightText} - {Boolean(section.subRightText) && {section.subRightText}} + const termsData = [ + { + title: translate('termsStep.longTermsForm.openingAccountTitle'), + rightText: CurrencyUtils.convertToDisplayString(0, 'USD'), + details: translate('termsStep.longTermsForm.openingAccountDetails'), + }, + { + title: translate('termsStep.monthlyFee'), + rightText: CurrencyUtils.convertToDisplayString(0, 'USD'), + details: translate('termsStep.longTermsForm.monthlyFeeDetails'), + }, + { + title: translate('termsStep.longTermsForm.customerServiceTitle'), + subTitle: translate('termsStep.longTermsForm.automated'), + rightText: CurrencyUtils.convertToDisplayString(0, 'USD'), + details: translate('termsStep.longTermsForm.customerServiceDetails'), + }, + { + title: translate('termsStep.longTermsForm.customerServiceTitle'), + subTitle: translate('termsStep.longTermsForm.liveAgent'), + rightText: CurrencyUtils.convertToDisplayString(0, 'USD'), + details: translate('termsStep.longTermsForm.customerServiceDetails'), + }, + { + title: translate('termsStep.inactivity'), + rightText: CurrencyUtils.convertToDisplayString(0, 'USD'), + details: translate('termsStep.longTermsForm.inactivityDetails'), + }, + { + title: translate('termsStep.longTermsForm.sendingFundsTitle'), + rightText: CurrencyUtils.convertToDisplayString(0, 'USD'), + details: translate('termsStep.longTermsForm.sendingFundsDetails'), + }, + { + title: translate('termsStep.electronicFundsWithdrawal'), + subTitle: translate('termsStep.standard'), + rightText: CurrencyUtils.convertToDisplayString(0, 'USD'), + details: translate('termsStep.longTermsForm.electronicFundsStandardDetails'), + }, + { + title: translate('termsStep.electronicFundsWithdrawal'), + subTitle: translate('termsStep.longTermsForm.instant'), + rightText: `${numberFormat(1.5)}%`, + subRightText: translate('termsStep.longTermsForm.electronicFundsInstantFeeMin', {amount: CurrencyUtils.convertToDisplayString(25, 'USD')}), + details: translate('termsStep.longTermsForm.electronicFundsInstantDetails', {percentage: numberFormat(1.5), amount: CurrencyUtils.convertToDisplayString(25, 'USD')}), + }, + ]; + + const getLongTermsSections = () => + _.map(termsData, (section, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + + {section.title} + {Boolean(section.subTitle) && {section.subTitle}} + + + {section.rightText} + {Boolean(section.subRightText) && {section.subRightText}} + + {section.details} - {section.details} - - )); + )); -function LongTermsForm() { - const theme = useTheme(); - const styles = useThemeStyles(); return ( <> - {getLongTermsSections(styles)} + {getLongTermsSections()} - {Localize.translateLocal('termsStep.longTermsForm.fdicInsuranceBancorp')} {CONST.TERMS.FDIC_PREPAID}{' '} - {Localize.translateLocal('termsStep.longTermsForm.fdicInsuranceBancorp2')} + {translate('termsStep.longTermsForm.fdicInsuranceBancorp', {amount: CurrencyUtils.convertToDisplayString(25000000, 'USD')})} {CONST.TERMS.FDIC_PREPAID}{' '} + {translate('termsStep.longTermsForm.fdicInsuranceBancorp2')} - {Localize.translateLocal('termsStep.noOverdraftOrCredit')} + {translate('termsStep.noOverdraftOrCredit')} - {Localize.translateLocal('termsStep.longTermsForm.contactExpensifyPayments')} {CONST.EMAIL.CONCIERGE}{' '} - {Localize.translateLocal('termsStep.longTermsForm.contactExpensifyPayments2')} {CONST.NEW_EXPENSIFY_URL}. + {translate('termsStep.longTermsForm.contactExpensifyPayments')} {CONST.EMAIL.CONCIERGE} {translate('termsStep.longTermsForm.contactExpensifyPayments2')}{' '} + {CONST.NEW_EXPENSIFY_URL}. - {Localize.translateLocal('termsStep.longTermsForm.generalInformation')} {CONST.TERMS.CFPB_PREPAID} + {translate('termsStep.longTermsForm.generalInformation')} {CONST.TERMS.CFPB_PREPAID} {'. '} - {Localize.translateLocal('termsStep.longTermsForm.generalInformation2')} {CONST.TERMS.CFPB_COMPLAINT}. + {translate('termsStep.longTermsForm.generalInformation2')} {CONST.TERMS.CFPB_COMPLAINT}. @@ -109,7 +112,7 @@ function LongTermsForm() { style={styles.ml1} href={CONST.FEES_URL} > - {Localize.translateLocal('termsStep.longTermsForm.printerFriendlyView')} + {translate('termsStep.longTermsForm.printerFriendlyView')} diff --git a/src/pages/EnablePayments/TermsPage/ShortTermsForm.js b/src/pages/EnablePayments/TermsPage/ShortTermsForm.js index 77f77f3cb34b..40824f47b036 100644 --- a/src/pages/EnablePayments/TermsPage/ShortTermsForm.js +++ b/src/pages/EnablePayments/TermsPage/ShortTermsForm.js @@ -2,8 +2,9 @@ import React from 'react'; import {View} from 'react-native'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as Localize from '@libs/Localize'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; import userWalletPropTypes from '@pages/EnablePayments/userWalletPropTypes'; import CONST from '@src/CONST'; @@ -18,10 +19,11 @@ const defaultProps = { function ShortTermsForm(props) { const styles = useThemeStyles(); + const {translate, numberFormat} = useLocalize(); return ( <> - {Localize.translateLocal('termsStep.shortTermsForm.expensifyPaymentsAccount', { + {translate('termsStep.shortTermsForm.expensifyPaymentsAccount', { walletProgram: props.userWallet.walletProgramID === CONST.WALLET.MTL_WALLET_PROGRAM_ID ? CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS : CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK, })} @@ -31,19 +33,19 @@ function ShortTermsForm(props) { - {Localize.translateLocal('termsStep.monthlyFee')} + {translate('termsStep.monthlyFee')} - {Localize.translateLocal('termsStep.feeAmountZero')} + {CurrencyUtils.convertToDisplayString(0, 'USD')} - {Localize.translateLocal('termsStep.shortTermsForm.perPurchase')} + {translate('termsStep.shortTermsForm.perPurchase')} - {Localize.translateLocal('termsStep.feeAmountZero')} + {CurrencyUtils.convertToDisplayString(0, 'USD')} @@ -52,28 +54,28 @@ function ShortTermsForm(props) { - {Localize.translateLocal('termsStep.shortTermsForm.atmWithdrawal')} + {translate('termsStep.shortTermsForm.atmWithdrawal')} - {Localize.translateLocal('common.na')} + {translate('common.na')} - {Localize.translateLocal('termsStep.shortTermsForm.inNetwork')} + {translate('termsStep.shortTermsForm.inNetwork')} - {Localize.translateLocal('common.na')} + {translate('common.na')} - {Localize.translateLocal('termsStep.shortTermsForm.outOfNetwork')} + {translate('termsStep.shortTermsForm.outOfNetwork')} - {Localize.translateLocal('termsStep.shortTermsForm.cashReload')} + {translate('termsStep.shortTermsForm.cashReload')} - {Localize.translateLocal('common.na')} + {translate('common.na')} @@ -83,11 +85,11 @@ function ShortTermsForm(props) { - {Localize.translateLocal('termsStep.shortTermsForm.atmBalanceInquiry')} {Localize.translateLocal('termsStep.shortTermsForm.inOrOutOfNetwork')} + {translate('termsStep.shortTermsForm.atmBalanceInquiry')} {translate('termsStep.shortTermsForm.inOrOutOfNetwork')} - {Localize.translateLocal('common.na')} + {translate('common.na')} @@ -95,11 +97,11 @@ function ShortTermsForm(props) { - {Localize.translateLocal('termsStep.shortTermsForm.customerService')} {Localize.translateLocal('termsStep.shortTermsForm.automatedOrLive')} + {translate('termsStep.shortTermsForm.customerService')} {translate('termsStep.shortTermsForm.automatedOrLive')} - {Localize.translateLocal('termsStep.feeAmountZero')} + {CurrencyUtils.convertToDisplayString(0, 'USD')} @@ -107,40 +109,40 @@ function ShortTermsForm(props) { - {Localize.translateLocal('termsStep.inactivity')} {Localize.translateLocal('termsStep.shortTermsForm.afterTwelveMonths')} + {translate('termsStep.inactivity')} {translate('termsStep.shortTermsForm.afterTwelveMonths')} - {Localize.translateLocal('termsStep.feeAmountZero')} + {CurrencyUtils.convertToDisplayString(0, 'USD')} - {Localize.translateLocal('termsStep.shortTermsForm.weChargeOneFee')} + {translate('termsStep.shortTermsForm.weChargeOneFee')} - {Localize.translateLocal('termsStep.electronicFundsWithdrawal')} {Localize.translateLocal('termsStep.shortTermsForm.instant')} + {translate('termsStep.electronicFundsWithdrawal')} {translate('termsStep.shortTermsForm.instant')} - {Localize.translateLocal('termsStep.electronicFundsInstantFee')} - {Localize.translateLocal('termsStep.shortTermsForm.electronicFundsInstantFeeMin')} + {numberFormat(1.5)}% + {translate('termsStep.shortTermsForm.electronicFundsInstantFeeMin', {amount: CurrencyUtils.convertToDisplayString(25, 'USD')})} - {Localize.translateLocal('termsStep.noOverdraftOrCredit')} - {Localize.translateLocal('termsStep.shortTermsForm.fdicInsurance')} + {translate('termsStep.noOverdraftOrCredit')} + {translate('termsStep.shortTermsForm.fdicInsurance')} - {Localize.translateLocal('termsStep.shortTermsForm.generalInfo')} {CONST.TERMS.CFPB_PREPAID}. + {translate('termsStep.shortTermsForm.generalInfo')} {CONST.TERMS.CFPB_PREPAID}. - {Localize.translateLocal('termsStep.shortTermsForm.conditionsDetails')} {CONST.TERMS.USE_EXPENSIFY_FEES}{' '} - {Localize.translateLocal('termsStep.shortTermsForm.conditionsPhone')} + {translate('termsStep.shortTermsForm.conditionsDetails')} {CONST.TERMS.USE_EXPENSIFY_FEES}{' '} + {translate('termsStep.shortTermsForm.conditionsPhone')} diff --git a/src/pages/LogInWithShortLivedAuthTokenPage.tsx b/src/pages/LogInWithShortLivedAuthTokenPage.tsx index c5f8a9c20d5b..811c35fff34e 100644 --- a/src/pages/LogInWithShortLivedAuthTokenPage.tsx +++ b/src/pages/LogInWithShortLivedAuthTokenPage.tsx @@ -12,6 +12,7 @@ import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import type {PublicScreensParamList} from '@libs/Navigation/types'; import * as Session from '@userActions/Session'; @@ -38,6 +39,7 @@ function LogInWithShortLivedAuthTokenPage({route, account}: LogInWithShortLivedA // Try to authenticate using the shortLivedToken if we're not already trying to load the accounts if (token && !account?.isLoading) { + Log.info('LogInWithShortLivedAuthTokenPage - Successfully received shortLivedAuthToken. Signing in...'); Session.signInWithShortLivedAuthToken(email, token); return; } diff --git a/src/pages/LogOutPreviousUserPage.js b/src/pages/LogOutPreviousUserPage.js index 5c8a39204467..974823f489ed 100644 --- a/src/pages/LogOutPreviousUserPage.js +++ b/src/pages/LogOutPreviousUserPage.js @@ -4,6 +4,7 @@ import React, {useEffect} from 'react'; import {Linking} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import Log from '@libs/Log'; import * as SessionUtils from '@libs/SessionUtils'; import * as Session from '@userActions/Session'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -51,6 +52,7 @@ function LogOutPreviousUserPage(props) { // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken const shouldForceLogin = lodashGet(props, 'route.params.shouldForceLogin', '') === 'true'; if (shouldForceLogin) { + Log.info('LogOutPreviousUserPage - forcing login with shortLivedAuthToken'); const email = lodashGet(props, 'route.params.email', ''); const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', ''); Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken); diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.js b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx similarity index 68% rename from src/pages/PrivateNotes/PrivateNotesEditPage.js rename to src/pages/PrivateNotes/PrivateNotesEditPage.tsx index 0d4bc2c3e7e1..805f2d8fcb90 100644 --- a/src/pages/PrivateNotes/PrivateNotesEditPage.js +++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx @@ -1,81 +1,71 @@ import {useFocusEffect} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; +import lodashDebounce from 'lodash/debounce'; import React, {useCallback, useMemo, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; +import type {OnyxCollection} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withLocalize from '@components/withLocalize'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; +import type {PrivateNotesNavigatorParamList} from '@libs/Navigation/types'; import * as ReportUtils from '@libs/ReportUtils'; import updateMultilineInputRange from '@libs/updateMultilineInputRange'; import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; -import reportPropTypes from '@pages/reportPropTypes'; -import * as Report from '@userActions/Report'; +import * as ReportActions from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PersonalDetails, Report} from '@src/types/onyx'; +import type {Note} from '@src/types/onyx/Report'; -const propTypes = { +type PrivateNotesEditPageOnyxProps = { /** All of the personal details for everyone */ - personalDetailsList: PropTypes.objectOf(personalDetailsPropType), - - /** The report currently being looked at */ - report: reportPropTypes, - route: PropTypes.shape({ - /** Params from the URL path */ - params: PropTypes.shape({ - /** reportID and accountID passed via route: /r/:reportID/notes */ - reportID: PropTypes.string, - accountID: PropTypes.string, - }), - }).isRequired, + personalDetailsList: OnyxCollection; }; -const defaultProps = { - report: {}, - personalDetailsList: {}, -}; +type PrivateNotesEditPageProps = PrivateNotesEditPageOnyxProps & + StackScreenProps & { + /** The report currently being looked at */ + report: Report; + }; -function PrivateNotesEditPage({route, personalDetailsList, report}) { +function PrivateNotesEditPage({route, personalDetailsList, report}: PrivateNotesEditPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); // We need to edit the note in markdown format, but display it in HTML format const parser = new ExpensiMark(); const [privateNote, setPrivateNote] = useState( - () => Report.getDraftPrivateNote(report.reportID).trim() || parser.htmlToMarkdown(lodashGet(report, ['privateNotes', route.params.accountID, 'note'], '')).trim(), + () => ReportActions.getDraftPrivateNote(report.reportID).trim() || parser.htmlToMarkdown(report?.privateNotes?.[Number(route.params.accountID)]?.note ?? '').trim(), ); /** * Save the draft of the private note. This debounced so that we're not ceaselessly saving your edit. Saving the draft * allows one to navigate somewhere else and come back to the private note and still have it in edit mode. - * @param {String} newDraft */ const debouncedSavePrivateNote = useMemo( () => - _.debounce((text) => { - Report.savePrivateNotesDraft(report.reportID, text); + lodashDebounce((text: string) => { + ReportActions.savePrivateNotesDraft(report.reportID, text); }, 1000), [report.reportID], ); // To focus on the input field when the page loads - const privateNotesInput = useRef(null); - const focusTimeoutRef = useRef(null); + const privateNotesInput = useRef(null); + const focusTimeoutRef = useRef(null); useFocusEffect( useCallback(() => { @@ -94,18 +84,18 @@ function PrivateNotesEditPage({route, personalDetailsList, report}) { ); const savePrivateNote = () => { - const originalNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], ''); + const originalNote = report?.privateNotes?.[Number(route.params.accountID)]?.note ?? ''; let editedNote = ''; if (privateNote.trim() !== originalNote.trim()) { - editedNote = Report.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim()); - Report.updatePrivateNotes(report.reportID, route.params.accountID, editedNote); + editedNote = ReportActions.handleUserDeletedLinksInHtml(privateNote.trim(), parser.htmlToMarkdown(originalNote).trim()); + ReportActions.updatePrivateNotes(report.reportID, Number(route.params.accountID), editedNote); } // We want to delete saved private note draft after saving the note debouncedSavePrivateNote(''); Keyboard.dismiss(); - if (!_.some({...report.privateNotes, [route.params.accountID]: {note: editedNote}}, (item) => item.note)) { + if (!Object.values({...report.privateNotes, [route.params.accountID]: {note: editedNote}}).some((item) => item.note)) { ReportUtils.navigateToDetailsPage(report); } else { Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID)); @@ -120,7 +110,7 @@ function PrivateNotesEditPage({route, personalDetailsList, report}) { > Navigation.goBack(ROUTES.PRIVATE_NOTES_VIEW.getRoute(report.reportID, route.params.accountID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID))} shouldShowBackButton onCloseButtonPress={() => Navigation.dismissModal()} /> @@ -133,16 +123,16 @@ function PrivateNotesEditPage({route, personalDetailsList, report}) { > {translate( - Str.extractEmailDomain(lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')) === CONST.EMAIL.GUIDES_DOMAIN + Str.extractEmailDomain(personalDetailsList?.[route.params.accountID]?.login ?? '') === CONST.EMAIL.GUIDES_DOMAIN ? 'privateNotes.sharedNoteMessage' : 'privateNotes.personalNoteMessage', )} Report.clearPrivateNotesError(report.reportID, route.params.accountID)} + onClose={() => ReportActions.clearPrivateNotesError(report.reportID, Number(route.params.accountID))} style={[styles.mb3]} > { + onChangeText={(text: string) => { debouncedSavePrivateNote(text); setPrivateNote(text); }} - ref={(el) => { + ref={(el: AnimatedTextInputRef) => { if (!el) { return; } @@ -177,15 +167,11 @@ function PrivateNotesEditPage({route, personalDetailsList, report}) { } PrivateNotesEditPage.displayName = 'PrivateNotesEditPage'; -PrivateNotesEditPage.propTypes = propTypes; -PrivateNotesEditPage.defaultProps = defaultProps; -export default compose( - withLocalize, - withReportAndPrivateNotesOrNotFound('privateNotes.title'), - withOnyx({ +export default withReportAndPrivateNotesOrNotFound('privateNotes.title')( + withOnyx({ personalDetailsList: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, - }), -)(PrivateNotesEditPage); + })(PrivateNotesEditPage), +); diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.js b/src/pages/PrivateNotes/PrivateNotesListPage.js deleted file mode 100644 index 8e2f8c9f43e0..000000000000 --- a/src/pages/PrivateNotes/PrivateNotesListPage.js +++ /dev/null @@ -1,157 +0,0 @@ -import {useIsFocused} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useEffect, useMemo} from 'react'; -import {ScrollView} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {withNetwork} from '@components/OnyxProvider'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import Navigation from '@libs/Navigation/Navigation'; -import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; -import reportPropTypes from '@pages/reportPropTypes'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -const propTypes = { - /** The report currently being looked at */ - report: reportPropTypes, - route: PropTypes.shape({ - /** Params from the URL path */ - params: PropTypes.shape({ - /** reportID and accountID passed via route: /r/:reportID/notes */ - reportID: PropTypes.string, - accountID: PropTypes.string, - }), - }).isRequired, - - /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user accountID */ - accountID: PropTypes.number, - }), - - /** All of the personal details for everyone */ - personalDetailsList: PropTypes.objectOf(personalDetailsPropType), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - report: {}, - session: { - accountID: null, - }, - personalDetailsList: {}, -}; - -function PrivateNotesListPage({report, personalDetailsList, session}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const isFocused = useIsFocused(); - - useEffect(() => { - const navigateToEditPageTimeout = setTimeout(() => { - if (_.some(report.privateNotes, (item) => item.note) || !isFocused) { - return; - } - Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, session.accountID)); - }, CONST.ANIMATED_TRANSITION); - - return () => { - clearTimeout(navigateToEditPageTimeout); - }; - }, [report.privateNotes, report.reportID, session.accountID, isFocused]); - - /** - * Gets the menu item for each workspace - * - * @param {Object} item - * @param {Number} index - * @returns {JSX} - */ - function getMenuItem(item, index) { - const keyTitle = item.translationKey ? translate(item.translationKey) : item.title; - return ( - - - - ); - } - - /** - * Returns a list of private notes on the given chat report - * @returns {Array} the menu item list - */ - const privateNotes = useMemo(() => { - const privateNoteBrickRoadIndicator = (accountID) => (!_.isEmpty(lodashGet(report, ['privateNotes', accountID, 'errors'], '')) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''); - return _.chain(lodashGet(report, 'privateNotes', {})) - .map((privateNote, accountID) => ({ - title: Number(lodashGet(session, 'accountID', null)) === Number(accountID) ? translate('privateNotes.myNote') : lodashGet(personalDetailsList, [accountID, 'login'], ''), - action: () => Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, accountID)), - brickRoadIndicator: privateNoteBrickRoadIndicator(accountID), - note: lodashGet(privateNote, 'note', ''), - disabled: Number(session.accountID) !== Number(accountID), - })) - .value(); - }, [report, personalDetailsList, session, translate]); - - return ( - - Navigation.dismissModal()} - /> - {translate('privateNotes.personalNoteMessage')} - {_.map(privateNotes, (item, index) => getMenuItem(item, index))} - - ); -} - -PrivateNotesListPage.propTypes = propTypes; -PrivateNotesListPage.defaultProps = defaultProps; -PrivateNotesListPage.displayName = 'PrivateNotesListPage'; - -export default compose( - withLocalize, - withReportAndPrivateNotesOrNotFound('privateNotes.title'), - withOnyx({ - personalDetailsList: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - session: { - key: ONYXKEYS.SESSION, - }, - }), - withNetwork(), -)(PrivateNotesListPage); diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx new file mode 100644 index 000000000000..d7fb1f6497be --- /dev/null +++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx @@ -0,0 +1,108 @@ +import React, {useMemo} from 'react'; +import {ScrollView} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {PersonalDetails, Report, Session} from '@src/types/onyx'; + +type PrivateNotesListPageOnyxProps = { + /** All of the personal details for everyone */ + personalDetailsList: OnyxCollection; + + /** Session info for the currently logged in user. */ + session: OnyxEntry; +}; + +type PrivateNotesListPageProps = PrivateNotesListPageOnyxProps & { + /** The report currently being looked at */ + report: Report; +}; + +type NoteListItem = { + title: string; + action: () => void; + brickRoadIndicator: ValueOf | undefined; + note: string; + disabled: boolean; +}; + +function PrivateNotesListPage({report, personalDetailsList, session}: PrivateNotesListPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + /** + * Gets the menu item for each workspace + */ + function getMenuItem(item: NoteListItem) { + return ( + + ); + } + + /** + * Returns a list of private notes on the given chat report + */ + const privateNotes = useMemo(() => { + const privateNoteBrickRoadIndicator = (accountID: number) => (report.privateNotes?.[accountID].errors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined); + return Object.keys(report.privateNotes ?? {}).map((accountID: string) => { + const privateNote = report.privateNotes?.[Number(accountID)]; + return { + title: Number(session?.accountID) === Number(accountID) ? translate('privateNotes.myNote') : personalDetailsList?.[accountID]?.login ?? '', + action: () => Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, accountID)), + brickRoadIndicator: privateNoteBrickRoadIndicator(Number(accountID)), + note: privateNote?.note ?? '', + disabled: Number(session?.accountID) !== Number(accountID), + }; + }); + }, [report, personalDetailsList, session, translate]); + + return ( + + Navigation.dismissModal()} + /> + {translate('privateNotes.personalNoteMessage')} + {privateNotes.map((item) => getMenuItem(item))} + + ); +} + +PrivateNotesListPage.displayName = 'PrivateNotesListPage'; + +export default withReportAndPrivateNotesOrNotFound('privateNotes.title')( + withOnyx({ + personalDetailsList: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + session: { + key: ONYXKEYS.SESSION, + }, + })(PrivateNotesListPage), +); diff --git a/src/pages/PrivateNotes/PrivateNotesViewPage.js b/src/pages/PrivateNotes/PrivateNotesViewPage.js deleted file mode 100644 index f71259a2b685..000000000000 --- a/src/pages/PrivateNotes/PrivateNotesViewPage.js +++ /dev/null @@ -1,112 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React from 'react'; -import {ScrollView} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScreenWrapper from '@components/ScreenWrapper'; -import withLocalize from '@components/withLocalize'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import Navigation from '@libs/Navigation/Navigation'; -import withReportAndPrivateNotesOrNotFound from '@pages/home/report/withReportAndPrivateNotesOrNotFound'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; -import reportPropTypes from '@pages/reportPropTypes'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -const propTypes = { - /** All of the personal details for everyone */ - personalDetailsList: PropTypes.objectOf(personalDetailsPropType), - - /** The report currently being looked at */ - report: reportPropTypes, - route: PropTypes.shape({ - /** Params from the URL path */ - params: PropTypes.shape({ - /** reportID and accountID passed via route: /r/:reportID/notes */ - reportID: PropTypes.string, - accountID: PropTypes.string, - }), - }).isRequired, - - /** Session of currently logged in user */ - session: PropTypes.shape({ - /** Currently logged in user accountID */ - accountID: PropTypes.number, - }), -}; - -const defaultProps = { - report: {}, - session: { - accountID: null, - }, - personalDetailsList: {}, -}; - -function PrivateNotesViewPage({route, personalDetailsList, session, report}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const isCurrentUserNote = Number(session.accountID) === Number(route.params.accountID); - const privateNote = lodashGet(report, ['privateNotes', route.params.accountID, 'note'], ''); - - const getFallbackRoute = () => { - const privateNotes = lodashGet(report, 'privateNotes', {}); - - if (_.keys(privateNotes).length === 1) { - return ROUTES.HOME; - } - - return ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID); - }; - - return ( - - Navigation.goBack(getFallbackRoute())} - subtitle={isCurrentUserNote ? translate('privateNotes.myNote') : `${lodashGet(personalDetailsList, [route.params.accountID, 'login'], '')} note`} - shouldShowBackButton - onCloseButtonPress={() => Navigation.dismissModal()} - /> - - - isCurrentUserNote && Navigation.navigate(ROUTES.PRIVATE_NOTES_EDIT.getRoute(report.reportID, route.params.accountID))} - shouldShowRightIcon={isCurrentUserNote} - numberOfLinesTitle={0} - shouldRenderAsHTML - brickRoadIndicator={!_.isEmpty(lodashGet(report, ['privateNotes', route.params.accountID, 'errors'], '')) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - disabled={!isCurrentUserNote} - shouldGreyOutWhenDisabled={false} - /> - - - - ); -} - -PrivateNotesViewPage.displayName = 'PrivateNotesViewPage'; -PrivateNotesViewPage.propTypes = propTypes; -PrivateNotesViewPage.defaultProps = defaultProps; - -export default compose( - withLocalize, - withReportAndPrivateNotesOrNotFound('privateNotes.title'), - withOnyx({ - personalDetailsList: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - }), -)(PrivateNotesViewPage); diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js index 806e438d0397..625a29ddc130 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -159,7 +159,7 @@ function ACHContractStep(props) { guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_BANK_ACCOUNT} /> { - const sections = []; + useEffect(() => { + const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { + setDidScreenTransitionEnd(true); + }); + + return () => { + unsubscribeTransitionEnd(); + }; + // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sections = useMemo(() => { + const sectionsArr = []; let indexOffset = 0; + if (!didScreenTransitionEnd) { + return []; + } + // Filter all options that is a part of the search term or in the personal details let filterSelectedOptions = selectedOptions; if (searchTerm !== '') { @@ -112,7 +131,7 @@ function RoomInvitePage(props) { }); } - sections.push({ + sectionsArr.push({ title: undefined, data: filterSelectedOptions, shouldShow: true, @@ -126,7 +145,7 @@ function RoomInvitePage(props) { const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, (personalDetail) => OptionsListUtils.formatMemberForList(personalDetail, false)); const hasUnselectedUserToInvite = userToInvite && !_.contains(selectedLogins, userToInvite.login); - sections.push({ + sectionsArr.push({ title: translate('common.contacts'), data: personalDetailsFormatted, shouldShow: !_.isEmpty(personalDetailsFormatted), @@ -135,7 +154,7 @@ function RoomInvitePage(props) { indexOffset += personalDetailsFormatted.length; if (hasUnselectedUserToInvite) { - sections.push({ + sectionsArr.push({ title: undefined, data: [OptionsListUtils.formatMemberForList(userToInvite, false)], shouldShow: true, @@ -143,8 +162,8 @@ function RoomInvitePage(props) { }); } - return sections; - }; + return sectionsArr; + }, [personalDetails, searchTerm, selectedOptions, translate, userToInvite, didScreenTransitionEnd]); const toggleOption = useCallback( (option) => { @@ -213,49 +232,43 @@ function RoomInvitePage(props) { shouldEnableMaxHeight testID={RoomInvitePage.displayName} > - {({didScreenTransitionEnd}) => { - const sections = didScreenTransitionEnd ? getSections() : []; - - return ( - Navigation.goBack(backRoute)} - > - { - Navigation.goBack(backRoute); - }} - /> - - - - - - ); - }} + Navigation.goBack(backRoute)} + > + { + Navigation.goBack(backRoute); + }} + /> + + + + + ); } diff --git a/src/pages/SearchPage/SearchPageFooter.tsx b/src/pages/SearchPage/SearchPageFooter.tsx index e0ef67ad9ec3..fb3644d8e570 100644 --- a/src/pages/SearchPage/SearchPageFooter.tsx +++ b/src/pages/SearchPage/SearchPageFooter.tsx @@ -1,16 +1,24 @@ -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; function SearchPageFooter() { + const [shouldShowReferralCTA, setShouldShowReferralCTA] = useState(true); const themeStyles = useThemeStyles(); return ( - - - + <> + {shouldShowReferralCTA && ( + + setShouldShowReferralCTA(false)} + /> + + )} + ); } diff --git a/src/pages/TeachersUnite/IntroSchoolPrincipalPage.tsx b/src/pages/TeachersUnite/IntroSchoolPrincipalPage.tsx index b84ad62858eb..92645e18dfd5 100644 --- a/src/pages/TeachersUnite/IntroSchoolPrincipalPage.tsx +++ b/src/pages/TeachersUnite/IntroSchoolPrincipalPage.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx/lib/types'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import type {OnyxFormValuesFields} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; @@ -21,12 +22,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {LoginList} from '@src/types/onyx'; - -type IntroSchoolPrincipalFormData = { - firstName: string; - lastName: string; - partnerUserID: string; -}; +import type {Errors} from '@src/types/onyx/OnyxCommon'; type IntroSchoolPrincipalPageOnyxProps = { loginList: OnyxEntry; @@ -42,7 +38,7 @@ function IntroSchoolPrincipalPage(props: IntroSchoolPrincipalPageProps) { /** * Submit form to pass firstName, partnerUserID and lastName */ - const onSubmit = (values: IntroSchoolPrincipalFormData) => { + const onSubmit = (values: OnyxFormValuesFields) => { const policyID = isProduction ? CONST.TEACHERS_UNITE.PROD_POLICY_ID : CONST.TEACHERS_UNITE.TEST_POLICY_ID; TeachersUnite.addSchoolPrincipal(values.firstName.trim(), values.partnerUserID.trim(), values.lastName.trim(), policyID); }; @@ -51,8 +47,8 @@ function IntroSchoolPrincipalPage(props: IntroSchoolPrincipalPageProps) { * @returns - An object containing the errors for each inputID */ const validate = useCallback( - (values: IntroSchoolPrincipalFormData) => { - const errors = {}; + (values: OnyxFormValuesFields) => { + const errors: Errors = {}; if (!ValidationUtils.isValidLegalName(values.firstName)) { ErrorUtils.addErrorMessage(errors, 'firstName', 'privatePersonalDetails.error.hasInvalidCharacter'); @@ -91,7 +87,6 @@ function IntroSchoolPrincipalPage(props: IntroSchoolPrincipalPageProps) { title={translate('teachersUnitePage.introSchoolPrincipal')} onBackButtonPress={() => Navigation.goBack(ROUTES.TEACHERS_UNITE)} /> - {/* @ts-expect-error TODO: Remove this once FormProvider (https://github.com/Expensify/App/issues/31972) is migrated to TypeScript. */} {translate('teachersUnitePage.schoolPrincipalVerfiyExpense')} ; }; @@ -42,7 +37,7 @@ function KnowATeacherPage(props: KnowATeacherPageProps) { /** * Submit form to pass firstName, partnerUserID and lastName */ - const onSubmit = (values: KnowATeacherFormData) => { + const onSubmit = (values: OnyxFormValuesFields) => { const phoneLogin = LoginUtils.getPhoneLogin(values.partnerUserID); const validateIfnumber = LoginUtils.validateNumber(phoneLogin); const contactMethod = (validateIfnumber || values.partnerUserID).trim().toLowerCase(); @@ -58,7 +53,7 @@ function KnowATeacherPage(props: KnowATeacherPageProps) { * @returns - An object containing the errors for each inputID */ const validate = useCallback( - (values: KnowATeacherFormData) => { + (values: OnyxFormValuesFields) => { const errors = {}; const phoneLogin = LoginUtils.getPhoneLogin(values.partnerUserID); const validateIfnumber = LoginUtils.validateNumber(phoneLogin); @@ -97,7 +92,6 @@ function KnowATeacherPage(props: KnowATeacherPageProps) { title={translate('teachersUnitePage.iKnowATeacher')} onBackButtonPress={() => Navigation.goBack(ROUTES.TEACHERS_UNITE)} /> - {/* @ts-expect-error TODO: Remove this once FormProvider (https://github.com/Expensify/App/issues/31972) is migrated to TypeScript. */} {translate('teachersUnitePage.getInTouch')} 1; const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(participantPersonalDetails, isMultipleParticipant); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index c072666920ae..c52b8ec6760a 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -354,6 +354,13 @@ function ReportActionCompose({ runOnJS(submitForm)(); }, [isSendDisabled, resetFullComposerSize, submitForm, animatedRef, isReportReadyForDisplay]); + const emojiShiftVertical = useMemo(() => { + const chatItemComposeSecondaryRowHeight = styles.chatItemComposeSecondaryRow.height + styles.chatItemComposeSecondaryRow.marginTop + styles.chatItemComposeSecondaryRow.marginBottom; + const reportActionComposeHeight = styles.chatItemComposeBox.minHeight + chatItemComposeSecondaryRowHeight; + const emojiOffsetWithComposeBox = (styles.chatItemComposeBox.minHeight - styles.chatItemEmojiButton.height) / 2; + return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; + }, [styles]); + return ( @@ -453,6 +460,7 @@ function ReportActionCompose({ onModalHide={focus} onEmojiSelected={(...args) => composerRef.current.replaceSelectionWithText(...args)} emojiPickerID={report.reportID} + shiftVertical={emojiShiftVertical} /> )} Report.navigateToConciergeChatAndDeleteReport(props.report.reportID)} - needsOffscreenAlphaCompositing - > - - - - ReportUtils.navigateToDetailsPage(props.report)} - style={[styles.mh5, styles.mb3, styles.alignSelfStart]} - accessibilityLabel={props.translate('common.details')} - role={CONST.ROLE.BUTTON} - disabled={shouldDisableDetailPage} - > - - - - - - - - - ); -} - -ReportActionItemCreated.defaultProps = defaultProps; -ReportActionItemCreated.propTypes = propTypes; -ReportActionItemCreated.displayName = 'ReportActionItemCreated'; - -export default compose( - withWindowDimensions, - withLocalize, - withOnyx({ - report: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - selector: reportWithoutHasDraftSelector, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - policy: { - key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - }, - }), -)( - memo( - ReportActionItemCreated, - (prevProps, nextProps) => - lodashGet(prevProps.props, 'policy.name') === lodashGet(nextProps, 'policy.name') && - lodashGet(prevProps.props, 'policy.avatar') === lodashGet(nextProps, 'policy.avatar') && - lodashGet(prevProps.props, 'report.lastReadTime') === lodashGet(nextProps, 'report.lastReadTime') && - lodashGet(prevProps.props, 'report.statusNum') === lodashGet(nextProps, 'report.statusNum') && - lodashGet(prevProps.props, 'report.stateNum') === lodashGet(nextProps, 'report.stateNum'), - ), -); diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx new file mode 100644 index 000000000000..82c6bebd9ba1 --- /dev/null +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -0,0 +1,120 @@ +import React, {memo} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import MultipleAvatars from '@components/MultipleAvatars'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import ReportWelcomeText from '@components/ReportWelcomeText'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import reportWithoutHasDraftSelector from '@libs/OnyxSelectors/reportWithoutHasDraftSelector'; +import * as ReportUtils from '@libs/ReportUtils'; +import {navigateToConciergeChatAndDeleteReport} from '@userActions/Report'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, Report} from '@src/types/onyx'; +import AnimatedEmptyStateBackground from './AnimatedEmptyStateBackground'; + +type ReportActionItemCreatedOnyxProps = { + /** The report currently being looked at */ + report: OnyxEntry; + + /** The policy object for the current route */ + policy: OnyxEntry; + + /** Personal details of all the users */ + personalDetails: OnyxEntry; +}; + +type ReportActionItemCreatedProps = ReportActionItemCreatedOnyxProps & { + /** The id of the report */ + reportID: string; + + /** The id of the policy */ + // eslint-disable-next-line react/no-unused-prop-types + policyID: string; +}; +function ReportActionItemCreated(props: ReportActionItemCreatedProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const {translate} = useLocalize(); + const {isSmallScreenWidth, isLargeScreenWidth} = useWindowDimensions(); + + if (!ReportUtils.isChatReport(props.report)) { + return null; + } + + const icons = ReportUtils.getIcons(props.report, props.personalDetails); + const shouldDisableDetailPage = ReportUtils.shouldDisableDetailPage(props.report); + + return ( + navigateToConciergeChatAndDeleteReport(props.report?.reportID ?? props.reportID)} + needsOffscreenAlphaCompositing + > + + + + ReportUtils.navigateToDetailsPage(props.report)} + style={[styles.mh5, styles.mb3, styles.alignSelfStart]} + accessibilityLabel={translate('common.details')} + role={CONST.ROLE.BUTTON} + disabled={shouldDisableDetailPage} + > + + + + + + + + + ); +} + +ReportActionItemCreated.displayName = 'ReportActionItemCreated'; + +export default withOnyx({ + report: { + key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + selector: reportWithoutHasDraftSelector, + }, + + policy: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + }, + + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, +})( + memo( + ReportActionItemCreated, + (prevProps, nextProps) => + prevProps.policy?.name === nextProps.policy?.name && + prevProps.policy?.avatar === nextProps.policy?.avatar && + prevProps.report?.stateNum === nextProps.report?.stateNum && + prevProps.report?.statusNum === nextProps.report?.statusNum && + prevProps.report?.lastReadTime === nextProps.report?.lastReadTime, + ), +); diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js deleted file mode 100644 index f05b3decc6d7..000000000000 --- a/src/pages/home/report/ReportActionItemFragment.js +++ /dev/null @@ -1,179 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {memo} from 'react'; -import avatarPropTypes from '@components/avatarPropTypes'; -import {withNetwork} from '@components/OnyxProvider'; -import RenderHTML from '@components/RenderHTML'; -import Text from '@components/Text'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import convertToLTR from '@libs/convertToLTR'; -import * as ReportUtils from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import AttachmentCommentFragment from './comment/AttachmentCommentFragment'; -import TextCommentFragment from './comment/TextCommentFragment'; -import reportActionFragmentPropTypes from './reportActionFragmentPropTypes'; - -const propTypes = { - /** Users accountID */ - accountID: PropTypes.number.isRequired, - - /** The message fragment needing to be displayed */ - fragment: reportActionFragmentPropTypes.isRequired, - - /** If this fragment is attachment than has info? */ - attachmentInfo: PropTypes.shape({ - /** The file name of attachment */ - name: PropTypes.string, - - /** The file size of the attachment in bytes. */ - size: PropTypes.number, - - /** The MIME type of the attachment. */ - type: PropTypes.string, - - /** Attachment's URL represents the specified File object or Blob object */ - source: PropTypes.string, - }), - - /** Message(text) of an IOU report action */ - iouMessage: PropTypes.string, - - /** The reportAction's source */ - source: PropTypes.oneOf(['Chronos', 'email', 'ios', 'android', 'web', 'email', '']), - - /** Should this fragment be contained in a single line? */ - isSingleLine: PropTypes.bool, - - // Additional styles to add after local styles - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - /** The accountID of the copilot who took this action on behalf of the user */ - delegateAccountID: PropTypes.number, - - /** icon */ - actorIcon: avatarPropTypes, - - /** Whether the comment is a thread parent message/the first message in a thread */ - isThreadParentMessage: PropTypes.bool, - - /** Should the comment have the appearance of being grouped with the previous comment? */ - displayAsGroup: PropTypes.bool, - - /** Whether the report action type is 'APPROVED' or 'SUBMITTED'. Used to style system messages from Old Dot */ - isApprovedOrSubmittedReportAction: PropTypes.bool, - - /** Used to format RTL display names in Old Dot system messages e.g. Arabic */ - isFragmentContainingDisplayName: PropTypes.bool, - - ...windowDimensionsPropTypes, - - /** localization props */ - ...withLocalizePropTypes, -}; - -const defaultProps = { - attachmentInfo: { - name: '', - size: 0, - type: '', - source: '', - }, - iouMessage: '', - isSingleLine: false, - source: '', - style: [], - delegateAccountID: 0, - actorIcon: {}, - isThreadParentMessage: false, - isApprovedOrSubmittedReportAction: false, - isFragmentContainingDisplayName: false, - displayAsGroup: false, -}; - -function ReportActionItemFragment(props) { - const styles = useThemeStyles(); - const fragment = props.fragment; - - switch (fragment.type) { - case 'COMMENT': { - const isPendingDelete = props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; - - // Threaded messages display "[Deleted message]" instead of being hidden altogether. - // While offline we display the previous message with a strikethrough style. Once online we want to - // immediately display "[Deleted message]" while the delete action is pending. - - if ((!props.network.isOffline && props.isThreadParentMessage && isPendingDelete) || props.fragment.isDeletedParentAction) { - return ${props.translate('parentReportAction.deletedMessage')}`} />; - } - - if (ReportUtils.isReportMessageAttachment(fragment)) { - return ( - - ); - } - - return ( - - ); - } - case 'TEXT': { - return props.isApprovedOrSubmittedReportAction ? ( - - {props.isFragmentContainingDisplayName ? convertToLTR(props.fragment.text) : props.fragment.text} - - ) : ( - - - {fragment.text} - - - ); - } - case 'LINK': - return LINK; - case 'INTEGRATION_COMMENT': - return REPORT_LINK; - case 'REPORT_LINK': - return REPORT_LINK; - case 'POLICY_LINK': - return POLICY_LINK; - - // If we have a message fragment type of OLD_MESSAGE this means we have not yet converted this over to the - // new data structure. So we simply set this message as inner html and render it like we did before. - // This wil allow us to convert messages over to the new structure without needing to do it all at once. - case 'OLD_MESSAGE': - return OLD_MESSAGE; - default: - return props.fragment.text; - } -} - -ReportActionItemFragment.propTypes = propTypes; -ReportActionItemFragment.defaultProps = defaultProps; -ReportActionItemFragment.displayName = 'ReportActionItemFragment'; - -export default compose(withWindowDimensions, withLocalize, withNetwork())(memo(ReportActionItemFragment)); diff --git a/src/pages/home/report/ReportActionItemFragment.tsx b/src/pages/home/report/ReportActionItemFragment.tsx new file mode 100644 index 000000000000..01918b377c62 --- /dev/null +++ b/src/pages/home/report/ReportActionItemFragment.tsx @@ -0,0 +1,156 @@ +import React, {memo} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; +import type {AvatarProps} from '@components/Avatar'; +import RenderHTML from '@components/RenderHTML'; +import Text from '@components/Text'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import convertToLTR from '@libs/convertToLTR'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; +import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; +import type {Message} from '@src/types/onyx/ReportAction'; +import AttachmentCommentFragment from './comment/AttachmentCommentFragment'; +import TextCommentFragment from './comment/TextCommentFragment'; + +type ReportActionItemFragmentProps = { + /** Users accountID */ + accountID: number; + + /** The message fragment needing to be displayed */ + fragment: Message; + + /** Message(text) of an IOU report action */ + iouMessage?: string; + + /** The reportAction's source */ + source?: OriginalMessageSource; + + /** Should this fragment be contained in a single line? */ + isSingleLine?: boolean; + + /** Additional styles to add after local styles */ + style?: StyleProp; + + /** The accountID of the copilot who took this action on behalf of the user */ + delegateAccountID?: number; + + /** icon */ + actorIcon?: AvatarProps; + + /** Whether the comment is a thread parent message/the first message in a thread */ + isThreadParentMessage?: boolean; + + /** Should the comment have the appearance of being grouped with the previous comment? */ + displayAsGroup?: boolean; + + /** Whether the report action type is 'APPROVED' or 'SUBMITTED'. Used to style system messages from Old Dot */ + isApprovedOrSubmittedReportAction?: boolean; + + /** Used to format RTL display names in Old Dot system messages e.g. Arabic */ + isFragmentContainingDisplayName?: boolean; + + /** The pending action for the report action */ + pendingAction?: OnyxCommon.PendingAction; +}; + +function ReportActionItemFragment({ + pendingAction, + fragment, + accountID, + iouMessage = '', + isSingleLine = false, + source = '', + style = [], + delegateAccountID = 0, + actorIcon = {}, + isThreadParentMessage = false, + isApprovedOrSubmittedReportAction = false, + isFragmentContainingDisplayName = false, + displayAsGroup = false, +}: ReportActionItemFragmentProps) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {translate} = useLocalize(); + + switch (fragment.type) { + case 'COMMENT': { + const isPendingDelete = pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + // Threaded messages display "[Deleted message]" instead of being hidden altogether. + // While offline we display the previous message with a strikethrough style. Once online we want to + // immediately display "[Deleted message]" while the delete action is pending. + + if ((!isOffline && isThreadParentMessage && isPendingDelete) || fragment.isDeletedParentAction) { + return ${translate('parentReportAction.deletedMessage')}`} />; + } + + if (ReportUtils.isReportMessageAttachment(fragment)) { + return ( + + ); + } + + return ( + + ); + } + case 'TEXT': { + return isApprovedOrSubmittedReportAction ? ( + + {isFragmentContainingDisplayName ? convertToLTR(fragment.text) : fragment.text} + + ) : ( + + + {fragment.text} + + + ); + } + case 'LINK': + return LINK; + case 'INTEGRATION_COMMENT': + return REPORT_LINK; + case 'REPORT_LINK': + return REPORT_LINK; + case 'POLICY_LINK': + return POLICY_LINK; + + // If we have a message fragment type of OLD_MESSAGE this means we have not yet converted this over to the + // new data structure. So we simply set this message as inner html and render it like we did before. + // This wil allow us to convert messages over to the new structure without needing to do it all at once. + case 'OLD_MESSAGE': + return OLD_MESSAGE; + default: + return fragment.text; + } +} + +ReportActionItemFragment.displayName = 'ReportActionItemFragment'; + +export default memo(ReportActionItemFragment); diff --git a/src/pages/home/report/ReportActionItemMessage.tsx b/src/pages/home/report/ReportActionItemMessage.tsx index 6fc70c78e605..5f0e3ea4b72c 100644 --- a/src/pages/home/report/ReportActionItemMessage.tsx +++ b/src/pages/home/report/ReportActionItemMessage.tsx @@ -1,6 +1,6 @@ import type {ReactElement} from 'react'; import React from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -21,7 +21,7 @@ type ReportActionItemMessageProps = { displayAsGroup: boolean; /** Additional styles to add after local styles. */ - style?: StyleProp; + style?: StyleProp; /** Whether or not the message is hidden by moderation */ isHidden?: boolean; @@ -77,10 +77,9 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid fragment={fragment} iouMessage={iouMessage} isThreadParentMessage={ReportActionsUtils.isThreadParentMessage(action, reportID)} - attachmentInfo={action.attachmentInfo} pendingAction={action.pendingAction} source={(action.originalMessage as OriginalMessageAddComment['originalMessage'])?.source} - accountID={action.actorAccountID} + accountID={action.actorAccountID ?? 0} style={style} displayAsGroup={displayAsGroup} isApprovedOrSubmittedReportAction={isApprovedOrSubmittedReportAction} diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 6c4e2b39a329..ae5c3d75cfff 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -155,7 +155,7 @@ function ReportActionItemSingle({ Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(iouReportID)); return; } - showUserDetails(action.delegateAccountID ? action.delegateAccountID : String(actorAccountID)); + showUserDetails(action.delegateAccountID ? String(action.delegateAccountID) : String(actorAccountID)); } }, [isWorkspaceActor, reportID, actorAccountID, action.delegateAccountID, iouReportID, displayAllActors]); @@ -238,8 +238,8 @@ function ReportActionItemSingle({ { if (!cacheUnreadMarkers.has(report.reportID)) { @@ -387,7 +388,7 @@ function ReportActionsList({ [currentUnreadMarker, sortedVisibleReportActions, report.reportID, messageManuallyMarkedUnread], ); - useEffect(() => { + const calculateUnreadMarker = useCallback(() => { // Iterate through the report actions and set appropriate unread marker. // This is to avoid a warning of: // Cannot update a component (ReportActionsList) while rendering a different component (CellRenderer). @@ -405,7 +406,48 @@ function ReportActionsList({ if (!markerFound) { setCurrentUnreadMarker(null); } - }, [sortedVisibleReportActions, report.lastReadTime, report.reportID, messageManuallyMarkedUnread, shouldDisplayNewMarker, currentUnreadMarker]); + }, [sortedVisibleReportActions, shouldDisplayNewMarker, currentUnreadMarker, report.reportID]); + + useEffect(() => { + calculateUnreadMarker(); + }, [calculateUnreadMarker, report.lastReadTime, messageManuallyMarkedUnread]); + + const onVisibilityChange = useCallback(() => { + if (!Visibility.isVisible()) { + userInactiveSince.current = DateUtils.getDBTime(); + return; + } + // In case the user read new messages (after being inactive) with other device we should + // show marker based on report.lastReadTime + const newMessageTimeReference = userInactiveSince.current > report.lastReadTime ? userActiveSince.current : report.lastReadTime; + if ( + scrollingVerticalOffset.current >= MSG_VISIBLE_THRESHOLD || + !( + sortedVisibleReportActions && + _.some( + sortedVisibleReportActions, + (reportAction) => + newMessageTimeReference < reportAction.created && + (ReportActionsUtils.isReportPreviewAction(reportAction) ? reportAction.childLastActorAccountID : reportAction.actorAccountID) !== Report.getCurrentUserAccountID(), + ) + ) + ) { + return; + } + + Report.readNewestAction(report.reportID, false); + userActiveSince.current = DateUtils.getDBTime(); + lastReadTimeRef.current = newMessageTimeReference; + setCurrentUnreadMarker(null); + cacheUnreadMarkers.delete(report.reportID); + calculateUnreadMarker(); + }, [calculateUnreadMarker, report, sortedVisibleReportActions]); + + useEffect(() => { + const unsubscribeVisibilityListener = Visibility.onVisibilityChange(onVisibilityChange); + + return unsubscribeVisibilityListener; + }, [onVisibilityChange]); const renderItem = useCallback( ({item: reportAction, index}) => ( diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 2758437a3962..4843a29dde60 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -87,8 +87,7 @@ function ReportActionsView(props) { const didSubscribeToReportTypingEvents = useRef(false); const isFirstRender = useRef(true); const hasCachedActions = useInitialValue(() => _.size(props.reportActions) > 0); - const mostRecentIOUReportActionID = useInitialValue(() => ReportActionsUtils.getMostRecentIOURequestActionID(props.reportActions)); - + const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(props.reportActions), [props.reportActions]); const prevNetworkRef = useRef(props.network); const prevAuthTokenType = usePrevious(props.session.authTokenType); diff --git a/src/pages/home/report/comment/AttachmentCommentFragment.js b/src/pages/home/report/comment/AttachmentCommentFragment.tsx similarity index 55% rename from src/pages/home/report/comment/AttachmentCommentFragment.js rename to src/pages/home/report/comment/AttachmentCommentFragment.tsx index 30c131b57060..3c49cb90a106 100644 --- a/src/pages/home/report/comment/AttachmentCommentFragment.js +++ b/src/pages/home/report/comment/AttachmentCommentFragment.tsx @@ -1,22 +1,16 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; -import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType'; +import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; import RenderCommentHTML from './RenderCommentHTML'; -const propTypes = { - /** The reportAction's source */ - source: reportActionSourcePropType.isRequired, - - /** The message fragment's HTML */ - html: PropTypes.string.isRequired, - - /** Should extra margin be added on top of the component? */ - addExtraMargin: PropTypes.bool.isRequired, +type AttachmentCommentFragmentProps = { + source: OriginalMessageSource; + html: string; + addExtraMargin: boolean; }; -function AttachmentCommentFragment({addExtraMargin, html, source}) { +function AttachmentCommentFragment({addExtraMargin, html, source}: AttachmentCommentFragmentProps) { const styles = useThemeStyles(); return ( @@ -28,7 +22,6 @@ function AttachmentCommentFragment({addExtraMargin, html, source}) { ); } -AttachmentCommentFragment.propTypes = propTypes; AttachmentCommentFragment.displayName = 'AttachmentCommentFragment'; export default AttachmentCommentFragment; diff --git a/src/pages/home/report/comment/RenderCommentHTML.js b/src/pages/home/report/comment/RenderCommentHTML.js deleted file mode 100644 index 14039af21189..000000000000 --- a/src/pages/home/report/comment/RenderCommentHTML.js +++ /dev/null @@ -1,23 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import RenderHTML from '@components/RenderHTML'; -import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType'; - -const propTypes = { - /** The reportAction's source */ - source: reportActionSourcePropType.isRequired, - - /** The comment's HTML */ - html: PropTypes.string.isRequired, -}; - -function RenderCommentHTML({html, source}) { - const commentHtml = source === 'email' ? `${html}` : `${html}`; - - return ; -} - -RenderCommentHTML.propTypes = propTypes; -RenderCommentHTML.displayName = 'RenderCommentHTML'; - -export default RenderCommentHTML; diff --git a/src/pages/home/report/comment/RenderCommentHTML.tsx b/src/pages/home/report/comment/RenderCommentHTML.tsx new file mode 100644 index 000000000000..e730ae061519 --- /dev/null +++ b/src/pages/home/report/comment/RenderCommentHTML.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import RenderHTML from '@components/RenderHTML'; +import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; + +type RenderCommentHTMLProps = { + source: OriginalMessageSource; + html: string; +}; + +function RenderCommentHTML({html, source}: RenderCommentHTMLProps) { + const commentHtml = source === 'email' ? `${html}` : `${html}`; + + return ; +} + +RenderCommentHTML.displayName = 'RenderCommentHTML'; + +export default RenderCommentHTML; diff --git a/src/pages/home/report/comment/TextCommentFragment.js b/src/pages/home/report/comment/TextCommentFragment.tsx similarity index 59% rename from src/pages/home/report/comment/TextCommentFragment.js rename to src/pages/home/report/comment/TextCommentFragment.tsx index 3d6482344450..998ed9f6616f 100644 --- a/src/pages/home/report/comment/TextCommentFragment.js +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -1,56 +1,48 @@ import Str from 'expensify-common/lib/str'; -import PropTypes from 'prop-types'; +import {isEmpty} from 'lodash'; import React, {memo} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import ZeroWidthView from '@components/ZeroWidthView'; +import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import convertToLTR from '@libs/convertToLTR'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as EmojiUtils from '@libs/EmojiUtils'; -import reportActionFragmentPropTypes from '@pages/home/report/reportActionFragmentPropTypes'; -import reportActionSourcePropType from '@pages/home/report/reportActionSourcePropType'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; +import type {Message} from '@src/types/onyx/ReportAction'; import RenderCommentHTML from './RenderCommentHTML'; -const propTypes = { +type TextCommentFragmentProps = { /** The reportAction's source */ - source: reportActionSourcePropType.isRequired, + source: OriginalMessageSource; /** The message fragment needing to be displayed */ - fragment: reportActionFragmentPropTypes.isRequired, + fragment: Message; /** Should this message fragment be styled as deleted? */ - styleAsDeleted: PropTypes.bool.isRequired, - - /** Text of an IOU report action */ - iouMessage: PropTypes.string, + styleAsDeleted: boolean; /** Should the comment have the appearance of being grouped with the previous comment? */ - displayAsGroup: PropTypes.bool.isRequired, + displayAsGroup: boolean; /** Additional styles to add after local styles. */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]).isRequired, - - ...windowDimensionsPropTypes, + style: StyleProp; - /** localization props */ - ...withLocalizePropTypes, -}; - -const defaultProps = { - iouMessage: undefined, + /** Text of an IOU report action */ + iouMessage?: string; }; -function TextCommentFragment(props) { +function TextCommentFragment({fragment, styleAsDeleted, source, style, displayAsGroup, iouMessage = ''}: TextCommentFragmentProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {fragment, styleAsDeleted} = props; - const {html, text} = fragment; + const {html = '', text} = fragment; + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); // If the only difference between fragment.text and fragment.html is
tags // we render it as text, not as html. @@ -66,35 +58,36 @@ function TextCommentFragment(props) { return ( ); } const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text); + const message = isEmpty(iouMessage) ? text : iouMessage; return ( - + - {convertToLTR(props.iouMessage || text)} + {convertToLTR(message)} - {Boolean(fragment.isEdited) && ( + {fragment.isEdited && ( <> {' '} @@ -102,9 +95,9 @@ function TextCommentFragment(props) { - {props.translate('reportActionCompose.edited')} + {translate('reportActionCompose.edited')} )} @@ -112,8 +105,6 @@ function TextCommentFragment(props) { ); } -TextCommentFragment.propTypes = propTypes; -TextCommentFragment.defaultProps = defaultProps; TextCommentFragment.displayName = 'TextCommentFragment'; -export default compose(withWindowDimensions, withLocalize)(memo(TextCommentFragment)); +export default memo(TextCommentFragment); diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index ffcba2048d18..71f53b109e00 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -17,9 +17,11 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import Timing from '@libs/actions/Timing'; import KeyboardShortcut from '@libs/KeyboardShortcut'; import Navigation from '@libs/Navigation/Navigation'; import onyxSubscribe from '@libs/onyxSubscribe'; +import Performance from '@libs/Performance'; import SidebarUtils from '@libs/SidebarUtils'; import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportActionContextMenu'; import safeAreaInsetPropTypes from '@pages/safeAreaInsetPropTypes'; @@ -68,7 +70,6 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority }, [isSmallScreenWidth]); useEffect(() => { - App.setSidebarLoaded(); SidebarUtils.setIsSidebarLoadedReady(); InteractionManager.runAfterInteractions(() => { @@ -123,6 +124,10 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority return; } + // Capture metric for opening the search page + Timing.start(CONST.TIMING.OPEN_SEARCH); + Performance.markStart(CONST.TIMING.OPEN_SEARCH); + Navigation.navigate(ROUTES.SEARCH); }, [isCreateMenuOpen]); @@ -192,6 +197,7 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority onSelectRow={showReportPage} shouldDisableFocusOptions={isSmallScreenWidth} optionMode={viewMode} + onFirstItemRendered={App.setSidebarLoaded} /> {isLoading && optionListItems.length === 0 && ( diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index 379b010f16e7..d64734a78085 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -14,6 +14,7 @@ import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import * as ReportUtils from '@libs/ReportUtils'; import SidebarUtils from '@libs/SidebarUtils'; import reportPropTypes from '@pages/reportPropTypes'; import CONST from '@src/CONST'; @@ -172,6 +173,7 @@ const chatReportSelector = (report) => hasDraft: report.hasDraft, isPinned: report.isPinned, isHidden: report.isHidden, + notificationPreference: report.notificationPreference, errorFields: { addWorkspaceRoom: report.errorFields && report.errorFields.addWorkspaceRoom, }, @@ -201,6 +203,7 @@ const chatReportSelector = (report) => parentReportActionID: report.parentReportActionID, parentReportID: report.parentReportID, isDeletedParentAction: report.isDeletedParentAction, + isUnreadWithMention: ReportUtils.isUnreadWithMention(report), }; /** diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index 0949081435c4..15f98205839e 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -17,6 +17,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as Report from '@libs/actions/Report'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as ReportUtils from '@libs/ReportUtils'; import reportPropTypes from '@pages/reportPropTypes'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -54,8 +55,8 @@ const propTypes = { /** The request type, ie. manual, scan, distance */ iouRequestType: PropTypes.oneOf(_.values(CONST.IOU.REQUEST_TYPE)).isRequired, - /** Whether we are searching for reports in the server */ - isSearchingForReports: PropTypes.bool, + /** Whether the parent screen transition has ended */ + didScreenTransitionEnd: PropTypes.bool, }; const defaultProps = { @@ -63,7 +64,7 @@ const defaultProps = { safeAreaPaddingBottomStyle: {}, reports: {}, betas: [], - isSearchingForReports: false, + didScreenTransitionEnd: false, }; function MoneyTemporaryForRefactorRequestParticipantsSelector({ @@ -75,11 +76,12 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ safeAreaPaddingBottomStyle, iouType, iouRequestType, - isSearchingForReports, + didScreenTransitionEnd, }) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, setSearchTerm] = useState(''); + const [shouldShowReferralCTA, setShouldShowReferralCTA] = useState(true); const {isOffline} = useNetwork(); const personalDetails = usePersonalDetails(); @@ -94,6 +96,9 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ */ const [sections, newChatOptions] = useMemo(() => { const newSections = []; + if (!didScreenTransitionEnd) { + return [newSections, {}]; + } let indexOffset = 0; const chatOptions = OptionsListUtils.getFilteredOptions( @@ -168,7 +173,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ } return [newSections, chatOptions]; - }, [reports, personalDetails, betas, searchTerm, participants, iouType, iouRequestType, maxParticipantsReached, translate]); + }, [didScreenTransitionEnd, reports, personalDetails, betas, searchTerm, participants, iouType, iouRequestType, maxParticipantsReached, translate]); /** * Adds a single participant to the request @@ -265,9 +270,14 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ const footerContent = useMemo( () => ( - - - + {shouldShowReferralCTA && ( + + setShouldShowReferralCTA(false)} + /> + + )} {shouldShowSplitBillErrorMessage && ( ), - [handleConfirmSelection, participants.length, referralContentType, shouldShowSplitBillErrorMessage, styles, translate], + [handleConfirmSelection, participants.length, referralContentType, shouldShowSplitBillErrorMessage, shouldShowReferralCTA, styles, translate], ); const itemRightSideComponent = useCallback( @@ -322,11 +332,13 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ [addParticipantToSelection, isAllowedToSplit, styles, translate], ); + const isOptionsDataReady = useMemo(() => ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails), [personalDetails]); + return ( 0 ? safeAreaPaddingBottomStyle : {}]}> @@ -353,8 +365,4 @@ export default withOnyx({ betas: { key: ONYXKEYS.BETAS, }, - isSearchingForReports: { - key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, - initWithStoredValues: false, - }, })(MoneyTemporaryForRefactorRequestParticipantsSelector); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 9df2564ae38d..801568b1c5b8 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -8,6 +8,7 @@ import categoryPropTypes from '@components/categoryPropTypes'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestConfirmationList from '@components/MoneyTemporaryForRefactorRequestConfirmationList'; +import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import tagPropTypes from '@components/tagPropTypes'; import transactionPropTypes from '@components/transactionPropTypes'; @@ -23,7 +24,6 @@ import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; import reportPropTypes from '@pages/reportPropTypes'; import {policyPropTypes} from '@pages/workspace/withPolicy'; import * as IOU from '@userActions/IOU'; @@ -43,9 +43,6 @@ const propTypes = { /** The personal details of the current user */ ...withCurrentUserPersonalDetailsPropTypes, - /** Personal details of all users */ - personalDetails: personalDetailsPropType, - /** The policy of the report */ ...policyPropTypes, @@ -62,7 +59,6 @@ const propTypes = { transaction: transactionPropTypes, }; const defaultProps = { - personalDetails: {}, policy: {}, policyCategories: {}, policyTags: {}, @@ -72,7 +68,6 @@ const defaultProps = { }; function IOURequestStepConfirmation({ currentUserPersonalDetails, - personalDetails, policy, policyTags, policyCategories, @@ -86,6 +81,7 @@ function IOURequestStepConfirmation({ const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); const {isOffline} = useNetwork(); + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const [receiptFile, setReceiptFile] = useState(); const receiptFilename = lodashGet(transaction, 'filename'); const receiptPath = lodashGet(transaction, 'receipt.source'); @@ -385,12 +381,6 @@ export default compose( withCurrentUserPersonalDetails, withWritableReportOrNotFound, withFullTransactionOrNotFound, - withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file withOnyx({ policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.js b/src/pages/iou/request/step/IOURequestStepParticipants.js index 730441035d08..4846c3c4c8a4 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.js +++ b/src/pages/iou/request/step/IOURequestStepParticipants.js @@ -87,13 +87,16 @@ function IOURequestStepParticipants({ testID={IOURequestStepParticipants.displayName} includeSafeAreaPaddingBottom > - + {({didScreenTransitionEnd}) => ( + + )} ); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js index 23e30ce25711..b6200f48c507 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js @@ -217,6 +217,7 @@ function IOURequestStepScan({ return ( lodashGet(props.route, 'params.iouType', '')); - const reportID = useInitialValue(() => lodashGet(props.route, 'params.reportID', '')); - const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType, props.selectedTab); - const isScanRequest = MoneyRequestUtils.isScanRequest(props.selectedTab); - const [receiptFile, setReceiptFile] = useState(); - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; - - const participants = useMemo( - () => - _.map(props.iou.participants, (participant) => { - const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); - return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails); - }), - [props.iou.participants, personalDetails], - ); - const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(props.report)), [props.report]); - const isManualRequestDM = props.selectedTab === CONST.TAB_REQUEST.MANUAL && iouType === CONST.IOU.TYPE.REQUEST; - - useEffect(() => { - const policyExpenseChat = _.find(participants, (participant) => participant.isPolicyExpenseChat); - if (policyExpenseChat) { - Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID); - } - }, [isOffline, participants, props.iou.billable, props.policy]); - - const defaultBillable = lodashGet(props.policy, 'defaultBillable', false); - useEffect(() => { - IOU.setMoneyRequestBillable(defaultBillable); - }, [defaultBillable, isOffline]); - - useEffect(() => { - if (!props.iou.receiptPath || !props.iou.receiptFilename) { - return; - } - const onSuccess = (file) => { - const receipt = file; - receipt.state = file && isManualRequestDM ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCANREADY; - setReceiptFile(receipt); - }; - const onFailure = () => { - Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID)); - }; - FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename, onSuccess, onFailure); - }, [props.iou.receiptPath, props.iou.receiptFilename, isManualRequestDM, iouType, reportID]); - - useEffect(() => { - // ID in Onyx could change by initiating a new request in a separate browser tab or completing a request - if (!isDistanceRequest && prevMoneyRequestId.current !== props.iou.id) { - // The ID is cleared on completing a request. In that case, we will do nothing. - if (props.iou.id) { - Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); - } - return; - } - - // Reset the money request Onyx if the ID in Onyx does not match the ID from params - const moneyRequestId = `${iouType}${reportID}`; - const shouldReset = !isDistanceRequest && props.iou.id !== moneyRequestId && !_.isEmpty(reportID); - if (shouldReset) { - IOU.resetMoneyRequestInfo(moneyRequestId); - } - - if (_.isEmpty(props.iou.participants) || (props.iou.amount === 0 && !props.iou.receiptPath && !isDistanceRequest) || shouldReset || ReportUtils.isArchivedRoom(props.report)) { - Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); - } - - return () => { - prevMoneyRequestId.current = props.iou.id; - }; - }, [props.iou.participants, props.iou.amount, props.iou.id, props.iou.receiptPath, isDistanceRequest, props.report, iouType, reportID]); - - const navigateBack = () => { - let fallback; - if (reportID) { - fallback = ROUTES.MONEY_REQUEST.getRoute(iouType, reportID); - } else { - fallback = ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(iouType); - } - Navigation.goBack(fallback); - }; - - /** - * @param {Array} selectedParticipants - * @param {String} trimmedComment - * @param {File} [receipt] - */ - const requestMoney = useCallback( - (selectedParticipants, trimmedComment, receipt) => { - IOU.requestMoney( - props.report, - props.iou.amount, - props.iou.currency, - props.iou.created, - props.iou.merchant, - props.currentUserPersonalDetails.login, - props.currentUserPersonalDetails.accountID, - selectedParticipants[0], - trimmedComment, - receipt, - props.iou.category, - props.iou.tag, - props.iou.billable, - props.policy, - props.policyTags, - props.policyCategories, - ); - }, - [ - props.report, - props.iou.amount, - props.iou.currency, - props.iou.created, - props.iou.merchant, - props.currentUserPersonalDetails.login, - props.currentUserPersonalDetails.accountID, - props.iou.category, - props.iou.tag, - props.iou.billable, - props.policy, - props.policyTags, - props.policyCategories, - ], - ); - - /** - * @param {Array} selectedParticipants - * @param {String} trimmedComment - */ - const createDistanceRequest = useCallback( - (selectedParticipants, trimmedComment) => { - IOU.createDistanceRequest( - props.report, - selectedParticipants[0], - trimmedComment, - props.iou.created, - props.iou.transactionID, - props.iou.category, - props.iou.tag, - props.iou.amount, - props.iou.currency, - props.iou.merchant, - props.iou.billable, - props.policy, - props.policyTags, - props.policyCategories, - ); - }, - [ - props.report, - props.iou.created, - props.iou.transactionID, - props.iou.category, - props.iou.tag, - props.iou.amount, - props.iou.currency, - props.iou.merchant, - props.iou.billable, - props.policy, - props.policyTags, - props.policyCategories, - ], - ); - - const createTransaction = useCallback( - (selectedParticipants) => { - const trimmedComment = props.iou.comment.trim(); - - // If we have a receipt let's start the split bill by creating only the action, the transaction, and the group DM if needed - if (iouType === CONST.IOU.TYPE.SPLIT && props.iou.receiptPath) { - const existingSplitChatReportID = CONST.REGEX.NUMBER.test(reportID) ? reportID : ''; - const onSuccess = (receipt) => { - IOU.startSplitBill( - selectedParticipants, - props.currentUserPersonalDetails.login, - props.currentUserPersonalDetails.accountID, - trimmedComment, - receipt, - existingSplitChatReportID, - ); - }; - FileUtils.readFileAsync(props.iou.receiptPath, props.iou.receiptFilename, onSuccess); - return; - } - - // IOUs created from a group report will have a reportID param in the route. - // Since the user is already viewing the report, we don't need to navigate them to the report - if (iouType === CONST.IOU.TYPE.SPLIT && CONST.REGEX.NUMBER.test(reportID)) { - IOU.splitBill( - selectedParticipants, - props.currentUserPersonalDetails.login, - props.currentUserPersonalDetails.accountID, - props.iou.amount, - trimmedComment, - props.iou.currency, - props.iou.category, - props.iou.tag, - reportID, - props.iou.merchant, - ); - return; - } - - // If the request is created from the global create menu, we also navigate the user to the group report - if (iouType === CONST.IOU.TYPE.SPLIT) { - IOU.splitBillAndOpenReport( - selectedParticipants, - props.currentUserPersonalDetails.login, - props.currentUserPersonalDetails.accountID, - props.iou.amount, - trimmedComment, - props.iou.currency, - props.iou.category, - props.iou.tag, - props.iou.merchant, - ); - return; - } - - if (receiptFile) { - requestMoney(selectedParticipants, trimmedComment, receiptFile); - return; - } - - if (isDistanceRequest) { - createDistanceRequest(selectedParticipants, trimmedComment); - return; - } - - requestMoney(selectedParticipants, trimmedComment); - }, - [ - props.iou.amount, - props.iou.comment, - props.currentUserPersonalDetails.login, - props.currentUserPersonalDetails.accountID, - props.iou.currency, - props.iou.category, - props.iou.tag, - props.iou.receiptPath, - props.iou.receiptFilename, - isDistanceRequest, - requestMoney, - createDistanceRequest, - receiptFile, - iouType, - reportID, - props.iou.merchant, - ], - ); - - /** - * Checks if user has a GOLD wallet then creates a paid IOU report on the fly - * - * @param {String} paymentMethodType - */ - const sendMoney = useCallback( - (paymentMethodType) => { - const currency = props.iou.currency; - const trimmedComment = props.iou.comment.trim(); - const participant = participants[0]; - - if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - IOU.sendMoneyElsewhere(props.report, props.iou.amount, currency, trimmedComment, props.currentUserPersonalDetails.accountID, participant); - return; - } - - if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - IOU.sendMoneyWithWallet(props.report, props.iou.amount, currency, trimmedComment, props.currentUserPersonalDetails.accountID, participant); - } - }, - [props.iou.amount, props.iou.comment, participants, props.iou.currency, props.currentUserPersonalDetails.accountID, props.report], - ); - - const headerTitle = () => { - if (isDistanceRequest) { - return props.translate('common.distance'); - } - - if (iouType === CONST.IOU.TYPE.SPLIT) { - return props.translate('iou.split'); - } - - if (iouType === CONST.IOU.TYPE.SEND) { - return props.translate('common.send'); - } - - if (isScanRequest) { - return props.translate('tabSelector.scan'); - } - - return props.translate('tabSelector.manual'); - }; - - return ( - - {({safeAreaPaddingBottomStyle}) => ( - - Navigation.navigate(ROUTES.MONEY_REQUEST_RECEIPT.getRoute(iouType, reportID)), - }, - ]} - /> - { - const newParticipants = _.map(props.iou.participants, (participant) => { - if (participant.accountID === option.accountID) { - return {...participant, selected: !participant.selected}; - } - return participant; - }); - IOU.setMoneyRequestParticipants(newParticipants); - }} - receiptPath={props.iou.receiptPath} - receiptFilename={props.iou.receiptFilename} - iouType={iouType} - reportID={reportID} - isPolicyExpenseChat={isPolicyExpenseChat} - // The participants can only be modified when the action is initiated from directly within a group chat and not the floating-action-button. - // This is because when there is a group of people, say they are on a trip, and you have some shared expenses with some of the people, - // but not all of them (maybe someone skipped out on dinner). Then it's nice to be able to select/deselect people from the group chat bill - // split rather than forcing the user to create a new group, just for that expense. The reportID is empty, when the action was initiated from - // the floating-action-button (since it is something that exists outside the context of a report). - canModifyParticipants={!_.isEmpty(reportID)} - policyID={props.report.policyID} - bankAccountRoute={ReportUtils.getBankAccountRoute(props.report)} - iouMerchant={props.iou.merchant} - iouCreated={props.iou.created} - isScanRequest={isScanRequest} - isDistanceRequest={isDistanceRequest} - shouldShowSmartScanFields={_.isEmpty(props.iou.receiptPath)} - /> - - )} - - ); -} - -MoneyRequestConfirmPage.displayName = 'MoneyRequestConfirmPage'; -MoneyRequestConfirmPage.propTypes = propTypes; -MoneyRequestConfirmPage.defaultProps = defaultProps; - -export default compose( - withCurrentUserPersonalDetails, - withLocalize, - withOnyx({ - iou: { - key: ONYXKEYS.IOU, - }, - }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file - withOnyx({ - report: { - key: ({route, iou}) => { - const reportID = IOU.getIOUReportID(iou, route); - - return `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; - }, - }, - selectedTab: { - key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, - }, - }), - withOnyx({ - policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, - }, - policyCategories: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, - }, - policyTags: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, - }, - }), -)(MoneyRequestConfirmPage); diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 9567b17ecdf5..59081599736c 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -85,6 +85,7 @@ function MoneyRequestParticipantsSelector({ const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, setSearchTerm] = useState(''); + const [shouldShowReferralCTA, setShouldShowReferralCTA] = useState(true); const {isOffline} = useNetwork(); const personalDetails = usePersonalDetails(); @@ -284,9 +285,14 @@ function MoneyRequestParticipantsSelector({ const footerContent = useMemo( () => ( - - - + {shouldShowReferralCTA && ( + + setShouldShowReferralCTA(false)} + /> + + )} {shouldShowSplitBillErrorMessage && ( ), - [handleConfirmSelection, participants.length, referralContentType, shouldShowSplitBillErrorMessage, styles, translate], + [handleConfirmSelection, participants.length, referralContentType, shouldShowSplitBillErrorMessage, shouldShowReferralCTA, styles, translate], ); const itemRightSideComponent = useCallback( diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js index 488afa7c5a71..c6044bd81efe 100644 --- a/src/pages/settings/Report/NotificationPreferencePage.js +++ b/src/pages/settings/Report/NotificationPreferencePage.js @@ -43,7 +43,9 @@ function NotificationPreferencePage(props) { /> Report.updateNotificationPreference(props.reportID, props.report.notificationPreference, option.value, true, undefined, undefined, props.report)} + onSelectRow={(option) => + Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, option.value, true, undefined, undefined, props.report) + } initiallyFocusedOptionKey={_.find(notificationPreferenceOptions, (locale) => locale.isSelected).keyForList} /> diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.js b/src/pages/settings/Wallet/WalletPage/WalletPage.js index 8382014a01e5..c2bb0b0ffe1c 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.js +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.js @@ -54,6 +54,7 @@ function WalletPage({bankAccountList, cardList, fundList, isLoadingPaymentMethod methodID: null, selectedPaymentMethodType: null, }); + const addPaymentMethodAnchorRef = useRef(null); const paymentMethodButtonRef = useRef(null); const [anchorPosition, setAnchorPosition] = useState({ diff --git a/src/pages/signin/SAMLSignInPage/index.native.js b/src/pages/signin/SAMLSignInPage/index.native.js index 502e26e337b9..9fe60e56353e 100644 --- a/src/pages/signin/SAMLSignInPage/index.native.js +++ b/src/pages/signin/SAMLSignInPage/index.native.js @@ -7,6 +7,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import SAMLLoadingIndicator from '@components/SAMLLoadingIndicator'; import ScreenWrapper from '@components/ScreenWrapper'; import getPlatform from '@libs/getPlatform'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; @@ -19,13 +20,20 @@ const propTypes = { /** The email/phone the user logged in with */ login: PropTypes.string, }), + + /** State of the logging in user's account */ + account: PropTypes.shape({ + /** Whether the account is loading */ + isLoading: PropTypes.bool, + }), }; const defaultProps = { credentials: {}, + account: {}, }; -function SAMLSignInPage({credentials}) { +function SAMLSignInPage({credentials, account}) { const samlLoginURL = `${CONFIG.EXPENSIFY.SAML_URL}?email=${credentials.login}&referer=${CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER}&platform=${getPlatform()}`; const [showNavigation, shouldShowNavigation] = useState(true); @@ -36,13 +44,15 @@ function SAMLSignInPage({credentials}) { */ const handleNavigationStateChange = useCallback( ({url}) => { + Log.info('SAMLSignInPage - Handling SAML navigation change'); // If we've gotten a callback then remove the option to navigate back to the sign in page if (url.includes('loginCallback')) { shouldShowNavigation(false); } const searchParams = new URLSearchParams(new URL(url).search); - if (searchParams.has('shortLivedAuthToken')) { + if (searchParams.has('shortLivedAuthToken') && !account.isLoading) { + Log.info('SAMLSignInPage - Successfully received shortLivedAuthToken. Signing in...'); const shortLivedAuthToken = searchParams.get('shortLivedAuthToken'); Session.signInWithShortLivedAuthToken(credentials.login, shortLivedAuthToken); } @@ -54,7 +64,7 @@ function SAMLSignInPage({credentials}) { Navigation.navigate(ROUTES.HOME); } }, - [credentials.login, shouldShowNavigation], + [credentials.login, shouldShowNavigation, account.isLoading], ); return ( @@ -92,4 +102,5 @@ SAMLSignInPage.displayName = 'SAMLSignInPage'; export default withOnyx({ credentials: {key: ONYXKEYS.CREDENTIALS}, + account: {key: ONYXKEYS.ACCOUNT}, })(SAMLSignInPage); diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index 80813c847239..6c8476fed5cb 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -67,6 +67,15 @@ function dismissError(policyID) { Policy.removeWorkspace(policyID); } +/** + * Whether the policy report should be archived when we delete the policy. + * @param {Object} report + * @returns {Boolean} + */ +function shouldArchiveReport(report) { + return ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report); +} + function WorkspaceInitialPage(props) { const styles = useThemeStyles(); const policy = props.policyDraft && props.policyDraft.id ? props.policyDraft : props.policy; @@ -111,7 +120,7 @@ function WorkspaceInitialPage(props) { * Call the delete policy and hide the modal */ const confirmDeleteAndHideModal = useCallback(() => { - Policy.deleteWorkspace(policyID, policyReports, policy.name); + Policy.deleteWorkspace(policyID, _.filter(policyReports, shouldArchiveReport), policy.name); setIsDeleteModalOpen(false); // Pop the deleted workspace page before opening workspace settings. Navigation.goBack(ROUTES.SETTINGS_WORKSPACES); diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index 9c831ebda428..72f3747c127c 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -1,3 +1,4 @@ +import {useNavigation} from '@react-navigation/native'; import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; @@ -76,6 +77,8 @@ function WorkspaceInvitePage(props) { const [selectedOptions, setSelectedOptions] = useState([]); const [personalDetails, setPersonalDetails] = useState([]); const [usersToInvite, setUsersToInvite] = useState([]); + const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); + const navigation = useNavigation(); const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(props.policyMembers, props.personalDetails); Policy.openWorkspaceInvitePage(props.route.params.policyID, _.keys(policyMemberEmailsToAccountIDs)); @@ -94,6 +97,18 @@ function WorkspaceInvitePage(props) { // eslint-disable-next-line react-hooks/exhaustive-deps -- policyID changes remount the component }, []); + useEffect(() => { + const unsubscribeTransitionEnd = navigation.addListener('transitionEnd', () => { + setDidScreenTransitionEnd(true); + }); + + return () => { + unsubscribeTransitionEnd(); + }; + // Rule disabled because this effect is only for component did mount & will component unmount lifecycle event + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useNetwork({onReconnect: openWorkspaceInvitePage}); const excludedUsers = useMemo(() => PolicyUtils.getIneligibleInvitees(props.policyMembers, props.personalDetails), [props.policyMembers, props.personalDetails]); @@ -145,10 +160,14 @@ function WorkspaceInvitePage(props) { // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change }, [props.personalDetails, props.policyMembers, props.betas, searchTerm, excludedUsers]); - const getSections = () => { - const sections = []; + const sections = useMemo(() => { + const sectionsArr = []; let indexOffset = 0; + if (!didScreenTransitionEnd) { + return []; + } + // Filter all options that is a part of the search term or in the personal details let filterSelectedOptions = selectedOptions; if (searchTerm !== '') { @@ -163,7 +182,7 @@ function WorkspaceInvitePage(props) { }); } - sections.push({ + sectionsArr.push({ title: undefined, data: filterSelectedOptions, shouldShow: true, @@ -176,7 +195,7 @@ function WorkspaceInvitePage(props) { const personalDetailsWithoutSelected = _.filter(personalDetails, ({login}) => !_.contains(selectedLogins, login)); const personalDetailsFormatted = _.map(personalDetailsWithoutSelected, OptionsListUtils.formatMemberForList); - sections.push({ + sectionsArr.push({ title: translate('common.contacts'), data: personalDetailsFormatted, shouldShow: !_.isEmpty(personalDetailsFormatted), @@ -188,7 +207,7 @@ function WorkspaceInvitePage(props) { const hasUnselectedUserToInvite = !_.contains(selectedLogins, userToInvite.login); if (hasUnselectedUserToInvite) { - sections.push({ + sectionsArr.push({ title: undefined, data: [OptionsListUtils.formatMemberForList(userToInvite)], shouldShow: true, @@ -197,8 +216,8 @@ function WorkspaceInvitePage(props) { } }); - return sections; - }; + return sectionsArr; + }, [personalDetails, searchTerm, selectedOptions, usersToInvite, translate, didScreenTransitionEnd]); const toggleOption = (option) => { Policy.clearErrors(props.route.params.policyID); @@ -269,56 +288,50 @@ function WorkspaceInvitePage(props) { shouldEnableMaxHeight testID={WorkspaceInvitePage.displayName} > - {({didScreenTransitionEnd}) => { - const sections = didScreenTransitionEnd ? getSections() : []; - - return ( - Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} - > - { - Policy.clearErrors(props.route.params.policyID); - Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(props.route.params.policyID)); - }} - /> - { - SearchInputManager.searchInput = value; - setSearchTerm(value); - }} - headerMessage={headerMessage} - onSelectRow={toggleOption} - onConfirm={inviteUser} - showScrollIndicator - showLoadingPlaceholder={!didScreenTransitionEnd || !OptionsListUtils.isPersonalDetailsReady(props.personalDetails)} - shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} - /> - - - - - ); - }} + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} + > + { + Policy.clearErrors(props.route.params.policyID); + Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(props.route.params.policyID)); + }} + /> + { + SearchInputManager.searchInput = value; + setSearchTerm(value); + }} + headerMessage={headerMessage} + onSelectRow={toggleOption} + onConfirm={inviteUser} + showScrollIndicator + showLoadingPlaceholder={!didScreenTransitionEnd || !OptionsListUtils.isPersonalDetailsReady(props.personalDetails)} + shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} + /> + + + + ); } diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 8817f813a990..7a4d9c1f4106 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -14,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import BankAccount from '@libs/models/BankAccount'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; import * as BankAccounts from '@userActions/BankAccounts'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -79,7 +80,7 @@ function WorkspacePageWithSections({ guidesCallTaskID = '', headerText, policy, - reimbursementAccount = {}, + reimbursementAccount = ReimbursementAccountProps.reimbursementAccountDefaultProps, route, shouldUseScrollView = false, shouldSkipVBBACall = false, diff --git a/src/pages/workspace/WorkspacesListRow.tsx b/src/pages/workspace/WorkspacesListRow.tsx old mode 100755 new mode 100644 index d6bb3fb05385..3f084b4f770b --- a/src/pages/workspace/WorkspacesListRow.tsx +++ b/src/pages/workspace/WorkspacesListRow.tsx @@ -4,7 +4,7 @@ import type {ValueOf} from 'type-fest'; import Avatar from '@components/Avatar'; import Icon from '@components/Icon'; import * as Illustrations from '@components/Icon/Illustrations'; -import type {MenuItemProps} from '@components/MenuItem'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; import Text from '@components/Text'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; @@ -34,7 +34,7 @@ type WorkspacesListRowProps = WithCurrentUserPersonalDetailsProps & { fallbackWorkspaceIcon?: AvatarSource; /** Items for the three dots menu */ - menuItems: MenuItemProps[]; + menuItems: PopoverMenuItem[]; /** Renders the component using big screen layout or small screen layout. When layoutWidth === WorkspaceListRowLayout.NONE, * component will return null to prevent layout from jumping on initial render and when parent width changes. */ @@ -111,7 +111,7 @@ function WorkspacesListRow({ {isNarrow && ( )} @@ -165,7 +165,7 @@ function WorkspacesListRow({ {isWide && ( )} diff --git a/src/styles/index.ts b/src/styles/index.ts index 9bfe407593df..8a81c0185521 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -340,10 +340,6 @@ const styles = (theme: ThemeColors) => textAlign: 'left', }, - verticalAlignMiddle: { - verticalAlign: 'middle', - }, - verticalAlignTop: { verticalAlign: 'top', }, @@ -1467,7 +1463,7 @@ const styles = (theme: ThemeColors) => createMenuPositionReportActionCompose: (windowHeight: number) => ({ horizontal: 18 + variables.sideBarWidth, - vertical: windowHeight - 83, + vertical: windowHeight - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM, } satisfies AnchorPosition), createMenuPositionRightSidepane: { diff --git a/src/styles/stylePropTypes.js b/src/styles/stylePropTypes.js index f9ecdb98ff13..b82db94140ee 100644 --- a/src/styles/stylePropTypes.js +++ b/src/styles/stylePropTypes.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -const stylePropTypes = PropTypes.oneOfType([PropTypes.object, PropTypes.arrayOf(PropTypes.object), PropTypes.func]); +const stylePropTypes = PropTypes.oneOfType([PropTypes.object, PropTypes.bool, PropTypes.arrayOf(PropTypes.object), PropTypes.func]); export default stylePropTypes; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index ea8b3f258c89..cdefc8bc1c91 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -1431,6 +1431,21 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ return containerStyles; }, + /** + * Returns a style that sets the maximum height of the composer based on the number of lines and whether the composer is full size or not. + */ + getComposerMaxHeightStyle: (maxLines: number | undefined, isComposerFullSize: boolean): TextStyle => { + if (isComposerFullSize || maxLines == null) { + return {}; + } + + const composerLineHeight = styles.textInputCompose.lineHeight ?? 0; + + return { + maxHeight: maxLines * composerLineHeight, + }; + }, + getFullscreenCenteredContentStyles: () => [StyleSheet.absoluteFill, styles.justifyContentCenter, styles.alignItemsCenter], }); diff --git a/src/types/onyx/Form.ts b/src/types/onyx/Form.ts index ca8d6574adf5..c3bcec2a2d3b 100644 --- a/src/types/onyx/Form.ts +++ b/src/types/onyx/Form.ts @@ -1,26 +1,59 @@ import type * as OnyxCommon from './OnyxCommon'; -type Form = { +type FormValueType = string | boolean | Date; + +type BaseForm = { /** Controls the loading state of the form */ isLoading?: boolean; /** Server side errors keyed by microtime */ - errors?: OnyxCommon.Errors; + errors?: OnyxCommon.Errors | null; /** Field-specific server side errors keyed by microtime */ - errorFields?: OnyxCommon.ErrorFields; + errorFields?: OnyxCommon.ErrorFields | null; }; -type AddDebitCardForm = Form & { - /** Whether or not the form has been submitted */ +type Form = Record> = TFormValues & BaseForm; + +type AddDebitCardForm = Form<{ + /** Whether the form has been submitted */ setupComplete: boolean; -}; +}>; -type DateOfBirthForm = Form & { +type DateOfBirthForm = Form<{ /** Date of birth */ dob?: string; -}; +}>; + +type DisplayNameForm = Form<{ + firstName: string; + lastName: string; +}>; + +type NewRoomForm = Form<{ + roomName?: string; + welcomeMessage?: string; + policyID?: string; + writeCapability?: string; + visibility?: string; +}>; + +type IKnowATeacherForm = Form<{ + firstName: string; + lastName: string; + partnerUserID: string; +}>; + +type IntroSchoolPrincipalForm = Form<{ + firstName: string; + lastName: string; + partnerUserID: string; +}>; + +type PrivateNotesForm = Form<{ + privateNotes: string; +}>; export default Form; -export type {AddDebitCardForm, DateOfBirthForm}; +export type {AddDebitCardForm, DateOfBirthForm, PrivateNotesForm, DisplayNameForm, FormValueType, NewRoomForm, BaseForm, IKnowATeacherForm, IntroSchoolPrincipalForm}; diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index a89b0d4530ef..e4a195dc291e 100644 --- a/src/types/onyx/IOU.ts +++ b/src/types/onyx/IOU.ts @@ -1,10 +1,21 @@ +import type {Icon} from './OnyxCommon'; + type Participant = { accountID: number; - login?: string; + login: string; isPolicyExpenseChat?: boolean; isOwnPolicyExpenseChat?: boolean; selected?: boolean; reportID?: string; + searchText?: string; + alternateText: string; + firstName: string; + icons: Icon[]; + keyForList: string; + lastName: string; + phoneNumber: string; + text: string; + isSelected?: boolean; }; type IOU = { @@ -23,3 +34,4 @@ type IOU = { }; export default IOU; +export type {Participant}; diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index f00fd8c4c972..19c62f692c84 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -282,5 +282,6 @@ export type { OriginalMessageIOU, OriginalMessageCreated, OriginalMessageAddComment, + OriginalMessageSource, OriginalMessageReimbursementDequeued, }; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 97e6597c6444..eca7e9d1ee06 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -26,6 +26,11 @@ type CustomUnit = { errors?: OnyxCommon.Errors; }; +type DisabledFields = { + defaultBillable?: boolean; + reimbursable?: boolean; +}; + type AutoReportingOffset = number | ValueOf; type Policy = { @@ -118,6 +123,30 @@ type Policy = { /** Informative messages about which policy members were added with primary logins when invited with their secondary login */ primaryLoginsInvited?: Record; + + /** The approval mode set up on this policy */ + approvalMode?: ValueOf; + + /** Whether transactions should be billable by default */ + defaultBillable?: boolean; + + /** The workspace description */ + description?: string; + + /** List of field names that are disabled */ + disabledFields?: DisabledFields; + + /** Whether new transactions need to be tagged */ + requiresTag?: boolean; + + /** Whether new transactions need to be categorized */ + requiresCategory?: boolean; + + /** Whether the workspace has multiple levels of tags enabled */ + hasMultipleTagLists?: boolean; + + /** When tax tracking is enabled */ + isTaxTrackingEnabled?: boolean; }; export default Policy; diff --git a/src/types/onyx/PolicyTaxRates.ts b/src/types/onyx/PolicyTaxRates.ts index d549b620f51f..e2bea4a3fa44 100644 --- a/src/types/onyx/PolicyTaxRates.ts +++ b/src/types/onyx/PolicyTaxRates.ts @@ -1,14 +1,20 @@ type PolicyTaxRate = { - /** Name of a tax */ + /** The name of the tax rate. */ name: string; - /** The value of a tax */ + /** The value of the tax rate. */ value: string; - /** Whether the tax is disabled */ + /** The code associated with the tax rate. */ + code: string; + + /** This contains the tax name and tax value as one name */ + modifiedName: string; + + /** Indicates if the tax rate is disabled. */ isDisabled?: boolean; }; type PolicyTaxRates = Record; -export default PolicyTaxRate; -export type {PolicyTaxRates}; + +export type {PolicyTaxRates, PolicyTaxRate}; diff --git a/src/types/onyx/ReimbursementAccount.ts b/src/types/onyx/ReimbursementAccount.ts index c0ade25e4d79..8ab10e79cb09 100644 --- a/src/types/onyx/ReimbursementAccount.ts +++ b/src/types/onyx/ReimbursementAccount.ts @@ -8,7 +8,7 @@ type BankAccountSubStep = ValueOf; type ACHData = { /** Step of the setup flow that we are on. Determines which view is presented. */ - currentStep: BankAccountStep; + currentStep?: BankAccountStep; /** Optional subStep we would like the user to start back on */ subStep?: BankAccountSubStep; diff --git a/src/types/onyx/ReimbursementAccountDraft.ts b/src/types/onyx/ReimbursementAccountDraft.ts index cab1283943bc..5b3c604fdab6 100644 --- a/src/types/onyx/ReimbursementAccountDraft.ts +++ b/src/types/onyx/ReimbursementAccountDraft.ts @@ -1,3 +1,5 @@ +import type * as OnyxTypes from './index'; + type OnfidoData = Record; type BankAccountStepProps = { @@ -57,5 +59,7 @@ type ReimbursementAccountProps = { type ReimbursementAccountDraft = BankAccountStepProps & CompanyStepProps & RequestorStepProps & ACHContractStepProps & ReimbursementAccountProps; +type ReimbursementAccountFormDraft = ReimbursementAccountDraft & OnyxTypes.Form; + export default ReimbursementAccountDraft; -export type {ACHContractStepProps, RequestorStepProps, OnfidoData, BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps}; +export type {ACHContractStepProps, RequestorStepProps, OnfidoData, BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps, ReimbursementAccountFormDraft}; diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts index b1571e7514e4..90f25ab6a8cb 100644 --- a/src/types/onyx/Report.ts +++ b/src/types/onyx/Report.ts @@ -121,6 +121,7 @@ type Report = { visibleChatMemberAccountIDs?: number[]; total?: number; currency?: string; + errors?: OnyxCommon.Errors; managerEmail?: string; parentReportActionIDs?: number[]; errorFields?: OnyxCommon.ErrorFields; @@ -158,6 +159,7 @@ type Report = { updateReportInLHN?: boolean; privateNotes?: Record; isLoadingPrivateNotes?: boolean; + selected?: boolean; /** If the report contains reportFields, save the field id and its value */ reportFields?: Record; @@ -165,4 +167,4 @@ type Report = { export default Report; -export type {NotificationPreference, WriteCapability}; +export type {NotificationPreference, WriteCapability, Note}; diff --git a/src/types/onyx/ReportAction.ts b/src/types/onyx/ReportAction.ts index 3dbd4c1e3667..d2f0afad5b7a 100644 --- a/src/types/onyx/ReportAction.ts +++ b/src/types/onyx/ReportAction.ts @@ -175,7 +175,7 @@ type ReportActionBase = { /** Is this action pending? */ pendingAction?: OnyxCommon.PendingAction; - delegateAccountID?: string; + delegateAccountID?: number; /** Server side errors keyed by microtime */ errors?: OnyxCommon.Errors; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 8e54f97874e3..5b04cae58671 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -9,7 +9,7 @@ import type Credentials from './Credentials'; import type Currency from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; import type Download from './Download'; -import type {AddDebitCardForm, DateOfBirthForm} from './Form'; +import type {AddDebitCardForm, DateOfBirthForm, DisplayNameForm, IKnowATeacherForm, IntroSchoolPrincipalForm, NewRoomForm, PrivateNotesForm} from './Form'; import type Form from './Form'; import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import type {FundList} from './Fund'; @@ -40,6 +40,7 @@ import type RecentlyUsedTags from './RecentlyUsedTags'; import type RecentWaypoint from './RecentWaypoint'; import type ReimbursementAccount from './ReimbursementAccount'; import type ReimbursementAccountDraft from './ReimbursementAccountDraft'; +import type {ReimbursementAccountFormDraft} from './ReimbursementAccountDraft'; import type Report from './Report'; import type {ReportActions} from './ReportAction'; import type ReportAction from './ReportAction'; @@ -72,6 +73,7 @@ export type { Account, AccountData, AddDebitCardForm, + DisplayNameForm, BankAccount, BankAccountList, Beta, @@ -113,6 +115,7 @@ export type { RecentlyUsedTags, ReimbursementAccount, ReimbursementAccountDraft, + ReimbursementAccountFormDraft, Report, ReportAction, ReportActionReactions, @@ -144,4 +147,8 @@ export type { PolicyReportField, PolicyReportFields, RecentlyUsedReportFields, + NewRoomForm, + IKnowATeacherForm, + IntroSchoolPrincipalForm, + PrivateNotesForm, }; diff --git a/src/types/utils/CustomRefObject.ts b/src/types/utils/CustomRefObject.ts deleted file mode 100644 index 13bb0f27a42e..000000000000 --- a/src/types/utils/CustomRefObject.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type {RefObject} from 'react'; - -type CustomRefObject = RefObject & {onselectstart: () => boolean}; - -export default CustomRefObject; diff --git a/src/utils/times.ts b/src/utils/times.ts new file mode 100644 index 000000000000..1dc97eb74659 --- /dev/null +++ b/src/utils/times.ts @@ -0,0 +1,6 @@ +function times(n: number, func: (index: number) => TReturnType = (i) => i as TReturnType): TReturnType[] { + // eslint-disable-next-line @typescript-eslint/naming-convention + return Array.from({length: n}).map((_, i) => func(i)); +} + +export default times; diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 8c7be011502d..a142530d4388 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -2,7 +2,7 @@ This directory contains the scripts and configuration files for running the performance regression tests. These tests are called E2E tests because they -run the app on a real device (physical or emulated). +run the actual app on a real device (physical or emulated). ![Example of a e2e test run](https://raw.githubusercontent.com/hannojg/expensify-app/5f945c25e2a0650753f47f3f541b984f4d114f6d/e2e/example.gif) @@ -116,6 +116,18 @@ from one test (e.g. measuring multiple things at the same time). To finish a test call `E2EClient.submitTestDone()`. +## Network calls + +Network calls can add a variance to the test results. To mitigate this in the past we used to provide mocks for the API +calls. However, this is not a realistic scenario, as we want to test the app in a realistic environment. + +Now we have a module called `NetworkInterceptor`. The interceptor will intercept all network calls and will +cache the request and response. The next time the same request is made, it will return the cached response. + +When writing a test you usually don't need to care about this, as the interceptor is enabled by default. +However, look out for "!!! Missed cache hit for url" logs when developing your test. This can indicate a bug +with the NetworkInterceptor where a request should have been cached but wasn't (which would introduce variance in your test!). + ## Android specifics diff --git a/tests/e2e/config.js b/tests/e2e/config.js index 41c1668fb6ba..a7447a29c954 100644 --- a/tests/e2e/config.js +++ b/tests/e2e/config.js @@ -1,10 +1,5 @@ const OUTPUT_DIR = process.env.WORKING_DIRECTORY || './tests/e2e/results'; -/** - * @typedef TestConfig - * @property {string} name - */ - // add your test name here … const TEST_NAMES = { AppStartTime: 'App start time', @@ -31,6 +26,10 @@ module.exports = { ENTRY_FILE: 'src/libs/E2E/reactNativeLaunchingTest.ts', + // The path to the activity within the app that we want to launch. + // Note: even though we have different package _names_, this path doesn't change. + ACTIVITY_PATH: 'com.expensify.chat.MainActivity', + // The port of the testing server that communicates with the app SERVER_PORT: 4723, @@ -78,9 +77,13 @@ module.exports = { reportScreen: { autoFocus: true, }, + // Crowded Policy (Do Not Delete) Report, has a input bar available: + reportID: '8268282951170052', }, [TEST_NAMES.ChatOpening]: { name: TEST_NAMES.ChatOpening, + // #announce Chat with many messages + reportID: '5421294415618529', }, }, }; diff --git a/tests/e2e/server/index.js b/tests/e2e/server/index.js index 4c2e00126fd5..c2365f259bb7 100644 --- a/tests/e2e/server/index.js +++ b/tests/e2e/server/index.js @@ -84,6 +84,7 @@ const createServerInstance = () => { const [testDoneListeners, addTestDoneListener] = createListenerState(); let activeTestConfig; + const networkCache = {}; /** * @param {TestConfig} testConfig @@ -146,6 +147,39 @@ const createServerInstance = () => { break; } + case Routes.testGetNetworkCache: { + getPostJSONRequestData(req, res).then((data) => { + const appInstanceId = data && data.appInstanceId; + if (!appInstanceId) { + res.statusCode = 400; + res.end('Invalid request missing appInstanceId'); + return; + } + + const cachedData = networkCache[appInstanceId] || {}; + res.end(JSON.stringify(cachedData)); + }); + + break; + } + + case Routes.testUpdateNetworkCache: { + getPostJSONRequestData(req, res).then((data) => { + const appInstanceId = data && data.appInstanceId; + const cache = data && data.cache; + if (!appInstanceId || !cache) { + res.statusCode = 400; + res.end('Invalid request missing appInstanceId or cache'); + return; + } + + networkCache[appInstanceId] = cache; + res.end('ok'); + }); + + break; + } + default: res.statusCode = 404; res.end('Page not found!'); diff --git a/tests/e2e/server/routes.js b/tests/e2e/server/routes.js index 84fc2f89fd9b..0d23866ec808 100644 --- a/tests/e2e/server/routes.js +++ b/tests/e2e/server/routes.js @@ -10,4 +10,10 @@ module.exports = { // Commands to execute from the host machine (there are pre-defined types like scroll or type) testNativeCommand: '/test_native_command', + + // Updates the network cache + testUpdateNetworkCache: '/test_update_network_cache', + + // Gets the network cache + testGetNetworkCache: '/test_get_network_cache', }; diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index 880e8641dd6f..98faff32397d 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -335,7 +335,9 @@ const runTests = async () => { await killApp('android', config.MAIN_APP_PACKAGE); Logger.log('Starting main app'); - await launchApp('android', config.MAIN_APP_PACKAGE); + await launchApp('android', config.MAIN_APP_PACKAGE, config.ACTIVITY_PATH, { + mockNetwork: true, + }); // Wait for a test to finish by waiting on its done call to the http server try { @@ -359,7 +361,9 @@ const runTests = async () => { await killApp('android', config.MAIN_APP_PACKAGE); Logger.log('Starting delta app'); - await launchApp('android', config.DELTA_APP_PACKAGE); + await launchApp('android', config.DELTA_APP_PACKAGE, config.ACTIVITY_PATH, { + mockNetwork: true, + }); // Wait for a test to finish by waiting on its done call to the http server try { diff --git a/tests/e2e/utils/launchApp.js b/tests/e2e/utils/launchApp.js index e0726d081086..f63e2e71cd8b 100644 --- a/tests/e2e/utils/launchApp.js +++ b/tests/e2e/utils/launchApp.js @@ -1,11 +1,15 @@ -const {APP_PACKAGE} = require('../config'); +/* eslint-disable rulesdir/prefer-underscore-method */ +const {APP_PACKAGE, ACTIVITY_PATH} = require('../config'); const execAsync = require('./execAsync'); -module.exports = function (platform = 'android', packageName = APP_PACKAGE) { +module.exports = function (platform = 'android', packageName = APP_PACKAGE, activityPath = ACTIVITY_PATH, launchArgs = {}) { if (platform !== 'android') { throw new Error(`launchApp() missing implementation for platform: ${platform}`); } // Use adb to start the app - return execAsync(`adb shell monkey -p ${packageName} -c android.intent.category.LAUNCHER 1`); + const launchArgsString = Object.keys(launchArgs) + .map((key) => `${typeof launchArgs[key] === 'boolean' ? '--ez' : '--es'} ${key} ${launchArgs[key]}`) + .join(' '); + return execAsync(`adb shell am start -n ${packageName}/${activityPath} ${launchArgsString}`); }; diff --git a/tests/perf-test/README.md b/tests/perf-test/README.md index a9b1643d191d..35af9dc35b7d 100644 --- a/tests/perf-test/README.md +++ b/tests/perf-test/README.md @@ -60,6 +60,7 @@ We use Reassure for monitoring performance regression. It helps us check if our - Investigate the code changes that might be causing this and address them to maintain a stable render count. More info [here](https://github.com/Expensify/App/blob/fe9e9e3e31bae27c2398678aa632e808af2690b5/tests/perf-test/README.md?plain=1#L32). - It is important to run Reassure tests locally and see if our changes caused a regression. + - One of the potential factors that may influence variation in the number of renders is adding unnecesary providers to the component we want to test using `````` . Ensure that all providers are necessary for running the test. ## What can be tested (scenarios) diff --git a/tests/perf-test/SearchPage.perf-test.js b/tests/perf-test/SearchPage.perf-test.js index 046d651469f1..e948ade014dc 100644 --- a/tests/perf-test/SearchPage.perf-test.js +++ b/tests/perf-test/SearchPage.perf-test.js @@ -5,11 +5,7 @@ import {measurePerformance} from 'reassure'; import _ from 'underscore'; import SearchPage from '@pages/SearchPage'; import ComposeProviders from '../../src/components/ComposeProviders'; -import {LocaleContextProvider} from '../../src/components/LocaleContextProvider'; import OnyxProvider from '../../src/components/OnyxProvider'; -import {CurrentReportIDContextProvider} from '../../src/components/withCurrentReportID'; -import {KeyboardStateProvider} from '../../src/components/withKeyboardState'; -import {WindowDimensionsProvider} from '../../src/components/withWindowDimensions'; import CONST from '../../src/CONST'; import ONYXKEYS from '../../src/ONYXKEYS'; import createCollection from '../utils/collections/createCollection'; @@ -81,7 +77,7 @@ afterEach(() => { function SearchPageWrapper(args) { return ( - + { .then(() => measurePerformance(, {scenario, runs})); }); -test.skip('[Search Page] should search in options list', async () => { +test('[Search Page] should search in options list', async () => { const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock(); const scenario = async () => { @@ -156,10 +152,6 @@ test.skip('[Search Page] should search in options list', async () => { fireEvent.changeText(input, mockedPersonalDetails['88'].login); await act(triggerTransitionEnd); await screen.findByText(mockedPersonalDetails['88'].login); - - fireEvent.changeText(input, mockedPersonalDetails['45'].login); - await act(triggerTransitionEnd); - await screen.findByText(mockedPersonalDetails['45'].login); }; const navigation = {addListener}; @@ -177,16 +169,16 @@ test.skip('[Search Page] should search in options list', async () => { .then(() => measurePerformance(, {scenario, runs})); }); -test.skip('[Search Page] should click on list item', async () => { +test('[Search Page] should click on list item', async () => { const {triggerTransitionEnd, addListener} = TestHelper.createAddListenerMock(); const scenario = async () => { await screen.findByTestId('SearchPage'); const input = screen.getByTestId('options-selector-input'); - fireEvent.changeText(input, mockedPersonalDetails['6'].login); + fireEvent.changeText(input, mockedPersonalDetails['4'].login); await act(triggerTransitionEnd); - const optionButton = await screen.findByText(mockedPersonalDetails['6'].login); + const optionButton = await screen.findByText(mockedPersonalDetails['4'].login); fireEvent.press(optionButton); }; diff --git a/tests/unit/DateUtilsTest.js b/tests/unit/DateUtilsTest.js index 7480da456d7f..a752eea1a990 100644 --- a/tests/unit/DateUtilsTest.js +++ b/tests/unit/DateUtilsTest.js @@ -213,4 +213,35 @@ describe('DateUtils', () => { }); }); }); + + describe('getLastBusinessDayOfMonth', () => { + const scenarios = [ + { + // Last business day of May in 2025 + inputDate: new Date(2025, 4), + expectedResult: 30, + }, + { + // Last business day of February in 2024 + inputDate: new Date(2024, 2), + expectedResult: 29, + }, + { + // Last business day of January in 2024 + inputDate: new Date(2024, 0), + expectedResult: 31, + }, + { + // Last business day of September in 2023 + inputDate: new Date(2023, 8), + expectedResult: 29, + }, + ]; + + test.each(scenarios)('returns a last business day based on the input date', ({inputDate, expectedResult}) => { + const lastBusinessDay = DateUtils.getLastBusinessDayOfMonth(inputDate); + + expect(lastBusinessDay).toEqual(expectedResult); + }); + }); }); diff --git a/tests/unit/times.ts b/tests/unit/times.ts new file mode 100644 index 000000000000..bc601b40be14 --- /dev/null +++ b/tests/unit/times.ts @@ -0,0 +1,33 @@ +import times from '@src/utils/times'; + +describe('times', () => { + it('should create an array of n elements', () => { + const result = times(3); + expect(result).toEqual([0, 1, 2]); + }); + + it('should create an array of n elements with values from the function', () => { + const result = times(3, (i) => i * 2); + expect(result).toEqual([0, 2, 4]); + }); + + it('should create an empty array if n is 0', () => { + const result = times(0); + expect(result).toEqual([]); + }); + + it('should create an array of undefined if no function is provided', () => { + const result = times(3, () => undefined); + expect(result).toEqual([undefined, undefined, undefined]); + }); + + it('should create an array of n elements with string values from the function', () => { + const result = times(3, (i) => `item ${i}`); + expect(result).toEqual(['item 0', 'item 1', 'item 2']); + }); + + it('should create an array of n elements with constant string value', () => { + const result = times(3, () => 'constant'); + expect(result).toEqual(['constant', 'constant', 'constant']); + }); +}); diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js index 67831bd32bca..6c72558e5df3 100644 --- a/tests/utils/LHNTestUtils.js +++ b/tests/utils/LHNTestUtils.js @@ -262,6 +262,7 @@ function getFakePolicy(id = 1, name = 'Workspace-Test-001') { submitsTo: 123456, defaultBillable: false, disabledFields: {defaultBillable: true, reimbursable: false}, + approvalMode: 'BASIC', }; } diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts index acdd22253173..8547c171c7a7 100644 --- a/tests/utils/collections/policies.ts +++ b/tests/utils/collections/policies.ts @@ -26,5 +26,6 @@ export default function createRandomPolicy(index: number): Policy { errors: {}, customUnits: {}, errorFields: {}, + approvalMode: rand(Object.values(CONST.POLICY.APPROVAL_MODE)), }; } diff --git a/tests/utils/collections/reportActions.ts b/tests/utils/collections/reportActions.ts index 747cbe5b6a1a..cc258e89c041 100644 --- a/tests/utils/collections/reportActions.ts +++ b/tests/utils/collections/reportActions.ts @@ -65,7 +65,7 @@ export default function createRandomReportAction(index: number): ReportAction { shouldShow: randBoolean(), lastModified: randPastDate().toISOString(), pendingAction: rand(Object.values(CONST.RED_BRICK_ROAD_PENDING_ACTION)), - delegateAccountID: index.toString(), + delegateAccountID: index, errors: {}, isAttachment: randBoolean(), };