diff --git a/.eslintrc.js b/.eslintrc.js
index 6194ccd39d3f..f852c970f85c 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -14,6 +14,11 @@ const restrictedImportPaths = [
importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'],
message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from 'src/components/Pressable' instead.",
},
+ {
+ name: 'awesome-phonenumber',
+ importNames: ['parsePhoneNumber'],
+ message: "Please use '@libs/PhoneNumber' instead.",
+ },
{
name: 'react-native-safe-area-context',
importNames: ['useSafeAreaInsets', 'SafeAreaConsumer', 'SafeAreaInsetsContext'],
diff --git a/.github/scripts/createDocsRoutes.js b/.github/scripts/createDocsRoutes.js
index 6604a9d207fa..7c9ac532850d 100644
--- a/.github/scripts/createDocsRoutes.js
+++ b/.github/scripts/createDocsRoutes.js
@@ -16,7 +16,12 @@ const platformNames = {
* @returns {String}
*/
function toTitleCase(str) {
- return str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1));
+ return _.map(str.split(' '), (word, i) => {
+ if (i !== 0 && (word.toLowerCase() === 'a' || word.toLowerCase() === 'the' || word.toLowerCase() === 'and')) {
+ return word.toLowerCase();
+ }
+ return word.charAt(0).toUpperCase() + word.substring(1);
+ }).join(' ');
}
/**
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index 0951b194430b..156b9764bcca 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -30,9 +30,9 @@ jobs:
# - git diff is used to see the files that were added on this branch
# - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main
# - wc counts the words in the result of the intersection
- count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
+ count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
if [ "$count_new_js" -gt "0" ]; then
- echo "ERROR: Found new JavaScript files in the /src/libs, /src/hooks, /src/styles, or /src/languages directories; use TypeScript instead."
+ echo "ERROR: Found new JavaScript files in the project; use TypeScript instead."
exit 1
fi
env:
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 645f36ef876a..fea9cdad0a90 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -96,8 +96,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001042400
- versionName "1.4.24-0"
+ versionCode 1001042404
+ versionName "1.4.24-4"
}
flavorDimensions "default"
diff --git a/contributingGuides/TS_STYLE.md b/contributingGuides/TS_STYLE.md
index b60c28147a45..b96f24a7c949 100644
--- a/contributingGuides/TS_STYLE.md
+++ b/contributingGuides/TS_STYLE.md
@@ -671,7 +671,7 @@ declare module "external-library-name" {
> This section contains instructions that are applicable during the migration.
-- 🚨 DO NOT write new code in TypeScript yet. The only time you write TypeScript code is when the file you're editing has already been migrated to TypeScript by the migration team, or when you need to add new files under `src/libs`, `src/hooks`, `src/styles`, and `src/languages` directories. This guideline will be updated once it's time for new code to be written in TypeScript. If you're doing a major overhaul or refactoring of particular features or utilities of App and you believe it might be beneficial to migrate relevant code to TypeScript as part of the refactoring, please ask in the #expensify-open-source channel about it (and prefix your message with `TS ATTENTION:`).
+- 🚨 Any new files under `src/` directory MUST be created in TypeScript now! New files in other directories (e.g. `tests/`, `desktop/`) can be created in TypeScript, if desired.
- If you're migrating a module that doesn't have a default implementation (i.e. `index.ts`, e.g. `getPlatform`), convert `index.website.js` to `index.ts`. Without `index.ts`, TypeScript cannot get type information where the module is imported.
diff --git a/desktop/package-lock.json b/desktop/package-lock.json
index 6f62e6a5ba00..268c706bedef 100644
--- a/desktop/package-lock.json
+++ b/desktop/package-lock.json
@@ -10,7 +10,7 @@
"electron-context-menu": "^2.3.0",
"electron-log": "^4.4.8",
"electron-serve": "^1.2.0",
- "electron-updater": "^6.1.6",
+ "electron-updater": "^6.1.7",
"node-machine-id": "^1.1.12"
}
},
@@ -50,9 +50,9 @@
}
},
"node_modules/builder-util-runtime": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.2.tgz",
- "integrity": "sha512-Or2/ycVYRGQ876hKMfiz2Ghgzh3WllgPW75jqt1Ta2a5wprpnziFrHpQ9eUq6/ScsVXMnG4PmQqlMsE9NFg8DQ==",
+ "version": "9.2.3",
+ "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.3.tgz",
+ "integrity": "sha512-FGhkqXdFFZ5dNC4C+yuQB9ak311rpGAw+/ASz8ZdxwODCv1GGMWgLDeofRkdi0F3VCHQEWy/aXcJQozx2nOPiw==",
"dependencies": {
"debug": "^4.3.4",
"sax": "^1.2.4"
@@ -156,11 +156,11 @@
}
},
"node_modules/electron-updater": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.6.tgz",
- "integrity": "sha512-G2bO72i7kv+bVdBAjq6lQn8zkZ3wMRRjxBD4TGBjB77UiuMDUBeP45YAs4y08udPzttGW2qzpnbOUJY5RYWZfw==",
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.7.tgz",
+ "integrity": "sha512-SNOhYizjkm4ET+Y8ilJyUzcVsFJDtINzVN1TyHnZeMidZEG3YoBebMyXc/J6WSiXdUaOjC7ngekN6rNp6ardHA==",
"dependencies": {
- "builder-util-runtime": "9.2.2",
+ "builder-util-runtime": "9.2.3",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
@@ -467,9 +467,9 @@
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="
},
"builder-util-runtime": {
- "version": "9.2.2",
- "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.2.tgz",
- "integrity": "sha512-Or2/ycVYRGQ876hKMfiz2Ghgzh3WllgPW75jqt1Ta2a5wprpnziFrHpQ9eUq6/ScsVXMnG4PmQqlMsE9NFg8DQ==",
+ "version": "9.2.3",
+ "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.3.tgz",
+ "integrity": "sha512-FGhkqXdFFZ5dNC4C+yuQB9ak311rpGAw+/ASz8ZdxwODCv1GGMWgLDeofRkdi0F3VCHQEWy/aXcJQozx2nOPiw==",
"requires": {
"debug": "^4.3.4",
"sax": "^1.2.4"
@@ -541,11 +541,11 @@
"integrity": "sha512-zJG3wisMrDn2G/gnjrhyB074COvly1FnS0U7Edm8bfXLB8MYX7UtwR9/y2LkFreYjzQHm9nEbAfgCmF+9M9LHQ=="
},
"electron-updater": {
- "version": "6.1.6",
- "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.6.tgz",
- "integrity": "sha512-G2bO72i7kv+bVdBAjq6lQn8zkZ3wMRRjxBD4TGBjB77UiuMDUBeP45YAs4y08udPzttGW2qzpnbOUJY5RYWZfw==",
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.7.tgz",
+ "integrity": "sha512-SNOhYizjkm4ET+Y8ilJyUzcVsFJDtINzVN1TyHnZeMidZEG3YoBebMyXc/J6WSiXdUaOjC7ngekN6rNp6ardHA==",
"requires": {
- "builder-util-runtime": "9.2.2",
+ "builder-util-runtime": "9.2.3",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
diff --git a/desktop/package.json b/desktop/package.json
index 7545e4b57dba..563a45851eb2 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -7,7 +7,7 @@
"electron-context-menu": "^2.3.0",
"electron-log": "^4.4.8",
"electron-serve": "^1.2.0",
- "electron-updater": "^6.1.6",
+ "electron-updater": "^6.1.7",
"node-machine-id": "^1.1.12"
},
"author": "Expensify, Inc.",
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements.md
similarity index 96%
rename from docs/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements.md
rename to docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements.md
index 5a5827149a4f..2ff74760b376 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/International-Reimbursements.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/Global-Reimbursements.md
@@ -79,7 +79,7 @@ Examples of additional requested information:
## How many people can send reimbursements internationally?
-Once your company is authorized to send global payments, only the individual who went through the verification can reimburse international employees.
+Once your company is authorized to send global payments, the individual who verified the bank account can share it with additional admins on the workspace. That way, multiple workspace members can send international reimbursements.
## How long does it take to verify an account for international payments?
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md
deleted file mode 100644
index 85b534338b53..000000000000
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Export-To-GL-Accounts.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Export to GL Accounts
-description: Export to GL Accounts
----
-## Resource Coming Soon!
diff --git a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md b/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md
deleted file mode 100644
index 868ade604451..000000000000
--- a/docs/articles/expensify-classic/expensify-card/Expensify-Card-Perks.md
+++ /dev/null
@@ -1,193 +0,0 @@
----
-title: Expensify Card Perks
-description: Get the most out of your Expensify Card with exclusive perks!
----
-
-
-# Overview
-The Expensify Visa® Commercial Card is packed with perks, both native to our Card program and through exclusive discounts with partnering solutions. The Expensify Card’s primary perks include:
-- Unbeatable cash back incentive with each USD purchase
-
-Below, we’ll cover all of our exclusive offers in more detail and how to claim discounts with our partners.
-
-# Partner Specific Perks
-
-## Amazon AWS
-Whether you are a two-person startup launching a new company or a venture-backed startup, we all could use a little relief in these difficult times. AWS Activate provides you with access to the resources you need to quickly get started on AWS - including free credits, technical support, and training.
-
-All Expensify customers that have adopted The Expensify Card qualify when they add their Expensify Card for billing with AWS!
-
-**Apply now by going [to this link](https://aws.amazon.com/startups/credits) and using the OrgID: 0qyIA (Case Sensitive)**
-
-The full details on the AWS Activate program can be found in AWS's [terms & conditions](https://aws.amazon.com/activate/terms/) and the [Activate FAQs](https://aws.amazon.com/startups/faq).
-
-## Stripe
-Whether you’re creating a subscription service, an on-demand marketplace, or an e-commerce store, Stripe’s integrated payments platform helps you build and scale your business globally.
-
-**Receive waived Stripe fees, if you’re new to Stripe, for your first $5,000 in processed payments.**
-
-**How to redeem:** Sign up for Stripe using your Expensify Card.
-
-## Lamar Advertising
-Lamar provides out-of-home advertising space for clients on billboards, digital, airport displays, transit, and highway logo signs.
-
-**Receive at minimum a 10% discount on your first campaign.**
-
-**How do redeem:** Contact Expensify’s dedicated account manager, Lisa Kane, and mention you’re an Expensify cardholder.
-
-Email: lkane@lamar.com
-
-## Carta
-Simplify equity management with Carta.
-
-**Receive a 20% first-year discount and waived implementation fees for Carta.**
-
-**How to redeem:** Sign up using your Expensify Card
-
-## Pilot
-Pilot specializes in bookkeeping and tax prep for startups and e-commerce providers. When you work with Pilot, you’re paired with a dedicated finance expert who takes the work off your plate and is on hand to answer your questions.
-
-**20% off the first 6-months of Pilot Core**
-
-**How to redeem:** Sign-up using your Expensify Card.
-
-## Spotlight Reporting
-The integrated cloud reporting and forecasting tool that allows you to create insights for better business decisions. Designed by Accountants, for Accountants
-
-**20% discount off your subscription for the first 6 months, plus one free seat to Spotlight Certification.**
-
-**How to redeem:** Sign up using your Expensify Card.
-
-## Guideline
-Guideline's full-service 401(k) plans make it easier and more affordable to offer your employees the retirement benefits they deserve.
-
-**Receive 3 months free.**
-
-**How to redeem:** Sign up using your Expensify Card.
-
-## Gusto
-Gusto's people platform helps businesses like yours onboard, pay, insure, and support your hardworking team. Payroll, benefits, and more
-
-**3 months free service**
-
-**How to redeem:** Sign-up using your Expensify Card.
-
-## QuickBooks Online
-QuickBooks accounting software helps keep your books accurate and up to date, automatically such as: invoicing, cashflow, expense tracking, and more.
-
-**Receive 30% off QuickBooks Online for the first 12 months.**
-
-**How to redeem:** Sign up using your Expensify Card.
-
-## Highfive
-Highfive improves the ease and quality of intelligent in-room video conferencing.
-
-**Receive 50% off the Highfive Select starter package. 10% off the Highfive Premium Package.**
-
-**How to redeem:** Sign-up with your Expensify Card.
-
-## Zendesk
-**$436 in credits for Zendesk Suite products per month for the first year**
-
-How to redeem:
-1. Reach out to startups@zendesk.com with the following: "Expensify asked me to send an email regarding the Zendesk promotion”. You'll receive a code you use in step 5 below.
-2. Start a Zendesk Trial (can be a suite trial or something different) in USD. If your trial is not in USD, contact Zendesk. If you already have a current trial, the code applies and can be used.
-3. From inside your Zendesk trial, click the Buy Now button.
-4. Select your chosen plan with monthly billing. The $436 monthly credit works for up to 4 licenses of the Suite, but the code can also apply $436 to any alternative monthly plan selection.
-5. Enter the promo code that was provided to you in step 1 after emailing Zendesk.
-6. Complete the checkout process and note that once your free credit runs out after 12 monthly billing periods, you will be charged for your next month with Zendesk.
-
-## Xero
-Accounting Software With Everything You Need To Run Your Business Beautifully. Smart Online Accounting. Bank Connections
-
-**U.S. residents get 50% off Xero for six months.**
-
-Head to [this](https://apps.xero.com/us/app/expensify?xtid=x30expensify&utm_source=expensify&utm_medium=web&utm_campaign=cardoffer) page and sign-up for Xero using your Expensify Card!
-
-## Freshworks
-Boost your startup journey with leading customer and employee engagement solutions from Freshworks including CRM, livechat, support, marketing automation, ITSM and HRMS.
-
-How to receive $4,000 in credits on Freshworks products:
-
-[Click here](https://www.freshworks.com/partners/startup-program/expensify-card/) and fill out the form and enter your details, Freshbooks will recognize your company as an Expensify Card customer automatically.
-
-## Slack
-**Receive 25% off for the first year:** You’ll enjoy premium features like unlimited messaging and apps, Slack Connect channels, group video calls, priority support, and much more. It’s all just a click away.
-
-**How to redeem with your Expensify Card:** [Click here](https://slack.com/promo/partner?remote_promo=ead919f5) to redeem the offer by using your Expensify Card to manage the billing.
-
-## Deel.com
-Deel makes onboarding international team members in 150 different countries painless. Quickly bring on contractors or hire employees in seconds with Deel as your employer of record (EOR). It’s one simple, powerful dashboard that houses everything you need. Finalize contracts, pay employees, and manage all your payroll data in one place seamlessly.
-
-**How to redeem 3 months free, then 30% off the rest of the year with Deel.com:** Click [here](https://www.deel.com/partners/expensify) and sign up using your Expensify Card.
-
-## Snap
-**$1,000 in Snap credits**
-Whether you're looking to increase online sales, drive app installs, or get more leads, Snapchat can connect you with a unique mobile audience primed to take action. For a limited time, spend $1000 in Snapchat's Ads Manager and receive $1000 in ad credit to use towards your next campaign!
-
-**How to redeem with your Expensify Card:** Click on `create ad` or `request a call` by clicking here. Enter your details to set up your account if you don't already have one.Add the Expensify Card as your payment option for your Snap Business account.Credits will be automatically placed in your account once you've reached $1,000 in spend.
-
-## Aircall
-Aircall is the cloud-based phone system of choice for modern brands. Aircall allows sales and support teams to have meaningful and efficient phone conversations, and integrates with the most popular CRMs, Help desks, and business tools. Pricing is dependent on the number of users within the account. Discount could range from $270-$9,000+
-
-**2 Months Free**
-
-**How to redeem with your Expensify Card:**
-1. Click [here])(http://pages.aircall.io/Expensify-RewardsPartnerReferral.html)
-2. Sign up for a demo
-3. Let our team know you're an Expensify customer
-
-## NetSuite
-NetSuite helps companies manage core business processes with a cloud-based ERP and accounting software. Expensify has a direct integration with NetSuite so that expenses are coded to your exact preference and data is always synchronized across the two systems.
-
-**10% OFF for the First Year**
-
-**How to redeem:**
-1. Fill out this [Google form](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fdocs.google.com%2Fforms%2Fd%2Fe%2F1FAIpQLSeiOzLrdO-MgqeEMwEqgdQoh4SJBi42MZME9ycHp4SQjlk3bQ%2Fviewform%3Fusp%3Dsf_link).
-2. An Expensify rep will make an introduction to a NetSuite sales rep to get the process started. This offer is only for prospective NetSuite customers. If you are currently a NetSuite customer, this promotion does not apply.
-3. Once you are set up and pay for your first year with NetSuite, we will send you a payment equal to 10% of your first year contract within three months of paying your first NetSuite invoice.
-
-## PagerDuty
-PagerDuty's Platform for Real-Time Operations integrates machine data & human intelligence to improve visibility & agility across organizations.
-
-**25% OFF**
-
-**How to redeem:**
-1. Sign-up using your Expensify Card
-2. Use the discount code EXPENSIFYPDTEAM for a 25% discount on the Team plan or EXPENSIFYPDBUSINESS for a 25% discount on the Business plan within the Cost Summary section upon checkout.
-
-## Typeform
-Typeform makes collecting and sharing information comfortable and conversational. It's a web-based platform you can use to create anything from surveys to apps, without needing to write a single line of code.
-
-**30% off annual premium and professional plans**
-
-**How to redeem with your Expensify Card:**
-1. Click on the 'Get Typeform` by [clicking here](https://try.typeform.com/expensify/?utm_source=expensify&utm_medium=referral&utm_campaign=expensify_integration&utm_content=directory)
-2. Enter your details and setup your free account
-3. Verify your email by clicking on the link that Typeform sends you
-4. Go through the on boarding flow within Tyepform
-5. Click on the 'Upgrade' button from within your workspace
-6. Select your plan
-7. Enter the coupon 'EXPENSIFY30' on the checkout page
-8. Click on 'Upgrade now' once you've filled out all of your payment details with your Expensify Card
-
-## Intercom
-Intercom builds a suite of messaging-first products for businesses to accelerate growth across the customer lifecycle.
-
-**3-months free service**
-
-**How to redeem:** Sign-up using your Expensify Card.
-
-## Talkspace
-Prescription management and personalized treatment from a network of licensed prescribers trained in mental healthcare. Therapists are licensed, verified and background-checked. Working with a Talkspace therapist will give you an unbiased, trained perspective and provide you with the guidance and tools to help you feel better. When it comes to your mental health, the right therapist makes all the difference.
-
-**$125 OFF Talkspace purchases**
-
-**How to redeem with your Expensify Card:** Use the code at EXPENSIFY at the time of checkout.
-
-## Stripe Atlas
-Stripe Atlas helps removes obstacles typically associated with starting a business so you can build your startup from anywhere in the world.
-
-**Receive $100 off Stripe Atlas and get access to a startup toolkit and special offers on additional Strip Atlas services.**
-
-**How to redeem:** Sign up with your Expensify Card.
diff --git a/docs/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.md b/docs/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.md
deleted file mode 100644
index e14fadbec915..000000000000
--- a/docs/articles/expensify-classic/expensify-partner-program/How-to-Join-the-ExpensifyApproved!-Partner-Program.md
+++ /dev/null
@@ -1,38 +0,0 @@
----
-title: ExpensifyApproved! Partner Program
-description: How to Join the ExpensifyApproved! Partner Program
----
-
-# Overview
-
-As trusted accountants and financial advisors, you strive to offer your clients the best tools available. Expensify is recognized as a leading, all-in-one expense and corporate card management platform suitable for clients of every size. By becoming an ExpensifyApproved! Partner, you unlock exclusive benefits for both you and your clientele.
-## Key Benefits
-Dedicated Partner Manager: Enjoy personalized assistance with an assigned Partner Manager post-course completion.
-Client Onboarding Support: A dedicated Client Onboarding Manager will aid in smooth transitions.
-Free Expensify Account: Complimentary access to our platform for your convenience.
-Revenue share (US-only): All partners receive 0.5% revenue share on client Expensify Card transactions. Keep this as a bonus or offer it to your clients as cash back.
-Exclusive CPA Card (US-only): Automated expense reconciliation from swipe to journal entry with the CPA Card.
-Special Pricing Offers (US-only): Avail partner-specific discounts for your clients and a revenue share from client Expensify Card transactions.
-Professional Growth (US-only): Earn 3 CPE credits after completing the ExpensifyApproved! University.
-Cobranded Marketing - Collaborate with your Partner Manager to craft custom marketing materials, case studies, and more.
-
-# How to join the ExpensifyApproved! Partner Program
-
-1. Enroll in ExpensifyApproved! University (EA!U)
-Visit university.expensify.com and enroll in the “Getting Started with Expensify” course.
-This course imparts the essentials of Expensify, ensuring you follow the best practices for client setups.
-
-2. Complete the course
-Grasp the core features and functionalities of Expensify.
-Ensure you're equipped to serve your clients using Expensify to its fullest.
-Once completed, you’ll be prompted to schedule a call with your Partner Manager. **This call is required to earn your certification.**
-
-3. Once you successfully complete the course, you'll unlock:
-- A dedicated Partner Manager - assigned to you after you have completed the course!
-- A dedicated Client Setup Specialist
-- Membership to the ExpensifyApproved! Partner Program.
-- A complimentary free Expensify account
-- Access to the exclusive CPA Card (US-only).
-- Partner-specific discounts to extend to your clients.
-- A 0.5% revenue share on client Expensify Card expenses (US-only)
-- 3 CPE credits (US-only).
diff --git a/docs/articles/expensify-classic/getting-started/Tips-And-Tricks.md b/docs/articles/expensify-classic/getting-started/Tips-and-Tricks.md
similarity index 100%
rename from docs/articles/expensify-classic/getting-started/Tips-And-Tricks.md
rename to docs/articles/expensify-classic/getting-started/Tips-and-Tricks.md
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 443e35990496..2dc6d59d5e4f 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.24.0
+ 1.4.24.4
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 7756d9837e9a..5f68b5ba2579 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.24.0
+ 1.4.24.4
diff --git a/package-lock.json b/package-lock.json
index b1af06e89fed..5416b5d4ea19 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.24-0",
+ "version": "1.4.24-4",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.24-0",
+ "version": "1.4.24-4",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index 192f42ee45fc..7753368b6632 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.24-0",
+ "version": "1.4.24-4",
"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.",
diff --git a/patches/@react-native+virtualized-lists+0.72.8.patch b/patches/@react-native+virtualized-lists+0.72.8.patch
new file mode 100644
index 000000000000..a3bef95f1618
--- /dev/null
+++ b/patches/@react-native+virtualized-lists+0.72.8.patch
@@ -0,0 +1,34 @@
+diff --git a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
+index ef5a3f0..2590edd 100644
+--- a/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
++++ b/node_modules/@react-native/virtualized-lists/Lists/VirtualizedList.js
+@@ -125,19 +125,6 @@ function windowSizeOrDefault(windowSize: ?number) {
+ return windowSize ?? 21;
+ }
+
+-function findLastWhere(
+- arr: $ReadOnlyArray,
+- predicate: (element: T) => boolean,
+-): T | null {
+- for (let i = arr.length - 1; i >= 0; i--) {
+- if (predicate(arr[i])) {
+- return arr[i];
+- }
+- }
+-
+- return null;
+-}
+-
+ /**
+ * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist)
+ * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better
+@@ -1019,7 +1006,8 @@ class VirtualizedList extends StateSafePureComponent {
+ const spacerKey = this._getSpacerKey(!horizontal);
+
+ const renderRegions = this.state.renderMask.enumerateRegions();
+- const lastSpacer = findLastWhere(renderRegions, r => r.isSpacer);
++ const lastRegion = renderRegions[renderRegions.length - 1];
++ const lastSpacer = lastRegion?.isSpacer ? lastRegion : null;
+
+ for (const section of renderRegions) {
+ if (section.isSpacer) {
diff --git a/patches/react-native-web+0.19.9+004+fixLastSpacer.patch b/patches/react-native-web+0.19.9+004+fixLastSpacer.patch
new file mode 100644
index 000000000000..fc48c00094dc
--- /dev/null
+++ b/patches/react-native-web+0.19.9+004+fixLastSpacer.patch
@@ -0,0 +1,29 @@
+diff --git a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+index faeb323..68d740a 100644
+--- a/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
++++ b/node_modules/react-native-web/dist/vendor/react-native/VirtualizedList/index.js
+@@ -78,14 +78,6 @@ function scrollEventThrottleOrDefault(scrollEventThrottle) {
+ function windowSizeOrDefault(windowSize) {
+ return windowSize !== null && windowSize !== void 0 ? windowSize : 21;
+ }
+-function findLastWhere(arr, predicate) {
+- for (var i = arr.length - 1; i >= 0; i--) {
+- if (predicate(arr[i])) {
+- return arr[i];
+- }
+- }
+- return null;
+-}
+
+ /**
+ * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist)
+@@ -1119,7 +1111,8 @@ class VirtualizedList extends StateSafePureComponent {
+ _keylessItemComponentName = '';
+ var spacerKey = this._getSpacerKey(!horizontal);
+ var renderRegions = this.state.renderMask.enumerateRegions();
+- var lastSpacer = findLastWhere(renderRegions, r => r.isSpacer);
++ var lastRegion = renderRegions[renderRegions.length - 1];
++ var lastSpacer = lastRegion?.isSpacer ? lastRegion : null;
+ for (var _iterator = _createForOfIteratorHelperLoose(renderRegions), _step; !(_step = _iterator()).done;) {
+ var section = _step.value;
+ if (section.isSpacer) {
diff --git a/src/CONST.ts b/src/CONST.ts
index c6849db630f2..b1a6b6895de7 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1180,6 +1180,10 @@ const CONST = {
EXPENSIFY: 'Expensify',
VBBA: 'ACH',
},
+ ACTION: {
+ EDIT: 'edit',
+ CREATE: 'create',
+ },
DEFAULT_AMOUNT: 0,
TYPE: {
SEND: 'send',
@@ -3063,6 +3067,11 @@ const CONST = {
CAROUSEL: 3,
},
+ BRICK_ROAD: {
+ GBR: 'GBR',
+ RBR: 'RBR',
+ },
+
VIOLATIONS: {
ALL_TAG_LEVELS_REQUIRED: 'allTagLevelsRequired',
AUTO_REPORTED_REJECTED_EXPENSE: 'autoReportedRejectedExpense',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index e8a860582bb1..7538a16d1a2c 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -368,9 +368,9 @@ const ROUTES = {
getUrlWithBackToParam(`create/${iouType}/participants/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_SCAN: {
- route: 'create/:iouType/scan/:transactionID/:reportID',
- getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
- getUrlWithBackToParam(`create/${iouType}/scan/${transactionID}/${reportID}`, backTo),
+ route: ':action/:iouType/scan/:transactionID/:reportID',
+ getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') =>
+ getUrlWithBackToParam(`${action}/${iouType}/scan/${transactionID}/${reportID}`, backTo),
},
MONEY_REQUEST_STEP_TAG: {
route: 'create/:iouType/tag/:transactionID/:reportID',
diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js
index 1b4d350f7d4f..149dd7039151 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -383,7 +383,15 @@ function AttachmentModal(props) {
text: props.translate('common.replace'),
onSelected: () => {
closeModal();
- Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT));
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ CONST.IOU.TYPE.REQUEST,
+ props.transaction.transactionID,
+ props.report.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ );
},
});
}
diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
index fc3bf4659bd7..5da9c6981603 100644
--- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
+++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
@@ -107,7 +107,7 @@ function BaseAutoCompleteSuggestions(
keyExtractor={keyExtractor}
removeClippedSubviews={false}
showsVerticalScrollIndicator={innerHeight > rowHeight.value}
- extraData={highlightedSuggestionIndex}
+ extraData={[highlightedSuggestionIndex, renderSuggestionMenuItem]}
/>
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index fb72f0cc845f..f8b820d559b7 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -1,6 +1,6 @@
-import {useIsFocused} from '@react-navigation/native';
+import {useFocusEffect} from '@react-navigation/native';
import type {ForwardedRef} from 'react';
-import React, {useCallback} from 'react';
+import React, {memo, useCallback, useMemo, useRef} from 'react';
import type {GestureResponderEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
import {ActivityIndicator, View} from 'react-native';
import Icon from '@components/Icon';
@@ -8,7 +8,7 @@ import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Text from '@components/Text';
import withNavigationFallback from '@components/withNavigationFallback';
-import useActiveElement from '@hooks/useActiveElement';
+import useActiveElementRole from '@hooks/useActiveElementRole';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -118,6 +118,57 @@ type ButtonProps = (ButtonWithText | ChildrenProps) & {
accessibilityLabel?: string;
};
+type KeyboardShortcutComponentProps = Pick;
+
+const accessibilityRoles: string[] = Object.values(CONST.ACCESSIBILITY_ROLE);
+
+const KeyboardShortcutComponent = memo(
+ ({isDisabled = false, isLoading = false, onPress = () => {}, pressOnEnter, allowBubble, enterKeyEventListenerPriority}: KeyboardShortcutComponentProps) => {
+ const isFocused = useRef(false);
+ const activeElementRole = useActiveElementRole();
+
+ const shouldDisableEnterShortcut = useMemo(() => accessibilityRoles.includes(activeElementRole ?? '') && activeElementRole !== CONST.ACCESSIBILITY_ROLE.TEXT, [activeElementRole]);
+
+ useFocusEffect(
+ useCallback(() => {
+ isFocused.current = true;
+
+ return () => {
+ isFocused.current = false;
+ };
+ }, []),
+ );
+
+ const keyboardShortcutCallback = useCallback(
+ (event?: GestureResponderEvent | KeyboardEvent) => {
+ if (!validateSubmitShortcut(isDisabled, isLoading, event)) {
+ return;
+ }
+ onPress();
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [isDisabled, isLoading],
+ );
+
+ const config = useMemo(
+ () => ({
+ isActive: pressOnEnter && !shouldDisableEnterShortcut && isFocused.current,
+ shouldBubble: allowBubble,
+ priority: enterKeyEventListenerPriority,
+ shouldPreventDefault: false,
+ }),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [shouldDisableEnterShortcut],
+ );
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, config);
+
+ return null;
+ },
+);
+
+KeyboardShortcutComponent.displayName = 'KeyboardShortcutComponent';
+
function Button(
{
allowBubble = false,
@@ -164,27 +215,6 @@ function Button(
) {
const theme = useTheme();
const styles = useThemeStyles();
- const isFocused = useIsFocused();
- const activeElement = useActiveElement();
- const accessibilityRoles: string[] = Object.values(CONST.ACCESSIBILITY_ROLE);
- const shouldDisableEnterShortcut = accessibilityRoles.includes(activeElement?.role ?? '') && activeElement?.role !== CONST.ACCESSIBILITY_ROLE.TEXT;
-
- const keyboardShortcutCallback = useCallback(
- (event?: GestureResponderEvent | KeyboardEvent) => {
- if (!validateSubmitShortcut(isDisabled, isLoading, event)) {
- return;
- }
- onPress();
- },
- [isDisabled, isLoading, onPress],
- );
-
- useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, keyboardShortcutCallback, {
- isActive: pressOnEnter && !shouldDisableEnterShortcut && isFocused,
- shouldBubble: allowBubble,
- priority: enterKeyEventListenerPriority,
- shouldPreventDefault: false,
- });
const renderContent = () => {
if ('children' in rest) {
@@ -247,72 +277,82 @@ function Button(
};
return (
- {
- if (event?.type === 'click') {
- const currentTarget = event?.currentTarget as HTMLElement;
- currentTarget?.blur();
- }
-
- if (shouldEnableHapticFeedback) {
- HapticFeedback.press();
- }
- return onPress(event);
- }}
- onLongPress={(event) => {
- if (isLongPressDisabled) {
- return;
- }
- if (shouldEnableHapticFeedback) {
- HapticFeedback.longPress();
- }
- onLongPress(event);
- }}
- onPressIn={onPressIn}
- onPressOut={onPressOut}
- onMouseDown={onMouseDown}
- disabled={isLoading || isDisabled}
- wrapperStyle={[
- isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {},
- styles.buttonContainer,
- shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
- shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- style,
- ]}
- style={[
- styles.button,
- small ? styles.buttonSmall : undefined,
- medium ? styles.buttonMedium : undefined,
- large ? styles.buttonLarge : undefined,
- success ? styles.buttonSuccess : undefined,
- danger ? styles.buttonDanger : undefined,
- isDisabled && (success || danger) ? styles.buttonOpacityDisabled : undefined,
- isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
- shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
- shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined,
- innerStyles,
- ]}
- hoverStyle={[
- shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
- success && !isDisabled ? styles.buttonSuccessHovered : undefined,
- danger && !isDisabled ? styles.buttonDangerHovered : undefined,
- ]}
- id={id}
- accessibilityLabel={accessibilityLabel}
- role={CONST.ROLE.BUTTON}
- hoverDimmingValue={1}
- >
- {renderContent()}
- {isLoading && (
-
- )}
-
+ <>
+
+ {
+ if (event?.type === 'click') {
+ const currentTarget = event?.currentTarget as HTMLElement;
+ currentTarget?.blur();
+ }
+
+ if (shouldEnableHapticFeedback) {
+ HapticFeedback.press();
+ }
+ return onPress(event);
+ }}
+ onLongPress={(event) => {
+ if (isLongPressDisabled) {
+ return;
+ }
+ if (shouldEnableHapticFeedback) {
+ HapticFeedback.longPress();
+ }
+ onLongPress(event);
+ }}
+ onPressIn={onPressIn}
+ onPressOut={onPressOut}
+ onMouseDown={onMouseDown}
+ disabled={isLoading || isDisabled}
+ wrapperStyle={[
+ isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {},
+ styles.buttonContainer,
+ shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
+ shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
+ style,
+ ]}
+ style={[
+ styles.button,
+ small ? styles.buttonSmall : undefined,
+ medium ? styles.buttonMedium : undefined,
+ large ? styles.buttonLarge : undefined,
+ success ? styles.buttonSuccess : undefined,
+ danger ? styles.buttonDanger : undefined,
+ isDisabled && (success || danger) ? styles.buttonOpacityDisabled : undefined,
+ isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
+ shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
+ shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined,
+ innerStyles,
+ ]}
+ hoverStyle={[
+ shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
+ success && !isDisabled ? styles.buttonSuccessHovered : undefined,
+ danger && !isDisabled ? styles.buttonDangerHovered : undefined,
+ ]}
+ id={id}
+ accessibilityLabel={accessibilityLabel}
+ role={CONST.ROLE.BUTTON}
+ hoverDimmingValue={1}
+ >
+ {renderContent()}
+ {isLoading && (
+
+ )}
+
+ >
);
}
diff --git a/src/components/Composer/types.ts b/src/components/Composer/types.ts
index bfdcb6715d40..d8d88970ea78 100644
--- a/src/components/Composer/types.ts
+++ b/src/components/Composer/types.ts
@@ -6,6 +6,12 @@ type TextSelection = {
};
type ComposerProps = {
+ /** identify id in the text input */
+ id?: string;
+
+ /** Indicate whether input is multiline */
+ multiline?: boolean;
+
/** Maximum number of lines in the text input */
maxLines?: number;
@@ -18,6 +24,9 @@ type ComposerProps = {
/** Number of lines for the comment */
numberOfLines?: number;
+ /** Callback method handle when the input is changed */
+ onChangeText?: (numberOfLines: string) => void;
+
/** Callback method to update number of lines for the comment */
onNumberOfLinesChange?: (numberOfLines: number) => void;
@@ -69,6 +78,8 @@ type ComposerProps = {
onFocus?: (event: NativeSyntheticEvent) => void;
+ onBlur?: (event: NativeSyntheticEvent) => void;
+
/** Should make the input only scroll inside the element avoid scroll out to parent */
shouldContainScroll?: boolean;
};
diff --git a/src/components/ConfirmedRoute.js b/src/components/ConfirmedRoute.tsx
similarity index 61%
rename from src/components/ConfirmedRoute.js
rename to src/components/ConfirmedRoute.tsx
index 466666dd9ef6..05d6557a72e3 100644
--- a/src/components/ConfirmedRoute.js
+++ b/src/components/ConfirmedRoute.tsx
@@ -1,9 +1,7 @@
-import lodashGet from 'lodash/get';
-import lodashIsNil from 'lodash/isNil';
-import PropTypes from 'prop-types';
import React, {useCallback, useEffect} from 'react';
+import type {ReactNode} from 'react';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
+import type {OnyxEntry} from 'react-native-onyx/lib/types';
import useNetwork from '@hooks/useNetwork';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -11,54 +9,51 @@ import * as TransactionUtils from '@libs/TransactionUtils';
import * as MapboxToken from '@userActions/MapboxToken';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type {MapboxAccessToken, Transaction} from '@src/types/onyx';
+import type {WaypointCollection} from '@src/types/onyx/Transaction';
+import type IconAsset from '@src/types/utils/IconAsset';
import DistanceMapView from './DistanceMapView';
import * as Expensicons from './Icon/Expensicons';
import ImageSVG from './ImageSVG';
import PendingMapView from './MapView/PendingMapView';
-import transactionPropTypes from './transactionPropTypes';
-const propTypes = {
- /** Transaction that stores the distance request data */
- transaction: transactionPropTypes,
+type WayPoint = {
+ id: string;
+ coordinate: [number, number];
+ markerComponent: () => ReactNode;
+};
+type ConfirmedRoutePropsOnyxProps = {
/** Data about Mapbox token for calling Mapbox API */
- mapboxAccessToken: PropTypes.shape({
- /** Temporary token for Mapbox API */
- token: PropTypes.string,
-
- /** Time when the token will expire in ISO 8601 */
- expiration: PropTypes.string,
- }),
+ mapboxAccessToken: OnyxEntry;
};
-const defaultProps = {
- transaction: {},
- mapboxAccessToken: {
- token: '',
- },
+type ConfirmedRouteProps = ConfirmedRoutePropsOnyxProps & {
+ /** Transaction that stores the distance request data */
+ transaction: Transaction;
};
-function ConfirmedRoute({mapboxAccessToken, transaction}) {
+function ConfirmedRoute({mapboxAccessToken, transaction}: ConfirmedRouteProps) {
const {isOffline} = useNetwork();
- const {route0: route} = transaction.routes || {};
- const waypoints = lodashGet(transaction, 'comment.waypoints', {});
- const coordinates = lodashGet(route, 'geometry.coordinates', []);
+ const {route0: route} = transaction.routes ?? {};
+ const waypoints = transaction.comment?.waypoints ?? {};
+ const coordinates = route.geometry?.coordinates ?? [];
const theme = useTheme();
const styles = useThemeStyles();
const getWaypointMarkers = useCallback(
- (waypointsData) => {
- const numberOfWaypoints = _.size(waypointsData);
+ (waypointsData: WaypointCollection): WayPoint[] => {
+ const numberOfWaypoints = Object.keys(waypointsData).length;
const lastWaypointIndex = numberOfWaypoints - 1;
- return _.filter(
- _.map(waypointsData, (waypoint, key) => {
- if (!waypoint || lodashIsNil(waypoint.lat) || lodashIsNil(waypoint.lng)) {
+ return Object.entries(waypointsData)
+ .map(([key, waypoint]) => {
+ if (!waypoint?.lat || !waypoint?.lng) {
return;
}
const index = TransactionUtils.getWaypointIndex(key);
- let MarkerComponent;
+ let MarkerComponent: IconAsset;
if (index === 0) {
MarkerComponent = Expensicons.DotIndicatorUnfilled;
} else if (index === lastWaypointIndex) {
@@ -69,8 +64,8 @@ function ConfirmedRoute({mapboxAccessToken, transaction}) {
return {
id: `${waypoint.lng},${waypoint.lat},${index}`,
- coordinate: [waypoint.lng, waypoint.lat],
- markerComponent: () => (
+ coordinate: [waypoint.lng, waypoint.lat] as const,
+ markerComponent: (): ReactNode => (
),
};
- }),
- (waypoint) => waypoint,
- );
+ })
+ .filter((waypoint): waypoint is WayPoint => !!waypoint);
},
[theme],
);
@@ -95,16 +89,16 @@ function ConfirmedRoute({mapboxAccessToken, transaction}) {
return (
<>
- {!isOffline && Boolean(mapboxAccessToken.token) ? (
+ {!isOffline && Boolean(mapboxAccessToken?.token) ? (
}
style={[styles.mapView, styles.br4]}
waypoints={waypointMarkers}
styleURL={CONST.MAPBOX.STYLE_URL}
@@ -116,12 +110,10 @@ function ConfirmedRoute({mapboxAccessToken, transaction}) {
);
}
-export default withOnyx({
+export default withOnyx({
mapboxAccessToken: {
key: ONYXKEYS.MAPBOX_ACCESS_TOKEN,
},
})(ConfirmedRoute);
ConfirmedRoute.displayName = 'ConfirmedRoute';
-ConfirmedRoute.propTypes = propTypes;
-ConfirmedRoute.defaultProps = defaultProps;
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 0791e5113a1d..a723eed446a4 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -36,7 +36,7 @@ const throttleTime = Browser.isMobile() ? 200 : 50;
function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const {isSmallScreenWidth} = useWindowDimensions();
+ const {isSmallScreenWidth, windowWidth} = useWindowDimensions();
const {translate} = useLocalize();
const {singleExecution} = useSingleExecution();
const {
@@ -335,7 +335,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
if (item.header) {
return (
-
+
{translate(`emojiPicker.headers.${code}`)}
);
@@ -368,18 +368,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
/>
);
},
- [
- preferredSkinTone,
- highlightedIndex,
- isUsingKeyboardMovement,
- highlightFirstEmoji,
- singleExecution,
- styles.emojiHeaderContainer,
- styles.mh4,
- styles.textLabelSupporting,
- translate,
- onEmojiSelected,
- ],
+ [preferredSkinTone, highlightedIndex, isUsingKeyboardMovement, highlightFirstEmoji, singleExecution, translate, onEmojiSelected, isSmallScreenWidth, windowWidth, styles],
);
return (
diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx
index 209803f2a5d1..6cbfde0645de 100755
--- a/src/components/HeaderWithBackButton/index.tsx
+++ b/src/components/HeaderWithBackButton/index.tsx
@@ -61,7 +61,6 @@ function HeaderWithBackButton({
const StyleUtils = useStyleUtils();
const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState();
const {translate} = useLocalize();
- // @ts-expect-error TODO: Remove this once useKeyboardState (https://github.com/Expensify/App/issues/24941) is migrated to TypeScript.
const {isKeyboardShown} = useKeyboardState();
const waitForNavigate = useWaitForNavigation();
diff --git a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
index 4a4ba5560e60..e28400505280 100644
--- a/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
+++ b/src/components/InvertedFlatList/BaseInvertedFlatList.tsx
@@ -4,6 +4,7 @@ import type {FlatListProps} from 'react-native';
import FlatList from '@components/FlatList';
const WINDOW_SIZE = 15;
+const AUTOSCROLL_TO_TOP_THRESHOLD = 128;
function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef) {
return (
@@ -14,6 +15,7 @@ function BaseInvertedFlatList(props: FlatListProps, ref: ForwardedRef
diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js
index d1bf02b08191..71b14b6fadcd 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.js
+++ b/src/components/LHNOptionsList/LHNOptionsList.js
@@ -1,7 +1,7 @@
import {FlashList} from '@shopify/flash-list';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React, {memo, useCallback} from 'react';
+import React, {useCallback} from 'react';
import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
@@ -190,4 +190,4 @@ export default compose(
key: ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT,
},
}),
-)(memo(LHNOptionsList));
+)(LHNOptionsList);
diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js
index 488630dd0590..be42abc797dd 100644
--- a/src/components/MoneyRequestHeader.js
+++ b/src/components/MoneyRequestHeader.js
@@ -100,7 +100,16 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
threeDotsMenuItems.push({
icon: Expensicons.Receipt,
text: translate('receipt.addReceipt'),
- onSelected: () => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT)),
+ onSelected: () =>
+ Navigation.navigate(
+ ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(
+ CONST.IOU.ACTION.EDIT,
+ CONST.IOU.TYPE.REQUEST,
+ transaction.transactionID,
+ report.reportID,
+ Navigation.getActiveRouteWithoutParams(),
+ ),
+ ),
});
}
threeDotsMenuItems.push({
diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
index 2fee67a3d632..36d424ea28f2 100755
--- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
+++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
@@ -726,26 +726,6 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
)}
{shouldShowAllFields && (
<>
- {shouldShowDate && (
- {
- if (isEditingSplitBill) {
- Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DATE));
- return;
- }
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()));
- }}
- disabled={didConfirm}
- interactive={!isReadOnly}
- brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''}
- />
- )}
{isDistanceRequest && (
)}
+ {shouldShowDate && (
+ {
+ if (isEditingSplitBill) {
+ Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DATE));
+ return;
+ }
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()));
+ }}
+ disabled={didConfirm}
+ interactive={!isReadOnly}
+ brickRoadIndicator={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ error={shouldDisplayFieldError && TransactionUtils.isCreatedMissing(transaction) ? translate('common.error.enterDate') : ''}
+ />
+ )}
{shouldShowCategories && (
{
+ if (pinchEnabled) {
+ return;
+ }
+ setPinchEnabled(true);
+ }, [pinchEnabled]);
+
const pinchGesture = Gesture.Pinch()
+ .enabled(pinchEnabled)
.onTouchesDown((evt, state) => {
// we don't want to activate pinch gesture when we are scrolling pager
if (!isScrolling.value) {
@@ -466,6 +476,11 @@ function MultiGestureCanvas({canvasSize, isActive = true, onScaleChanged, childr
origin.y.value = adjustFocal.y;
})
.onChange((evt) => {
+ if (evt.numberOfPointers !== 2) {
+ runOnJS(setPinchEnabled)(false);
+ return;
+ }
+
const newZoomScale = pinchScaleOffset.value * evt.scale;
if (zoomScale.value >= zoomRange.min * zoomScaleBounceFactors.min && zoomScale.value <= zoomRange.max * zoomScaleBounceFactors.max) {
diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js
index 8b24066af969..bd3695eb7aa9 100644
--- a/src/components/OptionsList/BaseOptionsList.js
+++ b/src/components/OptionsList/BaseOptionsList.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
-import React, {forwardRef, memo, useEffect, useMemo, useRef} from 'react';
+import React, {forwardRef, memo, useEffect, useRef} from 'react';
import {View} from 'react-native';
import _ from 'underscore';
import OptionRow from '@components/OptionRow';
@@ -36,278 +36,268 @@ const defaultProps = {
...optionsListDefaultProps,
};
-const viewabilityConfig = {viewAreaCoveragePercentThreshold: 95};
-
-const BaseOptionsList = forwardRef(
- (
- {
- keyboardDismissMode,
- onScrollBeginDrag,
- onScroll,
- listStyles,
- focusedIndex,
- selectedOptions,
- headerMessage,
- isLoading,
- sections,
- onLayout,
- hideSectionHeaders,
- shouldHaveOptionSeparator,
- showTitleTooltip,
- optionHoveredStyle,
- sectionHeaderStyle,
- showScrollIndicator,
- contentContainerStyles: contentContainerStylesProp,
- listContainerStyles: listContainerStylesProp,
- shouldDisableRowInnerPadding,
- shouldPreventDefaultFocusOnSelectRow,
- disableFocusOptions,
- canSelectMultipleOptions,
- shouldShowMultipleOptionSelectorAsButton,
- multipleOptionSelectorButtonText,
- onAddToSelection,
- highlightSelectedOptions,
- onSelectRow,
- boldStyle,
- isDisabled,
- isRowMultilineSupported,
- isLoadingNewOptions,
- nestedScrollEnabled,
- bounces,
- renderFooterContent,
- safeAreaPaddingBottomStyle,
- },
- innerRef,
- ) => {
- const styles = useThemeStyles();
- const flattenedData = useRef();
- const previousSections = usePrevious(sections);
- const didLayout = useRef(false);
-
- const listContainerStyles = useMemo(() => listContainerStylesProp || [styles.flex1], [listContainerStylesProp, styles.flex1]);
- const contentContainerStyles = useMemo(() => [safeAreaPaddingBottomStyle, ...contentContainerStylesProp], [contentContainerStylesProp, safeAreaPaddingBottomStyle]);
-
- /**
- * This helper function is used to memoize the computation needed for getItemLayout. It is run whenever section data changes.
- *
- * @returns {Array
)}
@@ -62,7 +51,6 @@ function EditRequestReceiptPage({route, transactionID}) {
}
EditRequestReceiptPage.propTypes = propTypes;
-EditRequestReceiptPage.defaultProps = defaultProps;
EditRequestReceiptPage.displayName = 'EditRequestReceiptPage';
export default EditRequestReceiptPage;
diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js
index 18da2c11a0e6..faa525a318ab 100644
--- a/src/pages/EnablePayments/AdditionalDetailsStep.js
+++ b/src/pages/EnablePayments/AdditionalDetailsStep.js
@@ -1,4 +1,3 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import {subYears} from 'date-fns';
import PropTypes from 'prop-types';
import React from 'react';
@@ -17,6 +16,7 @@ import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultPro
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as ValidationUtils from '@libs/ValidationUtils';
import AddressForm from '@pages/ReimbursementAccount/AddressForm';
import * as PersonalDetails from '@userActions/PersonalDetails';
diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js
index 211cb303d061..3a58727eddb7 100755
--- a/src/pages/NewChatPage.js
+++ b/src/pages/NewChatPage.js
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
-import {View} from 'react-native';
+import {InteractionManager, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
@@ -60,6 +60,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
const [selectedOptions, setSelectedOptions] = useState([]);
const {isOffline} = useNetwork();
const {isSmallScreenWidth} = useWindowDimensions();
+ const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS;
const headerMessage = OptionsListUtils.getHeaderMessage(
@@ -115,7 +116,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
* Removes a selected option from list if already selected. If not already selected add this option to the list.
* @param {Object} option
*/
- function toggleOption(option) {
+ const toggleOption = (option) => {
const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login);
let newSelectedOptions;
@@ -153,7 +154,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
setFilteredRecentReports(recentReports);
setFilteredPersonalDetails(newChatPersonalDetails);
setFilteredUserToInvite(userToInvite);
- }
+ };
/**
* Creates a new 1:1 chat with the option and the current user,
@@ -161,9 +162,9 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
*
* @param {Object} option
*/
- function createChat(option) {
+ const createChat = (option) => {
Report.navigateToAndOpenReport([option.login]);
- }
+ };
/**
* Creates a new group chat with all the selected options and the current user,
@@ -177,7 +178,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
Report.navigateToAndOpenReport(logins);
};
- useEffect(() => {
+ const updateOptions = useCallback(() => {
const {
recentReports,
personalDetails: newChatPersonalDetails,
@@ -207,6 +208,21 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reports, personalDetails, searchTerm]);
+ useEffect(() => {
+ const interactionTask = InteractionManager.runAfterInteractions(() => {
+ setDidScreenTransitionEnd(true);
+ });
+
+ return interactionTask.cancel;
+ }, []);
+
+ useEffect(() => {
+ if (!didScreenTransitionEnd) {
+ return;
+ }
+ updateOptions();
+ }, [didScreenTransitionEnd, updateOptions]);
+
// When search term updates we will fetch any reports
const setSearchTermAndSearchInServer = useCallback((text = '') => {
Report.searchInServer(text);
@@ -238,15 +254,15 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i
canSelectMultipleOptions
shouldShowMultipleOptionSelectorAsButton
multipleOptionSelectorButtonText={translate('newChatPage.addToGroup')}
- onAddToSelection={(option) => toggleOption(option)}
+ onAddToSelection={toggleOption}
sections={sections}
selectedOptions={selectedOptions}
- onSelectRow={(option) => createChat(option)}
+ onSelectRow={createChat}
onChangeText={setSearchTermAndSearchInServer}
headerMessage={headerMessage}
boldStyle
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
- shouldShowOptions={isOptionsDataReady}
+ shouldShowOptions={isOptionsDataReady && didScreenTransitionEnd}
shouldShowConfirmButton
shouldShowReferralCTA
referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT}
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index 8432d25b6ad7..c0c782f176ca 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -1,4 +1,3 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
@@ -27,6 +26,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
diff --git a/src/pages/ReimbursementAccount/CompanyStep.js b/src/pages/ReimbursementAccount/CompanyStep.js
index 9f985e15a95e..8828cce5cc74 100644
--- a/src/pages/ReimbursementAccount/CompanyStep.js
+++ b/src/pages/ReimbursementAccount/CompanyStep.js
@@ -1,4 +1,3 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
@@ -20,6 +19,7 @@ import TextLink from '@components/TextLink';
import withLocalize from '@components/withLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as ValidationUtils from '@libs/ValidationUtils';
import * as BankAccounts from '@userActions/BankAccounts';
import CONST from '@src/CONST';
diff --git a/src/pages/RoomInvitePage.js b/src/pages/RoomInvitePage.js
index f290eff91669..d2ef900b051f 100644
--- a/src/pages/RoomInvitePage.js
+++ b/src/pages/RoomInvitePage.js
@@ -1,3 +1,4 @@
+import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
@@ -13,9 +14,11 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import * as LoginUtils from '@libs/LoginUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as Report from '@userActions/Report';
@@ -102,8 +105,9 @@ function RoomInvitePage(props) {
filterSelectedOptions = _.filter(selectedOptions, (option) => {
const accountID = lodashGet(option, 'accountID', null);
const isOptionInPersonalDetails = _.some(personalDetails, (personalDetail) => personalDetail.accountID === accountID);
-
- const isPartOfSearchTerm = option.text.toLowerCase().includes(searchTerm.trim().toLowerCase());
+ const parsedPhoneNumber = parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm)));
+ const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : searchTerm.toLowerCase();
+ const isPartOfSearchTerm = option.text.toLowerCase().includes(searchValue) || option.login.toLowerCase().includes(searchValue);
return isPartOfSearchTerm || isOptionInPersonalDetails;
});
}
diff --git a/src/pages/SearchPage.js b/src/pages/SearchPage.js
index a6e453d9f211..061f43e73de8 100755
--- a/src/pages/SearchPage.js
+++ b/src/pages/SearchPage.js
@@ -1,8 +1,7 @@
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useRef, useState} from 'react';
-import {InteractionManager, View} from 'react-native';
+import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import OptionsSelector from '@components/OptionsSelector';
import ScreenWrapper from '@components/ScreenWrapper';
@@ -43,18 +42,6 @@ const defaultProps = {
isSearchingForReports: false,
};
-function isSectionsEmpty(sections) {
- if (!sections.length) {
- return true;
- }
-
- if (!sections[0].data.length) {
- return true;
- }
-
- return _.isEmpty(sections[0].data[0]);
-}
-
function SearchPage({betas, personalDetails, reports, isSearchingForReports}) {
const [searchValue, setSearchValue] = useState('');
const [searchOptions, setSearchOptions] = useState({
@@ -67,45 +54,21 @@ function SearchPage({betas, personalDetails, reports, isSearchingForReports}) {
const {translate} = useLocalize();
const themeStyles = useThemeStyles();
const isMounted = useRef(false);
- const interactionTask = useRef(null);
const updateOptions = useCallback(() => {
- if (interactionTask.current) {
- interactionTask.current.cancel();
- }
-
- /**
- * Execute the callback after all interactions are done, which means
- * after all animations have finished.
- */
- interactionTask.current = InteractionManager.runAfterInteractions(() => {
- const {
- recentReports: localRecentReports,
- personalDetails: localPersonalDetails,
- userToInvite: localUserToInvite,
- } = OptionsListUtils.getSearchOptions(reports, personalDetails, searchValue.trim(), betas);
-
- setSearchOptions({
- recentReports: localRecentReports,
- personalDetails: localPersonalDetails,
- userToInvite: localUserToInvite,
- });
+ const {
+ recentReports: localRecentReports,
+ personalDetails: localPersonalDetails,
+ userToInvite: localUserToInvite,
+ } = OptionsListUtils.getSearchOptions(reports, personalDetails, searchValue.trim(), betas);
+
+ setSearchOptions({
+ recentReports: localRecentReports,
+ personalDetails: localPersonalDetails,
+ userToInvite: localUserToInvite,
});
}, [reports, personalDetails, searchValue, betas]);
- /**
- * Cancel the interaction task when the component unmounts
- */
- useEffect(
- () => () => {
- if (!interactionTask.current) {
- return;
- }
- interactionTask.current.cancel();
- },
- [],
- );
-
useEffect(() => {
Timing.start(CONST.TIMING.SEARCH_RENDER);
Performance.markStart(CONST.TIMING.SEARCH_RENDER);
@@ -196,7 +159,7 @@ function SearchPage({betas, personalDetails, reports, isSearchingForReports}) {
Boolean(searchOptions.userToInvite),
searchValue,
);
- const sections = getSections();
+
return (
);
}
@@ -609,6 +606,17 @@ export default compose(
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`,
initialValue: false,
},
+ parentReportAction: {
+ key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : 0}`,
+ selector: (parentReportActions, props) => {
+ const parentReportActionID = lodashGet(props, 'report.parentReportActionID');
+ if (!parentReportActionID) {
+ return {};
+ }
+ return parentReportActions[parentReportActionID];
+ },
+ canEvict: false,
+ },
},
true,
),
diff --git a/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js b/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js
index 77bcc7bdd38e..e059c2f06019 100644
--- a/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js
+++ b/src/pages/home/report/ListBoundaryLoader/ListBoundaryLoader.js
@@ -39,17 +39,17 @@ function ListBoundaryLoader({type, isLoadingOlderReportActions, isLoadingInitial
// We use two different loading components for the header and footer
// to reduce the jumping effect when the user is scrolling to the newer report actions
if (type === CONST.LIST_COMPONENTS.FOOTER) {
- if (isLoadingOlderReportActions) {
- return ;
- }
+ /*
+ Ensure that the report chat is not loaded until the beginning.
+ This is to avoid displaying the skeleton view above the "created" action in a newly generated optimistic chat or one with not that many comments.
+ Additionally, if we are offline and the report is not loaded until the beginning, we assume there are more actions to load;
+ Therefore, show the skeleton view even though the actions are not actually loading.
+ */
+ const isReportLoadedUntilBeginning = lastReportActionName === CONST.REPORT.ACTIONS.TYPE.CREATED;
+ const mayLoadMoreActions = !isReportLoadedUntilBeginning && (isLoadingInitialReportActions || isOffline);
- // Make sure the report chat is not loaded till the beginning. This is so we do not show the
- // skeleton view above the "created" action in a newly generated optimistic chat or one with not
- // that many comments.
- // Also, if we are offline and the report is not yet loaded till the beginning, we assume there are more actions to load,
- // therefore show the skeleton view, even though the actions are not loading.
- if (lastReportActionName !== CONST.REPORT.ACTIONS.TYPE.CREATED && (isLoadingInitialReportActions || isOffline)) {
- return ;
+ if (isLoadingOlderReportActions || mayLoadMoreActions) {
+ return ;
}
}
if (type === CONST.LIST_COMPONENTS.HEADER && isLoadingNewerReportActions) {
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.tsx
similarity index 75%
rename from src/pages/home/report/ReportActionItemMessageEdit.js
rename to src/pages/home/report/ReportActionItemMessageEdit.tsx
index dbd3262f30d5..5934c4c333cb 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.tsx
@@ -1,17 +1,17 @@
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 React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import lodashDebounce from 'lodash/debounce';
+import type {ForwardedRef} from 'react';
+import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager, Keyboard, View} from 'react-native';
-import _ from 'underscore';
+import type {NativeSyntheticEvent, TextInput, TextInputFocusEventData, TextInputKeyPressEventData} from 'react-native';
+import type {Emoji} from '@assets/emojis/types';
import Composer from '@components/Composer';
import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton';
import ExceededCommentLength from '@components/ExceededCommentLength';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import refPropTypes from '@components/refPropTypes';
import Tooltip from '@components/Tooltip';
import useHandleExceedMaxCommentLength from '@hooks/useHandleExceedMaxCommentLength';
import useKeyboardState from '@hooks/useKeyboardState';
@@ -30,48 +30,37 @@ import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManag
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import setShouldShowComposeInputKeyboardAware from '@libs/setShouldShowComposeInputKeyboardAware';
-import reportPropTypes from '@pages/reportPropTypes';
import * as EmojiPickerAction from '@userActions/EmojiPickerAction';
import * as InputFocus from '@userActions/InputFocus';
import * as Report from '@userActions/Report';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import type * as OnyxTypes from '@src/types/onyx';
import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu';
-import reportActionPropTypes from './reportActionPropTypes';
-const propTypes = {
+type ReportActionItemMessageEditProps = {
/** All the data of the action */
- action: PropTypes.shape(reportActionPropTypes).isRequired,
+ action: OnyxTypes.ReportAction;
/** Draft message */
- draftMessage: PropTypes.string.isRequired,
+ draftMessage: string;
/** ReportID that holds the comment we're editing */
- reportID: PropTypes.string.isRequired,
+ reportID: string;
/** Position index of the report action in the overall report FlatList view */
- index: PropTypes.number.isRequired,
-
- /** A ref to forward to the text input */
- forwardedRef: refPropTypes,
+ index: number;
/** The report currently being looked at */
// eslint-disable-next-line react/no-unused-prop-types
- report: reportPropTypes,
+ report?: OnyxTypes.Report;
/** Whether or not the emoji picker is disabled */
- shouldDisableEmojiPicker: PropTypes.bool,
+ shouldDisableEmojiPicker?: boolean;
/** Stores user's preferred skin tone */
- preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
-};
-
-const defaultProps = {
- forwardedRef: () => {},
- report: {},
- shouldDisableEmojiPicker: false,
- preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
+ preferredSkinTone?: number;
};
// native ids
@@ -80,7 +69,10 @@ const messageEditInput = 'messageEditInput';
const isMobileSafari = Browser.isMobileSafari();
-function ReportActionItemMessageEdit(props) {
+function ReportActionItemMessageEdit(
+ {action, draftMessage, reportID, index, shouldDisableEmojiPicker = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE}: ReportActionItemMessageEditProps,
+ forwardedRef: ForwardedRef,
+) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -90,13 +82,13 @@ function ReportActionItemMessageEdit(props) {
const {isSmallScreenWidth} = useWindowDimensions();
const getInitialDraft = () => {
- if (props.draftMessage === props.action.message[0].html) {
+ if (draftMessage === action?.message?.[0].html) {
// We only convert the report action message to markdown if the draft message is unchanged.
const parser = new ExpensiMark();
- return parser.htmlToMarkdown(props.draftMessage).trim();
+ return parser.htmlToMarkdown(draftMessage).trim();
}
// We need to decode saved draft message because it's escaped before saving.
- return Str.htmlDecode(props.draftMessage);
+ return Str.htmlDecode(draftMessage);
};
const getInitialSelection = () => {
@@ -107,7 +99,7 @@ function ReportActionItemMessageEdit(props) {
const length = getInitialDraft().length;
return {start: length, end: length};
};
- const emojisPresentBefore = useRef([]);
+ const emojisPresentBefore = useRef([]);
const [draft, setDraft] = useState(() => {
const initialDraft = getInitialDraft();
if (initialDraft) {
@@ -115,23 +107,29 @@ function ReportActionItemMessageEdit(props) {
}
return initialDraft;
});
- const [selection, setSelection] = useState(getInitialSelection);
- const [isFocused, setIsFocused] = useState(false);
+ const [selection, setSelection] = useState<{
+ start: number;
+ end: number;
+ }>(getInitialSelection);
+ const [isFocused, setIsFocused] = useState(false);
const {hasExceededMaxCommentLength, validateCommentMaxLength} = useHandleExceedMaxCommentLength();
- const [modal, setModal] = useState(false);
- const [onyxFocused, setOnyxFocused] = useState(false);
+ const [modal, setModal] = useState({
+ willAlertModalBecomeVisible: false,
+ isVisible: false,
+ });
+ const [onyxFocused, setOnyxFocused] = useState(false);
- const textInputRef = useRef(null);
- const isFocusedRef = useRef(false);
- const insertedEmojis = useRef([]);
+ const textInputRef = useRef<(HTMLTextAreaElement & TextInput) | null>(null);
+ const isFocusedRef = useRef(false);
+ const insertedEmojis = useRef([]);
const draftRef = useRef(draft);
useEffect(() => {
- if (ReportActionsUtils.isDeletedAction(props.action) || props.draftMessage === props.action.message[0].html) {
+ if (ReportActionsUtils.isDeletedAction(action) || (action.message && draftMessage === action.message[0].html)) {
return;
}
- setDraft(Str.htmlDecode(props.draftMessage));
- }, [props.draftMessage, props.action]);
+ setDraft(Str.htmlDecode(draftMessage));
+ }, [draftMessage, action]);
useEffect(() => {
// required for keeping last state of isFocused variable
@@ -139,14 +137,14 @@ function ReportActionItemMessageEdit(props) {
}, [isFocused]);
useEffect(() => {
- InputFocus.composerFocusKeepFocusOn(textInputRef.current, isFocused, modal, onyxFocused);
+ InputFocus.composerFocusKeepFocusOn(textInputRef.current as HTMLElement, isFocused, modal, onyxFocused);
}, [isFocused, modal, onyxFocused]);
useEffect(() => {
const unsubscribeOnyxModal = onyxSubscribe({
key: ONYXKEYS.MODAL,
callback: (modalArg) => {
- if (_.isNull(modalArg)) {
+ if (modalArg === null) {
return;
}
setModal(modalArg);
@@ -156,7 +154,7 @@ function ReportActionItemMessageEdit(props) {
const unsubscribeOnyxFocused = onyxSubscribe({
key: ONYXKEYS.INPUT_FOCUSED,
callback: (modalArg) => {
- if (_.isNull(modalArg)) {
+ if (modalArg === null) {
return;
}
setOnyxFocused(modalArg);
@@ -170,8 +168,8 @@ function ReportActionItemMessageEdit(props) {
// We consider the report action active if it's focused, its emoji picker is open or its context menu is open
const isActive = useCallback(
- () => isFocusedRef.current || EmojiPickerAction.isActive(props.action.reportActionID) || ReportActionContextMenu.isActiveReportAction(props.action.reportActionID),
- [props.action.reportActionID],
+ () => isFocusedRef.current || EmojiPickerAction.isActive(action.reportActionID) || ReportActionContextMenu.isActiveReportAction(action.reportActionID),
+ [action.reportActionID],
);
useEffect(() => {
@@ -188,7 +186,9 @@ function ReportActionItemMessageEdit(props) {
});
// Scroll content of textInputRef to bottom
- textInputRef.current.scrollTop = textInputRef.current.scrollHeight;
+ if (textInputRef.current) {
+ textInputRef.current.scrollTop = textInputRef.current.scrollHeight;
+ }
}
return () => {
@@ -200,10 +200,10 @@ function ReportActionItemMessageEdit(props) {
return;
}
- if (EmojiPickerAction.isActive(props.action.reportActionID)) {
+ if (EmojiPickerAction.isActive(action.reportActionID)) {
EmojiPickerAction.clearActive();
}
- if (ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)) {
+ if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) {
ReportActionContextMenu.clearActiveReportAction();
}
@@ -212,7 +212,7 @@ function ReportActionItemMessageEdit(props) {
setShouldShowComposeInputKeyboardAware(true);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- this cleanup needs to be called only on unmount
- }, [props.action.reportActionID]);
+ }, [action.reportActionID]);
/**
* Save the draft of the comment. This debounced so that we're not ceaselessly saving your edit. Saving the draft
@@ -221,10 +221,10 @@ function ReportActionItemMessageEdit(props) {
*/
const debouncedSaveDraft = useMemo(
() =>
- _.debounce((newDraft) => {
- Report.saveReportActionDraft(props.reportID, props.action, newDraft);
+ lodashDebounce((newDraft: string) => {
+ Report.saveReportActionDraft(reportID, action, newDraft);
}, 1000),
- [props.reportID, props.action],
+ [reportID, action],
);
/**
@@ -233,7 +233,7 @@ function ReportActionItemMessageEdit(props) {
*/
const debouncedUpdateFrequentlyUsedEmojis = useMemo(
() =>
- _.debounce(() => {
+ lodashDebounce(() => {
User.updateFrequentlyUsedEmojis(EmojiUtils.getFrequentlyUsedEmojis(insertedEmojis.current));
insertedEmojis.current = [];
}, 1000),
@@ -246,12 +246,12 @@ function ReportActionItemMessageEdit(props) {
* @param {String} newDraftInput
*/
const updateDraft = useCallback(
- (newDraftInput) => {
- const {text: newDraft, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale);
+ (newDraftInput: string) => {
+ const {text: newDraft, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, preferredSkinTone, preferredLocale);
- if (!_.isEmpty(emojis)) {
+ if (emojis?.length > 0) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
- if (!_.isEmpty(newEmojis)) {
+ if (newEmojis?.length > 0) {
insertedEmojis.current = [...insertedEmojis.current, ...newEmojis];
debouncedUpdateFrequentlyUsedEmojis();
}
@@ -261,7 +261,7 @@ function ReportActionItemMessageEdit(props) {
setDraft(newDraft);
if (newDraftInput !== newDraft) {
- const position = Math.max(selection.end + (newDraft.length - draftRef.current.length), cursorPosition || 0);
+ const position = Math.max(selection.end + (newDraft.length - draftRef.current.length), cursorPosition ?? 0);
setSelection({
start: position,
end: position,
@@ -271,22 +271,22 @@ function ReportActionItemMessageEdit(props) {
draftRef.current = newDraft;
// We want to escape the draft message to differentiate the HTML from the report action and the HTML the user drafted.
- debouncedSaveDraft(_.escape(newDraft));
+ debouncedSaveDraft(newDraft);
},
- [debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end],
+ [debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, preferredSkinTone, preferredLocale, selection.end],
);
useEffect(() => {
updateDraft(draft);
// eslint-disable-next-line react-hooks/exhaustive-deps -- run this only when language is changed
- }, [props.action.reportActionID, preferredLocale]);
+ }, [action.reportActionID, preferredLocale]);
/**
* Delete the draft of the comment being edited. This will take the comment out of "edit mode" with the old content.
*/
const deleteDraft = useCallback(() => {
debouncedSaveDraft.cancel();
- Report.deleteReportActionDraft(props.reportID, props.action);
+ Report.deleteReportActionDraft(reportID, action);
if (isActive()) {
ReportActionComposeFocusManager.clear();
@@ -294,13 +294,13 @@ function ReportActionItemMessageEdit(props) {
}
// Scroll to the last comment after editing to make sure the whole comment is clearly visible in the report.
- if (props.index === 0) {
+ if (index === 0) {
const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => {
- reportScrollManager.scrollToIndex(props.index, false);
+ reportScrollManager.scrollToIndex(index, false);
keyboardDidHideListener.remove();
});
}
- }, [props.action, debouncedSaveDraft, props.index, props.reportID, reportScrollManager, isActive]);
+ }, [action, debouncedSaveDraft, index, reportID, reportScrollManager, isActive]);
/**
* Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with
@@ -320,18 +320,25 @@ function ReportActionItemMessageEdit(props) {
// When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting.
if (!trimmedNewDraft) {
- textInputRef.current.blur();
- ReportActionContextMenu.showDeleteModal(props.reportID, props.action, true, deleteDraft, () => InteractionManager.runAfterInteractions(() => textInputRef.current.focus()));
+ textInputRef.current?.blur();
+ ReportActionContextMenu.showDeleteModal(
+ reportID,
+ action,
+ true,
+ deleteDraft,
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
+ () => InteractionManager.runAfterInteractions(() => textInputRef.current?.focus()),
+ );
return;
}
- Report.editReportComment(props.reportID, props.action, trimmedNewDraft);
+ Report.editReportComment(reportID, action, trimmedNewDraft);
deleteDraft();
- }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID]);
+ }, [action, debouncedSaveDraft, deleteDraft, draft, reportID]);
/**
- * @param {String} emoji
+ * @param emoji
*/
- const addEmojiToTextBox = (emoji) => {
+ const addEmojiToTextBox = (emoji: string) => {
setSelection((prevSelection) => ({
start: prevSelection.start + emoji.length + CONST.SPACE_LENGTH,
end: prevSelection.start + emoji.length + CONST.SPACE_LENGTH,
@@ -345,14 +352,15 @@ function ReportActionItemMessageEdit(props) {
* @param {Event} e
*/
const triggerSaveOrCancel = useCallback(
- (e) => {
+ (e: NativeSyntheticEvent | KeyboardEvent) => {
if (!e || ComposerUtils.canSkipTriggerHotkeys(isSmallScreenWidth, isKeyboardShown)) {
return;
}
- if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) {
+ const keyEvent = e as KeyboardEvent;
+ if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !keyEvent.shiftKey) {
e.preventDefault();
publishDraft();
- } else if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
+ } else if (keyEvent.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey) {
e.preventDefault();
deleteDraft();
}
@@ -404,11 +412,14 @@ function ReportActionItemMessageEdit(props) {
{
- ReportActionComposeFocusManager.editComposerRef.current = el;
+ ref={(el: TextInput & HTMLTextAreaElement) => {
textInputRef.current = el;
- // eslint-disable-next-line no-param-reassign
- props.forwardedRef.current = el;
+ if (typeof forwardedRef === 'function') {
+ forwardedRef(el);
+ } else if (forwardedRef) {
+ // eslint-disable-next-line no-param-reassign
+ forwardedRef.current = el;
+ }
}}
id={messageEditInput}
onChangeText={updateDraft} // Debounced saveDraftComment
@@ -418,21 +429,22 @@ function ReportActionItemMessageEdit(props) {
style={[styles.textInputCompose, styles.flex1, styles.bgTransparent]}
onFocus={() => {
setIsFocused(true);
- reportScrollManager.scrollToIndex(props.index, true);
+ reportScrollManager.scrollToIndex(index, true);
setShouldShowComposeInputKeyboardAware(false);
// Clear active report action when another action gets focused
- if (!EmojiPickerAction.isActive(props.action.reportActionID)) {
+ if (!EmojiPickerAction.isActive(action.reportActionID)) {
EmojiPickerAction.clearActive();
}
- if (!ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)) {
+ if (!ReportActionContextMenu.isActiveReportAction(action.reportActionID)) {
ReportActionContextMenu.clearActiveReportAction();
}
}}
- onBlur={(event) => {
+ onBlur={(event: NativeSyntheticEvent) => {
setIsFocused(false);
- const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
- if (_.contains([messageEditInput, emojiButtonID], relatedTargetId)) {
+ // @ts-expect-error TODO: TextInputFocusEventData doesn't contain relatedTarget.
+ const relatedTargetId = event.nativeEvent?.relatedTarget?.id;
+ if (relatedTargetId && [messageEditInput, emojiButtonID].includes(relatedTargetId)) {
return;
}
setShouldShowComposeInputKeyboardAware(true);
@@ -443,11 +455,11 @@ function ReportActionItemMessageEdit(props) {
focus(true)}
onEmojiSelected={addEmojiToTextBox}
id={emojiButtonID}
- emojiPickerID={props.action.reportActionID}
+ emojiPickerID={action.reportActionID}
/>
@@ -478,18 +490,6 @@ function ReportActionItemMessageEdit(props) {
);
}
-ReportActionItemMessageEdit.propTypes = propTypes;
-ReportActionItemMessageEdit.defaultProps = defaultProps;
ReportActionItemMessageEdit.displayName = 'ReportActionItemMessageEdit';
-const ReportActionItemMessageEditWithRef = React.forwardRef((props, ref) => (
-
-));
-
-ReportActionItemMessageEditWithRef.displayName = 'ReportActionItemMessageEditWithRef';
-
-export default ReportActionItemMessageEditWithRef;
+export default forwardRef(ReportActionItemMessageEdit);
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index fd2e5e7d8f57..dba8ef2e11d0 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -1,7 +1,7 @@
import {useRoute} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, InteractionManager} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import _ from 'underscore';
@@ -513,4 +513,4 @@ ReportActionsList.propTypes = propTypes;
ReportActionsList.defaultProps = defaultProps;
ReportActionsList.displayName = 'ReportActionsList';
-export default compose(withWindowDimensions, withPersonalDetails(), withCurrentUserPersonalDetails)(memo(ReportActionsList));
+export default compose(withWindowDimensions, withPersonalDetails(), withCurrentUserPersonalDetails)(ReportActionsList);
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index f3c51cb72bbb..2758437a3962 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -1,7 +1,7 @@
import {useIsFocused} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
+import React, {useContext, useEffect, useMemo, useRef} from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import networkPropTypes from '@components/networkPropTypes';
@@ -174,25 +174,25 @@ function ReportActionsView(props) {
}
}, [props.report, didSubscribeToReportTypingEvents, reportID]);
- const oldestReportAction = useMemo(() => _.last(props.reportActions), [props.reportActions]);
-
/**
* Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently
* displaying.
*/
- const loadOlderChats = useCallback(() => {
+ const loadOlderChats = () => {
// Only fetch more if we are neither already fetching (so that we don't initiate duplicate requests) nor offline.
if (props.network.isOffline || props.isLoadingOlderReportActions) {
return;
}
+ const oldestReportAction = _.last(props.reportActions);
+
// Don't load more chats if we're already at the beginning of the chat history
if (!oldestReportAction || oldestReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) {
return;
}
// Retrieve the next REPORT.ACTIONS.LIMIT sized page of comments
Report.getOlderActions(reportID, oldestReportAction.reportActionID);
- }, [props.network.isOffline, props.isLoadingOlderReportActions, oldestReportAction, reportID]);
+ };
/**
* Retrieves the next set of report actions for the chat once we are nearing the end of what we are currently
@@ -229,7 +229,7 @@ function ReportActionsView(props) {
/**
* Runs when the FlatList finishes laying out
*/
- const recordTimeToMeasureItemLayout = useCallback(() => {
+ const recordTimeToMeasureItemLayout = () => {
if (didLayout.current) {
return;
}
@@ -244,7 +244,7 @@ function ReportActionsView(props) {
} else {
Performance.markEnd(CONST.TIMING.SWITCH_REPORT);
}
- }, [hasCachedActions]);
+ };
// Comments have not loaded at all yet do nothing
if (!_.size(props.reportActions)) {
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 1b7b21d2f8a8..ffcba2048d18 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -1,6 +1,6 @@
/* eslint-disable rulesdir/onyx-props-must-have-default */
import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
+import React, {useCallback, useEffect, useRef} from 'react';
import {InteractionManager, StyleSheet, View} from 'react-native';
import _ from 'underscore';
import LogoComponent from '@assets/images/expensify-wordmark.svg';
@@ -149,8 +149,6 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority
);
const viewMode = priorityMode === CONST.PRIORITY_MODE.GSD ? CONST.OPTION_MODE.COMPACT : CONST.OPTION_MODE.DEFAULT;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- const contentContainerStyles = useMemo(() => [styles.sidebarListContainer, {paddingBottom: StyleUtils.getSafeAreaMargins(insets).marginBottom}], [insets]);
return (
@@ -189,7 +187,7 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority
-
+ {() => }
{shouldDisplayDistanceRequest && (
{
- const trackRef = useRef(null);
- const shouldShowCamera = useTabNavigatorFocus({
- tabIndex: cameraTabIndex,
- });
-
- const handleOnUserMedia = (stream) => {
- if (props.onUserMedia) {
- props.onUserMedia(stream);
- }
-
- const [track] = stream.getVideoTracks();
- const capabilities = track.getCapabilities();
- if (capabilities.torch) {
- trackRef.current = track;
- }
- if (onTorchAvailability) {
- onTorchAvailability(!!capabilities.torch);
- }
- };
-
- useEffect(() => {
- if (!trackRef.current) {
- return;
- }
-
- trackRef.current.applyConstraints({
- advanced: [{torch: torchOn}],
- });
- }, [torchOn]);
-
- if (!shouldShowCamera) {
- return null;
- }
- return (
-
-
-
- );
-});
-
-NavigationAwareCamera.propTypes = propTypes;
-NavigationAwareCamera.displayName = 'NavigationAwareCamera';
-NavigationAwareCamera.defaultProps = defaultProps;
-
-export default NavigationAwareCamera;
diff --git a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js b/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js
deleted file mode 100644
index 65c17d3cb7ab..000000000000
--- a/src/pages/iou/ReceiptSelector/NavigationAwareCamera/index.native.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {Camera} from 'react-native-vision-camera';
-import useTabNavigatorFocus from '@hooks/useTabNavigatorFocus';
-
-const propTypes = {
- /* The index of the tab that contains this camera */
- cameraTabIndex: PropTypes.number.isRequired,
-};
-
-// Wraps a camera that will only be active when the tab is focused or as soon as it starts to become focused.
-const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, ...props}, ref) => {
- const isCameraActive = useTabNavigatorFocus({tabIndex: cameraTabIndex});
-
- return (
-
- );
-});
-
-NavigationAwareCamera.propTypes = propTypes;
-NavigationAwareCamera.displayName = 'NavigationAwareCamera';
-
-export default NavigationAwareCamera;
diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js
deleted file mode 100644
index ae871260b03e..000000000000
--- a/src/pages/iou/ReceiptSelector/index.js
+++ /dev/null
@@ -1,340 +0,0 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {useCallback, useContext, useReducer, useRef, useState} from 'react';
-import {ActivityIndicator, PanResponder, PixelRatio, Text, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import Hand from '@assets/images/hand.svg';
-import ReceiptUpload from '@assets/images/receipt-upload.svg';
-import Shutter from '@assets/images/shutter.svg';
-import AttachmentPicker from '@components/AttachmentPicker';
-import Button from '@components/Button';
-import ConfirmModal from '@components/ConfirmModal';
-import CopyTextToClipboard from '@components/CopyTextToClipboard';
-import {DragAndDropContext} from '@components/DragAndDrop/Provider';
-import Icon from '@components/Icon';
-import * as Expensicons from '@components/Icon/Expensicons';
-import ImageSVG from '@components/ImageSVG';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import useLocalize from '@hooks/useLocalize';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import useWindowDimensions from '@hooks/useWindowDimensions';
-import * as Browser from '@libs/Browser';
-import * as FileUtils from '@libs/fileDownload/FileUtils';
-import Navigation from '@libs/Navigation/Navigation';
-import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes';
-import ReceiptDropUI from '@pages/iou/ReceiptDropUI';
-import reportPropTypes from '@pages/reportPropTypes';
-import * as IOU from '@userActions/IOU';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import NavigationAwareCamera from './NavigationAwareCamera';
-
-const propTypes = {
- /** The report on which the request is initiated on */
- report: reportPropTypes,
-
- /** React Navigation route */
- route: PropTypes.shape({
- /** Params from the route */
- params: PropTypes.shape({
- /** The type of IOU report, i.e. bill, request, send */
- iouType: PropTypes.string,
-
- /** The report ID of the IOU */
- reportID: PropTypes.string,
- }),
-
- /** The current route path */
- path: PropTypes.string,
- }).isRequired,
-
- /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
- iou: iouPropTypes,
-
- /** The id of the transaction we're editing */
- transactionID: PropTypes.string,
-};
-
-const defaultProps = {
- report: {},
- iou: iouDefaultProps,
- transactionID: '',
-};
-
-function ReceiptSelector({route, transactionID, iou, report}) {
- const theme = useTheme();
- const styles = useThemeStyles();
- const iouType = lodashGet(route, 'params.iouType', '');
- const pageIndex = lodashGet(route, 'params.pageIndex', 1);
-
- // Grouping related states
- const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false);
- const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState('');
- const [attachmentInvalidReason, setAttachmentValidReason] = useState('');
-
- const [receiptImageTopPosition, setReceiptImageTopPosition] = useState(0);
- const {isSmallScreenWidth} = useWindowDimensions();
- const {translate} = useLocalize();
- const {isDraggingOver} = useContext(DragAndDropContext);
-
- const [cameraPermissionState, setCameraPermissionState] = useState('prompt');
- const [isFlashLightOn, toggleFlashlight] = useReducer((state) => !state, false);
- const [isTorchAvailable, setIsTorchAvailable] = useState(false);
- const cameraRef = useRef(null);
-
- const hideReciptModal = () => {
- setIsAttachmentInvalid(false);
- };
-
- /**
- * Sets the upload receipt error modal content when an invalid receipt is uploaded
- * @param {*} isInvalid
- * @param {*} title
- * @param {*} reason
- */
- const setUploadReceiptError = (isInvalid, title, reason) => {
- setIsAttachmentInvalid(isInvalid);
- setAttachmentInvalidReasonTitle(title);
- setAttachmentValidReason(reason);
- };
-
- function validateReceipt(file) {
- const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', ''));
- if (!CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase())) {
- setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension');
- return false;
- }
-
- if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
- setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceeded');
- return false;
- }
-
- if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
- setUploadReceiptError(true, 'attachmentPicker.attachmentTooSmall', 'attachmentPicker.sizeNotMet');
- return false;
- }
-
- return true;
- }
-
- /**
- * Sets the Receipt objects and navigates the user to the next page
- * @param {Object} file
- * @param {Object} iouObject
- * @param {Object} reportObject
- */
- const setReceiptAndNavigate = (file, iouObject, reportObject) => {
- if (!validateReceipt(file)) {
- return;
- }
-
- const filePath = URL.createObjectURL(file);
- IOU.setMoneyRequestReceipt(filePath, file.name);
-
- if (transactionID) {
- IOU.replaceReceipt(transactionID, file, filePath);
- Navigation.dismissModal();
- return;
- }
-
- IOU.navigateToNextPage(iouObject, iouType, reportObject, route.path);
- };
-
- const capturePhoto = useCallback(() => {
- if (!cameraRef.current.getScreenshot) {
- return;
- }
- const imageBase64 = cameraRef.current.getScreenshot();
- const filename = `receipt_${Date.now()}.png`;
- const imageFile = FileUtils.base64ToFile(imageBase64, filename);
- const filePath = URL.createObjectURL(imageFile);
- IOU.setMoneyRequestReceipt(filePath, imageFile.name);
-
- if (transactionID) {
- IOU.replaceReceipt(transactionID, imageFile, filePath);
- Navigation.dismissModal();
- return;
- }
-
- IOU.navigateToNextPage(iou, iouType, report, route.path);
- }, [cameraRef, iou, report, iouType, transactionID, route.path]);
-
- const panResponder = useRef(
- PanResponder.create({
- onPanResponderTerminationRequest: () => false,
- }),
- ).current;
-
- const mobileCameraView = () => (
- <>
-
- {(cameraPermissionState === 'prompt' || !cameraPermissionState) && (
-
- )}
-
- {cameraPermissionState === 'denied' && (
-
-
- {translate('receipt.takePhoto')}
- {translate('receipt.cameraAccess')}
-
- )}
- setCameraPermissionState('granted')}
- onUserMediaError={() => setCameraPermissionState('denied')}
- style={{...styles.videoContainer, display: cameraPermissionState !== 'granted' ? 'none' : 'block'}}
- ref={cameraRef}
- screenshotFormat="image/png"
- videoConstraints={{facingMode: {exact: 'environment'}}}
- torchOn={isFlashLightOn}
- onTorchAvailability={setIsTorchAvailable}
- forceScreenshotSourceSize
- cameraTabIndex={pageIndex}
- />
-
-
-
-
- {({openPicker}) => (
- {
- openPicker({
- onPicked: (file) => {
- setReceiptAndNavigate(file, iou, report);
- },
- });
- }}
- >
-
-
- )}
-
-
-
-
-
-
-
-
- >
- );
-
- const desktopUploadView = () => (
- <>
- setReceiptImageTopPosition(PixelRatio.roundToNearestPixel(nativeEvent.layout.top))}>
-
-
-
-
- {translate('receipt.upload')}
-
- {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')}
-
- {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')}
-
-
-
-
- {({openPicker}) => (
-
- >
- );
-
- return (
-
- {!isDraggingOver && (Browser.isMobile() ? mobileCameraView() : desktopUploadView())}
- {
- const file = lodashGet(e, ['dataTransfer', 'files', 0]);
- setReceiptAndNavigate(file, iou, report);
- }}
- receiptImageTopPosition={receiptImageTopPosition}
- />
-
-
- );
-}
-
-ReceiptSelector.defaultProps = defaultProps;
-ReceiptSelector.propTypes = propTypes;
-ReceiptSelector.displayName = 'ReceiptSelector';
-
-export default withOnyx({
- iou: {key: ONYXKEYS.IOU},
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
- },
-})(ReceiptSelector);
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
deleted file mode 100644
index 3cae5389d86f..000000000000
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ /dev/null
@@ -1,303 +0,0 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React, {useCallback, useEffect, useRef, useState} from 'react';
-import {ActivityIndicator, Alert, AppState, Text, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import {RESULTS} from 'react-native-permissions';
-import {useCameraDevices} from 'react-native-vision-camera';
-import Hand from '@assets/images/hand.svg';
-import Shutter from '@assets/images/shutter.svg';
-import AttachmentPicker from '@components/AttachmentPicker';
-import Button from '@components/Button';
-import Icon from '@components/Icon';
-import * as Expensicons from '@components/Icon/Expensicons';
-import ImageSVG from '@components/ImageSVG';
-import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import useLocalize from '@hooks/useLocalize';
-import useTheme from '@hooks/useTheme';
-import useThemeStyles from '@hooks/useThemeStyles';
-import * as FileUtils from '@libs/fileDownload/FileUtils';
-import Log from '@libs/Log';
-import Navigation from '@libs/Navigation/Navigation';
-import {iouDefaultProps, iouPropTypes} from '@pages/iou/propTypes';
-import reportPropTypes from '@pages/reportPropTypes';
-import * as IOU from '@userActions/IOU';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import * as CameraPermission from './CameraPermission';
-import NavigationAwareCamera from './NavigationAwareCamera';
-
-const propTypes = {
- /** React Navigation route */
- route: PropTypes.shape({
- /** Params from the route */
- params: PropTypes.shape({
- /** The type of IOU report, i.e. bill, request, send */
- iouType: PropTypes.string,
-
- /** The report ID of the IOU */
- reportID: PropTypes.string,
- }),
-
- /** The current route path */
- path: PropTypes.string,
- }).isRequired,
-
- /** The report on which the request is initiated on */
- report: reportPropTypes,
-
- /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
- iou: iouPropTypes,
-
- /** The id of the transaction we're editing */
- transactionID: PropTypes.string,
-};
-
-const defaultProps = {
- report: {},
- iou: iouDefaultProps,
- transactionID: '',
-};
-
-function ReceiptSelector({route, report, iou, transactionID}) {
- const theme = useTheme();
- const styles = useThemeStyles();
- const devices = useCameraDevices('wide-angle-camera');
- const device = devices.back;
-
- const camera = useRef(null);
- const [flash, setFlash] = useState(false);
- const [cameraPermissionStatus, setCameraPermissionStatus] = useState(undefined);
-
- const iouType = lodashGet(route, 'params.iouType', '');
- const pageIndex = lodashGet(route, 'params.pageIndex', 1);
-
- const {translate} = useLocalize();
-
- useEffect(() => {
- const refreshCameraPermissionStatus = () => {
- CameraPermission.getCameraPermissionStatus()
- .then(setCameraPermissionStatus)
- .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE));
- };
-
- // Check initial camera permission status
- refreshCameraPermissionStatus();
-
- // Refresh permission status when app gain focus
- const subscription = AppState.addEventListener('change', (appState) => {
- if (appState !== 'active') {
- return;
- }
-
- refreshCameraPermissionStatus();
- });
-
- return () => {
- subscription.remove();
- };
- }, []);
-
- const validateReceipt = (file) => {
- const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(file, 'name', ''));
- if (!CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(fileExtension.toLowerCase())) {
- Alert.alert(translate('attachmentPicker.wrongFileType'), translate('attachmentPicker.notAllowedExtension'));
- return false;
- }
-
- if (lodashGet(file, 'size', 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
- Alert.alert(translate('attachmentPicker.attachmentTooLarge'), translate('attachmentPicker.sizeExceeded'));
- return false;
- }
-
- if (lodashGet(file, 'size', 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) {
- Alert.alert(translate('attachmentPicker.attachmentTooSmall'), translate('attachmentPicker.sizeNotMet'));
- return false;
- }
- return true;
- };
-
- const askForPermissions = () => {
- // There's no way we can check for the BLOCKED status without requesting the permission first
- // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670
- CameraPermission.requestCameraPermission()
- .then((status) => {
- setCameraPermissionStatus(status);
-
- if (status === RESULTS.BLOCKED) {
- FileUtils.showCameraPermissionsAlert();
- }
- })
- .catch(() => {
- setCameraPermissionStatus(RESULTS.UNAVAILABLE);
- });
- };
-
- const takePhoto = useCallback(() => {
- const showCameraAlert = () => {
- Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage'));
- };
-
- if (!camera.current) {
- showCameraAlert();
- return;
- }
-
- camera.current
- .takePhoto({
- qualityPrioritization: 'speed',
- flash: flash ? 'on' : 'off',
- })
- .then((photo) => {
- const filePath = `file://${photo.path}`;
- IOU.setMoneyRequestReceipt(filePath, photo.path);
-
- const onSuccess = (receipt) => {
- IOU.replaceReceipt(transactionID, receipt, filePath);
- };
-
- if (transactionID) {
- FileUtils.readFileAsync(filePath, photo.path, onSuccess);
- Navigation.dismissModal();
- return;
- }
-
- IOU.navigateToNextPage(iou, iouType, report, route.path);
- })
- .catch((error) => {
- showCameraAlert();
- Log.warn('Error taking photo', error);
- });
- }, [flash, iouType, iou, report, translate, transactionID, route.path]);
-
- // Wait for camera permission status to render
- if (cameraPermissionStatus == null) {
- return null;
- }
-
- return (
-
- {cameraPermissionStatus !== RESULTS.GRANTED && (
-
-
- {translate('receipt.takePhoto')}
- {translate('receipt.cameraAccess')}
-
-
- )}
- {cameraPermissionStatus === RESULTS.GRANTED && device == null && (
-
-
-
- )}
- {cameraPermissionStatus === RESULTS.GRANTED && device != null && (
-
-
-
-
-
- )}
-
-
- {({openPicker}) => (
- {
- openPicker({
- onPicked: (file) => {
- if (!validateReceipt(file)) {
- return;
- }
- const filePath = file.uri;
- IOU.setMoneyRequestReceipt(filePath, file.name);
-
- if (transactionID) {
- IOU.replaceReceipt(transactionID, file, filePath);
- Navigation.dismissModal();
- return;
- }
-
- IOU.navigateToNextPage(iou, iouType, report, route.path);
- },
- });
- }}
- >
-
-
- )}
-
-
-
-
- setFlash((prevFlash) => !prevFlash)}
- >
-
-
-
-
- );
-}
-
-ReceiptSelector.defaultProps = defaultProps;
-ReceiptSelector.propTypes = propTypes;
-ReceiptSelector.displayName = 'ReceiptSelector';
-
-export default withOnyx({
- iou: {
- key: ONYXKEYS.IOU,
- },
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
- },
-})(ReceiptSelector);
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js
index d41442edd670..bbe703e50d18 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.js
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js
@@ -131,7 +131,7 @@ function IOURequestStepConfirmation({
}, [transaction, iouType, requestType, transactionID, reportID]);
const navigateToAddReceipt = useCallback(() => {
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()));
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()));
}, [iouType, transactionID, reportID]);
// When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow.
diff --git a/src/pages/iou/request/step/IOURequestStepRoutePropTypes.js b/src/pages/iou/request/step/IOURequestStepRoutePropTypes.js
index 8153e2644568..dbcf83bda62a 100644
--- a/src/pages/iou/request/step/IOURequestStepRoutePropTypes.js
+++ b/src/pages/iou/request/step/IOURequestStepRoutePropTypes.js
@@ -5,6 +5,9 @@ import CONST from '@src/CONST';
export default PropTypes.shape({
/** Route specific parameters used on this screen via route :iouType/new/category/:reportID? */
params: PropTypes.shape({
+ /** What action is being performed, ie. create, edit */
+ action: PropTypes.oneOf(_.values(CONST.IOU.ACTION)),
+
/** The type of IOU report, i.e. bill, request, send */
iouType: PropTypes.oneOf(_.values(CONST.IOU.TYPE)).isRequired,
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.js b/src/pages/iou/request/step/IOURequestStepScan/index.js
index c51727f0143b..c0c96826d124 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.js
@@ -12,6 +12,7 @@ import {DragAndDropContext} from '@components/DragAndDrop/Provider';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -38,17 +39,22 @@ const propTypes = {
/* Onyx Props */
/** The report that the transaction belongs to */
report: reportPropTypes,
+
+ /** The transaction (or draft transaction) being changed */
+ transaction: transactionPropTypes,
};
const defaultProps = {
report: {},
+ transaction: {},
};
function IOURequestStepScan({
report,
route: {
- params: {iouType, reportID, transactionID, backTo},
+ params: {action, iouType, reportID, transactionID, backTo},
},
+ transaction: {isFromGlobalCreate},
}) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -108,40 +114,55 @@ function IOURequestStepScan({
Navigation.goBack(backTo || ROUTES.HOME);
};
- /**
- * Sets the Receipt objects and navigates the user to the next page
- * @param {Object} file
- */
- const setReceiptAndNavigate = (file) => {
- if (!validateReceipt(file)) {
+ const navigateToConfirmationStep = useCallback(() => {
+ if (backTo) {
+ Navigation.goBack(backTo);
return;
}
- const fileSource = URL.createObjectURL(file);
- IOU.setMoneyRequestReceipt_temporaryForRefactor(transactionID, fileSource, file.name);
-
- if (backTo) {
- Navigation.goBack(backTo);
+ // If the transaction was created from the global create, the person needs to select participants, so take them there.
+ if (isFromGlobalCreate) {
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
return;
}
- // When an existing transaction is being edited (eg. not the create transaction flow)
- if (transactionID !== CONST.IOU.OPTIMISTIC_TRANSACTION_ID) {
- IOU.replaceReceipt(transactionID, file, fileSource);
+ // If the transaction was created from the + menu from the composer inside of a chat, the participants can automatically
+ // be added to the transaction (taken from the chat report participants) and then the person is taken to the confirmation step.
+ IOU.setMoneyRequestParticipantsFromReport(transactionID, report);
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID));
+ }, [iouType, report, reportID, transactionID, isFromGlobalCreate, backTo]);
+
+ const updateScanAndNavigate = useCallback(
+ (file, source) => {
+ IOU.replaceReceipt(transactionID, file, source);
+ if (backTo) {
+ Navigation.goBack(backTo);
+ return;
+ }
Navigation.dismissModal();
+ },
+ [backTo, transactionID],
+ );
+
+ /**
+ * Sets the Receipt objects and navigates the user to the next page
+ * @param {Object} file
+ */
+ const setReceiptAndNavigate = (file) => {
+ if (!validateReceipt(file)) {
return;
}
- // If a reportID exists in the report object, it's because the user started this flow from using the + button in the composer
- // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight
- // to the confirm step.
- if (report.reportID) {
- IOU.setMoneyRequestParticipantsFromReport(transactionID, report);
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID));
+ // Store the receipt on the transaction object in Onyx
+ const source = URL.createObjectURL(file);
+ IOU.setMoneyRequestReceipt(transactionID, source, file.name, action !== CONST.IOU.ACTION.EDIT);
+
+ if (action === CONST.IOU.ACTION.EDIT) {
+ updateScanAndNavigate(file, source);
return;
}
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
+ navigateToConfirmationStep();
};
const capturePhoto = useCallback(() => {
@@ -150,33 +171,17 @@ function IOURequestStepScan({
}
const imageBase64 = cameraRef.current.getScreenshot();
const filename = `receipt_${Date.now()}.png`;
- const imageFile = FileUtils.base64ToFile(imageBase64, filename);
- const fileSource = URL.createObjectURL(imageFile);
- IOU.setMoneyRequestReceipt_temporaryForRefactor(transactionID, fileSource, imageFile.name);
-
- if (backTo) {
- Navigation.goBack(backTo);
- return;
- }
-
- // When an existing transaction is being edited (eg. not the create transaction flow)
- if (transactionID !== CONST.IOU.OPTIMISTIC_TRANSACTION_ID) {
- IOU.replaceReceipt(transactionID, imageFile, fileSource);
- Navigation.dismissModal();
- return;
- }
+ const file = FileUtils.base64ToFile(imageBase64, filename);
+ const source = URL.createObjectURL(file);
+ IOU.setMoneyRequestReceipt(transactionID, source, file.name, action !== CONST.IOU.ACTION.EDIT);
- // If a reportID exists in the report object, it's because the user started this flow from using the + button in the composer
- // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight
- // to the confirm step.
- if (report.reportID) {
- IOU.setMoneyRequestParticipantsFromReport(transactionID, report);
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID));
+ if (action === CONST.IOU.ACTION.EDIT) {
+ updateScanAndNavigate(file, source);
return;
}
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
- }, [cameraRef, report, iouType, transactionID, reportID, backTo]);
+ navigateToConfirmationStep();
+ }, [cameraRef, action, transactionID, updateScanAndNavigate, navigateToConfirmationStep]);
const panResponder = useRef(
PanResponder.create({
diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
index 66896fc178bf..38bcd16faf39 100644
--- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js
+++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js
@@ -11,6 +11,7 @@ import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import ImageSVG from '@components/ImageSVG';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import transactionPropTypes from '@components/transactionPropTypes';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -36,17 +37,22 @@ const propTypes = {
/* Onyx Props */
/** The report that the transaction belongs to */
report: reportPropTypes,
+
+ /** The transaction (or draft transaction) being changed */
+ transaction: transactionPropTypes,
};
const defaultProps = {
report: {},
+ transaction: {},
};
function IOURequestStepScan({
report,
route: {
- params: {iouType, reportID, transactionID, backTo},
+ params: {action, iouType, reportID, transactionID, backTo},
},
+ transaction: {isFromGlobalCreate},
}) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -118,7 +124,57 @@ function IOURequestStepScan({
});
};
- const takePhoto = useCallback(() => {
+ const navigateBack = () => {
+ Navigation.goBack();
+ };
+
+ const navigateToConfirmationStep = useCallback(() => {
+ if (backTo) {
+ Navigation.goBack(backTo);
+ return;
+ }
+
+ // If the transaction was created from the global create, the person needs to select participants, so take them there.
+ if (isFromGlobalCreate) {
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
+ return;
+ }
+
+ // If the transaction was created from the + menu from the composer inside of a chat, the participants can automatically
+ // be added to the transaction (taken from the chat report participants) and then the person is taken to the confirmation step.
+ IOU.setMoneyRequestParticipantsFromReport(transactionID, report);
+ Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID));
+ }, [iouType, report, reportID, transactionID, isFromGlobalCreate, backTo]);
+
+ const updateScanAndNavigate = useCallback(
+ (file, source) => {
+ Navigation.dismissModal();
+ IOU.replaceReceipt(transactionID, file, source);
+ },
+ [transactionID],
+ );
+
+ /**
+ * Sets the Receipt objects and navigates the user to the next page
+ * @param {Object} file
+ */
+ const setReceiptAndNavigate = (file) => {
+ if (!validateReceipt(file)) {
+ return;
+ }
+
+ // Store the receipt on the transaction object in Onyx
+ IOU.setMoneyRequestReceipt(transactionID, file.uri, file.name, action !== CONST.IOU.ACTION.EDIT);
+
+ if (action === CONST.IOU.ACTION.EDIT) {
+ updateScanAndNavigate(file, file.uri);
+ return;
+ }
+
+ navigateToConfirmationStep();
+ };
+
+ const capturePhoto = useCallback(() => {
const showCameraAlert = () => {
Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage'));
};
@@ -134,51 +190,30 @@ function IOURequestStepScan({
flash: flash ? 'on' : 'off',
})
.then((photo) => {
- const filePath = `file://${photo.path}`;
- IOU.setMoneyRequestReceipt_temporaryForRefactor(transactionID, filePath, photo.path);
-
- if (backTo) {
- Navigation.goBack(backTo);
- return;
- }
-
- const onSuccess = (receipt) => {
- IOU.replaceReceipt(transactionID, receipt, filePath);
- };
-
- // When an existing transaction is being edited (eg. not the create transaction flow)
- if (transactionID !== CONST.IOU.OPTIMISTIC_TRANSACTION_ID) {
- FileUtils.readFileAsync(filePath, photo.path, onSuccess);
- Navigation.dismissModal();
- return;
- }
-
- // If a reportID exists in the report object, it's because the user started this flow from using the + button in the composer
- // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight
- // to the confirm step.
- if (report.reportID) {
- IOU.setMoneyRequestParticipantsFromReport(transactionID, report);
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID));
+ // Store the receipt on the transaction object in Onyx
+ const source = `file://${photo.path}`;
+ IOU.setMoneyRequestReceipt(transactionID, source, photo.path, action !== CONST.IOU.ACTION.EDIT);
+
+ if (action === CONST.IOU.ACTION.EDIT) {
+ FileUtils.readFileAsync(source, photo.path, (file) => {
+ updateScanAndNavigate(file, source);
+ });
return;
}
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
+ navigateToConfirmationStep();
})
.catch((error) => {
showCameraAlert();
Log.warn('Error taking photo', error);
});
- }, [flash, iouType, report, translate, transactionID, reportID, backTo]);
+ }, [flash, action, translate, transactionID, updateScanAndNavigate, navigateToConfirmationStep]);
// Wait for camera permission status to render
if (cameraPermissionStatus == null) {
return null;
}
- const navigateBack = () => {
- Navigation.goBack(backTo || ROUTES.HOME);
- };
-
return (
{
openPicker({
- onPicked: (file) => {
- if (!validateReceipt(file)) {
- return;
- }
- const filePath = file.uri;
- IOU.setMoneyRequestReceipt_temporaryForRefactor(transactionID, filePath, file.name);
-
- if (backTo) {
- Navigation.goBack(backTo);
- return;
- }
-
- // When a transaction is being edited (eg. not in the creation flow)
- if (transactionID !== CONST.IOU.OPTIMISTIC_TRANSACTION_ID) {
- IOU.replaceReceipt(transactionID, file, filePath);
- Navigation.dismissModal();
- return;
- }
-
- // If a reportID exists in the report object, it's because the user started this flow from using the + button in the composer
- // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight
- // to the confirm step.
- if (report.reportID) {
- IOU.setMoneyRequestParticipantsFromReport(transactionID, report);
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(iouType, transactionID, reportID));
- return;
- }
-
- Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
- },
+ onPicked: setReceiptAndNavigate,
});
}}
>
@@ -286,7 +292,7 @@ function IOURequestStepScan({
role={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={translate('receipt.shutter')}
style={[styles.alignItemsCenter]}
- onPress={takePhoto}
+ onPress={capturePhoto}
>
`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${lodashGet(route, 'params.transactionID', 0)}`,
+ key: ({route}) => {
+ const transactionID = lodashGet(route, 'params.transactionID', 0);
+ return `${transactionID === CONST.IOU.OPTIMISTIC_TRANSACTION_ID ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
+ },
},
})(WithFullTransactionOrNotFoundWithRef);
}
diff --git a/src/pages/settings/Security/SecuritySettingsPage.js b/src/pages/settings/Security/SecuritySettingsPage.tsx
similarity index 69%
rename from src/pages/settings/Security/SecuritySettingsPage.js
rename to src/pages/settings/Security/SecuritySettingsPage.tsx
index 392a264977c6..ad563282d0cd 100644
--- a/src/pages/settings/Security/SecuritySettingsPage.js
+++ b/src/pages/settings/Security/SecuritySettingsPage.tsx
@@ -1,42 +1,22 @@
-import PropTypes from 'prop-types';
import React, {useMemo} from 'react';
import {ScrollView, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
import * as Expensicons from '@components/Icon/Expensicons';
import IllustratedHeaderPageLayout from '@components/IllustratedHeaderPageLayout';
import LottieAnimations from '@components/LottieAnimations';
import MenuItemList from '@components/MenuItemList';
-import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWaitForNavigation from '@hooks/useWaitForNavigation';
-import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
-import ONYXKEYS from '@src/ONYXKEYS';
+import type {TranslationPaths} from '@src/languages/types';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
-const propTypes = {
- ...withLocalizePropTypes,
-
- /* Onyx Props */
-
- /** Holds information about the users account that is logging in */
- account: PropTypes.shape({
- /** Whether this account has 2FA enabled or not */
- requiresTwoFactorAuth: PropTypes.bool,
- }),
-};
-
-const defaultProps = {
- account: {},
-};
-
-function SecuritySettingsPage(props) {
+function SecuritySettingsPage() {
const theme = useTheme();
const styles = useThemeStyles();
- const {translate} = props;
+ const {translate} = useLocalize();
const waitForNavigate = useWaitForNavigation();
const menuItems = useMemo(() => {
@@ -53,13 +33,13 @@ function SecuritySettingsPage(props) {
},
];
- return _.map(baseMenuItems, (item) => ({
+ return baseMenuItems.map((item) => ({
key: item.translationKey,
- title: translate(item.translationKey),
+ title: translate(item.translationKey as TranslationPaths),
icon: item.icon,
- iconRight: item.iconRight,
onPress: item.action,
shouldShowRightIcon: true,
+ link: '',
}));
}, [translate, waitForNavigate]);
@@ -83,13 +63,6 @@ function SecuritySettingsPage(props) {
);
}
-SecuritySettingsPage.propTypes = propTypes;
-SecuritySettingsPage.defaultProps = defaultProps;
SecuritySettingsPage.displayName = 'SettingSecurityPage';
-export default compose(
- withLocalize,
- withOnyx({
- account: {key: ONYXKEYS.ACCOUNT},
- }),
-)(SecuritySettingsPage);
+export default SecuritySettingsPage;
diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js
index cd1f4591a61a..f1c3fbe90533 100644
--- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js
+++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js
@@ -118,8 +118,7 @@ const defaultProps = {
formID={ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM}
submitButtonText={submitButtonText}
onSubmit={onSubmit}
- style={styles.flex1}
- submitButtonStyles={[styles.mh5]}
+ style={[styles.flex1, styles.mh5]}
validate={onValidate}
>
{children}
diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardName.js b/src/pages/settings/Wallet/Card/GetPhysicalCardName.js
index 5b954d432cce..abe94e7a0c2f 100644
--- a/src/pages/settings/Wallet/Card/GetPhysicalCardName.js
+++ b/src/pages/settings/Wallet/Card/GetPhysicalCardName.js
@@ -82,7 +82,6 @@ function GetPhysicalCardName({
role={CONST.ACCESSIBILITY_ROLE.TEXT}
autoCapitalize="words"
defaultValue={legalFirstName}
- containerStyles={[styles.mh5]}
shouldSaveDraft
/>
diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js
index ef0eb3e4eddc..27897c08d125 100644
--- a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js
+++ b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js
@@ -1,4 +1,3 @@
-import {parsePhoneNumber} from 'awesome-phonenumber';
import Str from 'expensify-common/lib/str';
import PropTypes from 'prop-types';
import React from 'react';
@@ -7,8 +6,8 @@ import _ from 'underscore';
import InputWrapper from '@components/Form/InputWrapper';
import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
-import useThemeStyles from '@hooks/useThemeStyles';
import FormUtils from '@libs/FormUtils';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -43,7 +42,6 @@ function GetPhysicalCardPhone({
params: {domain},
},
}) {
- const styles = useThemeStyles();
const {translate} = useLocalize();
const onValidate = (values) => {
@@ -75,7 +73,6 @@ function GetPhysicalCardPhone({
aria-label={translate('getPhysicalCard.phoneNumber')}
role={CONST.ACCESSIBILITY_ROLE.TEXT}
defaultValue={phoneNumber}
- containerStyles={[styles.mh5]}
shouldSaveDraft
/>
diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js
index 1ac12dca0a09..8fcea461eacd 100644
--- a/src/pages/signin/LoginForm/BaseLoginForm.js
+++ b/src/pages/signin/LoginForm/BaseLoginForm.js
@@ -1,5 +1,4 @@
import {useIsFocused} from '@react-navigation/native';
-import {parsePhoneNumber} from 'awesome-phonenumber';
import Str from 'expensify-common/lib/str';
import PropTypes from 'prop-types';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
@@ -25,6 +24,7 @@ import * as ErrorUtils from '@libs/ErrorUtils';
import isInputAutoFilled from '@libs/isInputAutoFilled';
import Log from '@libs/Log';
import * as LoginUtils from '@libs/LoginUtils';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import Visibility from '@libs/Visibility';
diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js
index 6496fbecfc9f..c7a1da7b64ff 100644
--- a/src/pages/workspace/WorkspaceInvitePage.js
+++ b/src/pages/workspace/WorkspaceInvitePage.js
@@ -1,3 +1,4 @@
+import Str from 'expensify-common/lib/str';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useEffect, useMemo, useState} from 'react';
@@ -14,8 +15,10 @@ import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
+import * as LoginUtils from '@libs/LoginUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
+import {parsePhoneNumber} from '@libs/PhoneNumber';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
@@ -141,8 +144,10 @@ function WorkspaceInvitePage(props) {
filterSelectedOptions = _.filter(selectedOptions, (option) => {
const accountID = lodashGet(option, 'accountID', null);
const isOptionInPersonalDetails = _.some(personalDetails, (personalDetail) => personalDetail.accountID === accountID);
+ const parsedPhoneNumber = parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm)));
+ const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number.e164 : searchTerm.toLowerCase();
- const isPartOfSearchTerm = option.text.toLowerCase().includes(searchTerm.trim().toLowerCase());
+ const isPartOfSearchTerm = option.text.toLowerCase().includes(searchValue) || option.login.toLowerCase().includes(searchValue);
return isPartOfSearchTerm || isOptionInPersonalDetails;
});
}
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 5bbf26f79c46..919fc696217c 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -3331,6 +3331,13 @@ const styles = (theme: ThemeColors) =>
verticalAlign: 'middle',
},
+ stickyHeaderEmoji: (isSmallScreenWidth: boolean, windowWidth: number) =>
+ ({
+ position: 'absolute',
+ width: isSmallScreenWidth ? windowWidth - 32 : CONST.EMOJI_PICKER_SIZE.WIDTH - 32,
+ ...spacing.mh4,
+ } satisfies ViewStyle),
+
reactionCounterText: {
fontSize: 13,
marginLeft: 4,
diff --git a/src/types/modules/appleAuth.d.ts b/src/types/modules/appleAuth.d.ts
new file mode 100644
index 000000000000..1394768d613e
--- /dev/null
+++ b/src/types/modules/appleAuth.d.ts
@@ -0,0 +1,29 @@
+type ClientConfig = {
+ clientId?: string;
+ redirectURI?: string;
+ scope?: string;
+ state?: string;
+ nonce?: string;
+ usePopup?: boolean;
+};
+
+type Auth = {
+ init: (config: ClientConfig) => void;
+ signIn: (signInConfig?: ClientConfig) => Promise;
+ renderButton: () => void;
+};
+
+type AppleID = {
+ auth: Auth;
+};
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+ interface Window {
+ AppleID: AppleID;
+ appleAuthScriptLoaded: boolean;
+ }
+}
+
+// We used the export {} line to mark this file as an external module
+export {};
diff --git a/src/types/modules/dom.d.ts b/src/types/modules/dom.d.ts
new file mode 100644
index 000000000000..60bd9c9ae983
--- /dev/null
+++ b/src/types/modules/dom.d.ts
@@ -0,0 +1,24 @@
+type AppleIDSignInOnSuccessEvent = {
+ detail: {
+ authorization: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ id_token: string;
+ };
+ };
+};
+
+type AppleIDSignInOnFailureEvent = {
+ detail: {
+ error: string;
+ };
+};
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+ interface DocumentEventMap extends GlobalEventHandlersEventMap {
+ AppleIDSignInOnSuccess: AppleIDSignInOnSuccessEvent;
+ AppleIDSignInOnFailure: AppleIDSignInOnFailureEvent;
+ }
+}
+
+export type {AppleIDSignInOnFailureEvent, AppleIDSignInOnSuccessEvent};
diff --git a/src/types/modules/google.d.ts b/src/types/modules/google.d.ts
new file mode 100644
index 000000000000..3c29e62bf9b3
--- /dev/null
+++ b/src/types/modules/google.d.ts
@@ -0,0 +1,35 @@
+type Response = {
+ credential: string;
+};
+
+type Initialize = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ client_id: string;
+ callback: (response: Response) => void;
+};
+
+type Options = {
+ theme?: 'outline';
+ size?: 'large';
+ type?: 'standard' | 'icon';
+ shape?: 'circle' | 'pill';
+ width?: string;
+};
+
+type Google = {
+ accounts: {
+ id: {
+ initialize: ({client_id, callback}: Initialize) => void;
+ renderButton: (client_id, options: Options) => void;
+ };
+ };
+};
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+ interface Window {
+ google: Google;
+ }
+}
+
+export default Response;
diff --git a/src/types/modules/navigator.d.ts b/src/types/modules/navigator.d.ts
new file mode 100644
index 000000000000..aca32a543b67
--- /dev/null
+++ b/src/types/modules/navigator.d.ts
@@ -0,0 +1,9 @@
+declare global {
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+ interface Navigator {
+ userLanguage: string;
+ }
+}
+
+// We used the export {} line to mark this file as an external module
+export {};
diff --git a/src/types/onyx/Network.ts b/src/types/onyx/Network.ts
index 5af4c1170c3f..32b084bbf2f7 100644
--- a/src/types/onyx/Network.ts
+++ b/src/types/onyx/Network.ts
@@ -7,6 +7,9 @@ type Network = {
/** Whether we should fail all network requests */
shouldFailAllRequests?: boolean;
+
+ /** Skew between the client and server clocks */
+ timeSkew?: number;
};
export default Network;
diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts
index a9af3339eeb4..da4522487a7a 100644
--- a/src/types/onyx/Policy.ts
+++ b/src/types/onyx/Policy.ts
@@ -62,6 +62,9 @@ type Policy = {
/** The custom units data for this policy */
customUnits?: Record;
+ /** Whether chat rooms can be created and used on this policy. Enabled manually by CQ/JS snippet. Always true for free policies. */
+ areChatRoomsEnabled: boolean;
+
/** Whether policy expense chats can be created and used on this policy. Enabled manually by CQ/JS snippet. Always true for free policies. */
isPolicyExpenseChatEnabled: boolean;
diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts
index fa87e1d194c2..03d44b1ce94c 100644
--- a/src/types/onyx/Request.ts
+++ b/src/types/onyx/Request.ts
@@ -4,6 +4,7 @@ import type Response from './Response';
type OnyxData = {
successData?: OnyxUpdate[];
failureData?: OnyxUpdate[];
+ finallyData?: OnyxUpdate[];
optimisticData?: OnyxUpdate[];
};
@@ -17,6 +18,7 @@ type RequestData = {
shouldUseSecure?: boolean;
successData?: OnyxUpdate[];
failureData?: OnyxUpdate[];
+ finallyData?: OnyxUpdate[];
idempotencyKey?: string;
resolve?: (value: Response) => void;
diff --git a/tests/perf-test/OptionsSelector.perf-test.js b/tests/perf-test/OptionsSelector.perf-test.js
index 557a0baf1ba4..da706d7bb629 100644
--- a/tests/perf-test/OptionsSelector.perf-test.js
+++ b/tests/perf-test/OptionsSelector.perf-test.js
@@ -20,22 +20,20 @@ jest.mock('../../src/components/withLocalize', () => (Component) => {
return WrappedComponent;
});
-jest.mock('../../src/components/withNavigation', () => (Component) => {
- function withNavigation(props) {
+jest.mock('../../src/components/withNavigationFocus', () => (Component) => {
+ function WithNavigationFocus(props) {
return (
jest.fn(),
- }}
+ isFocused={false}
/>
);
}
- withNavigation.displayName = 'withNavigation';
- return withNavigation;
+ WithNavigationFocus.displayName = 'WithNavigationFocus';
+
+ return WithNavigationFocus;
});
const generateSections = (sectionConfigs) =>
@@ -120,10 +118,10 @@ test('[OptionsSelector] should scroll and press few items', () => {
const eventData = generateEventData(100, variables.optionRowHeight);
const eventData2 = generateEventData(200, variables.optionRowHeight);
- const scenario = async (screen) => {
+ const scenario = (screen) => {
fireEvent.press(screen.getByText('Item 10'));
fireEvent.scroll(screen.getByTestId('options-list'), eventData);
- fireEvent.press(await screen.findByText('Item 100'));
+ fireEvent.press(screen.getByText('Item 100'));
fireEvent.scroll(screen.getByTestId('options-list'), eventData2);
fireEvent.press(screen.getByText('Item 200'));
};
diff --git a/tests/unit/PhoneNumberTest.js b/tests/unit/PhoneNumberTest.js
new file mode 100644
index 000000000000..f720dc6a88e1
--- /dev/null
+++ b/tests/unit/PhoneNumberTest.js
@@ -0,0 +1,43 @@
+import {parsePhoneNumber} from '@libs/PhoneNumber';
+
+describe('PhoneNumber', () => {
+ describe('parsePhoneNumber', () => {
+ it('Should return valid phone number', () => {
+ const validNumbers = [
+ '+1 (234) 567-8901',
+ '+12345678901',
+ '+54 11 8765-4321',
+ '+49 30 123456',
+ '+44 20 8759 9036',
+ '+34 606 49 95 99',
+ ' + 1 2 3 4 5 6 7 8 9 0 1',
+ '+ 4 4 2 0 8 7 5 9 9 0 3 6',
+ '+1 ( 2 3 4 ) 5 6 7 - 8 9 0 1',
+ ];
+
+ validNumbers.forEach((givenPhone) => {
+ const parsedPhone = parsePhoneNumber(givenPhone);
+ expect(parsedPhone.valid).toBe(true);
+ expect(parsedPhone.possible).toBe(true);
+ });
+ });
+ it('Should return invalid phone number if US number has extra 1 after country code', () => {
+ const validNumbers = ['+1 1 (234) 567-8901', '+112345678901', '+115550123355', '+ 1 1 5 5 5 0 1 2 3 3 5 5'];
+
+ validNumbers.forEach((givenPhone) => {
+ const parsedPhone = parsePhoneNumber(givenPhone);
+ expect(parsedPhone.valid).toBe(false);
+ expect(parsedPhone.possible).toBe(false);
+ });
+ });
+ it('Should return invalid phone number', () => {
+ const invalidNumbers = ['+165025300001', 'John Doe', '123', 'email@domain.com'];
+
+ invalidNumbers.forEach((givenPhone) => {
+ const parsedPhone = parsePhoneNumber(givenPhone);
+ expect(parsedPhone.valid).toBe(false);
+ expect(parsedPhone.possible).toBe(false);
+ });
+ });
+ });
+});
diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js
index 7403df3ef57f..535fb018dbc3 100644
--- a/tests/utils/LHNTestUtils.js
+++ b/tests/utils/LHNTestUtils.js
@@ -252,6 +252,7 @@ function getFakePolicy(id = 1, name = 'Workspace-Test-001') {
avatar: '',
employeeList: [],
isPolicyExpenseChatEnabled: true,
+ areChatRoomsEnabled: true,
lastModified: 1697323926777105,
autoReporting: true,
autoReportingFrequency: 'immediate',
diff --git a/tests/utils/collections/policies.ts b/tests/utils/collections/policies.ts
index d9da92d26e36..96e13f915c49 100644
--- a/tests/utils/collections/policies.ts
+++ b/tests/utils/collections/policies.ts
@@ -7,6 +7,7 @@ export default function createRandomPolicy(index: number): Policy {
id: index.toString(),
name: randWord(),
type: rand(Object.values(CONST.POLICY.TYPE)),
+ areChatRoomsEnabled: randBoolean(),
autoReporting: randBoolean(),
isPolicyExpenseChatEnabled: randBoolean(),
autoReportingFrequency: rand(Object.values(CONST.POLICY.AUTO_REPORTING_FREQUENCIES)),