diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
index 4f0c3b75c0ea..e2076c029f70 100644
--- a/.github/actionlint.yaml
+++ b/.github/actionlint.yaml
@@ -2,7 +2,7 @@
self-hosted-runner:
labels:
- ubuntu-latest-xl
- - macos-13-large
- - macos-13-xlarge
+ - macos-15-large
+ - macos-15-xlarge
- ubuntu-latest-reassure-tests
- macos-12
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 86bfa195cd75..d90df954b216 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -137,7 +137,7 @@ jobs:
- name: Setup Node
id: setup-node
uses: ./.github/actions/composite/setupNode
- with:
+ with:
IS_HYBRID_BUILD: 'true'
- name: Run grunt build
@@ -166,8 +166,9 @@ jobs:
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
run: |
- op document get --output ./upload-key.keystore upload-key.keystore
- op document get --output ./android-fastlane-json-key.json android-fastlane-json-key.json
+ op read op://Mobile-Deploy-CI/firebase.json/firebase.json --force --out-file ./firebase.json
+ op read op://Mobile-Deploy-CI/upload-key.keystore/upload-key.keystore --force --out-file ./upload-key.keystore
+ op read op://Mobile-Deploy-CI/android-fastlane-json-key.json/android-fastlane-json-key.json --force --out-file ./android-fastlane-json-key.json
# Copy the keystore to the Android directory for Fullstory
cp ./upload-key.keystore Mobile-Expensify/Android
@@ -218,7 +219,7 @@ jobs:
else
echo "Skipping the completion of the previous version rollout"
fi
-
+
# Submit the new version for review and slow rollout when it's approved
bundle exec fastlane android upload_google_play_production_hybrid_rollout
env:
@@ -230,6 +231,31 @@ jobs:
env:
BROWSERSTACK: ${{ secrets.BROWSERSTACK }}
+ - name: Generate APK from AAB
+ run: |
+ json=$(curl -s https://api.github.com/repos/google/bundletool/releases/latest)
+ downloadUrl=$(echo "$json" | jq -r ".assets | .[].browser_download_url")
+ curl "$downloadUrl" -4 -sL -o 'bundletool.jar'
+ java -jar bundletool.jar build-apks --bundle=${{ env.aabPath }} --output=Expensify.apks \
+ --mode=universal \
+ --ks=upload-key.keystore \
+ --ks-pass=pass:${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_PASSWORD }} \
+ --ks-key-alias=${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEYSTORE_ALIAS }} \
+ --key-pass=pass:${{ steps.load-credentials.outputs.ANDROID_UPLOAD_KEY_PASSWORD }}
+
+ unzip -p Expensify.apks universal.apk > Expensify.apk
+
+ - name: Upload Android APK build artifact
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: android-hybrid-apk-artifact
+ path: Expensify.apk
+
+ - name: Upload Android build to Firebase distribution
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ run: bundle exec fastlane android upload_firebase_distribution
+
- name: Upload Android build artifact
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
uses: actions/upload-artifact@v4
@@ -313,8 +339,8 @@ jobs:
name: Build and deploy iOS
needs: prep
env:
- DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer
- runs-on: macos-13-xlarge
+ DEVELOPER_DIR: /Applications/Xcode_16.2.0.app/Contents/Developer
+ runs-on: macos-15-xlarge
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -327,7 +353,7 @@ jobs:
uses: ./.github/actions/composite/setupNode
- name: Setup Ruby
- uses: ruby/setup-ruby@v1.190.0
+ uses: ruby/setup-ruby@v1.204.0
with:
bundler-cache: true
@@ -428,9 +454,9 @@ jobs:
iOS_hybrid:
name: Build and deploy iOS HybridApp
needs: prep
- runs-on: macos-13-xlarge
+ runs-on: macos-15-xlarge
env:
- DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer
+ DEVELOPER_DIR: /Applications/Xcode_16.2.0.app/Contents/Developer
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -451,11 +477,11 @@ jobs:
- name: Setup Node
id: setup-node
uses: ./.github/actions/composite/setupNode
- with:
+ with:
IS_HYBRID_BUILD: 'true'
- name: Setup Ruby
- uses: ruby/setup-ruby@v1.190.0
+ uses: ruby/setup-ruby@v1.204.0
with:
bundler-cache: true
@@ -488,9 +514,10 @@ jobs:
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
run: |
- op document get --output ./OldApp_AppStore.mobileprovision OldApp_AppStore
- op document get --output ./OldApp_AppStore_Share_Extension.mobileprovision OldApp_AppStore_Share_Extension
- op document get --output ./OldApp_AppStore_Notification_Service.mobileprovision OldApp_AppStore_Notification_Service
+ op read op://Mobile-Deploy-CI/firebase.json/firebase.json --force --out-file ./firebase.json
+ op read op://Mobile-Deploy-CI/OldApp_AppStore/OldApp_AppStore.mobileprovision --force --out-file ./OldApp_AppStore.mobileprovision
+ op read op://Mobile-Deploy-CI/OldApp_AppStore_Share_Extension/OldApp_AppStore_Share_Extension.mobileprovision --force --out-file ./OldApp_AppStore_Share_Extension.mobileprovision
+ op read op://Mobile-Deploy-CI/OldApp_AppStore_Notification_Service/OldApp_AppStore_Notification_Service.mobileprovision --force --out-file ./OldApp_AppStore_Notification_Service.mobileprovision
- name: Decrypt AppStore profile
run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg
@@ -537,8 +564,8 @@ jobs:
run: |
# Complete the previous version rollout
bundle exec fastlane ios complete_hybrid_rollout
-
- # Submit the new version for review and phased rollout when it's approved
+
+ # Submit the new version for review and phased rollout when it's approved
bundle exec fastlane ios submit_hybrid_for_rollout
env:
VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }}
@@ -549,6 +576,10 @@ jobs:
env:
BROWSERSTACK: ${{ secrets.BROWSERSTACK }}
+ - name: Upload iOS build to Firebase distribution
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ run: bundle exec fastlane ios upload_firebase_distribution
+
- name: Upload iOS build artifact
if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
uses: actions/upload-artifact@v4
@@ -756,6 +787,7 @@ jobs:
"./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap.js.map"
"./android-aab-artifact/app-production-release.aab#android.aab"
"./android-hybrid-build-artifact/Expensify-release.aab#android-hybrid.aab"
+ "./android-hybrid-apk-artifact/Expensify.apk#android-hybrid.apk"
"./android-hybrid-sourcemap-artifact/index.android.bundle.map#android-hybrid-sourcemap.js.map"
"./desktop-staging-sourcemaps-artifact/desktop-staging-merged-source-map.js.map#desktop-staging-sourcemap.js.map"
"./desktop-staging-build-artifact/NewExpensify.dmg#desktop-staging.dmg"
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index 1ce493715b78..b05a308c49de 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -141,8 +141,8 @@ jobs:
needs: [validateActor, getBranchRef]
if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
env:
- DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer
- runs-on: macos-13-xlarge
+ DEVELOPER_DIR: /Applications/Xcode_16.2.0.app/Contents/Developer
+ runs-on: macos-15-xlarge
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -163,10 +163,10 @@ jobs:
uses: ./.github/actions/composite/setupNode
- name: Setup XCode
- run: sudo xcode-select -switch /Applications/Xcode_15.2.0.app
+ run: sudo xcode-select -switch /Applications/Xcode_16.2.0.app
- name: Setup Ruby
- uses: ruby/setup-ruby@v1.190.0
+ uses: ruby/setup-ruby@v1.204.0
with:
bundler-cache: true
diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml
index d9f22ecb9178..58398223e190 100644
--- a/.github/workflows/testBuildHybrid.yml
+++ b/.github/workflows/testBuildHybrid.yml
@@ -197,8 +197,8 @@ jobs:
needs: [validateActor, getBranchRef]
if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
env:
- DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer
- runs-on: macos-13-xlarge
+ DEVELOPER_DIR: /Applications/Xcode_16.2.0.app/Contents/Developer
+ runs-on: macos-15-xlarge
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -235,7 +235,7 @@ jobs:
echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
- name: Setup Ruby
- uses: ruby/setup-ruby@v1.190.0
+ uses: ruby/setup-ruby@v1.204.0
with:
bundler-cache: true
diff --git a/Gemfile.lock b/Gemfile.lock
index 3c0202ff325c..53a966923ee9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -165,6 +165,9 @@ GEM
apktools (~> 0.7)
aws-sdk-s3 (~> 1)
mime-types (~> 3.3)
+ fastlane-plugin-firebase_app_distribution (0.10.0)
+ google-apis-firebaseappdistribution_v1 (~> 0.3.0)
+ google-apis-firebaseappdistribution_v1alpha (~> 0.2.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
ffi (1.17.0)
@@ -184,6 +187,10 @@ GEM
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
+ google-apis-firebaseappdistribution_v1 (0.3.0)
+ google-apis-core (>= 0.11.0, < 2.a)
+ google-apis-firebaseappdistribution_v1alpha (0.2.0)
+ google-apis-core (>= 0.11.0, < 2.a)
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
@@ -229,7 +236,7 @@ GEM
molinillo (0.8.0)
multi_json (1.15.0)
multipart-post (2.4.1)
- nanaimo (0.4.0)
+ nanaimo (0.3.0)
nap (1.1.0)
naturally (2.2.1)
netrc (0.11.0)
@@ -274,12 +281,12 @@ GEM
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
- xcodeproj (1.27.0)
+ xcodeproj (1.25.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
- nanaimo (~> 0.4.0)
+ nanaimo (~> 0.3.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
@@ -298,6 +305,7 @@ DEPENDENCIES
cocoapods (= 1.15.2)
fastlane (~> 2, >= 2.222.0)
fastlane-plugin-aws_s3
+ fastlane-plugin-firebase_app_distribution
xcodeproj (< 1.26.0)
xcpretty (~> 0)
diff --git a/Mobile-Expensify b/Mobile-Expensify
index b6ef9f667f25..1ff20b6812d2 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit b6ef9f667f25bfad2f932a020fbfc8ec5dd57e5a
+Subproject commit 1ff20b6812d20869b877b5cc48d01440054b3923
diff --git a/android/app/build.gradle b/android/app/build.gradle
index e144ee7d5082..8dbf42ce117d 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -110,8 +110,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009008200
- versionName "9.0.82-0"
+ versionCode 1009008401
+ versionName "9.0.84-1"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/assets/images/bolt-slash.svg b/assets/images/bolt-slash.svg
index 268e1b98c4c6..2637fdf5f5d8 100644
--- a/assets/images/bolt-slash.svg
+++ b/assets/images/bolt-slash.svg
@@ -1,9 +1 @@
-
-
+
\ No newline at end of file
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index ecb3d3a45b48..9307b3e712d8 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -131,7 +131,7 @@ details[open] > summary {
h1,
summary {
- font-family: 'Expensify Mono', 'SFMono-Regular', monospace, Arial, sans-serif;
+ font-family: 'Expensify New Kansas', 'Helvetica Neue', 'Helvetica', Arial, sans-serif;
font-weight: 500;
font-size: larger;
}
diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md
index 964664c1d519..5d1eda3c0819 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-payments/payments/Receive-and-Pay-Bills.md
@@ -42,7 +42,7 @@ Expensify makes it easy to manage and pay vendor bills with a straightforward wo
## Bill Pay Workflow
1. **SmartScan & Create**: When a vendor sends a bill, Expensify automatically SmartScans the document and creates a bill.
-2. **Submission to Primary Contact**: The bill is submitted to the primary contact, who can review it on the Reports page under their default group policy.
+2. **Submission to Primary Contact**: The bill is submitted to the primary contact, who can review it on the Reports page under their default group workspace.
3. **Communication**: If the approver needs clarification, they can communicate directly with the sender via the invoice linked to the bill.
4. **Approval Workflow**: Once reviewed, the bill follows your workspace’s approval process. The final approver handles the payment.
5. **Accounting Integration**: During approval, the bill is coded with the correct GL codes from your connected accounting software. Once approved, it can be exported back to your accounting system.
diff --git a/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md b/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
index 90d3970cbc94..ed390e928fa4 100644
--- a/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
+++ b/docs/articles/expensify-classic/connections/quickbooks-desktop/Configure-Quickbooks-Desktop.md
@@ -22,9 +22,9 @@ The following steps will determine how data will be exported from Expensify to Q
- **Check**: A single itemized check for each Expensify report.
- **Journal Entry**: A single itemized journal entry for each Expensify report.
- **Non-reimbursable expenses**: Non-reimbursable options include:
+ - **Credit Card**: Each expense appears as a separate credit card transaction with a post date that matches your credit card statement. If you centrally manage company cards through your domain, you can export expenses from each card to a specific QuickBooks account by clicking Edit Exports next to each user’s card. To display the merchant name in the payee field in QuickBooks Desktop, ensure that a matching Vendor exists in QuickBooks. Expensify searches for an exact match during export. If no match is found, the payee is mapped to a Credit Card Misc. Vendor created by Expensify.
+ - **Check**: Expenses are exported as individual itemized checks for each Expensify report. The check is written to the “vendor,” which is the person who created or submitted the report in Expensify.
- **Vendor Bill**: Each Expensify report results in a single itemized vendor bill. The bill is associated with the “vendor,” which is the individual responsible for creating or submitting the report in Expensify.
- - **Credit Card expenses**: Each expense appears as a separate credit card transaction with a post date that matches your credit card statement. If you centrally manage company cards through your domain, you can export expenses from each card to a specific QuickBooks account by clicking Edit Exports next to each user’s card. To display the merchant name in the payee field in QuickBooks Desktop, ensure that a matching Vendor exists in QuickBooks. Expensify searches for an exact match during export. If no match is found, the payee is mapped to a Credit Card Misc. Vendor created by Expensify.
- - **Debit Card expenses**: Expenses are exported as individual itemized checks for each Expensify report. The check is written to the “vendor,” which is the person who created or submitted the report in Expensify.
# Step 2: Configure coding/import settings
diff --git a/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md b/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md
index d7fa33221834..7a704f024ce7 100644
--- a/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md
+++ b/docs/articles/expensify-classic/expensify-card/Deactivate-or-cancel-an-Expensify-Card.md
@@ -4,25 +4,41 @@ description: Close an Expensify Card
---
-A cardholder or a Domain Admin can cancel an Expensify Card. You may want to cancel a card:
-- To cancel an old Expensify Card after upgrading to the new Expensify Visa® Commercial Card
+A cardholder can cancel an Expensify Card themselves, or a Domain Admin can deactivate it. You may want to cancel or deactivate a card:
- After a fraudulent or suspicious charge
+- When an Expensify Card is lost or damaged
- After an employee leaves the company
+# Cardholders
+
+To cancel an Expensify Card assigned to you,
+
+1. Hover over Settings, then click **Account**.
+2. Click the **Credit Card Import** tab.
+3. Click **Request a New Card** next to the card.
+4. Choose a reason.
+5. Confirm your address details for shipping a new card.
+6. Consult this [guide](https://help.expensify.com/articles/expensify-classic/expensify-card/Dispute-A-Transaction) for how to dispute fraudulent transactions (where relevant).
+
# Domain Admins
-To cancel an employee's Expensify Card as a Domain Admin,
+To deactivate an employee's Expensify Card as a Domain Admin,
1. Hover over Settings, then click **Domains**.
2. Click the name of the domain.
-3. Next to the card, click **Terminate**.
+3. Next to the card, click **Edit Limit**.
+4. Ensure the Custom Smart Limit toggle is enabled to be able to set a specific card limit. Otherwise, the card limit will be determined by the limit set for the group that the employee is in.
+5. In the Limit Amount field, set the limit to $0. The card will be disabled for use until the limit is increased.
+6. Click **Save**.
-# Cardholders
+Note: If you have concerns about fraudulent access to a Domain Admin's user account, please message Concierge or email concierge@expensify.com immediately. If necessary, our support team can manually suspend Expensify cards outside of the Expensify Domain as a temporary measure if your account is compromised.
-To cancel an Expensify Card assigned to you,
+# Terminating an old Expensify Card after upgrading to the new Expensify Visa® Commercial Card
-1. Hover over Settings, then click **Account**.
-2. Click the **Credit Card Import** tab.
-3. Click **Cancel** next to the card.
+To terminate old Expensify Cards that have since been upgraded,
+
+1. Hover over Settings, then click **Domains**.
+2. Click the name of the domain.
+3. Next to the card, click **Terminate**.
diff --git a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md
index a507ff263c92..abbd51f3efb7 100644
--- a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md
+++ b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md
@@ -39,10 +39,10 @@ When you log in to Expensify in the future, you’ll be emailed a magic code tha
If you have lost your mobile device and can’t find your recovery codes, your Domain Admin can complete the steps below to reset your 2FA **only if (1) you use a company email address or email address on a domain that you own and (2) the Domain Admin also has 2FA enabled**:
-Go to Settings > Domains > Domain Members and click **Edit Settings** for your email address.
-They then click **Reset** to reset two-factor authentication (2FA) on your account.
+If your domain has 2FA enabled, a domain admin can follow Settings > Domains > Domain Members and click **Edit Settings** for your email address.
+They can then click **Reset** to reset two-factor authentication (2FA) on your account. This will allow you to gain access to your account on the web or mobile app and configure 2FA again.
-This will allow you to gain access to your account on the web or mobile app and reconfigure 2FA again.
+If your domain does not have 2FA enabled, a domain admin can follow Settings > Domains > Domain Members and enable Two Factor Authentication. Then they can follow the previously mentioned steps to reset 2FA for your account.
{% include info.html %}
If you use a public email address such as gmail, hotmail, or yahoo, we unfortunately can’t help you disable your 2FA setting. If you are unable to find your recovery codes, you may need to create a new Expensify account with a different email address.
diff --git a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
index eac2723e5c9c..5636d1e49f4c 100644
--- a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
+++ b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
@@ -82,7 +82,7 @@ Enter any of the following formulas into the Formula field for each column. Be s
| {report:title} | Would output "Expense Expenses to 2019-11-05" assuming that is the report's title.|
| Report ID | Number is a unique number per report and can be used to identify specific reports.|
| {report:id} | Would output R00I7J3xs5fn assuming that is the report's ID.|
-| Old Report ID | A unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either [base10](https://community.expensify.com/home/leaving?allowTrusted=1&target=https%3A%2F%2Fwww.twinkl.co.uk%2Fteaching-wiki%2Fbase-10) or base62. |
+| Old Report ID | A unique number per report and can be used to identify specific reports as well. Every report has both an ID and an old ID - they're simply different ways of showing the same information in either base10 or base62. |
| {report:oldID} | Would output R3513250790654885 assuming that is the report's old ID.|
| Reimbursement ID | The unique number for a report that's been reimbursed via ACH in Expensify. The reimbursement ID is searchable on the Reports page and is found on your bank statement in the line-item detail for the reimbursed amount.|
| {report:reimbursementid} | Would output 123456789109876 assuming that is the ID on the line-item detail for the reimbursed amount in your business bank account.|
@@ -100,7 +100,7 @@ Enter any of the following formulas into the Formula field for each column. Be s
| Created date | The expense report was originally created by the user.|
| {report:created} | Would output 2010-09-15 12:00:00 assuming the expense report was created on September 15th, 2010 at noon.|
| {report:created:yyyy-MM-dd} | Would output 2010-09-15 assuming the expense report was created on September 15, 2010.|
-| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://community.expensify.com/discussion/5799/deep-dive-date-formating-for-formulas/p1?new=1).|
+| | Note - All Date Formulas accept an optional format string. The default if one is not provided is yyyy-MM-dd hh:mm:ss. For a full breakdown, check out the Date Formatting [here](https://help.expensify.com/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports#date-formats).|
| StartDate | Is the date of the earliest expense on the report.|
| {report:startdate} | Would output 2010-09-15 assuming that is the date of the earliest expense on the report.|
| EndDate| Is the date of the last expense on the report.|
diff --git a/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md b/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
index 497c618442b1..3b32f33266e7 100644
--- a/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
+++ b/docs/articles/new-expensify/connections/quickbooks-online/Quickbooks-Online-Troubleshooting.md
@@ -3,7 +3,9 @@ title: QuickBooks Online Troubleshooting
description: A list of common QuickBooks Online errors and how to resolve them
---
-## Report won’t automatically export to QuickBooks Online
+Occasionally, you might run into errors when exporting reports or syncing QuickBooks Online with Expensify. Below, you'll find detailed instructions to help you troubleshoot and resolve the most common connection and export issues quickly.
+
+# Issue: Report won’t automatically export to QuickBooks Online
If an error occurs during an automatic export to QuickBooks Online:
@@ -13,7 +15,7 @@ If an error occurs during an automatic export to QuickBooks Online:
An error on a report will prevent it from automatically exporting.
-### How to resolve
+## How to resolve
Open the expense and make the required changes. Then an admin must manually export the report to QuickBooks Online by clicking Details > Export.
@@ -21,32 +23,210 @@ Open the expense and make the required changes. Then an admin must manually expo
![Select QuickBooks Online in the Export tab](https://help.expensify.com/assets/images/QBO_help_03.png){:width="100%"}
-## Unable to manually export a report
+# Issue: Unable to manually export a report
To export a report, it must be in the Approved, Closed, or Reimbursed state. If it is in the Open state, clicking “Export” will lead to an empty page, as the data is not yet available for export:
![If the Report is in the Open status, the Not Ready to Export message shows](https://help.expensify.com/assets/images/QBO_help_04.png){:width="100%"}
-### How to resolve
+## How to resolve
Open the report and make the required changes:
1. If the report is in the Open status, ensure that it is submitted.
2. If the Report is in the Processing status, an admin or approver will need to approve it.
-Once this is done, an admin must manually export the report to QuickBooks Online.
+Once this is done, Workspace Admins must manually export the report to QuickBooks Online.
+
+# Error: When exporting billable expenses, please make sure the account in QuickBooks Online has been marked as billable
+
+**Why does this happen?**
+
+This error occurs when the account applied as a category to the expense in Expensify is not marked as a billable type account.
+
+## How to resolve
+1. Log in to QuickBooks Online.
+2. Click the Gear in the upper right-hand corner.
+3. Under Company Settings, click Expenses.
+4. Enable the option “Make expenses and items billable.”
+5. Click on the pencil icon on the right to check if you have "In multiple accounts" selected:
+6. If "In multiple accounts" is selected, go to Chart of Accounts and click Edit for the account in question.
+7. Check the billable option and select an income account within your Chart of Accounts.
+8. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**.
+9. Open the report and click on Details, then the Export button to re-export the data to QuickBooks Online.
+
+# Error: Feature Not Included in Subscription
+
+**Why does this happen?**
+
+This error occurs when your version of QuickBooks Online doesn’t support the feature you are using in Expensify.
+
+## How to resolve
+
+Though you will see all of these features available in Expensify, you will receive an error trying to export to QuickBooks if you have a feature enabled that isn't available with your QuickBooks Online subscription.
+
+**Here is a list of the features supported by each version:**
+![QuickBooks Online - Subscription types]({{site.url}}/assets/images/QBO1.png){:width="100%"}
+
+_Please note: Self-employed is not supported._
+
+# Error: Expenses are not categorized with a QuickBooks Online account
+
+**Why does this happen?**
+
+QuickBooks Online requires all expenses exported from Expensify to use a category matching an account in your Chart of Accounts. If a category from another source is used, QuickBooks Online will reject the expense. This error occurs when an expense on the report has a category applied that is not valid in QuickBooks Online.
+
+## How to resolve
+
+1. Sync your QuickBooks Online connection in Expensify from **Settings > Workspaces > Workspace Name > Accounting**, and click the **Sync Now** button.
+2. Review your expenses. If any appear with a red _Category no longer valid_ violation, recategorize the expense until all expenses are violation-free.
+3. Click the **Details** tab, then the **Export** button to export the data to QuickBooks Online.
+ - If you receive the same error, continue to the next step.
+4. Note the categories used on the expenses and check the **Settings > Workspaces > Workspace Name > Categories** page to confirm the exact categories used on the report are enabled and connected to QuickBooks Online (you'll see a green QB icon next to all connected categories).
+5. Confirm the categories used on the expenses in the report match exactly the accounts in your QuickBooks Online chart of accounts.
+6. If you make any changes in QuickBooks Online or in Expensify, always sync the connection and then try to export again.
+
+# Error: Error Creating Vendor
+
+**Why does this happen?**
+
+This error occurs when you have an Employee Record set up with the employee's name. This prevents the Expensify integration from automatically creating the Vendor Record with the same name since QuickBooks Online won't allow you to have an employee and vendor with the same name.
+
+## How to resolve
+
+There are two different ways you can resolve this error.
+
+**Option 1**:
+1. Log into QuickBooks Online.
+2. Access the Employee Records for your submitters.
+3. Edit the name to differentiate them from the name they have on their account in Expensify.
+4. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**.
+5. Open the report and click on the Details tab, then the Export button to export the data to QuickBooks Online.
+
+**Option 2**:
+1. Log into QuickBooks Online.
+2. Manually create all of your Vendor Records, making sure that the email matches the email address associated with the user in Expensify.
+
+With this option, we recommend disabling _Automatically Create Entities_ under **Settings > Workspaces > Workspace Name > Accounting > Configure > Advanced**. That way, you will receive the corresponding error messages if a vendor record doesn't exist.
+
+# Error: When You Use Accounts Payable, You Must Choose a Vendor in the Name Field
+
+**Why does this happen?**
+
+This error occurs when you are exporting reimbursable expenses as Journal Entries against an A/P account and also use Employee Records in QuickBooks Online.
+
+## How to resolve
+
+There are three different ways you can resolve this error:
+- **Option 1**: Under **Settings > Workspaces > Workspace Name > Accounting > Configure > Export tab**, select a different type of export for reimbursable expenses.
+- **Option 2**: Enable _Automatically Create Entities_ under **Settings > Workspaces > Workspace Name > Accounting > Configure > Advanced** to create vendor records automatically.
+- **Option 3**: Manually create vendor records in QuickBooks Online for each employee.
+
+# Error: Items marked as billable must have sales information checked
+
+**Why does this happen?**
+
+This error occurs when an Item category on an expense does not have sales information in QuickBooks Online.
+
+## How to resolve
+
+1. Log into QuickBooks Online.
+2. Navigate to your items list.
+3. Click **Edit** to the right of the item used on the report with the error. Here you will see an option to check either "Sales" or "Purchasing".
+4. Check the option for **Sales**.
+5. Select an income account.
+6. Save your changes.
+7. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**.
+8. Open the report, click on Details, and then click the Export button to re-export the data to QuickBooks Online.
+
+# Error: Couldn't Connect to QuickBooks Online
+
+**Why does this happen?**
+
+This error occurs when the QuickBooks Online credentials used to make the connection have changed.
+
+_Note: This error message can also show up as, "QuickBooks Reconnect error: OAuth Token rejected.”_
+
+## How to resolve
+
+1. Navigate to **Settings > Workspaces > Workspace Name > Accounting**.
+2. Click the **Sync Now** button.
+3. In the pop-up window, click **Reconnect** and enter your current QuickBooks Online credentials.
+
+If you are connecting with new credentials, you will need to reconfigure your settings and re-select the categories and tags you want enabled. We recommend taking a screenshot of your configuration settings beforehand so that you can reset the connection with those settings.
+
+# Error: Duplicate Document Number, This bill number has already been used.
+
+**Why does this happen?**
+
+This error happens when QuickBooks Online is set to flag duplicate document numbers.
+
+## How to resolve
+
+1. Log into QuickBooks Online.
+2. Navigate to Settings > Advanced.
+3. Under the Other Preferences section, make sure "Warn if duplicate bill number is used" is set to "Off."
+4. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**.
+5. Open the report and click on Details, then the Export button to re-export the data to QuickBooks Online
+
+# Error: The transaction needs to be in the same currency as the A/R and A/P accounts
+
+**Why does this happen?**
+
+This error occurs because the currency on the Vendor record in QuickBooks Online doesn't match the currency on the A/P account.
+
+## How to resolve
+
+1. Log into QuickBooks Online.
+2. Open the vendor record.
+3. Update the record to use with the correct A/P account, currency, and email matching their Expensify email.
+
+_Note: You can find the correct Vendor record by exporting your QuickBooks Online vendor list to a spreadsheet (click the export icon on the right-hand side of the page), and searching for the email address of the person who submitted the report._
+
+If you have multiple vendors with different currencies with the same email, Expensify is likely trying to export to the wrong one.
+
+**In that case, run through the following steps**:
+1. Try removing the email address from the vendor in QuickBooks Online that you aren't trying to export to.
+2. Sync your QuickBooks Online connection in **Settings > Workspaces > Workspace Name > Accounting**.
+3. Open the report and click on Details, then the Export button to re-export the data to QuickBooks Online.
+
+**If this still fails, you'll need to confirm that the A/P account selected in Expensify is set to the correct currency for the export**:
+1. Navigate to **Settings > Workspaces > Workspace Name > Accounting**.
+2. Under the Exports tab check that both A/P accounts are the correct currency.
+
{% include faq-begin.md %}
-**How do I disconnect the QuickBooks Online connection?**
+# Why are company card expenses exported to the wrong account in QuickBooks Online?
+Multiple factors could be causing your company card transactions to export to the wrong place in your accounting system, but the best place to start is always the same.
+
+1. Confirm that the company cards have been mapped to the correct accounts in Settings > Domains > Company Cards > click the **Edit Export** button for the card to view the account.
+2. Make sure the expenses in question have been imported from the company card.
+ - Only expenses with the Card+Lock icon next to them will export according to the mapping settings that you configure in the domain settings.
+
+It’s important to note that expenses imported from a card linked at the individual account level, expenses created from a SmartScanned receipt, and manually created cash expenses will export to the default bank account selected in your accounting connection's configuration settings.
+
+**Is the report exporter a domain admin?**
+
+The user exporting the report must be a domain admin. You can check the history and comment section at the bottom of the report to see who exported the report:
+- If your reports are being exported automatically by Concierge, the user listed as the Preferred Exporter under **Settings > Workspaces > Workspace Name > Accounting > Export** must also be a domain admin.
+- If the report exporter is not a domain admin, all company card expenses will be exported to the account set in **Settings > Workspaces > Workspace Name > Accounting > Export Company Card Expenses As**.
+
+# How do I disconnect the QuickBooks Online connection?
+
+You can disconnect QuickBooks Online from Expensify by running through the following steps:
1. Click your profile image or icon in the bottom left menu.
2. Scroll down and click **Workspaces** in the left menu.
3. Select the workspace you want to disconnect from QuickBooks Online.
4. Click **Accounting** in the left menu.
-5. Click the three dot menu icon to the right of QuickBooks Online and select **Disconnect**.
+5. Click the three-dot menu icon to the right of QuickBooks Online and select **Disconnect**.
6. Click **Disconnect** to confirm.
-You will no longer see the imported options from QuickBooks Online.
+Once you disconnect from QuickBooks, that will clear all of the previously imported options from Expensify.
+
+# Can I export negative expenses to QuickBooks Online?
+
+Yes, in general, you can export negative expenses successfully to QuickBooks Online regardless of which export method you choose.
{% include faq-end.md %}
diff --git a/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md
index 77587cc124f0..f933edfd6d3a 100644
--- a/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md
+++ b/docs/articles/new-expensify/expenses-&-payments/Approve-expenses.md
@@ -6,19 +6,19 @@ description: Approve, hold, and unapprove submitted expenses
Expenses can be created through manual entry, tracking distance, or scanning a receipt. They can be submitted to an individual or a workspace.
-This help article has more details about creating and submitting an expense to an individual or a workspace.
+This [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Create-an-expense) has more details about creating and submitting an expense to an individual or a workspace.
# Receiving an expense from an Individual
When an expense is submitted to an individual, it doesn’t need approval. It only needs to be paid.
-This help article has the steps to pay the expense.
+This [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-expense) has the steps to pay the expense.
# Receiving a workspace expense
-When an expense is submitted to a workspace with an “approval workflow”, it must be approved before it can be paid.
+When an expense is submitted to a workspace with an **approval workflow**, it must be approved before it can be paid.
-As a workspace admin, you can set an [approval workflow](https://help.expensify.com/articles/new-expensify/workspaces/Add-approvals) in the workspace settings. For each expense report, you’ll have the option to:
+A workspace admin can set an [approval workflow](https://help.expensify.com/articles/new-expensify/workspaces/Add-approvals) in the workspace settings. For each expense report, there will be an option to:
- **Approve:** Click Approve if you’re satisfied with the expense details.
- **Hold the expense:** If you need to delay a payment or provide more information before approval, you can hold an expense.
@@ -29,11 +29,11 @@ As a workspace admin, you can set an [approval workflow](https://help.expensify.
{% include selector.html values="desktop, mobile" %}
{% include option.html value="desktop or WebApp" %}
-1. When an expense is submitted, you will receive an email and in-app notification with the details of the expense.
+1. When an expense is submitted, the approver will receive an email and in-app notification with the details of the expense.
2. Click the expense in the email to be directed to New Expensify, where you can review it.
3. Click on the expense to view the receipt, amount, description, and additional details the submitter provides.
4. Click **Approve**.
-5. When you are ready to pay the expense, follow the steps in this help article.
+5. When you are ready to pay the expense, follow the steps in this [help article](https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-expense).
{% include end-option.html %}
{% include option.html value="mobile" %}
@@ -46,7 +46,7 @@ As a workspace admin, you can set an [approval workflow](https://help.expensify.
{% include end-selector.html %}
{% include info.html %}
-If the transaction is pending (a common occurrence with recent company cards or SmartScan expenses), you’ll need to wait until the transaction posts before approving it.
+If the transaction is pending, common for recent Expensify Card transactions or SmartScanning expenses, you’ll need to wait until the transaction posts before approving it.
{% include end-info.html %}
@@ -68,7 +68,7 @@ When you’re ready to remove the hold,
1. Locate the expense on the Search page.
2. Click **View**.
3. Click the drop-down arrow at the top of the expense.
-4. Select **UnHold**.
+4. Select **Unhold**.
5. Complete the steps above to “Approve expenses.” Once the expense has been approved, you can pay it.
{% include end-option.html %}
@@ -85,12 +85,18 @@ When you’re ready to remove the hold,
1. Tap **Search** and select the expense.
2. Tap the drop-down arrow at the top of the expense.
-3. Select **UnHold**.
+3. Select **Unhold**.
4. Complete the steps above to “Approve expenses.” Once the expense has been approved, you can pay it.
{% include end-option.html %}
{% include end-selector.html %}
+![Use Search to find an expense]({{site.url}}/assets/images/search-hold-01.png){:width="100%"}
+![Click on top of expense]({{site.url}}/assets/images/search-hold-02.png){:width="100%"}
+![Click Hold]({{site.url}}/assets/images/search-hold-03.png){:width="100%"}
+![Click Unhold]({{site.url}}/assets/images/search-hold-04.png){:width="100%"}
+![Click Approve]({{site.url}}/assets/images/search-hold-05.png){:width="100%"}
+
{% include info.html %}
Held expenses will not be available for payment until they have been approved.
{% include end-info.html %}
@@ -125,7 +131,7 @@ If the approved expense has already been exported to an accounting package, you
{% include faq-begin.md %}
-**Why is an employee expense showing as ‘pending?’**
+**Why is an employee expense showing as pending?**
An Expensify Card expense will show as pending if the merchant hasn’t posted it. This is usually the case with hotel holds, or card rental holds. A hold will normally last no more than 7-10 business days unless it’s a hotel hold, which can last 31 days.
diff --git a/docs/articles/new-expensify/travel/manage-travel-member-roles.md b/docs/articles/new-expensify/travel/Manage-Travel-Member-Roles.md
similarity index 88%
rename from docs/articles/new-expensify/travel/manage-travel-member-roles.md
rename to docs/articles/new-expensify/travel/Manage-Travel-Member-Roles.md
index 954e24550f05..33e260cb4d90 100644
--- a/docs/articles/new-expensify/travel/manage-travel-member-roles.md
+++ b/docs/articles/new-expensify/travel/Manage-Travel-Member-Roles.md
@@ -2,27 +2,23 @@
title: Manage Travel Member Roles
description: Modify member roles within Expensify Travel
---
-
+
Admins can assign roles to different travel members to determine who they can book travel for (whether for themselves and/or for others) and whether they can adjust administrative settings.
-
-
-
-
To assign a role to a travel member,
1. Click the + icon in the bottom left menu and select **Book travel**.
2. Click **Book or manage travel**.
-3. Click the **Program** tab at the top and select Users.
+3. Click the **Program** tab at the top and select **Users**.
4. Click the name of the member whose role you wish to update.
5. Click the **Roles** tab and select a role.
- **Traveler**: Can only book travel for themselves.
- **Travel Arranger**: Can book travel for themselves and for other workspace members. Arrangers can be set to arrange travel for everyone in the workspace or for specific individuals only.
- **Company Admin**: Can book travel for themselves as well as any other workspace members. They can also access administrative features to:
- Define travel policies
- - Add users
- - Remove users
+ - Add Users
+ - Remove Users
- Add and configure corporate cards as payment methods
- View analytics and metrics
- Use the Safety feature
diff --git a/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png b/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png
index e3c08b9133b8..270d6538146b 100644
Binary files a/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png and b/docs/assets/images/ExpensifyHelp-AttendeeTracking-1.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-1.png b/docs/assets/images/ExpensifyHelp-CreateExpense-1.png
index 7b6459440d5e..5f78346b2dfe 100644
Binary files a/docs/assets/images/ExpensifyHelp-CreateExpense-1.png and b/docs/assets/images/ExpensifyHelp-CreateExpense-1.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-2.png b/docs/assets/images/ExpensifyHelp-CreateExpense-2.png
index 65aaf8017a32..53b5cfc32c25 100644
Binary files a/docs/assets/images/ExpensifyHelp-CreateExpense-2.png and b/docs/assets/images/ExpensifyHelp-CreateExpense-2.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-3.png b/docs/assets/images/ExpensifyHelp-CreateExpense-3.png
index 0173de29d68d..84aeebdfc6fc 100644
Binary files a/docs/assets/images/ExpensifyHelp-CreateExpense-3.png and b/docs/assets/images/ExpensifyHelp-CreateExpense-3.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-4.png b/docs/assets/images/ExpensifyHelp-CreateExpense-4.png
index 901d08f1771d..10349a60ff7d 100644
Binary files a/docs/assets/images/ExpensifyHelp-CreateExpense-4.png and b/docs/assets/images/ExpensifyHelp-CreateExpense-4.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png
index 18318f782466..f6f7af6b7a64 100644
Binary files a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-1.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png
index 641c32a6a6b6..f4fb5e078402 100644
Binary files a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-2.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png
index 48c6f12fb75c..6d9bae5b84cd 100644
Binary files a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-3.png differ
diff --git a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png
index 5f8af1e46ac4..fc79f44b0341 100644
Binary files a/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png and b/docs/assets/images/ExpensifyHelp-CreateExpenseUpdate-4.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Postman-Successful-dryrun-response.png b/docs/assets/images/ExpensifyHelp-Postman-Successful-dryrun-response.png
index 484c7b0d9e33..94a01e129899 100644
Binary files a/docs/assets/images/ExpensifyHelp-Postman-Successful-dryrun-response.png and b/docs/assets/images/ExpensifyHelp-Postman-Successful-dryrun-response.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Billing.png b/docs/assets/images/ExpensifyHelp-Subscription-Billing.png
index 8a8c430e8020..d936b70be8b3 100644
Binary files a/docs/assets/images/ExpensifyHelp-Subscription-Billing.png and b/docs/assets/images/ExpensifyHelp-Subscription-Billing.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Default.png b/docs/assets/images/ExpensifyHelp-Subscription-Default.png
index ae289a8f29f8..a54228f344a0 100644
Binary files a/docs/assets/images/ExpensifyHelp-Subscription-Default.png and b/docs/assets/images/ExpensifyHelp-Subscription-Default.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Details.png b/docs/assets/images/ExpensifyHelp-Subscription-Details.png
index c96b39c4a3ec..352e0bf2bb30 100644
Binary files a/docs/assets/images/ExpensifyHelp-Subscription-Details.png and b/docs/assets/images/ExpensifyHelp-Subscription-Details.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png b/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png
index 3d958edefd3c..1e9fc4e090c0 100644
Binary files a/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png and b/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Subscription.png b/docs/assets/images/ExpensifyHelp-Subscription.png
index 403dd276743f..0321c1f04bff 100644
Binary files a/docs/assets/images/ExpensifyHelp-Subscription.png and b/docs/assets/images/ExpensifyHelp-Subscription.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Workflows-1.png b/docs/assets/images/ExpensifyHelp-Workflows-1.png
index b0841232f77c..2eb2a8a79b04 100644
Binary files a/docs/assets/images/ExpensifyHelp-Workflows-1.png and b/docs/assets/images/ExpensifyHelp-Workflows-1.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Workflows-2.png b/docs/assets/images/ExpensifyHelp-Workflows-2.png
index f7e845fbe81c..64561611c59c 100644
Binary files a/docs/assets/images/ExpensifyHelp-Workflows-2.png and b/docs/assets/images/ExpensifyHelp-Workflows-2.png differ
diff --git a/docs/assets/images/ExpensifyHelp-Workflows-3.png b/docs/assets/images/ExpensifyHelp-Workflows-3.png
index dc3358ab484e..e714a221c421 100644
Binary files a/docs/assets/images/ExpensifyHelp-Workflows-3.png and b/docs/assets/images/ExpensifyHelp-Workflows-3.png differ
diff --git a/docs/assets/images/ExpensifyHelp_OldDot_PayInvoice_1.png b/docs/assets/images/ExpensifyHelp_OldDot_PayInvoice_1.png
index 53c637736c95..5603aa75dfa8 100644
Binary files a/docs/assets/images/ExpensifyHelp_OldDot_PayInvoice_1.png and b/docs/assets/images/ExpensifyHelp_OldDot_PayInvoice_1.png differ
diff --git a/docs/assets/images/ExpensifyHelp_OldDot_PayInvoice_2.png b/docs/assets/images/ExpensifyHelp_OldDot_PayInvoice_2.png
index 92e607756de2..de99d1912570 100644
Binary files a/docs/assets/images/ExpensifyHelp_OldDot_PayInvoice_2.png and b/docs/assets/images/ExpensifyHelp_OldDot_PayInvoice_2.png differ
diff --git a/docs/assets/images/ExpensifyHelp_OldDot_SendInvoice.png b/docs/assets/images/ExpensifyHelp_OldDot_SendInvoice.png
index 402afb86cc40..e64764141d96 100644
Binary files a/docs/assets/images/ExpensifyHelp_OldDot_SendInvoice.png and b/docs/assets/images/ExpensifyHelp_OldDot_SendInvoice.png differ
diff --git a/docs/assets/images/ExpensifyHelp_OldDot_SendInvoice_02.png b/docs/assets/images/ExpensifyHelp_OldDot_SendInvoice_02.png
index 7aeb0fdfb7c5..3e7bffba43d8 100644
Binary files a/docs/assets/images/ExpensifyHelp_OldDot_SendInvoice_02.png and b/docs/assets/images/ExpensifyHelp_OldDot_SendInvoice_02.png differ
diff --git a/docs/assets/images/FAB_track_expense.png b/docs/assets/images/FAB_track_expense.png
index 6ee0cf5abba4..0f2a87529515 100644
Binary files a/docs/assets/images/FAB_track_expense.png and b/docs/assets/images/FAB_track_expense.png differ
diff --git a/docs/assets/images/NetSuite_Configure_06.png b/docs/assets/images/NetSuite_Configure_06.png
index cddfe2fabcd6..ee36eab2553b 100644
Binary files a/docs/assets/images/NetSuite_Configure_06.png and b/docs/assets/images/NetSuite_Configure_06.png differ
diff --git a/docs/assets/images/NetSuite_Configure_08.png b/docs/assets/images/NetSuite_Configure_08.png
index 77690a2c3aa1..af22b0d2738c 100644
Binary files a/docs/assets/images/NetSuite_Configure_08.png and b/docs/assets/images/NetSuite_Configure_08.png differ
diff --git a/docs/assets/images/NetSuite_Configure_09.png b/docs/assets/images/NetSuite_Configure_09.png
index 8da56f22838d..eff9030a646e 100644
Binary files a/docs/assets/images/NetSuite_Configure_09.png and b/docs/assets/images/NetSuite_Configure_09.png differ
diff --git a/docs/assets/images/NetSuite_Connect_Customization_01.png b/docs/assets/images/NetSuite_Connect_Customization_01.png
index 8a0c53b45d7f..5996593a21c2 100644
Binary files a/docs/assets/images/NetSuite_Connect_Customization_01.png and b/docs/assets/images/NetSuite_Connect_Customization_01.png differ
diff --git a/docs/assets/images/NetSuite_Expense_Categories_04.png b/docs/assets/images/NetSuite_Expense_Categories_04.png
index d13e9f95cfea..1fc2cb662ee5 100644
Binary files a/docs/assets/images/NetSuite_Expense_Categories_04.png and b/docs/assets/images/NetSuite_Expense_Categories_04.png differ
diff --git a/docs/assets/images/OldDot - Create & Pay Bills 1.png b/docs/assets/images/OldDot - Create & Pay Bills 1.png
index a880e012408a..cce667607f26 100644
Binary files a/docs/assets/images/OldDot - Create & Pay Bills 1.png and b/docs/assets/images/OldDot - Create & Pay Bills 1.png differ
diff --git a/docs/assets/images/OldDot - Create & Pay Bills 2.png b/docs/assets/images/OldDot - Create & Pay Bills 2.png
index ce022a95c6a1..2ab214d49f4e 100644
Binary files a/docs/assets/images/OldDot - Create & Pay Bills 2.png and b/docs/assets/images/OldDot - Create & Pay Bills 2.png differ
diff --git a/docs/assets/images/OldDot - Create & Pay Bills 3.png b/docs/assets/images/OldDot - Create & Pay Bills 3.png
index 071bcc997934..d83e3236e3ab 100644
Binary files a/docs/assets/images/OldDot - Create & Pay Bills 3.png and b/docs/assets/images/OldDot - Create & Pay Bills 3.png differ
diff --git a/docs/assets/images/Reports_PayExpense_01.png b/docs/assets/images/Reports_PayExpense_01.png
new file mode 100644
index 000000000000..665924146116
Binary files /dev/null and b/docs/assets/images/Reports_PayExpense_01.png differ
diff --git a/docs/assets/images/Reports_PayExpense_02.png b/docs/assets/images/Reports_PayExpense_02.png
new file mode 100644
index 000000000000..e17081de1fd9
Binary files /dev/null and b/docs/assets/images/Reports_PayExpense_02.png differ
diff --git a/docs/assets/images/SageConfigureIntegrationConfigureButton.png b/docs/assets/images/SageConfigureIntegrationConfigureButton.png
index e3ec52bacbb0..7a99190cfd30 100644
Binary files a/docs/assets/images/SageConfigureIntegrationConfigureButton.png and b/docs/assets/images/SageConfigureIntegrationConfigureButton.png differ
diff --git a/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png b/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png
index f126bb10dc51..5345951d7a65 100644
Binary files a/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png and b/docs/assets/images/SageConfigureUserDefinedDimensionsFilter.png differ
diff --git a/docs/assets/images/SageConnectCreatingWorkspace.png b/docs/assets/images/SageConnectCreatingWorkspace.png
index 6084d0a8c7fb..1595b5e2daab 100644
Binary files a/docs/assets/images/SageConnectCreatingWorkspace.png and b/docs/assets/images/SageConnectCreatingWorkspace.png differ
diff --git a/docs/assets/images/SageConnectEnableSage.png b/docs/assets/images/SageConnectEnableSage.png
index 25b43a510c15..9ce8cf4e2b3f 100644
Binary files a/docs/assets/images/SageConnectEnableSage.png and b/docs/assets/images/SageConnectEnableSage.png differ
diff --git a/docs/assets/images/SageConnectEnterCredentials.png b/docs/assets/images/SageConnectEnterCredentials.png
index 63772972290d..bd197c1f1968 100644
Binary files a/docs/assets/images/SageConnectEnterCredentials.png and b/docs/assets/images/SageConnectEnterCredentials.png differ
diff --git a/docs/assets/images/SageConnectSubscriptionSettings.png b/docs/assets/images/SageConnectSubscriptionSettings.png
index 2e74d27c71e6..eaccf66f936f 100644
Binary files a/docs/assets/images/SageConnectSubscriptionSettings.png and b/docs/assets/images/SageConnectSubscriptionSettings.png differ
diff --git a/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png b/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png
index 8750c1ed596b..e4d174780291 100644
Binary files a/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png and b/docs/assets/images/SageConnectTimeandExpenseSequenceNumbers.png differ
diff --git a/docs/assets/images/SageConnectWebServicesAuthorizations.png b/docs/assets/images/SageConnectWebServicesAuthorizations.png
index d0b9a786d1cc..b6b74bb93fbe 100644
Binary files a/docs/assets/images/SageConnectWebServicesAuthorizations.png and b/docs/assets/images/SageConnectWebServicesAuthorizations.png differ
diff --git a/docs/assets/images/Tax Exempt - Classic.png b/docs/assets/images/Tax Exempt - Classic.png
index 0987f5e4ca7d..92c0dc94960c 100644
Binary files a/docs/assets/images/Tax Exempt - Classic.png and b/docs/assets/images/Tax Exempt - Classic.png differ
diff --git a/docs/assets/images/Tax Exempt - New Expensify.png b/docs/assets/images/Tax Exempt - New Expensify.png
index 9ff6673da6b3..6544c08a31b5 100644
Binary files a/docs/assets/images/Tax Exempt - New Expensify.png and b/docs/assets/images/Tax Exempt - New Expensify.png differ
diff --git a/docs/assets/images/Workspace_category_toggle.png b/docs/assets/images/Workspace_category_toggle.png
index c6af6fe183c0..2c858d4a72af 100644
Binary files a/docs/assets/images/Workspace_category_toggle.png and b/docs/assets/images/Workspace_category_toggle.png differ
diff --git a/docs/assets/images/addbankaccount_01.png b/docs/assets/images/addbankaccount_01.png
index d1646127373f..66968cec580e 100644
Binary files a/docs/assets/images/addbankaccount_01.png and b/docs/assets/images/addbankaccount_01.png differ
diff --git a/docs/assets/images/addbankaccount_02.png b/docs/assets/images/addbankaccount_02.png
index 6fe22ca5b0db..fe06a876bfcb 100644
Binary files a/docs/assets/images/addbankaccount_02.png and b/docs/assets/images/addbankaccount_02.png differ
diff --git a/docs/assets/images/addbankaccount_03.png b/docs/assets/images/addbankaccount_03.png
index 3f571621eaa0..3afd2bae3540 100644
Binary files a/docs/assets/images/addbankaccount_03.png and b/docs/assets/images/addbankaccount_03.png differ
diff --git a/docs/assets/images/invoice-bulk-01.png b/docs/assets/images/invoice-bulk-01.png
index 1dbf7fa5088d..885d099bf174 100644
Binary files a/docs/assets/images/invoice-bulk-01.png and b/docs/assets/images/invoice-bulk-01.png differ
diff --git a/docs/assets/images/invoice-bulk-02.png b/docs/assets/images/invoice-bulk-02.png
index 82e388b0125f..238b68f7b4dd 100644
Binary files a/docs/assets/images/invoice-bulk-02.png and b/docs/assets/images/invoice-bulk-02.png differ
diff --git a/docs/assets/images/invoice-bulk-03.png b/docs/assets/images/invoice-bulk-03.png
index f51abec046b7..2e10025b1c82 100644
Binary files a/docs/assets/images/invoice-bulk-03.png and b/docs/assets/images/invoice-bulk-03.png differ
diff --git a/docs/assets/images/invoice-bulk-04.png b/docs/assets/images/invoice-bulk-04.png
index 35e12a095ba6..e5df20e5fd68 100644
Binary files a/docs/assets/images/invoice-bulk-04.png and b/docs/assets/images/invoice-bulk-04.png differ
diff --git a/docs/assets/images/invoice-bulk-05.png b/docs/assets/images/invoice-bulk-05.png
index c7044c259de2..d43da7b9dd74 100644
Binary files a/docs/assets/images/invoice-bulk-05.png and b/docs/assets/images/invoice-bulk-05.png differ
diff --git a/docs/assets/images/invoice_01.png b/docs/assets/images/invoice_01.png
index 8d6091471d21..3333cfa17006 100644
Binary files a/docs/assets/images/invoice_01.png and b/docs/assets/images/invoice_01.png differ
diff --git a/docs/assets/images/invoice_02.png b/docs/assets/images/invoice_02.png
index 97facb60bbb2..ae1b1ec63773 100644
Binary files a/docs/assets/images/invoice_02.png and b/docs/assets/images/invoice_02.png differ
diff --git a/docs/assets/images/invoices_01.png b/docs/assets/images/invoices_01.png
index fc6d5587bb03..67b37b2e8f33 100644
Binary files a/docs/assets/images/invoices_01.png and b/docs/assets/images/invoices_01.png differ
diff --git a/docs/assets/images/invoices_02.png b/docs/assets/images/invoices_02.png
index 29038987c18a..dc2631f91b69 100644
Binary files a/docs/assets/images/invoices_02.png and b/docs/assets/images/invoices_02.png differ
diff --git a/docs/assets/images/invoices_03.png b/docs/assets/images/invoices_03.png
index fd78aa731784..1170a667000d 100644
Binary files a/docs/assets/images/invoices_03.png and b/docs/assets/images/invoices_03.png differ
diff --git a/docs/assets/images/invoices_04.png b/docs/assets/images/invoices_04.png
index d2e301a9d1a5..abafcdafeb1b 100644
Binary files a/docs/assets/images/invoices_04.png and b/docs/assets/images/invoices_04.png differ
diff --git a/docs/assets/images/invoices_05.png b/docs/assets/images/invoices_05.png
index 8eae5efaa9df..cc39a3ce20d1 100644
Binary files a/docs/assets/images/invoices_05.png and b/docs/assets/images/invoices_05.png differ
diff --git a/docs/assets/images/invoices_06.png b/docs/assets/images/invoices_06.png
index 2858227891eb..9c489bb8d9cd 100644
Binary files a/docs/assets/images/invoices_06.png and b/docs/assets/images/invoices_06.png differ
diff --git a/docs/assets/images/profile-picture.png b/docs/assets/images/profile-picture.png
new file mode 100644
index 000000000000..96201c51c973
Binary files /dev/null and b/docs/assets/images/profile-picture.png differ
diff --git a/docs/assets/images/quickbooks-desktop-access-rights.png b/docs/assets/images/quickbooks-desktop-access-rights.png
index bcdd35b8c827..39eb90c16826 100644
Binary files a/docs/assets/images/quickbooks-desktop-access-rights.png and b/docs/assets/images/quickbooks-desktop-access-rights.png differ
diff --git a/docs/assets/images/quickbooks-desktop-advanced-settings.png b/docs/assets/images/quickbooks-desktop-advanced-settings.png
index 181380ed7674..081aa958d0e5 100644
Binary files a/docs/assets/images/quickbooks-desktop-advanced-settings.png and b/docs/assets/images/quickbooks-desktop-advanced-settings.png differ
diff --git a/docs/assets/images/quickbooks-desktop-coding-settings.png b/docs/assets/images/quickbooks-desktop-coding-settings.png
index 7b9fc8086c9f..41fce0180810 100644
Binary files a/docs/assets/images/quickbooks-desktop-coding-settings.png and b/docs/assets/images/quickbooks-desktop-coding-settings.png differ
diff --git a/docs/assets/images/quickbooks-desktop-company-preferences.png b/docs/assets/images/quickbooks-desktop-company-preferences.png
index 31f2be54bfb8..43eff226b28d 100644
Binary files a/docs/assets/images/quickbooks-desktop-company-preferences.png and b/docs/assets/images/quickbooks-desktop-company-preferences.png differ
diff --git a/docs/assets/images/quickbooks-desktop-export-settings.png b/docs/assets/images/quickbooks-desktop-export-settings.png
index 3ff190bc2d60..a1b1d6d16936 100644
Binary files a/docs/assets/images/quickbooks-desktop-export-settings.png and b/docs/assets/images/quickbooks-desktop-export-settings.png differ
diff --git a/docs/assets/images/quickbooks-desktop-exported-report-comments.png b/docs/assets/images/quickbooks-desktop-exported-report-comments.png
index 2b0d2939e4b0..cde2acf397fc 100644
Binary files a/docs/assets/images/quickbooks-desktop-exported-report-comments.png and b/docs/assets/images/quickbooks-desktop-exported-report-comments.png differ
diff --git a/docs/assets/images/quickbooks-desktop-web-connector.png b/docs/assets/images/quickbooks-desktop-web-connector.png
index b2086420edd8..b7275dda4482 100644
Binary files a/docs/assets/images/quickbooks-desktop-web-connector.png and b/docs/assets/images/quickbooks-desktop-web-connector.png differ
diff --git a/docs/assets/images/search-download.png b/docs/assets/images/search-download.png
index eb8591dea110..8d9210a82fba 100644
Binary files a/docs/assets/images/search-download.png and b/docs/assets/images/search-download.png differ
diff --git a/docs/assets/images/search-hold-03.png b/docs/assets/images/search-hold-03.png
index 81fbddcf5d75..0c2014c94a3c 100644
Binary files a/docs/assets/images/search-hold-03.png and b/docs/assets/images/search-hold-03.png differ
diff --git a/docs/assets/images/search-hold-04.png b/docs/assets/images/search-hold-04.png
index e5c1b71c0e37..8fe1df28d92b 100644
Binary files a/docs/assets/images/search-hold-04.png and b/docs/assets/images/search-hold-04.png differ
diff --git a/docs/assets/images/workspace_gl_payroll_codes.png b/docs/assets/images/workspace_gl_payroll_codes.png
index 6b7770dc01b0..2b48f64df5b7 100644
Binary files a/docs/assets/images/workspace_gl_payroll_codes.png and b/docs/assets/images/workspace_gl_payroll_codes.png differ
diff --git a/docs/redirects.csv b/docs/redirects.csv
index 04eba2e6152c..be546f165bf1 100644
--- a/docs/redirects.csv
+++ b/docs/redirects.csv
@@ -609,4 +609,5 @@ https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments
https://help.expensify.com/articles/expensify-classic/settings/Set-Notifications,https://help.expensify.com/articles/expensify-classic/settings/Email-Notifications
https://help.expensify.com/articles/new-expensify/expenses-&-payments/Export-expenses,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Export-download-expenses
https://help.expensify.com/articles/expensify-classic/expenses/Apply-Tax,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes
-https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes
\ No newline at end of file
+https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking,https://help.expensify.com/articles/expensify-classic/workspaces/Track-Taxes
+https://help.expensify.com/articles/new-expensify/travel/manage-travel-member-roles,https://help.expensify.com/articles/new-expensify/travel/Manage-Travel-Member-Roles
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 798e328f73fa..806ffe574031 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -71,7 +71,7 @@ platform :android do
desc "Generate a production HybridApp AAB"
lane :build_hybrid do
- ENV["ENVFILE"]="Mobile-Expensify/.env.production.hybridapp"
+ ENV["ENVFILE"]="Mobile-Expensify/.env.production.hybridapp.android"
gradle(
project_dir: 'Mobile-Expensify/Android',
task: "bundleRelease",
@@ -88,7 +88,7 @@ platform :android do
desc "Generate AdHoc HybridApp apk"
lane :build_adhoc_hybrid do
- ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp"
+ ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp.android"
gradle(
project_dir: 'Mobile-Expensify/Android',
task: 'assembleAdhoc',
@@ -116,7 +116,7 @@ platform :android do
desc "Generate a new local HybridApp APK"
lane :build_local_hybrid do
- ENV["ENVFILE"]=".env.production"
+ ENV["ENVFILE"]="Mobile-Expensify/.env.production.hybridapp.android"
gradle(
project_dir: 'Mobile-Expensify/Android',
task: 'assemble',
@@ -197,6 +197,17 @@ platform :android do
)
end
+ desc "Upload app to Firebase distribution"
+ lane :upload_firebase_distribution do
+ firebase_app_distribution(
+ app: "1:1008697809946:android:2e48f9ffe8d0b6a2",
+ service_credentials_file: "./firebase.json",
+ groups: "applause",
+ android_artifact_path: ENV[KEY_GRADLE_AAB_PATH],
+ android_artifact_type: "AAB"
+ )
+ end
+
desc "Upload HybridApp to Google Play for internal testing"
lane :upload_google_play_internal_hybrid do
# Google is very unreliable, so we retry a few times
@@ -372,7 +383,7 @@ platform :ios do
desc "Build an iOS HybridApp production build"
lane :build_hybrid do
- ENV["ENVFILE"]="Mobile-Expensify/.env.production.hybridapp"
+ ENV["ENVFILE"]="Mobile-Expensify/.env.production.hybridapp.ios"
setupIOSSigningCertificate()
@@ -408,7 +419,7 @@ platform :ios do
desc "Build an iOS HybridApp Adhoc build"
lane :build_adhoc_hybrid do
- ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp"
+ ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp.ios"
setupIOSSigningCertificate()
@@ -454,7 +465,7 @@ platform :ios do
desc "Build an unsigned iOS HybridApp production build"
lane :build_unsigned_hybrid do
- ENV["ENVFILE"]="./Mobile-Expensify/.env.production.hybridapp"
+ ENV["ENVFILE"]="./Mobile-Expensify/.env.production.hybridapp.ios"
build_app(
workspace: "./Mobile-Expensify/iOS/Expensify.xcworkspace",
scheme: "Expensify"
@@ -507,6 +518,16 @@ platform :ios do
sh("echo '{\"ipa_path\": \"#{lane_context[SharedValues::S3_IPA_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../ios_paths.json")
end
+ desc "Upload app to Firebase distribution"
+ lane :upload_firebase_distribution do
+ firebase_app_distribution(
+ app: "1:1008697809946:ios:3ffad71f664f2886",
+ service_credentials_file: "./firebase.json",
+ groups: "applause",
+ ipa_path: ENV[KEY_IPA_PATH],
+ )
+ end
+
desc "Upload app to TestFlight"
lane :upload_testflight do
upload_to_testflight(
diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile
index c6ab0dfb46a4..3844e2a6958c 100644
--- a/fastlane/Pluginfile
+++ b/fastlane/Pluginfile
@@ -3,3 +3,4 @@
# Ensure this file is checked in to source control!
gem 'fastlane-plugin-aws_s3'
+gem 'fastlane-plugin-firebase_app_distribution'
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 46101127a9c0..4ff19211ccd1 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 9.0.82
+ 9.0.84CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.82.0
+ 9.0.84.1FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index b3c1f5286ef7..bc4eedf042cf 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.0.82
+ 9.0.84CFBundleSignature????CFBundleVersion
- 9.0.82.0
+ 9.0.84.1
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 09ab2083a815..faddb3d2cdcc 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.0.82
+ 9.0.84CFBundleVersion
- 9.0.82.0
+ 9.0.84.1NSExtensionNSExtensionPointIdentifier
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 1ec4c0cd21a0..f516193d5246 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1775,7 +1775,7 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- - react-native-keyboard-controller (1.15.0):
+ - react-native-keyboard-controller (1.15.2):
- DoubleConversion
- glog
- hermes-engine
@@ -3307,7 +3307,7 @@ SPEC CHECKSUMS:
react-native-geolocation: b9bd12beaf0ebca61a01514517ca8455bd26fa06
react-native-image-picker: ba5067f7d833b9081102c0a33dd0188eb21d92dc
react-native-key-command: aae312752fcdfaa2240be9a015fc41ce54087546
- react-native-keyboard-controller: 3428e4761623fd6a242d9bf3573112f8ebe92238
+ react-native-keyboard-controller: dbd7fb6a233505f937c9242d6d8bb5ebe659ec32
react-native-launch-arguments: 5f41e0abf88a15e3c5309b8875d6fd5ac43df49d
react-native-netinfo: fb5112b1fa754975485884ae85a3fb6a684f49d5
react-native-pager-view: abc5ef92699233eb726442c7f452cac82f73d0cb
diff --git a/package-lock.json b/package-lock.json
index 655256a64fd6..0b5a9a92fdb0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.82-0",
+ "version": "9.0.84-1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.82-0",
+ "version": "9.0.84-1",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -51,7 +51,7 @@
"date-fns-tz": "^3.2.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "2.0.109",
+ "expensify-common": "2.0.114",
"expo": "52.0.14",
"expo-asset": "^11.0.1",
"expo-av": "^15.0.1",
@@ -93,7 +93,7 @@
"react-native-image-picker": "^7.1.2",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9",
"react-native-key-command": "^1.0.8",
- "react-native-keyboard-controller": "1.15.0",
+ "react-native-keyboard-controller": "1.15.2",
"react-native-launch-arguments": "^4.0.2",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
@@ -104,7 +104,7 @@
"react-native-permissions": "^3.10.0",
"react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf",
"react-native-plaid-link-sdk": "11.11.0",
- "react-native-qrcode-svg": "6.3.11",
+ "react-native-qrcode-svg": "6.3.14",
"react-native-quick-sqlite": "git+https://github.com/margelo/react-native-nitro-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0",
"react-native-reanimated": "3.16.4",
"react-native-release-profiler": "^0.2.1",
@@ -19229,6 +19229,8 @@
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": {
@@ -19777,10 +19779,6 @@
"node": ">= 4"
}
},
- "node_modules/encode-utf8": {
- "version": "1.0.3",
- "license": "MIT"
- },
"node_modules/encodeurl": {
"version": "1.0.2",
"license": "MIT",
@@ -21550,9 +21548,9 @@
}
},
"node_modules/expensify-common": {
- "version": "2.0.109",
- "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.109.tgz",
- "integrity": "sha512-5XTrJxiDSjQhojnJfXH1G+fSgRM92oAJ5HiLo28HppmJQuA350GOONVo88rRalcI029rlYGIMh8WfhMlOuE/gA==",
+ "version": "2.0.114",
+ "resolved": "https://registry.npmjs.org/expensify-common/-/expensify-common-2.0.114.tgz",
+ "integrity": "sha512-sfGUhZp11x/hDlyJ6GzK5Sr4A5zVV5jVrpOey99IpVXRlpKZMPthsa+ll5S0CJQJro4gE4FBb5qf5kLzADV6Mg==",
"license": "MIT",
"dependencies": {
"awesome-phonenumber": "^5.4.0",
@@ -31073,6 +31071,8 @@
},
"node_modules/pngjs": {
"version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -31441,11 +31441,12 @@
}
},
"node_modules/qrcode": {
- "version": "1.5.3",
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
- "encode-utf8": "^1.0.3",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
@@ -32249,9 +32250,9 @@
"license": "MIT"
},
"node_modules/react-native-keyboard-controller": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.15.0.tgz",
- "integrity": "sha512-Laqszs0Uciu9MFkHurLwaHs9kftzUueew75HVOndbdcGR3MbKs2MqKdQEg1AgXSHcGoGg5nKafMOLVIoYjK6kA==",
+ "version": "1.15.2",
+ "resolved": "https://registry.npmjs.org/react-native-keyboard-controller/-/react-native-keyboard-controller-1.15.2.tgz",
+ "integrity": "sha512-ZN151OyMJ2GQkhebARY/5G9rXgSlNCKy+WjS6p4o7S+5ulb4nGzl6UkpEuT7/C6bHDeAjDupdrET9tyyTee3nA==",
"license": "MIT",
"dependencies": {
"react-native-is-edge-to-edge": "^1.1.6"
@@ -32405,18 +32406,19 @@
}
},
"node_modules/react-native-qrcode-svg": {
- "version": "6.3.11",
- "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.11.tgz",
- "integrity": "sha512-bhjh4KT8NTQjJyu/tGaplR53OIqtvUJcWZ713H++GLKRpldNDyywwLVW+HdlGZ3N7jk3TxCchQMDMzndLlV4sA==",
+ "version": "6.3.14",
+ "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.14.tgz",
+ "integrity": "sha512-YtMI/C3Vfhs/3Y/g3DEiEEG7ZOgLNsACiTjKMNYPr66Z1pgTG5Ci7KDoZFYuXwD5OSN19Lxi52QarZdybd0dWw==",
+ "license": "MIT",
"dependencies": {
"prop-types": "^15.8.0",
- "qrcode": "^1.5.1",
+ "qrcode": "^1.5.4",
"text-encoding": "^0.7.0"
},
"peerDependencies": {
"react": "*",
"react-native": ">=0.63.4",
- "react-native-svg": ">=13.2.0"
+ "react-native-svg": ">=14.0.0"
}
},
"node_modules/react-native-quick-sqlite": {
diff --git a/package.json b/package.json
index f3e0112acf77..bf0e29afd252 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.82-0",
+ "version": "9.0.84-1",
"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.",
@@ -116,7 +116,7 @@
"date-fns-tz": "^3.2.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "2.0.109",
+ "expensify-common": "2.0.114",
"expo": "52.0.14",
"expo-asset": "^11.0.1",
"expo-av": "^15.0.1",
@@ -158,7 +158,7 @@
"react-native-image-picker": "^7.1.2",
"react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#cb392140db4953a283590d7cf93b4d0461baa2a9",
"react-native-key-command": "^1.0.8",
- "react-native-keyboard-controller": "1.15.0",
+ "react-native-keyboard-controller": "1.15.2",
"react-native-launch-arguments": "^4.0.2",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
@@ -169,7 +169,7 @@
"react-native-permissions": "^3.10.0",
"react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf",
"react-native-plaid-link-sdk": "11.11.0",
- "react-native-qrcode-svg": "6.3.11",
+ "react-native-qrcode-svg": "6.3.14",
"react-native-quick-sqlite": "git+https://github.com/margelo/react-native-nitro-sqlite#99f34ebefa91698945f3ed26622e002bd79489e0",
"react-native-reanimated": "3.16.4",
"react-native-release-profiler": "^0.2.1",
diff --git a/patches/react-native+0.76.3+027+disable-status-bar-hiding-fixes-keyboard-flicker-in-modals.patch b/patches/react-native+0.76.3+027+disable-status-bar-hiding-fixes-keyboard-flicker-in-modals.patch
deleted file mode 100644
index dd55ed2c88e8..000000000000
--- a/patches/react-native+0.76.3+027+disable-status-bar-hiding-fixes-keyboard-flicker-in-modals.patch
+++ /dev/null
@@ -1,17 +0,0 @@
-diff --git a/node_modules/react-native/Libraries/Components/StatusBar/StatusBar.js b/node_modules/react-native/Libraries/Components/StatusBar/StatusBar.js
-index 53a01ef..d4de477 100644
---- a/node_modules/react-native/Libraries/Components/StatusBar/StatusBar.js
-+++ b/node_modules/react-native/Libraries/Components/StatusBar/StatusBar.js
-@@ -456,9 +456,9 @@ class StatusBar extends React.Component {
- mergedProps.backgroundColor.animated,
- );
- }
-- if (!oldProps || oldProps.hidden.value !== mergedProps.hidden.value) {
-- NativeStatusBarManagerAndroid.setHidden(mergedProps.hidden.value);
-- }
-+ // if (!oldProps || oldProps.hidden.value !== mergedProps.hidden.value) {
-+ // NativeStatusBarManagerAndroid.setHidden(mergedProps.hidden.value);
-+ // }
- // Activities are not translucent by default, so always set if true.
- if (
- (oldProps && oldProps.translucent !== mergedProps.translucent) ||
diff --git a/patches/react-native+0.76.3+028+measureText-full-width-if-wraps.patch b/patches/react-native+0.76.3+027+measureText-full-width-if-wraps.patch
similarity index 100%
rename from patches/react-native+0.76.3+028+measureText-full-width-if-wraps.patch
rename to patches/react-native+0.76.3+027+measureText-full-width-if-wraps.patch
diff --git a/patches/react-native+0.76.3+029+fix-scroll-the-cursor-into-view-when-focus.patch b/patches/react-native+0.76.3+028+fix-scroll-the-cursor-into-view-when-focus.patch
similarity index 100%
rename from patches/react-native+0.76.3+029+fix-scroll-the-cursor-into-view-when-focus.patch
rename to patches/react-native+0.76.3+028+fix-scroll-the-cursor-into-view-when-focus.patch
diff --git a/patches/react-native+0.76.3+030+fix-crash-when-deleting-expense.patch b/patches/react-native+0.76.3+029+fix-crash-when-deleting-expense.patch
similarity index 100%
rename from patches/react-native+0.76.3+030+fix-crash-when-deleting-expense.patch
rename to patches/react-native+0.76.3+029+fix-crash-when-deleting-expense.patch
diff --git a/patches/react-native-image-picker+7.1.2+002+callback-in-completion-block.patch b/patches/react-native-image-picker+7.1.2+002+callback-in-completion-block.patch
new file mode 100644
index 000000000000..6011a582dcf5
--- /dev/null
+++ b/patches/react-native-image-picker+7.1.2+002+callback-in-completion-block.patch
@@ -0,0 +1,150 @@
+diff --git a/node_modules/react-native-image-picker/ios/ImagePickerManager.mm b/node_modules/react-native-image-picker/ios/ImagePickerManager.mm
+index 93e99be..0ef2a8a 100644
+--- a/node_modules/react-native-image-picker/ios/ImagePickerManager.mm
++++ b/node_modules/react-native-image-picker/ios/ImagePickerManager.mm
+@@ -506,81 +506,85 @@ @implementation ImagePickerManager (PHPickerViewControllerDelegate)
+
+ - (void)picker:(PHPickerViewController *)picker didFinishPicking:(NSArray *)results API_AVAILABLE(ios(14))
+ {
+- [picker dismissViewControllerAnimated:YES completion:nil];
+-
+- if (photoSelected == YES) {
+- return;
+- }
+- photoSelected = YES;
+-
+- if (results.count == 0) {
+- dispatch_async(dispatch_get_main_queue(), ^{
+- self.callback(@[@{@"didCancel": @YES}]);
+- });
+- return;
+- }
++ dispatch_block_t dismissCompletionBlock = ^{
+
+- dispatch_group_t completionGroup = dispatch_group_create();
+- NSMutableArray *assets = [[NSMutableArray alloc] initWithCapacity:results.count];
+- for (int i = 0; i < results.count; i++) {
+- [assets addObject:(NSDictionary *)[NSNull null]];
+- }
++ if (photoSelected == YES) {
++ return;
++ }
++ photoSelected = YES;
+
+- [results enumerateObjectsUsingBlock:^(PHPickerResult *result, NSUInteger index, BOOL *stop) {
+- PHAsset *asset = nil;
+- NSItemProvider *provider = result.itemProvider;
++ if (results.count == 0) {
++ dispatch_async(dispatch_get_main_queue(), ^{
++ self.callback(@[@{@"didCancel": @YES}]);
++ });
++ return;
++ }
+
+- // If include extra, we fetch the PHAsset, this required library permissions
+- if([self.options[@"includeExtra"] boolValue] && result.assetIdentifier != nil) {
+- PHFetchResult* fetchResult = [PHAsset fetchAssetsWithLocalIdentifiers:@[result.assetIdentifier] options:nil];
+- asset = fetchResult.firstObject;
++ dispatch_group_t completionGroup = dispatch_group_create();
++ NSMutableArray *assets = [[NSMutableArray alloc] initWithCapacity:results.count];
++ for (int i = 0; i < results.count; i++) {
++ [assets addObject:(NSDictionary *)[NSNull null]];
+ }
+
+- dispatch_group_enter(completionGroup);
++ [results enumerateObjectsUsingBlock:^(PHPickerResult *result, NSUInteger index, BOOL *stop) {
++ PHAsset *asset = nil;
++ NSItemProvider *provider = result.itemProvider;
+
+- if ([provider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) {
+- NSString *identifier = provider.registeredTypeIdentifiers.firstObject;
+- // Matches both com.apple.live-photo-bundle and com.apple.private.live-photo-bundle
+- if ([identifier containsString:@"live-photo-bundle"]) {
+- // Handle live photos
+- identifier = @"public.jpeg";
++ // If include extra, we fetch the PHAsset, this required library permissions
++ if([self.options[@"includeExtra"] boolValue] && result.assetIdentifier != nil) {
++ PHFetchResult* fetchResult = [PHAsset fetchAssetsWithLocalIdentifiers:@[result.assetIdentifier] options:nil];
++ asset = fetchResult.firstObject;
+ }
+
+- [provider loadFileRepresentationForTypeIdentifier:identifier completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) {
+- NSData *data = [[NSData alloc] initWithContentsOfURL:url];
+- UIImage *image = [[UIImage alloc] initWithData:data];
++ dispatch_group_enter(completionGroup);
+
+- assets[index] = [self mapImageToAsset:image data:data phAsset:asset];
+- dispatch_group_leave(completionGroup);
+- }];
+- } else if ([provider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeMovie]) {
+- [provider loadFileRepresentationForTypeIdentifier:(NSString *)kUTTypeMovie completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) {
+- NSDictionary *mappedAsset = [self mapVideoToAsset:url phAsset:asset error:nil];
+- if (nil != mappedAsset) {
+- assets[index] = mappedAsset;
++ if ([provider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeImage]) {
++ NSString *identifier = provider.registeredTypeIdentifiers.firstObject;
++ // Matches both com.apple.live-photo-bundle and com.apple.private.live-photo-bundle
++ if ([identifier containsString:@"live-photo-bundle"]) {
++ // Handle live photos
++ identifier = @"public.jpeg";
+ }
++
++ [provider loadFileRepresentationForTypeIdentifier:identifier completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) {
++ NSData *data = [[NSData alloc] initWithContentsOfURL:url];
++ UIImage *image = [[UIImage alloc] initWithData:data];
++
++ assets[index] = [self mapImageToAsset:image data:data phAsset:asset];
++ dispatch_group_leave(completionGroup);
++ }];
++ } else if ([provider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeMovie]) {
++ [provider loadFileRepresentationForTypeIdentifier:(NSString *)kUTTypeMovie completionHandler:^(NSURL * _Nullable url, NSError * _Nullable error) {
++ NSDictionary *mappedAsset = [self mapVideoToAsset:url phAsset:asset error:nil];
++ if (nil != mappedAsset) {
++ assets[index] = mappedAsset;
++ }
++ dispatch_group_leave(completionGroup);
++ }];
++ } else {
++ // The provider didn't have an item matching photo or video (fails on M1 Mac Simulator)
+ dispatch_group_leave(completionGroup);
+- }];
+- } else {
+- // The provider didn't have an item matching photo or video (fails on M1 Mac Simulator)
+- dispatch_group_leave(completionGroup);
+- }
+- }];
++ }
++ }];
+
+- dispatch_group_notify(completionGroup, dispatch_get_main_queue(), ^{
+- // mapVideoToAsset can fail and return nil, leaving asset NSNull.
+- for (NSDictionary *asset in assets) {
+- if ([asset isEqual:[NSNull null]]) {
+- self.callback(@[@{@"errorCode": errOthers}]);
+- return;
++ dispatch_group_notify(completionGroup, dispatch_get_main_queue(), ^{
++ // mapVideoToAsset can fail and return nil, leaving asset NSNull.
++ for (NSDictionary *asset in assets) {
++ if ([asset isEqual:[NSNull null]]) {
++ self.callback(@[@{@"errorCode": errOthers}]);
++ return;
++ }
+ }
+- }
+
+- NSMutableDictionary *response = [[NSMutableDictionary alloc] init];
+- [response setObject:assets forKey:@"assets"];
++ NSMutableDictionary *response = [[NSMutableDictionary alloc] init];
++ [response setObject:assets forKey:@"assets"];
++
++ self.callback(@[response]);
++ });
++ };
++
++ [picker dismissViewControllerAnimated:YES completion:dismissCompletionBlock];
+
+- self.callback(@[response]);
+- });
+ }
+
+ @end
diff --git a/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch b/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch
new file mode 100644
index 000000000000..b364e484ba59
--- /dev/null
+++ b/patches/react-native-render-html+6.3.1+002+fix-console-warning.patch
@@ -0,0 +1,719 @@
+diff --git a/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js
+index 9d16738..bbc66a0 100644
+--- a/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js
++++ b/node_modules/react-native-render-html/lib/commonjs/TChildrenRenderer.js
+@@ -3,7 +3,7 @@
+ Object.defineProperty(exports, "__esModule", {
+ value: true
+ });
+-exports.default = exports.tchildrenRendererDefaultProps = void 0;
++exports.default = void 0;
+
+ var _renderChildren = _interopRequireDefault(require("./renderChildren"));
+
+@@ -15,15 +15,6 @@ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { de
+ */
+ const TChildrenRenderer = _renderChildren.default.bind(null);
+
+-const tchildrenRendererDefaultProps = {
+- propsForChildren: {}
+-};
+-/**
+- * @ignore
+- */
+-
+-exports.tchildrenRendererDefaultProps = tchildrenRendererDefaultProps;
+-TChildrenRenderer.defaultProps = tchildrenRendererDefaultProps;
+ var _default = TChildrenRenderer;
+ exports.default = _default;
+ //# sourceMappingURL=TChildrenRenderer.js.map
+\ No newline at end of file
+diff --git a/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js
+index 50b43ca..5ecf4a4 100644
+--- a/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js
++++ b/node_modules/react-native-render-html/lib/commonjs/TNodeChildrenRenderer.js
+@@ -8,8 +8,6 @@ exports.default = void 0;
+
+ var _SharedPropsProvider = require("./context/SharedPropsProvider");
+
+-var _TChildrenRenderer = require("./TChildrenRenderer");
+-
+ var _renderChildren = _interopRequireDefault(require("./renderChildren"));
+
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+@@ -78,12 +76,7 @@ function TNodeChildrenRenderer(props) {
+
+ return (0, _renderChildren.default)(useTNodeChildrenProps(props));
+ }
+-/**
+- * @ignore
+- */
+-
+
+-TNodeChildrenRenderer.defaultProps = _TChildrenRenderer.tchildrenRendererDefaultProps;
+ var _default = TNodeChildrenRenderer;
+ exports.default = _default;
+ //# sourceMappingURL=TNodeChildrenRenderer.js.map
+\ No newline at end of file
+diff --git a/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js b/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js
+index eafc942..e083941 100644
+--- a/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js
++++ b/node_modules/react-native-render-html/lib/commonjs/TNodeRenderer.js
+@@ -57,7 +57,11 @@ const TNodeRenderer = /*#__PURE__*/(0, _react.memo)(function MemoizedTNodeRender
+ const sharedProps = (0, _SharedPropsProvider.useSharedProps)();
+ const renderRegistry = (0, _RenderRegistryProvider.useRendererRegistry)();
+ const TNodeChildrenRenderer = (0, _TChildrenRendererContext.useTNodeChildrenRenderer)();
+- const tnodeProps = { ...props,
++ const tnodeProps = {
++ propsFromParent: {
++ collapsedMarginTop: null
++ },
++ ...props,
+ TNodeChildrenRenderer,
+ sharedProps
+ };
+@@ -109,13 +113,6 @@ const TNodeRenderer = /*#__PURE__*/(0, _react.memo)(function MemoizedTNodeRender
+ const renderFn = tnode.type === 'block' || tnode.type === 'document' ? _renderBlockContent.default : _renderTextualContent.default;
+ return Renderer === null ? renderFn(assembledProps) : /*#__PURE__*/_react.default.createElement(Renderer, assembledProps);
+ });
+-const defaultProps = {
+- propsFromParent: {
+- collapsedMarginTop: null
+- }
+-}; // @ts-expect-error default props must be defined
+-
+-TNodeRenderer.defaultProps = defaultProps;
+ var _default = TNodeRenderer;
+ exports.default = _default;
+ //# sourceMappingURL=TNodeRenderer.js.map
+\ No newline at end of file
+diff --git a/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js b/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js
+index 3a700b6..4011a1b 100644
+--- a/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js
++++ b/node_modules/react-native-render-html/lib/commonjs/TRenderEngineProvider.js
+@@ -5,7 +5,6 @@ Object.defineProperty(exports, "__esModule", {
+ });
+ exports.useAmbientTRenderEngine = useAmbientTRenderEngine;
+ exports.default = TRenderEngineProvider;
+-exports.defaultTRenderEngineProviderProps = exports.defaultFallbackFonts = exports.tRenderEngineProviderPropTypes = void 0;
+
+ var _react = _interopRequireDefault(require("react"));
+
+@@ -23,38 +22,6 @@ const defaultTRenderEngine = {};
+
+ const TRenderEngineContext = /*#__PURE__*/_react.default.createContext(defaultTRenderEngine);
+
+-const tRenderEngineProviderPropTypes = {
+- customHTMLElementModels: _propTypes.default.object.isRequired,
+- enableCSSInlineProcessing: _propTypes.default.bool,
+- enableUserAgentStyles: _propTypes.default.bool,
+- idsStyles: _propTypes.default.object,
+- ignoredDomTags: _propTypes.default.array,
+- ignoreDomNode: _propTypes.default.func,
+- domVisitors: _propTypes.default.object,
+- ignoredStyles: _propTypes.default.array.isRequired,
+- allowedStyles: _propTypes.default.array,
+- htmlParserOptions: _propTypes.default.object,
+- tagsStyles: _propTypes.default.object,
+- classesStyles: _propTypes.default.object,
+- emSize: _propTypes.default.number.isRequired,
+- baseStyle: _propTypes.default.object,
+- systemFonts: _propTypes.default.arrayOf(_propTypes.default.string),
+- fallbackFonts: _propTypes.default.shape({
+- serif: _propTypes.default.string,
+- 'sans-serif': _propTypes.default.string,
+- monospace: _propTypes.default.string
+- }),
+- setMarkersForTNode: _propTypes.default.func,
+- dangerouslyDisableHoisting: _propTypes.default.bool,
+- dangerouslyDisableWhitespaceCollapsing: _propTypes.default.bool,
+- selectDomRoot: _propTypes.default.func
+-};
+-/**
+- * Default fallback font for special keys such as 'sans-serif', 'monospace',
+- * 'serif', based on current platform.
+- */
+-
+-exports.tRenderEngineProviderPropTypes = tRenderEngineProviderPropTypes;
+ const defaultFallbackFonts = {
+ 'sans-serif': _reactNative.Platform.select({
+ ios: 'system',
+@@ -70,33 +37,6 @@ const defaultFallbackFonts = {
+ })
+ };
+ exports.defaultFallbackFonts = defaultFallbackFonts;
+-const defaultTRenderEngineProviderProps = {
+- htmlParserOptions: {
+- decodeEntities: true
+- },
+- emSize: 14,
+- ignoredDomTags: [],
+- ignoredStyles: [],
+- baseStyle: {
+- fontSize: 14
+- },
+- tagsStyles: {},
+- classesStyles: {},
+- enableUserAgentStyles: true,
+- enableCSSInlineProcessing: true,
+- customHTMLElementModels: {},
+- fallbackFonts: defaultFallbackFonts,
+- systemFonts: _defaultSystemFonts.default
+-};
+-/**
+- * Use the ambient transient render engine.
+- *
+- * @returns The ambient transient render engine.
+- *
+- * @public
+- */
+-
+-exports.defaultTRenderEngineProviderProps = defaultTRenderEngineProviderProps;
+
+ function useAmbientTRenderEngine() {
+ const engine = _react.default.useContext(TRenderEngineContext);
+@@ -119,22 +59,26 @@ function useAmbientTRenderEngine() {
+
+ function TRenderEngineProvider({
+ children,
++ htmlParserOptions = {
++ decodeEntities: true
++ },
++ emSize = 14,
++ ignoredDomTags = [],
++ ignoredStyles = [],
++ baseStyle = { fontSize: 14 },
++ tagsStyles = {},
++ classesStyles = {},
++ enableUserAgentStyles = true,
++ enableCSSInlineProcessing = true,
++ customHTMLElementModels = {},
++ fallbackFonts = defaultFallbackFonts,
++ systemFonts = defaultSystemFonts,
+ ...config
+ }) {
+- const engine = (0, _useTRenderEngine.default)(config);
++ const engine = (0, _useTRenderEngine.default)({htmlParserOptions, emSize, ignoredDomTags, ignoredStyles, baseStyle, tagsStyles, classesStyles, enableUserAgentStyles, enableCSSInlineProcessing, customHTMLElementModels, fallbackFonts, systemFonts, ...config});
+ return /*#__PURE__*/_react.default.createElement(TRenderEngineContext.Provider, {
+ value: engine
+ }, children);
+ }
+-/**
+- * @ignore
+- */
+-
+-
+-TRenderEngineProvider.defaultProps = defaultTRenderEngineProviderProps;
+-/**
+- * @ignore
+- */
+
+-TRenderEngineProvider.propTypes = tRenderEngineProviderPropTypes;
+ //# sourceMappingURL=TRenderEngineProvider.js.map
+\ No newline at end of file
+diff --git a/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js b/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js
+index 1be151a..3a076d4 100644
+--- a/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js
++++ b/node_modules/react-native-render-html/lib/commonjs/elements/IMGElement.js
+@@ -7,8 +7,6 @@ exports.default = void 0;
+
+ var _react = _interopRequireDefault(require("react"));
+
+-var _propTypes = _interopRequireDefault(require("prop-types"));
+-
+ var _useIMGElementState = _interopRequireDefault(require("./useIMGElementState"));
+
+ var _IMGElementContentSuccess = _interopRequireDefault(require("./IMGElementContentSuccess"));
+@@ -19,15 +17,10 @@ var _IMGElementContentLoading = _interopRequireDefault(require("./IMGElementCont
+
+ var _IMGElementContentError = _interopRequireDefault(require("./IMGElementContentError"));
+
+-var _defaultInitialImageDimensions = _interopRequireDefault(require("./defaultInitialImageDimensions"));
+-
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
+
+ function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
+
+-function identity(arg) {
+- return arg;
+-}
+ /**
+ * A component to render images based on an internal loading state.
+ *
+@@ -37,8 +30,6 @@ function identity(arg) {
+ * {@link IMGElementContentSuccess}, {@link IMGElementContentLoading}
+ * and {@link IMGElementContentError} for customization.
+ */
+-
+-
+ function IMGElement(props) {
+ const state = (0, _useIMGElementState.default)(props);
+ let content;
+@@ -59,43 +50,6 @@ function IMGElement(props) {
+ }), content);
+ }
+
+-const imgDimensionsType = _propTypes.default.shape({
+- width: _propTypes.default.number,
+- height: _propTypes.default.number
+-});
+-
+-const propTypes = {
+- source: _propTypes.default.object.isRequired,
+- alt: _propTypes.default.string,
+- altColor: _propTypes.default.string,
+- height: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.number]),
+- width: _propTypes.default.oneOfType([_propTypes.default.string, _propTypes.default.number]),
+- style: _propTypes.default.oneOfType([_propTypes.default.object, _propTypes.default.array]),
+- computeMaxWidth: _propTypes.default.func.isRequired,
+- contentWidth: _propTypes.default.number,
+- enableExperimentalPercentWidth: _propTypes.default.bool,
+- initialDimensions: imgDimensionsType,
+- onPress: _propTypes.default.func,
+- testID: _propTypes.default.string,
+- objectFit: _propTypes.default.string,
+- cachedNaturalDimensions: imgDimensionsType,
+- containerProps: _propTypes.default.object
+-};
+-/**
+- * @ignore
+- */
+-
+-IMGElement.propTypes = propTypes;
+-/**
+- * @ignore
+- */
+-
+-IMGElement.defaultProps = {
+- enableExperimentalPercentWidth: false,
+- computeMaxWidth: identity,
+- imagesInitialDimensions: _defaultInitialImageDimensions.default,
+- style: {}
+-};
+ var _default = IMGElement;
+ exports.default = _default;
+ //# sourceMappingURL=IMGElement.js.map
+\ No newline at end of file
+diff --git a/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx b/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx
+index 0df5375..925062a 100644
+--- a/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx
++++ b/node_modules/react-native-render-html/src/RenderHTMLConfigProvider.tsx
+@@ -1,5 +1,4 @@
+ import React, { PropsWithChildren, ReactElement, useMemo } from 'react';
+-import PropTypes from 'prop-types';
+ import RenderersPropsProvider from './context/RenderersPropsProvider';
+ import SharedPropsProvider from './context/SharedPropsProvider';
+ import TChildrenRenderersContext from './context/TChildrenRendererContext';
+@@ -20,29 +19,6 @@ const childrenRendererContext = {
+ TNodeChildrenRenderer
+ };
+
+-export type RenderHTMLConfigPropTypes = Record;
+-
+-export const renderHTMLConfigPropTypes: RenderHTMLConfigPropTypes = {
+- bypassAnonymousTPhrasingNodes: PropTypes.bool,
+- defaultTextProps: PropTypes.object,
+- defaultViewProps: PropTypes.object,
+- enableExperimentalBRCollapsing: PropTypes.bool,
+- enableExperimentalGhostLinesPrevention: PropTypes.bool,
+- enableExperimentalMarginCollapsing: PropTypes.bool,
+- remoteErrorView: PropTypes.func,
+- remoteLoadingView: PropTypes.func,
+- debug: PropTypes.bool,
+- computeEmbeddedMaxWidth: PropTypes.func,
+- renderersProps: PropTypes.object,
+- WebView: PropTypes.any,
+- GenericPressable: PropTypes.any,
+- defaultWebViewProps: PropTypes.object,
+- pressableHightlightColor: PropTypes.string,
+- customListStyleSpecs: PropTypes.object,
+- renderers: PropTypes.object,
+- provideEmbeddedHeaders: PropTypes.func
+-};
+-
+ /**
+ * A component to provide configuration for {@link RenderHTMLSource}
+ * descendants, to be used in conjunction with {@link TRenderEngineProvider}.
+@@ -85,8 +61,3 @@ export default function RenderHTMLConfigProvider(
+
+ );
+ }
+-
+-/**
+- * @ignore
+- */
+-RenderHTMLConfigProvider.propTypes = renderHTMLConfigPropTypes;
+diff --git a/node_modules/react-native-render-html/src/RenderHTMLSource.tsx b/node_modules/react-native-render-html/src/RenderHTMLSource.tsx
+index c91da52..fd0e052 100644
+--- a/node_modules/react-native-render-html/src/RenderHTMLSource.tsx
++++ b/node_modules/react-native-render-html/src/RenderHTMLSource.tsx
+@@ -1,7 +1,6 @@
+ import equals from 'ramda/src/equals';
+ import React, { memo, ReactElement, useMemo } from 'react';
+ import { Dimensions } from 'react-native';
+-import PropTypes from 'prop-types';
+ import ttreeEventsContext from './context/ttreeEventsContext';
+ import isUriSource from './helpers/isUriSource';
+ import { SourceLoaderProps, TTreeEvents } from './internal-types';
+@@ -25,29 +24,6 @@ export type RenderHTMLSourcePropTypes = Record<
+ any
+ >;
+
+-export const renderSourcePropTypes: RenderHTMLSourcePropTypes = {
+- source: PropTypes.oneOfType([
+- PropTypes.shape({
+- html: PropTypes.string.isRequired,
+- baseUrl: PropTypes.string
+- }),
+- PropTypes.shape({
+- dom: PropTypes.object.isRequired,
+- baseUrl: PropTypes.string
+- }),
+- PropTypes.shape({
+- uri: PropTypes.string.isRequired,
+- method: PropTypes.string,
+- body: PropTypes.any,
+- headers: PropTypes.object
+- })
+- ]),
+- onTTreeChange: PropTypes.func,
+- onHTMLLoaded: PropTypes.func,
+- onDocumentMetadataLoaded: PropTypes.func,
+- contentWidth: PropTypes.number
+-};
+-
+ function isEmptySource(source: undefined | HTMLSource) {
+ return (
+ !source ||
+@@ -136,9 +112,4 @@ const RenderHTMLSource = memo(
+ }
+ );
+
+-/**
+- * @ignore
+- */
+-(RenderHTMLSource as any).propTypes = renderSourcePropTypes;
+-
+ export default RenderHTMLSource;
+diff --git a/node_modules/react-native-render-html/src/TChildrenRenderer.tsx b/node_modules/react-native-render-html/src/TChildrenRenderer.tsx
+index 618a592..e12888e 100644
+--- a/node_modules/react-native-render-html/src/TChildrenRenderer.tsx
++++ b/node_modules/react-native-render-html/src/TChildrenRenderer.tsx
+@@ -9,16 +9,4 @@ import renderChildren from './renderChildren';
+ const TChildrenRenderer: FunctionComponent =
+ renderChildren.bind(null);
+
+-export const tchildrenRendererDefaultProps: Pick<
+- TChildrenRendererProps,
+- 'propsForChildren'
+-> = {
+- propsForChildren: {}
+-};
+-
+-/**
+- * @ignore
+- */
+-TChildrenRenderer.defaultProps = tchildrenRendererDefaultProps;
+-
+ export default TChildrenRenderer;
+diff --git a/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx b/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx
+index bf5aef6..b820de0 100644
+--- a/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx
++++ b/node_modules/react-native-render-html/src/TNodeChildrenRenderer.tsx
+@@ -1,7 +1,6 @@
+ import { ReactElement } from 'react';
+ import { TNode } from '@native-html/transient-render-engine';
+ import { useSharedProps } from './context/SharedPropsProvider';
+-import { tchildrenRendererDefaultProps } from './TChildrenRenderer';
+ import {
+ TChildrenRendererProps,
+ TNodeChildrenRendererProps
+@@ -73,9 +72,4 @@ function TNodeChildrenRenderer(
+ return renderChildren(useTNodeChildrenProps(props));
+ }
+
+-/**
+- * @ignore
+- */
+-TNodeChildrenRenderer.defaultProps = tchildrenRendererDefaultProps;
+-
+ export default TNodeChildrenRenderer;
+diff --git a/node_modules/react-native-render-html/src/TNodeRenderer.tsx b/node_modules/react-native-render-html/src/TNodeRenderer.tsx
+index d32140f..0804ba7 100644
+--- a/node_modules/react-native-render-html/src/TNodeRenderer.tsx
++++ b/node_modules/react-native-render-html/src/TNodeRenderer.tsx
+@@ -49,6 +49,7 @@ const TNodeRenderer = memo(function MemoizedTNodeRenderer(
+ const renderRegistry = useRendererRegistry();
+ const TNodeChildrenRenderer = useTNodeChildrenRenderer();
+ const tnodeProps = {
++ propsFromParent: { collapsedMarginTop: null },
+ ...props,
+ TNodeChildrenRenderer,
+ sharedProps
+@@ -120,16 +121,6 @@ const TNodeRenderer = memo(function MemoizedTNodeRenderer(
+ : React.createElement(Renderer as any, assembledProps);
+ });
+
+-const defaultProps: Required, 'propsFromParent'>> =
+- {
+- propsFromParent: {
+- collapsedMarginTop: null
+- }
+- };
+-
+-// @ts-expect-error default props must be defined
+-TNodeRenderer.defaultProps = defaultProps;
+-
+ export {
+ TDefaultBlockRenderer,
+ TDefaultPhrasingRenderer,
+diff --git a/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx b/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx
+index 95b60df..db6fe0b 100644
+--- a/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx
++++ b/node_modules/react-native-render-html/src/TRenderEngineProvider.tsx
+@@ -1,7 +1,6 @@
+ import TRenderEngine from '@native-html/transient-render-engine';
+ import React, { PropsWithChildren, ReactElement } from 'react';
+ import { Platform } from 'react-native';
+-import PropTypes from 'prop-types';
+ import useTRenderEngine from './hooks/useTRenderEngine';
+ import { TRenderEngineConfig } from './shared-types';
+ import defaultSystemFonts from './defaultSystemFonts';
+@@ -11,36 +10,6 @@ const defaultTRenderEngine = {} as any;
+ const TRenderEngineContext =
+ React.createContext(defaultTRenderEngine);
+
+-export const tRenderEngineProviderPropTypes: Record<
+- keyof TRenderEngineConfig,
+- any
+-> = {
+- customHTMLElementModels: PropTypes.object.isRequired,
+- enableCSSInlineProcessing: PropTypes.bool,
+- enableUserAgentStyles: PropTypes.bool,
+- idsStyles: PropTypes.object,
+- ignoredDomTags: PropTypes.array,
+- ignoreDomNode: PropTypes.func,
+- domVisitors: PropTypes.object,
+- ignoredStyles: PropTypes.array.isRequired,
+- allowedStyles: PropTypes.array,
+- htmlParserOptions: PropTypes.object,
+- tagsStyles: PropTypes.object,
+- classesStyles: PropTypes.object,
+- emSize: PropTypes.number.isRequired,
+- baseStyle: PropTypes.object,
+- systemFonts: PropTypes.arrayOf(PropTypes.string),
+- fallbackFonts: PropTypes.shape({
+- serif: PropTypes.string,
+- 'sans-serif': PropTypes.string,
+- monospace: PropTypes.string
+- }),
+- setMarkersForTNode: PropTypes.func,
+- dangerouslyDisableHoisting: PropTypes.bool,
+- dangerouslyDisableWhitespaceCollapsing: PropTypes.bool,
+- selectDomRoot: PropTypes.func
+-};
+-
+ /**
+ * Default fallback font for special keys such as 'sans-serif', 'monospace',
+ * 'serif', based on current platform.
+@@ -51,23 +20,6 @@ export const defaultFallbackFonts = {
+ serif: Platform.select({ ios: 'Times New Roman', default: 'serif' })
+ };
+
+-export const defaultTRenderEngineProviderProps: TRenderEngineConfig = {
+- htmlParserOptions: {
+- decodeEntities: true
+- },
+- emSize: 14,
+- ignoredDomTags: [],
+- ignoredStyles: [],
+- baseStyle: { fontSize: 14 },
+- tagsStyles: {},
+- classesStyles: {},
+- enableUserAgentStyles: true,
+- enableCSSInlineProcessing: true,
+- customHTMLElementModels: {},
+- fallbackFonts: defaultFallbackFonts,
+- systemFonts: defaultSystemFonts
+-};
+-
+ /**
+ * Use the ambient transient render engine.
+ *
+@@ -97,22 +49,26 @@ export function useAmbientTRenderEngine() {
+ */
+ export default function TRenderEngineProvider({
+ children,
++ htmlParserOptions = {
++ decodeEntities: true
++ },
++ emSize = 14,
++ ignoredDomTags = [],
++ ignoredStyles = [],
++ baseStyle = { fontSize: 14 },
++ tagsStyles = {},
++ classesStyles = {},
++ enableUserAgentStyles = true,
++ enableCSSInlineProcessing = true,
++ customHTMLElementModels = {},
++ fallbackFonts = defaultFallbackFonts,
++ systemFonts = defaultSystemFonts,
+ ...config
+ }: PropsWithChildren): ReactElement {
+- const engine = useTRenderEngine(config);
++ const engine = useTRenderEngine({htmlParserOptions, emSize, ignoredDomTags, ignoredStyles, baseStyle, tagsStyles, classesStyles, enableUserAgentStyles, enableCSSInlineProcessing, customHTMLElementModels, fallbackFonts, systemFonts, ...config});
+ return (
+
+ {children}
+
+ );
+ }
+-
+-/**
+- * @ignore
+- */
+-TRenderEngineProvider.defaultProps = defaultTRenderEngineProviderProps;
+-
+-/**
+- * @ignore
+- */
+-TRenderEngineProvider.propTypes = tRenderEngineProviderPropTypes;
+diff --git a/node_modules/react-native-render-html/src/elements/IMGElement.tsx b/node_modules/react-native-render-html/src/elements/IMGElement.tsx
+index 573e7c1..a6fc90b 100644
+--- a/node_modules/react-native-render-html/src/elements/IMGElement.tsx
++++ b/node_modules/react-native-render-html/src/elements/IMGElement.tsx
+@@ -1,19 +1,13 @@
+ import React, { ReactElement, ReactNode } from 'react';
+-import PropTypes from 'prop-types';
+ import useIMGElementState from './useIMGElementState';
+ import IMGElementContentSuccess from './IMGElementContentSuccess';
+ import IMGElementContainer from './IMGElementContainer';
+ import IMGElementContentLoading from './IMGElementContentLoading';
+ import IMGElementContentError from './IMGElementContentError';
+ import type { IMGElementProps } from './img-types';
+-import defaultImageInitialDimensions from './defaultInitialImageDimensions';
+
+ export type { IMGElementProps } from './img-types';
+
+-function identity(arg: any) {
+- return arg;
+-}
+-
+ /**
+ * A component to render images based on an internal loading state.
+ *
+@@ -44,42 +38,4 @@ function IMGElement(props: IMGElementProps): ReactElement {
+ );
+ }
+
+-const imgDimensionsType = PropTypes.shape({
+- width: PropTypes.number,
+- height: PropTypes.number
+-});
+-
+-const propTypes: Record = {
+- source: PropTypes.object.isRequired,
+- alt: PropTypes.string,
+- altColor: PropTypes.string,
+- height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+- width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+- style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
+- computeMaxWidth: PropTypes.func.isRequired,
+- contentWidth: PropTypes.number,
+- enableExperimentalPercentWidth: PropTypes.bool,
+- initialDimensions: imgDimensionsType,
+- onPress: PropTypes.func,
+- testID: PropTypes.string,
+- objectFit: PropTypes.string,
+- cachedNaturalDimensions: imgDimensionsType,
+- containerProps: PropTypes.object
+-};
+-
+-/**
+- * @ignore
+- */
+-IMGElement.propTypes = propTypes;
+-
+-/**
+- * @ignore
+- */
+-IMGElement.defaultProps = {
+- enableExperimentalPercentWidth: false,
+- computeMaxWidth: identity,
+- imagesInitialDimensions: defaultImageInitialDimensions,
+- style: {}
+-};
+-
+ export default IMGElement;
+diff --git a/node_modules/react-native-render-html/src/elements/useIMGElementState.ts b/node_modules/react-native-render-html/src/elements/useIMGElementState.ts
+index 6590d21..b603f26 100644
+--- a/node_modules/react-native-render-html/src/elements/useIMGElementState.ts
++++ b/node_modules/react-native-render-html/src/elements/useIMGElementState.ts
+@@ -63,6 +63,10 @@ function useImageNaturalDimensions
(props: {
+ };
+ }
+
++function identity(arg: any) {
++ return arg;
++}
++
+ function useFetchedNaturalDimensions(props: {
+ cachedNaturalDimensions?: ImageDimensions;
+ source: ImageURISource;
+@@ -116,7 +120,7 @@ export default function useIMGElementState(
+ altColor,
+ source,
+ contentWidth,
+- computeMaxWidth,
++ computeMaxWidth = identity,
+ objectFit,
+ initialDimensions = defaultImageInitialDimensions,
+ cachedNaturalDimensions
+diff --git a/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts b/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts
+index 5d6271b..710c73f 100644
+--- a/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts
++++ b/node_modules/react-native-render-html/src/elements/useImageSpecifiedDimensions.ts
+@@ -71,8 +71,7 @@ function deriveSpecifiedDimensionsFromProps({
+ export default function useImageSpecifiedDimensions(
+ props: UseIMGElementStateProps
+ ) {
+- const { contentWidth, enableExperimentalPercentWidth, style, width, height } =
+- props;
++ const { contentWidth, enableExperimentalPercentWidth = false, style = {}, width, height } = props
+ const flatStyle = useMemo(() => StyleSheet.flatten(style) || {}, [style]);
+ const specifiedDimensions = useMemo(
+ () =>
+diff --git a/node_modules/react-native-render-html/src/index.ts b/node_modules/react-native-render-html/src/index.ts
+index 8569583..b59ec49 100644
+--- a/node_modules/react-native-render-html/src/index.ts
++++ b/node_modules/react-native-render-html/src/index.ts
+@@ -128,7 +128,6 @@ export {
+ export { default as TNodeRenderer } from './TNodeRenderer';
+ export {
+ default as TRenderEngineProvider,
+- defaultFallbackFonts,
+ useAmbientTRenderEngine
+ } from './TRenderEngineProvider';
+ export { default as RenderHTMLConfigProvider } from './RenderHTMLConfigProvider';
+diff --git a/node_modules/react-native-render-html/src/renderChildren.tsx b/node_modules/react-native-render-html/src/renderChildren.tsx
+index a669402..be9ffd6 100644
+--- a/node_modules/react-native-render-html/src/renderChildren.tsx
++++ b/node_modules/react-native-render-html/src/renderChildren.tsx
+@@ -4,8 +4,6 @@ import TNodeRenderer from './TNodeRenderer';
+ import { TChildrenRendererProps } from './shared-types';
+ import collapseTopMarginForChild from './helpers/collapseTopMarginForChild';
+
+-const empty = {};
+-
+ const mapCollapsibleChildren = (
+ propsForChildren: TChildrenRendererProps['propsForChildren'],
+ renderChild: TChildrenRendererProps['renderChild'],
+@@ -39,7 +37,7 @@ const mapCollapsibleChildren = (
+
+ export default function renderChildren({
+ tchildren,
+- propsForChildren = empty,
++ propsForChildren = {},
+ disableMarginCollapsing,
+ renderChild
+ }: TChildrenRendererProps): ReactElement {
diff --git a/src/App.tsx b/src/App.tsx
index cc824b78fa4c..de209d6f6631 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -29,11 +29,11 @@ import {FullScreenContextProvider} from './components/VideoPlayerContexts/FullSc
import {PlaybackContextProvider} from './components/VideoPlayerContexts/PlaybackContext';
import {VideoPopoverMenuContextProvider} from './components/VideoPlayerContexts/VideoPopoverMenuContext';
import {VolumeContextProvider} from './components/VideoPlayerContexts/VolumeContext';
-import {CurrentReportIDContextProvider} from './components/withCurrentReportID';
import {EnvironmentProvider} from './components/withEnvironment';
import {KeyboardStateProvider} from './components/withKeyboardState';
import CONFIG from './CONFIG';
import Expensify from './Expensify';
+import {CurrentReportIDContextProvider} from './hooks/useCurrentReportID';
import useDefaultDragAndDrop from './hooks/useDefaultDragAndDrop';
import {ReportIDsContextProvider} from './hooks/useReportIDs';
import OnyxUpdateManager from './libs/actions/OnyxUpdateManager';
diff --git a/src/CONST.ts b/src/CONST.ts
index 61528931b7c6..f2b9bf9a63fa 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -651,8 +651,6 @@ const CONST = {
P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests',
SPOTNANA_TRAVEL: 'spotnanaTravel',
REPORT_FIELDS_FEATURE: 'reportFieldsFeature',
- COMPANY_CARD_FEEDS: 'companyCardFeeds',
- DIRECT_FEEDS: 'directFeeds',
NETSUITE_USA_TAX: 'netsuiteUsaTax',
COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit',
CATEGORY_AND_TAG_APPROVERS: 'categoryAndTagApprovers',
@@ -926,6 +924,7 @@ const CONST = {
ADMIN_TOUR_STAGING: 'https://expensify.navattic.com/3i300k18',
EMPLOYEE_TOUR_PRODUCTION: 'https://expensify.navattic.com/35609gb',
EMPLOYEE_TOUR_STAGING: 'https://expensify.navattic.com/cf15002s',
+ COMPLETED: 'completed',
},
OLD_DOT_PUBLIC_URLS: {
TERMS_URL: `${EXPENSIFY_URL}/terms`,
@@ -1445,6 +1444,8 @@ const CONST = {
UNKNOWN: 'unknown',
},
},
+ // The number of milliseconds for an idle session to expire
+ SESSION_EXPIRATION_TIME_MS: 2 * 3600 * 1000, // 2 hours
WEEK_STARTS_ON: 1, // Monday
DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'},
DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false},
@@ -1467,8 +1468,8 @@ const CONST = {
// at least 8 characters, 1 capital letter, 1 lowercase number, 1 number
PASSWORD_COMPLEXITY_REGEX_STRING: '^(?=.*[A-Z])(?=.*[0-9])(?=.*[a-z]).{8,}$',
- // 6 numeric digits
- VALIDATE_CODE_REGEX_STRING: /^\d{6}$/,
+ // We allow either 6 digits for validated users or 9-character base26 for unvalidated users
+ VALIDATE_CODE_REGEX_STRING: /^\d{6}$|^[A-Z]{9}$/,
// 8 alphanumeric characters
RECOVERY_CODE_REGEX_STRING: /^[a-zA-Z0-9]{8}$/,
@@ -1560,6 +1561,7 @@ const CONST = {
ATTACHMENT_PREVIEW_ATTRIBUTE: 'src',
ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE: 'data-name',
ATTACHMENT_LOCAL_URL_PREFIX: ['blob:', 'file:'],
+ ATTACHMENT_OR_RECEIPT_LOCAL_URL: /^https:\/\/(www\.)?([a-z0-9_-]+\.)*expensify.com(:[0-9]+)?\/(chat-attachments|receipts)/,
ATTACHMENT_THUMBNAIL_URL_ATTRIBUTE: 'data-expensify-thumbnail-url',
ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE: 'data-expensify-width',
ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE: 'data-expensify-height',
@@ -1673,6 +1675,16 @@ const CONST = {
STUDENT_AMBASSADOR: 'studentambassadors@expensify.com',
SVFG: 'svfg@expensify.com',
EXPENSIFY_EMAIL_DOMAIN: '@expensify.com',
+ EXPENSIFY_TEAM_EMAIL_DOMAIN: '@team.expensify.com',
+ },
+
+ FULL_STORY: {
+ MASK: 'fs-mask',
+ UNMASK: 'fs-unmask',
+ CUSTOMER: 'customer',
+ CONCIERGE: 'concierge',
+ OTHER: 'other',
+ WEB_PROP_ATTR: 'data-testid',
},
CONCIERGE_DISPLAY_NAME: 'Concierge',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 020eb5262200..6b26ecd73700 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -115,6 +115,9 @@ const ONYXKEYS = {
STASHED_SESSION: 'stashedSession',
BETAS: 'betas',
+ /** Whether the user is a member of a policy other than their personal */
+ HAS_NON_PERSONAL_POLICY: 'hasNonPersonalPolicy',
+
/** NVP keys */
/** This NVP contains list of at most 5 recent attendees */
@@ -944,6 +947,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.LAST_EXPORT_METHOD]: OnyxTypes.LastExportMethod;
[ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[];
[ONYXKEYS.NVP_INTRO_SELECTED]: OnyxTypes.IntroSelected;
+ [ONYXKEYS.HAS_NON_PERSONAL_POLICY]: boolean;
[ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES]: OnyxTypes.LastSelectedDistanceRates;
[ONYXKEYS.NVP_SEEN_NEW_USER_MODAL]: boolean;
[ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED]: boolean;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 371cef9ef788..7f8b75f353e1 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -3,6 +3,7 @@ import type {SearchQueryString} from './components/Search/types';
import type CONST from './CONST';
import type {IOUAction, IOUType} from './CONST';
import type {IOURequestType} from './libs/actions/IOU';
+import Log from './libs/Log';
import type {ExitReason} from './types/form/ExitSurveyReasonForm';
import type {ConnectionName, SageIntacctMappingName} from './types/onyx/Policy';
import type AssertTypesNotEqual from './types/utils/AssertTypesNotEqual';
@@ -327,7 +328,7 @@ const ROUTES = {
ATTACHMENTS: {
route: 'attachment',
getRoute: (
- reportID: string,
+ reportID: string | undefined,
type: ValueOf,
url: string,
accountID?: number,
@@ -895,7 +896,7 @@ const ROUTES = {
},
WORKSPACE_PROFILE_DESCRIPTION: {
route: 'settings/workspaces/:policyID/profile/description',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/description` as const,
+ getRoute: (policyID: string | undefined) => `settings/workspaces/${policyID}/profile/description` as const,
},
WORKSPACE_PROFILE_SHARE: {
route: 'settings/workspaces/:policyID/profile/share',
@@ -1017,13 +1018,13 @@ const ROUTES = {
getRoute: (policyID: string, categoryName: string) => `settings/workspaces/${policyID}/category/${encodeURIComponent(categoryName)}` as const,
},
WORKSPACE_UPGRADE: {
- route: 'settings/workspaces/:policyID/upgrade/:featureName?',
- getRoute: (policyID: string, featureName?: string, backTo?: string) =>
- getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName ?? '')}` as const, backTo),
+ route: 'settings/workspaces/:policyID?/upgrade/:featureName?',
+ getRoute: (policyID?: string, featureName?: string, backTo?: string) =>
+ policyID ? getUrlWithBackToParam(`settings/workspaces/${policyID}/upgrade/${encodeURIComponent(featureName ?? '')}` as const, backTo) : (`settings/workspaces/upgrade` as const),
},
WORKSPACE_DOWNGRADE: {
- route: 'settings/workspaces/:policyID/downgrade/',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/downgrade/` as const,
+ route: 'settings/workspaces/:policyID?/downgrade/',
+ getRoute: (policyID?: string) => (policyID ? (`settings/workspaces/${policyID}/downgrade/` as const) : `settings/workspaces/downgrade`),
},
WORKSPACE_CATEGORIES_SETTINGS: {
route: 'settings/workspaces/:policyID/categories/settings',
@@ -1221,7 +1222,12 @@ const ROUTES = {
},
WORKSPACE_COMPANY_CARDS: {
route: 'settings/workspaces/:policyID/company-cards',
- getRoute: (policyID: string) => `settings/workspaces/${policyID}/company-cards` as const,
+ getRoute: (policyID: string | undefined) => {
+ if (!policyID) {
+ Log.warn('Invalid policyID is used to build the WORKSPACE_COMPANY_CARDS route');
+ }
+ return `settings/workspaces/${policyID}/company-cards` as const;
+ },
},
WORKSPACE_COMPANY_CARDS_ADD_NEW: {
route: 'settings/workspaces/:policyID/company-cards/add-card-feed',
diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts
index c0010af468af..62660980f56f 100644
--- a/src/components/Attachments/AttachmentCarousel/extractAttachments.ts
+++ b/src/components/Attachments/AttachmentCarousel/extractAttachments.ts
@@ -58,7 +58,7 @@ function extractAttachments(
}
if (name === 'img' && attribs.src) {
- const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE];
+ const expensifySource = attribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] ?? (new RegExp(CONST.ATTACHMENT_OR_RECEIPT_LOCAL_URL, 'i').test(attribs.src) ? attribs.src : null);
const source = tryResolveUrlFromApiRoot(expensifySource || attribs.src);
const previewSource = tryResolveUrlFromApiRoot(attribs.src);
const sourceLinkKey = `${source}|${currentLink}`;
diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx
index 4cbf85cb0014..3f1d78eae06b 100644
--- a/src/components/Breadcrumbs.tsx
+++ b/src/components/Breadcrumbs.tsx
@@ -52,6 +52,7 @@ function Breadcrumbs({breadcrumbs, style}: BreadcrumbsProps) {
height={variables.lhnLogoHeight * fontScale}
/>
}
+ style={styles.justifyContentCenter}
shouldShowEnvironmentBadge
/>
diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx
index 9c44bc4d0fd3..d846dd4d28ba 100644
--- a/src/components/ExplanationModal.tsx
+++ b/src/components/ExplanationModal.tsx
@@ -1,19 +1,11 @@
import React from 'react';
-import {useOnyx} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import FeatureTrainingModal from './FeatureTrainingModal';
function ExplanationModal() {
const {translate} = useLocalize();
- const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT);
- const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp;
-
- if (hasBeenAddedToNudgeMigration) {
- return null;
- }
return (
{
+ if (tnode.attributes.islarge === undefined) {
+ return {whiteSpace: 'pre'};
+ }
+ return {whiteSpace: 'pre', ...styles.onlyEmojisText};
+ },
contentModel: HTMLContentModel.block,
}),
'email-comment': HTMLElementModel.fromCustomModel({
tagName: 'email-comment',
- mixedUAStyles: {whiteSpace: 'normal'},
+ getMixedUAStyles: (tnode) => {
+ if (tnode.attributes.islarge === undefined) {
+ return {whiteSpace: 'normal'};
+ }
+ return {whiteSpace: 'normal', ...styles.onlyEmojisText};
+ },
contentModel: HTMLContentModel.block,
}),
strong: HTMLElementModel.fromCustomModel({
@@ -102,6 +112,7 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim
styles.textSupporting,
styles.textLineThrough,
styles.mutedNormalTextLabel,
+ styles.onlyEmojisText,
styles.onlyEmojisTextLineHeight,
],
);
diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx
index 88e5c1f42555..e8d7e0e85fdf 100644
--- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx
+++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx
@@ -12,9 +12,8 @@ function EditedRenderer({tnode, TDefaultRenderer, style, ...defaultRendererProps
const styles = useThemeStyles();
const {translate} = useLocalize();
const isPendingDelete = !!(tnode.attributes.deleted !== undefined);
- const isLarge = !!(tnode.attributes.islarge !== undefined);
return (
-
+ {
if (isDisabled) {
return;
}
- showContextMenuForReport(event, anchor, report?.reportID ?? '-1', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs));
+ showContextMenuForReport(event, anchor, report?.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report, reportNameValuePairs));
}}
shouldUseHapticsOnLongPress
accessibilityRole={CONST.ROLE.BUTTON}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 960d5647127b..25fcbccd8c9e 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -20,6 +20,9 @@ type HeaderProps = {
/** Additional text styles */
textStyles?: StyleProp;
+ /** Additional header styles */
+ style?: StyleProp;
+
/** Additional header container styles */
containerStyles?: StyleProp;
@@ -27,7 +30,7 @@ type HeaderProps = {
subTitleLink?: string;
};
-function Header({title = '', subtitle = '', textStyles = [], containerStyles = [], shouldShowEnvironmentBadge = false, subTitleLink = ''}: HeaderProps) {
+function Header({title = '', subtitle = '', textStyles = [], style, containerStyles = [], shouldShowEnvironmentBadge = false, subTitleLink = ''}: HeaderProps) {
const styles = useThemeStyles();
const renderedSubtitle = useMemo(
() => (
@@ -65,7 +68,7 @@ function Header({title = '', subtitle = '', textStyles = [], containerStyles = [
return (
-
+
{typeof title === 'string'
? !!title && (
(null);
const isObjectPositionTop = objectPosition === CONST.IMAGE_OBJECT_POSITION.TOP;
+ const session = useSession();
const {shouldSetAspectRatioInStyle} = useContext(ImageBehaviorContext);
@@ -37,6 +40,49 @@ function Image({source: propsSource, isAuthTokenRequired = false, session, onLoa
},
[onLoad, updateAspectRatio],
);
+
+ // accepted sessions are sessions of a certain criteria that we think can necessitate a reload of the images
+ // because images sources barely changes unless specific events occur like network issues (offline/online) per example.
+ // Here we target new session received less than 60s after the previous session (that could be from fresh reauthentication, the previous session was not necessarily expired)
+ // or new session after the previous session was expired (based on timestamp gap between the 2 creationDate and the freshness of the new session).
+ const isAcceptedSession = useCallback((sessionCreationDateDiff: number, sessionCreationDate: number) => {
+ return sessionCreationDateDiff < 60000 || (sessionCreationDateDiff >= CONST.SESSION_EXPIRATION_TIME_MS && new Date().getTime() - sessionCreationDate < 60000);
+ }, []);
+
+ /**
+ * trying to figure out if the current session is expired or fresh from a necessary reauthentication
+ */
+ const previousSessionAge = useRef();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const validSessionAge: number | undefined = useMemo(() => {
+ // Authentication is required only for certain types of images (attachments and receipts),
+ // so we only calculate the session age for those
+ if (!isAuthTokenRequired) {
+ return undefined;
+ }
+ if (session?.creationDate) {
+ if (previousSessionAge.current) {
+ // Most likely a reauthentication happened, but unless the calculated source is different from the previous, the image won't reload
+ if (isAcceptedSession(session.creationDate - previousSessionAge.current, session.creationDate)) {
+ return session.creationDate;
+ }
+ return previousSessionAge.current;
+ }
+ if (isExpiredSession(session.creationDate)) {
+ // reset the countdown to now so future sessions creationDate can be compared to that time
+ return new Date().getTime();
+ }
+ return session.creationDate;
+ }
+ return undefined;
+ }, [session, isAuthTokenRequired, isAcceptedSession]);
+ useEffect(() => {
+ if (!isAuthTokenRequired) {
+ return;
+ }
+ previousSessionAge.current = validSessionAge;
+ });
+
/**
* Check if the image source is a URL - if so the `encryptedAuthToken` is appended
* to the source.
@@ -48,24 +94,44 @@ function Image({source: propsSource, isAuthTokenRequired = false, session, onLoa
}
const authToken = session?.encryptedAuthToken ?? null;
if (isAuthTokenRequired && authToken) {
- return {
- ...propsSource,
- headers: {
- [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken,
- },
- };
+ if (!!session?.creationDate && !isExpiredSession(session.creationDate)) {
+ return {
+ ...propsSource,
+ headers: {
+ [CONST.CHAT_ATTACHMENT_TOKEN_KEY]: authToken,
+ },
+ };
+ }
+ if (session) {
+ activateReauthenticator(session);
+ }
+ return undefined;
}
}
return propsSource;
// The session prop is not required, as it causes the image to reload whenever the session changes. For more information, please refer to issue #26034.
+ // but we still need the image to reload sometimes (example : when the current session is expired)
+ // by forcing a recalculation of the source (which value could indeed change) through the modification of the variable validSessionAge
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [propsSource, isAuthTokenRequired]);
+ }, [propsSource, isAuthTokenRequired, validSessionAge]);
+ useEffect(() => {
+ if (!isAuthTokenRequired || source !== undefined) {
+ return;
+ }
+ forwardedProps?.waitForSession?.();
+ }, [source, isAuthTokenRequired, forwardedProps]);
/**
* If the image fails to load and the object position is top, we should hide the image by setting the opacity to 0.
*/
const shouldOpacityBeZero = isObjectPositionTop && !aspectRatio;
+ if (source === undefined && !!forwardedProps?.waitForSession) {
+ return undefined;
+ }
+ if (source === undefined) {
+ return ;
+ }
return (
({
- session: {
- key: ONYXKEYS.SESSION,
- },
- })(Image),
- imagePropsAreEqual,
-);
+const ImageWithOnyx = React.memo(Image, imagePropsAreEqual);
ImageWithOnyx.displayName = 'Image';
diff --git a/src/components/Image/types.ts b/src/components/Image/types.ts
index 27964d8a6764..d6f2a0e51ff9 100644
--- a/src/components/Image/types.ts
+++ b/src/components/Image/types.ts
@@ -1,19 +1,12 @@
import type {ImageSource} from 'expo-image';
import type {ImageRequireSource, ImageResizeMode, ImageStyle, ImageURISource, StyleProp} from 'react-native';
-import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
-import type {Session} from '@src/types/onyx';
type ExpoImageSource = ImageSource | number | ImageSource[];
type ImageObjectPosition = ValueOf;
-type ImageOnyxProps = {
- /** Session info for the currently logged in user. */
- session: OnyxEntry;
-};
-
type ImageOnLoadEvent = {
nativeEvent: {
width: number;
@@ -53,8 +46,15 @@ type ImageOwnProps = BaseImageProps & {
/** The object position of image */
objectPosition?: ImageObjectPosition;
+
+ /**
+ * Called when the image should wait for a valid session to reload
+ * At the moment this function is called, the image is not in cache anymore
+ * cf https://github.com/Expensify/App/issues/51888
+ */
+ waitForSession?: () => void;
};
-type ImageProps = ImageOnyxProps & ImageOwnProps;
+type ImageProps = ImageOwnProps;
-export type {BaseImageProps, ImageOwnProps, ImageOnyxProps, ImageProps, ExpoImageSource, ImageOnLoadEvent, ImageObjectPosition};
+export type {BaseImageProps, ImageOwnProps, ImageProps, ExpoImageSource, ImageOnLoadEvent, ImageObjectPosition};
diff --git a/src/components/ImageView/index.tsx b/src/components/ImageView/index.tsx
index 0bce2fd38432..319e81ac8c64 100644
--- a/src/components/ImageView/index.tsx
+++ b/src/components/ImageView/index.tsx
@@ -212,6 +212,7 @@ function ImageView({isAuthTokenRequired = false, url, fileName, onError}: ImageV
/>
);
}
+
return (
{
+ setIsLoading(true);
+ setZoomScale(0);
+ setIsZoomed(false);
+ }}
onError={onError}
/>
diff --git a/src/components/ImageWithSizeCalculation.tsx b/src/components/ImageWithSizeCalculation.tsx
index ebea1a90efca..f4b26597fd69 100644
--- a/src/components/ImageWithSizeCalculation.tsx
+++ b/src/components/ImageWithSizeCalculation.tsx
@@ -111,6 +111,13 @@ function ImageWithSizeCalculation({url, altText, style, onMeasure, onLoadFailure
}}
onError={onError}
onLoad={imageLoadedSuccessfully}
+ waitForSession={() => {
+ // Called when the image should wait for a valid session to reload
+ // At the moment this function is called, the image is not in cache anymore
+ isLoadedRef.current = false;
+ setIsImageCached(false);
+ setIsLoading(true);
+ }}
objectPosition={objectPosition}
/>
{isLoading && !isImageCached && !isOffline && }
diff --git a/src/components/Lightbox/index.tsx b/src/components/Lightbox/index.tsx
index 9e1b007321cc..ec0c6e5efcaa 100644
--- a/src/components/Lightbox/index.tsx
+++ b/src/components/Lightbox/index.tsx
@@ -234,6 +234,14 @@ function Lightbox({isAuthTokenRequired = false, uri, onScaleChanged: onScaleChan
updateContentSize(e);
setLightboxImageLoaded(true);
}}
+ waitForSession={() => {
+ // only active lightbox should call this function
+ if (!isActive || isFallbackVisible || !isLightboxVisible) {
+ return;
+ }
+ setContentSize(cachedImageDimensions.get(uri));
+ setLightboxImageLoaded(false);
+ }}
/>
diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx
index 8ae468262cca..7535a148261f 100644
--- a/src/components/MenuItem.tsx
+++ b/src/components/MenuItem.tsx
@@ -611,7 +611,7 @@ function MenuItem(
onPressOut={ControlSelection.unblock}
onSecondaryInteraction={onSecondaryInteraction}
wrapperStyle={outerWrapperStyle}
- activeOpacity={variables.pressDimValue}
+ activeOpacity={!interactive ? 1 : variables.pressDimValue}
opacityAnimationDuration={0}
testID={pressableTestID}
style={({pressed}) =>
diff --git a/src/components/OnyxProvider.tsx b/src/components/OnyxProvider.tsx
index 23ddf2b0c4dd..796920372c9d 100644
--- a/src/components/OnyxProvider.tsx
+++ b/src/components/OnyxProvider.tsx
@@ -6,7 +6,7 @@ import createOnyxContext from './createOnyxContext';
// Set up any providers for individual keys. This should only be used in cases where many components will subscribe to
// the same key (e.g. FlatList renderItem components)
const [withNetwork, NetworkProvider, NetworkContext] = createOnyxContext(ONYXKEYS.NETWORK);
-const [, PersonalDetailsProvider, , usePersonalDetails] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST);
+const [, PersonalDetailsProvider, PersonalDetailsContext, usePersonalDetails] = createOnyxContext(ONYXKEYS.PERSONAL_DETAILS_LIST);
const [withCurrentDate, CurrentDateProvider] = createOnyxContext(ONYXKEYS.CURRENT_DATE);
const [, BlockedFromConciergeProvider, , useBlockedFromConcierge] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE);
const [, BetasProvider, BetasContext, useBetas] = createOnyxContext(ONYXKEYS.BETAS);
@@ -55,6 +55,7 @@ export {
PreferredThemeContext,
useBetas,
useFrequentlyUsedEmojis,
+ PersonalDetailsContext,
PreferredEmojiSkinToneContext,
useBlockedFromConcierge,
useSession,
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index f5e27896c8e4..03b6c820da00 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -520,6 +520,7 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo
scrollEventThrottle={1}
shouldKeepFocusedItemAtTopOfViewableArea={type === CONST.SEARCH.DATA_TYPES.CHAT}
isScreenFocused={isSearchScreenFocused}
+ initialNumToRender={shouldUseNarrowLayout ? 5 : undefined}
/>
);
}
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index e946435a6890..f00454099a03 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -119,6 +119,7 @@ function BaseSelectionList(
shouldScrollToFocusedIndex = true,
onContentSizeChange,
listItemTitleStyles,
+ initialNumToRender = 12,
}: BaseSelectionListProps,
ref: ForwardedRef,
) {
@@ -320,7 +321,7 @@ function BaseSelectionList(
initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey),
maxIndex: Math.min(flattenedSections.allOptions.length - 1, CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage - 1),
disabledIndexes: disabledArrowKeyIndexes,
- isActive: isFocused,
+ isActive: true,
onFocusedIndexChange: (index: number) => {
const focusedItem = flattenedSections.allOptions.at(index);
if (focusedItem) {
@@ -333,14 +334,15 @@ function BaseSelectionList(
isFocused,
});
+ const selectedItemIndex = useMemo(() => flattenedSections.allOptions.findIndex((option) => option.isSelected), [flattenedSections.allOptions]);
+
useEffect(() => {
- const selectedItemIndex = flattenedSections.allOptions.findIndex((option) => option.isSelected);
if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex) {
return;
}
setFocusedIndex(selectedItemIndex);
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
- }, [flattenedSections]);
+ }, [selectedItemIndex]);
const clearInputAfterSelect = useCallback(() => {
onChangeText?.('');
@@ -753,14 +755,11 @@ function BaseSelectionList(
isTextInputFocusedRef.current = isTextInputFocused;
}, []);
- useImperativeHandle(ref, () => ({scrollAndHighlightItem, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, scrollToIndex, getFocusedOption}), [
- scrollAndHighlightItem,
- clearInputAfterSelect,
- updateAndScrollToFocusedIndex,
- updateExternalTextInputFocus,
- scrollToIndex,
- getFocusedOption,
- ]);
+ useImperativeHandle(
+ ref,
+ () => ({scrollAndHighlightItem, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, scrollToIndex, getFocusedOption, focusTextInput}),
+ [scrollAndHighlightItem, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, scrollToIndex, getFocusedOption, focusTextInput],
+ );
/** Selects row when pressing Enter */
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
@@ -830,7 +829,7 @@ function BaseSelectionList(
indicatorStyle="white"
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={showScrollIndicator}
- initialNumToRender={12}
+ initialNumToRender={initialNumToRender}
maxToRenderPerBatch={maxToRenderPerBatch}
windowSize={windowSize}
updateCellsBatchingPeriod={updateCellsBatchingPeriod}
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index 19c47414b089..213fd71d0632 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -633,6 +633,9 @@ type BaseSelectionListProps = Partial & {
/** Called when scrollable content view of the ScrollView changes */
onContentSizeChange?: (w: number, h: number) => void;
+
+ /** Initial number of items to render */
+ initialNumToRender?: number;
} & TRightHandSideComponent;
type SelectionListHandle = {
@@ -642,6 +645,7 @@ type SelectionListHandle = {
updateAndScrollToFocusedIndex: (newFocusedIndex: number) => void;
updateExternalTextInputFocus: (isTextInputFocused: boolean) => void;
getFocusedOption: () => ListItem | undefined;
+ focusTextInput: () => void;
};
type ItemLayout = {
diff --git a/src/components/ShowContextMenuContext.ts b/src/components/ShowContextMenuContext.ts
index 6fefa987fac3..3bd98e90eec3 100644
--- a/src/components/ShowContextMenuContext.ts
+++ b/src/components/ShowContextMenuContext.ts
@@ -44,7 +44,7 @@ ShowContextMenuContext.displayName = 'ShowContextMenuContext';
function showContextMenuForReport(
event: GestureResponderEvent | MouseEvent,
anchor: ContextMenuAnchor,
- reportID: string,
+ reportID: string | undefined,
action: OnyxEntry,
checkIfContextMenuActive: () => void,
isArchivedRoom = false,
@@ -60,7 +60,7 @@ function showContextMenuForReport(
anchor,
reportID,
action?.reportActionID,
- ReportUtils.getOriginalReportID(reportID, action),
+ reportID ? ReportUtils.getOriginalReportID(reportID, action) : undefined,
undefined,
checkIfContextMenuActive,
checkIfContextMenuActive,
diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx
index 89f3fbc528ef..83aba3d4b61e 100644
--- a/src/components/TestToolMenu.tsx
+++ b/src/components/TestToolMenu.tsx
@@ -111,6 +111,15 @@ function TestToolMenu() {
/>
+ {/* Sends an expired session to the FE and invalidates the session by the same time in the BE. Action is delayed for 15s */}
+
+
+
>
);
diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx
index 3df4a914f2d9..b0d9bcc44485 100644
--- a/src/components/VideoPlayer/BaseVideoPlayer.tsx
+++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx
@@ -342,8 +342,21 @@ function BaseVideoPlayer({
shareVideoPlayerElements(videoPlayerRef.current, videoPlayerElementParentRef.current, videoPlayerElementRef.current, isUploading || isFullScreenRef.current);
}, [currentlyPlayingURL, shouldUseSharedVideoElement, shareVideoPlayerElements, url, isUploading, isFullScreenRef]);
+ // Call bindFunctions() through the refs to avoid adding it to the dependency array of the DOM mutation effect, as doing so would change the DOM when the functions update.
+ const bindFunctionsRef = useRef<(() => void) | null>(null);
+ const shouldBindFunctionsRef = useRef(false);
+
+ useEffect(() => {
+ bindFunctionsRef.current = bindFunctions;
+ if (shouldBindFunctionsRef.current) {
+ bindFunctions();
+ }
+ }, [bindFunctions]);
+
// append shared video element to new parent (used for example in attachment modal)
useEffect(() => {
+ shouldBindFunctionsRef.current = false;
+
if (url !== currentlyPlayingURL || !sharedElement || isFullScreenRef.current) {
return;
}
@@ -360,7 +373,8 @@ function BaseVideoPlayer({
videoPlayerRef.current = currentVideoPlayerRef.current;
if (currentlyPlayingURL === url && newParentRef && 'appendChild' in newParentRef) {
newParentRef.appendChild(sharedElement as HTMLDivElement);
- bindFunctions();
+ bindFunctionsRef.current?.();
+ shouldBindFunctionsRef.current = true;
}
return () => {
if (!originalParent || !('appendChild' in originalParent)) {
@@ -373,7 +387,7 @@ function BaseVideoPlayer({
}
newParentRef.childNodes[0]?.remove();
};
- }, [bindFunctions, currentVideoPlayerRef, currentlyPlayingURL, isFullScreenRef, originalParent, sharedElement, shouldUseSharedVideoElement, url]);
+ }, [currentVideoPlayerRef, currentlyPlayingURL, isFullScreenRef, originalParent, sharedElement, shouldUseSharedVideoElement, url]);
useEffect(() => {
if (!shouldPlay) {
diff --git a/src/components/withCurrentReportID.tsx b/src/components/withCurrentReportID.tsx
deleted file mode 100644
index b551d321eb6c..000000000000
--- a/src/components/withCurrentReportID.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import type {NavigationState} from '@react-navigation/native';
-import type {ComponentType, ForwardedRef, RefAttributes} from 'react';
-import React, {createContext, forwardRef, useCallback, useMemo, useState} from 'react';
-import getComponentDisplayName from '@libs/getComponentDisplayName';
-import Navigation from '@libs/Navigation/Navigation';
-
-type CurrentReportIDContextValue = {
- updateCurrentReportID: (state: NavigationState) => void;
- currentReportID: string;
-};
-
-type CurrentReportIDContextProviderProps = {
- /** Actual content wrapped by this component */
- children: React.ReactNode;
-};
-
-const CurrentReportIDContext = createContext(null);
-
-const withCurrentReportIDDefaultProps = {
- currentReportID: '',
-};
-
-function CurrentReportIDContextProvider(props: CurrentReportIDContextProviderProps) {
- const [currentReportID, setCurrentReportID] = useState('');
-
- /**
- * This function is used to update the currentReportID
- * @param state root navigation state
- */
- const updateCurrentReportID = useCallback(
- (state: NavigationState) => {
- const reportID = Navigation.getTopmostReportId(state) ?? '-1';
-
- /*
- * Make sure we don't make the reportID undefined when switching between the chat list and settings tab.
- * This helps prevent unnecessary re-renders.
- */
- const params = state?.routes?.[state.index]?.params;
- if (params && 'screen' in params && typeof params.screen === 'string' && params.screen.indexOf('Settings_') !== -1) {
- return;
- }
- setCurrentReportID(reportID);
- },
- [setCurrentReportID],
- );
-
- /**
- * The context this component exposes to child components
- * @returns currentReportID to share between central pane and LHN
- */
- const contextValue = useMemo(
- (): CurrentReportIDContextValue => ({
- updateCurrentReportID,
- currentReportID,
- }),
- [updateCurrentReportID, currentReportID],
- );
-
- return {props.children};
-}
-
-CurrentReportIDContextProvider.displayName = 'CurrentReportIDContextProvider';
-
-export default function withCurrentReportID(
- WrappedComponent: ComponentType>,
-): (props: Omit & React.RefAttributes) => React.ReactElement | null {
- function WithCurrentReportID(props: Omit, ref: ForwardedRef) {
- return (
-
- {(currentReportIDUtils) => (
-
- )}
-
- );
- }
-
- WithCurrentReportID.displayName = `withCurrentReportID(${getComponentDisplayName(WrappedComponent)})`;
-
- return forwardRef(WithCurrentReportID);
-}
-
-export {withCurrentReportIDDefaultProps, CurrentReportIDContextProvider, CurrentReportIDContext};
-export type {CurrentReportIDContextValue};
diff --git a/src/hooks/useCurrentReportID.tsx b/src/hooks/useCurrentReportID.tsx
index d2934cd65b62..9b6f25f834cd 100644
--- a/src/hooks/useCurrentReportID.tsx
+++ b/src/hooks/useCurrentReportID.tsx
@@ -1,7 +1,63 @@
-import {useContext} from 'react';
-import type {CurrentReportIDContextValue} from '@components/withCurrentReportID';
-import {CurrentReportIDContext} from '@components/withCurrentReportID';
+import type {NavigationState} from '@react-navigation/native';
+import React, {createContext, useCallback, useContext, useMemo, useState} from 'react';
+import Navigation from '@libs/Navigation/Navigation';
+
+type CurrentReportIDContextValue = {
+ updateCurrentReportID: (state: NavigationState) => void;
+ currentReportID: string | undefined;
+};
+
+type CurrentReportIDContextProviderProps = {
+ /** Actual content wrapped by this component */
+ children: React.ReactNode;
+};
+
+const CurrentReportIDContext = createContext(null);
+
+function CurrentReportIDContextProvider(props: CurrentReportIDContextProviderProps) {
+ const [currentReportID, setCurrentReportID] = useState('');
+
+ /**
+ * This function is used to update the currentReportID
+ * @param state root navigation state
+ */
+ const updateCurrentReportID = useCallback(
+ (state: NavigationState) => {
+ const reportID = Navigation.getTopmostReportId(state);
+
+ /*
+ * Make sure we don't make the reportID undefined when switching between the chat list and settings tab.
+ * This helps prevent unnecessary re-renders.
+ */
+ const params = state?.routes?.[state.index]?.params;
+ if (params && 'screen' in params && typeof params.screen === 'string' && params.screen.indexOf('Settings_') !== -1) {
+ return;
+ }
+ setCurrentReportID(reportID);
+ },
+ [setCurrentReportID],
+ );
+
+ /**
+ * The context this component exposes to child components
+ * @returns currentReportID to share between central pane and LHN
+ */
+ const contextValue = useMemo(
+ (): CurrentReportIDContextValue => ({
+ updateCurrentReportID,
+ currentReportID,
+ }),
+ [updateCurrentReportID, currentReportID],
+ );
+
+ return {props.children};
+}
+
+CurrentReportIDContextProvider.displayName = 'CurrentReportIDContextProvider';
export default function useCurrentReportID(): CurrentReportIDContextValue | null {
return useContext(CurrentReportIDContext);
}
+
+export {CurrentReportIDContextProvider};
+export type {CurrentReportIDContextValue};
diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts
index a1a1ed2c00e5..a626606bf3e0 100644
--- a/src/hooks/useOnboardingFlow.ts
+++ b/src/hooks/useOnboardingFlow.ts
@@ -16,6 +16,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
* Warning: This hook should be used only once in the app
*/
function useOnboardingFlowRouter() {
+ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initialValue: true});
const [isOnboardingCompleted, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
selector: hasCompletedGuidedSetupFlowSelector,
});
@@ -32,6 +33,10 @@ function useOnboardingFlowRouter() {
useEffect(() => {
// This should delay opening the onboarding modal so it does not interfere with the ongoing ReportScreen params changes
InteractionManager.runAfterInteractions(() => {
+ if (isLoadingApp !== false) {
+ return;
+ }
+
if (isLoadingOnyxValue(isOnboardingCompletedMetadata, tryNewDotdMetadata, dismissedProductTrainingMetadata)) {
return;
}
@@ -76,6 +81,7 @@ function useOnboardingFlowRouter() {
}
});
}, [
+ isLoadingApp,
isOnboardingCompleted,
isHybridAppOnboardingCompleted,
isOnboardingCompletedMetadata,
diff --git a/src/hooks/useResetComposerFocus.ts b/src/hooks/useResetComposerFocus.ts
index 25fc578b6706..8bf0d32a92ae 100644
--- a/src/hooks/useResetComposerFocus.ts
+++ b/src/hooks/useResetComposerFocus.ts
@@ -1,6 +1,7 @@
import {useIsFocused} from '@react-navigation/native';
import type {MutableRefObject} from 'react';
import {useEffect, useRef} from 'react';
+import {InteractionManager} from 'react-native';
import type {TextInput} from 'react-native';
export default function useResetComposerFocus(inputRef: MutableRefObject) {
@@ -11,8 +12,13 @@ export default function useResetComposerFocus(inputRef: MutableRefObject {
+ inputRef.current?.focus(); // focus input again
+ shouldResetFocusRef.current = false;
+ });
+ return () => {
+ interactionTask.cancel();
+ };
}, [isFocused, inputRef]);
return {isFocused, shouldResetFocusRef};
diff --git a/src/hooks/useViewportOffsetTop/index.ts b/src/hooks/useViewportOffsetTop/index.ts
index 6f617aa38121..99a762167eb0 100644
--- a/src/hooks/useViewportOffsetTop/index.ts
+++ b/src/hooks/useViewportOffsetTop/index.ts
@@ -40,7 +40,8 @@ export default function useViewportOffsetTop(shouldAdjustScrollView = false): nu
useEffect(() => addViewportResizeListener(updateOffsetTop), [updateOffsetTop]);
useEffect(() => {
- if (!shouldAdjustScrollView) {
+ // We don't want to trigger window.scrollTo when we are already at the target position. It causes unnecessary style recalculations.
+ if (!shouldAdjustScrollView || viewportOffsetTop === window.scrollY) {
return;
}
window.scrollTo({top: viewportOffsetTop, behavior: 'smooth'});
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 3f17099c452e..3c762a0d8dd5 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -523,6 +523,7 @@ const translations = {
chooseDocument: 'Choose file',
attachmentTooLarge: 'Attachment is too large',
sizeExceeded: 'Attachment size is larger than 24 MB limit',
+ sizeExceededWithLimit: ({maxUploadSizeInMB}: SizeExceededParams) => `Attachment size is larger than ${maxUploadSizeInMB} MB limit`,
attachmentTooSmall: 'Attachment is too small',
sizeNotMet: 'Attachment size must be greater than 240 bytes',
wrongFileType: 'Invalid file type',
@@ -1275,6 +1276,7 @@ const translations = {
debugMode: 'Debug mode',
invalidFile: 'Invalid file',
invalidFileDescription: 'The file you are trying to import is not valid. Please try again.',
+ invalidateWithDelay: 'Invalidate with delay',
},
debugConsole: {
saveLog: 'Save log',
@@ -2574,6 +2576,7 @@ const translations = {
notAuthorized: `You don't have access to this page. If you're trying to join this workspace, just ask the workspace owner to add you as a member. Something else? Reach out to ${CONST.EMAIL.CONCIERGE}.`,
goToRoom: ({roomName}: GoToRoomParams) => `Go to ${roomName} room`,
goToWorkspace: 'Go to workspace',
+ goToWorkspaces: 'Go to workspaces',
clearFilter: 'Clear filter',
workspaceName: 'Workspace name',
workspaceOwner: 'Owner',
@@ -4382,7 +4385,8 @@ const translations = {
title: 'Upgrade to the Control plan',
note: 'Unlock our most powerful features, including:',
benefits: {
- note: 'The Control plan starts at $9 per active member per month.',
+ startsAt: 'The Control plan starts at ',
+ perMember: 'per active member per month.',
learnMore: 'Learn more',
pricing: 'about our plans and pricing.',
benefit1: 'Advanced accounting connections (NetSuite, Sage Intacct, and more)',
@@ -4409,7 +4413,7 @@ const translations = {
},
completed: {
headline: 'Your workspace has been downgraded',
- description: 'You have other workspace on the Control plan. To be billed at the Collect rate, you must downgrade all workspaces.',
+ description: 'You have other workspaces on the Control plan. To be billed at the Collect rate, you must downgrade all workspaces.',
gotIt: 'Got it, thanks',
},
},
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 3de91d7d2a71..bf8205af49d7 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -518,6 +518,7 @@ const translations = {
chooseDocument: 'Elegir un archivo',
attachmentTooLarge: 'Archivo adjunto demasiado grande',
sizeExceeded: 'El archivo adjunto supera el límite de 24 MB.',
+ sizeExceededWithLimit: ({maxUploadSizeInMB}: SizeExceededParams) => `El archivo adjunto supera el límite de ${maxUploadSizeInMB} MB.`,
attachmentTooSmall: 'Archivo adjunto demasiado pequeño',
sizeNotMet: 'El archivo adjunto debe ser más grande que 240 bytes.',
wrongFileType: 'Tipo de archivo inválido',
@@ -1273,7 +1274,8 @@ const translations = {
usingImportedState: 'Estás utilizando el estado importado. Pulsa aquí para borrarlo.',
debugMode: 'Modo depuración',
invalidFile: 'Archivo inválido',
- invalidFileDescription: 'El archivo que estás intentando importar no es válido. Por favor, inténtalo de nuevo.',
+ invalidFileDescription: 'El archivo que ests intentando importar no es válido. Por favor, inténtalo de nuevo.',
+ invalidateWithDelay: 'Invalidar con retraso',
},
debugConsole: {
saveLog: 'Guardar registro',
@@ -2597,6 +2599,7 @@ const translations = {
notAuthorized: `No tienes acceso a esta página. Si estás intentando unirte a este espacio de trabajo, pide al dueño del espacio de trabajo que te añada como miembro. ¿Necesitas algo más? Comunícate con ${CONST.EMAIL.CONCIERGE}`,
goToRoom: ({roomName}: GoToRoomParams) => `Ir a la sala ${roomName}`,
goToWorkspace: 'Ir al espacio de trabajo',
+ goToWorkspaces: 'Ir a espacios de trabajo',
clearFilter: 'Borrar filtro',
workspaceName: 'Nombre del espacio de trabajo',
workspaceOwner: 'Dueño',
@@ -4448,7 +4451,8 @@ const translations = {
title: 'Mejorar al plan Controlar',
note: 'Desbloquea nuestras funciones más potentes, incluyendo:',
benefits: {
- note: 'El plan Controlar comienza desde $9 por miembro activo al mes.',
+ startsAt: 'El plan Controlar comienza desde ',
+ perMember: 'por miembro activo al mes.',
learnMore: 'Más información',
pricing: 'sobre nuestros planes y precios.',
benefit1: 'Conexiones avanzadas de contabilidad (NetSuite, Sage Intacct y más)',
@@ -4475,7 +4479,7 @@ const translations = {
},
completed: {
headline: 'Tu espacio de trabajo ha sido bajado de categoría',
- description: 'Tienes otro espacio de trabajo en el plan Controlar. Para facturarte con la tasa del plan Recopilar, debes bajar de categoría todos los espacios de trabajo.',
+ description: 'Tienes otros espacios de trabajo en el plan Controlar. Para facturarte con la tasa del plan Recopilar, debes bajar de categoría todos los espacios de trabajo.',
gotIt: 'Entendido, gracias.',
},
},
diff --git a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts
index 78eb0adecc5e..d6d8f4169d76 100644
--- a/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts
+++ b/src/libs/API/parameters/CategorizeTrackedExpenseParams.ts
@@ -1,5 +1,3 @@
-import type {Receipt} from '@src/types/onyx/Transaction';
-
type CategorizeTrackedExpenseParams = {
amount: number;
currency: string;
@@ -16,7 +14,6 @@ type CategorizeTrackedExpenseParams = {
reportPreviewReportActionID: string;
category?: string;
tag?: string;
- receipt?: Receipt;
taxCode: string;
taxAmount: number;
billable?: boolean;
diff --git a/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts b/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts
index c51161b043a8..2942923f6b37 100644
--- a/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts
+++ b/src/libs/API/parameters/ConvertTrackedExpenseToRequestParams.ts
@@ -1,5 +1,3 @@
-import type {Receipt} from '@src/types/onyx/Transaction';
-
type ConvertTrackedExpenseToRequestParams = {
amount: number;
currency: string;
@@ -11,7 +9,6 @@ type ConvertTrackedExpenseToRequestParams = {
transactionID: string;
actionableWhisperReportActionID: string;
createdChatReportActionID: string;
- receipt?: Receipt;
moneyRequestReportID: string;
moneyRequestCreatedReportActionID: string;
moneyRequestPreviewReportActionID: string;
diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts
index cee4bc40d9ac..dc95d211427b 100644
--- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts
+++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts
@@ -1,5 +1,3 @@
-import type {Receipt} from '@src/types/onyx/Transaction';
-
type ShareTrackedExpenseParams = {
amount: number;
currency: string;
@@ -16,7 +14,6 @@ type ShareTrackedExpenseParams = {
reportPreviewReportActionID: string;
category?: string;
tag?: string;
- receipt?: Receipt;
taxCode: string;
taxAmount: number;
billable?: boolean;
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index a083e0203287..d13500913c4f 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -1,7 +1,7 @@
import {fromUnixTime, isBefore} from 'date-fns';
import groupBy from 'lodash/groupBy';
import Onyx from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import ExpensifyCardImage from '@assets/images/expensify-card.svg';
import * as Illustrations from '@src/components/Icon/Illustrations';
@@ -30,6 +30,15 @@ Onyx.connect({
},
});
+let allWorkspaceCards: OnyxCollection = {};
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST,
+ waitForCollectionCallback: true,
+ callback: (value) => {
+ allWorkspaceCards = value;
+ },
+});
+
/**
* @returns string with a month in MM format
*/
@@ -398,6 +407,17 @@ function checkIfNewFeedConnected(prevFeedsData: CompanyFeeds, currentFeedsData:
};
}
+function getAllCardsForWorkspace(workspaceAccountID: number): CardList {
+ const cards = {};
+ for (const [key, values] of Object.entries(allWorkspaceCards ?? {})) {
+ if (key.includes(workspaceAccountID.toString()) && values) {
+ const {cardList, ...rest} = values;
+ Object.assign(cards, rest);
+ }
+ }
+ return cards;
+}
+
export {
isExpensifyCard,
isCorporateCard,
@@ -427,4 +447,5 @@ export {
hasOnlyOneCardToAssign,
checkIfNewFeedConnected,
getDefaultCardName,
+ getAllCardsForWorkspace,
};
diff --git a/src/libs/CategoryUtils.ts b/src/libs/CategoryUtils.ts
index 207977ddb000..79ed264ade68 100644
--- a/src/libs/CategoryUtils.ts
+++ b/src/libs/CategoryUtils.ts
@@ -36,7 +36,7 @@ function formatRequireReceiptsOverText(translate: LocaleContextProps['translate'
return translate(`workspace.rules.categoryRules.requireReceiptsOverList.never`);
}
- const maxExpenseAmountToDisplay = policy?.maxExpenseAmount === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmount;
+ const maxExpenseAmountToDisplay = policy?.maxExpenseAmountNoReceipt === CONST.DISABLED_MAX_EXPENSE_VALUE ? 0 : policy?.maxExpenseAmountNoReceipt;
return translate(`workspace.rules.categoryRules.requireReceiptsOverList.default`, {
defaultAmount: CurrencyUtils.convertToShortDisplayString(maxExpenseAmountToDisplay, policy?.outputCurrency ?? CONST.CURRENCY.USD),
diff --git a/src/libs/E2E/actions/e2eLogin.ts b/src/libs/E2E/actions/e2eLogin.ts
index 25aee3c43d83..e325332d7dc7 100644
--- a/src/libs/E2E/actions/e2eLogin.ts
+++ b/src/libs/E2E/actions/e2eLogin.ts
@@ -55,6 +55,7 @@ export default function (): Promise {
.then((response) => {
Onyx.merge(ONYXKEYS.SESSION, {
authToken: response.authToken,
+ creationDate: new Date().getTime(),
email: e2eUserCredentials.email,
});
console.debug('[E2E] Signed in finished!');
diff --git a/src/libs/Fullstory/index.native.ts b/src/libs/Fullstory/index.native.ts
index 30a5a77ae9f3..930c12241b78 100644
--- a/src/libs/Fullstory/index.native.ts
+++ b/src/libs/Fullstory/index.native.ts
@@ -1,8 +1,10 @@
import FullStory, {FSPage} from '@fullstory/react-native';
import type {OnyxEntry} from 'react-native-onyx';
+import {isExpensifyTeam} from '@libs/PolicyUtils';
+import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import * as Environment from '@src/libs/Environment/Environment';
-import type {UserMetadata} from '@src/types/onyx';
+import type {OnyxInputOrEntry, PersonalDetailsList, Report, UserMetadata} from '@src/types/onyx';
/**
* Fullstory React-Native lib adapter
@@ -41,7 +43,7 @@ const FS = {
// UserMetadata onyx key.
Environment.getEnvironment().then((envName: string) => {
const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN);
- if (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) {
+ if ((CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) || isExpensifyTeam(value?.email)) {
return;
}
FullStory.restart();
@@ -63,5 +65,45 @@ const FS = {
},
};
+/**
+ * Placeholder function for Mobile-Web compatibility.
+ */
+function parseFSAttributes(): void {
+ // pass
+}
+
+/*
+ prefix? if component name should be used as a prefix,
+ in case data-test-id attribute usage,
+ clean component name should be preserved in data-test-id.
+*/
+function getFSAttributes(name: string, mask: boolean, prefix: boolean): string {
+ if (!name && !prefix) {
+ return `${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`;
+ }
+ // prefixed for Native apps should contain only component name
+ if (prefix) {
+ return name;
+ }
+
+ return `${name},${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`;
+}
+
+function getChatFSAttributes(context: OnyxEntry, name: string, report: OnyxInputOrEntry): string[] {
+ if (!name) {
+ return ['', ''];
+ }
+ if (isConciergeChatReport(report)) {
+ const formattedName = `${CONST.FULL_STORY.CONCIERGE}-${name}`;
+ return [`${formattedName}`, `${CONST.FULL_STORY.UNMASK},${formattedName}`];
+ }
+ if (shouldUnmaskChat(context, report)) {
+ const formattedName = `${CONST.FULL_STORY.CUSTOMER}-${name}`;
+ return [`${formattedName}`, `${CONST.FULL_STORY.UNMASK},${formattedName}`];
+ }
+ const formattedName = `${CONST.FULL_STORY.OTHER}-${name}`;
+ return [`${formattedName}`, `${CONST.FULL_STORY.MASK},${formattedName}`];
+}
+
export default FS;
-export {FSPage};
+export {FSPage, parseFSAttributes, getFSAttributes, getChatFSAttributes};
diff --git a/src/libs/Fullstory/index.ts b/src/libs/Fullstory/index.ts
index 0aa0b2094591..c7b1c2c9eb7a 100644
--- a/src/libs/Fullstory/index.ts
+++ b/src/libs/Fullstory/index.ts
@@ -1,10 +1,80 @@
import {FullStory, init, isInitialized} from '@fullstory/browser';
import type {OnyxEntry} from 'react-native-onyx';
+import {isExpensifyTeam} from '@libs/PolicyUtils';
+import {isConciergeChatReport, shouldUnmaskChat} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import * as Environment from '@src/libs/Environment/Environment';
-import type {UserMetadata} from '@src/types/onyx';
+import type {OnyxInputOrEntry, PersonalDetailsList, Report, UserMetadata} from '@src/types/onyx';
import type NavigationProperties from './types';
+/**
+ * Extract values from non-scraped at build time attribute WEB_PROP_ATTR,
+ * reevaluate "fs-class".
+ */
+function parseFSAttributes(): void {
+ window?.document?.querySelectorAll(`[${CONST.FULL_STORY.WEB_PROP_ATTR}]`).forEach((o) => {
+ const attr = o.getAttribute(CONST.FULL_STORY.WEB_PROP_ATTR) ?? '';
+ if (!/fs-/gim.test(attr)) {
+ return;
+ }
+
+ const fsAttrs = attr.match(/fs-[a-zA-Z0-9_-]+/g) ?? [];
+ o.setAttribute('fs-class', fsAttrs.join(','));
+
+ let cleanedAttrs = attr;
+ fsAttrs.forEach((fsAttr) => {
+ cleanedAttrs = cleanedAttrs.replace(fsAttr, '');
+ });
+
+ cleanedAttrs = cleanedAttrs
+ .replace(/,+/g, ',')
+ .replace(/\s*,\s*/g, ',')
+ .replace(/^,+|,+$/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ if (cleanedAttrs) {
+ o.setAttribute(CONST.FULL_STORY.WEB_PROP_ATTR, cleanedAttrs);
+ } else {
+ o.removeAttribute(CONST.FULL_STORY.WEB_PROP_ATTR);
+ }
+ });
+}
+
+/*
+ prefix? if component name should be used as a prefix,
+ in case data-test-id attribute usage,
+ clean component name should be preserved in data-test-id.
+*/
+function getFSAttributes(name: string, mask: boolean, prefix: boolean): string {
+ if (!name) {
+ return `${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`;
+ }
+
+ if (prefix) {
+ return `${name},${mask ? CONST.FULL_STORY.MASK : CONST.FULL_STORY.UNMASK}`;
+ }
+
+ return `${name}`;
+}
+
+function getChatFSAttributes(context: OnyxEntry, name: string, report: OnyxInputOrEntry): string[] {
+ if (!name) {
+ return ['', ''];
+ }
+ if (isConciergeChatReport(report)) {
+ const formattedName = `${CONST.FULL_STORY.CONCIERGE}-${name}`;
+ return [`${formattedName},${CONST.FULL_STORY.UNMASK}`, `${formattedName}`];
+ }
+ if (shouldUnmaskChat(context, report)) {
+ const formattedName = `${CONST.FULL_STORY.CUSTOMER}-${name}`;
+ return [`${formattedName},${CONST.FULL_STORY.UNMASK}`, `${formattedName}`];
+ }
+
+ const formattedName = `${CONST.FULL_STORY.OTHER}-${name}`;
+ return [`${formattedName},${CONST.FULL_STORY.MASK}`, `${formattedName}`];
+}
+
// Placeholder Browser API does not support Manual Page definition
class FSPage {
private pageName;
@@ -16,7 +86,9 @@ class FSPage {
this.properties = properties;
}
- start() {}
+ start() {
+ parseFSAttributes();
+ }
}
/**
@@ -58,7 +130,7 @@ const FS = {
try {
Environment.getEnvironment().then((envName: string) => {
const isTestEmail = value.email !== undefined && value.email.startsWith('fullstory') && value.email.endsWith(CONST.EMAIL.QA_DOMAIN);
- if (CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) {
+ if ((CONST.ENVIRONMENT.PRODUCTION !== envName && !isTestEmail) || isExpensifyTeam(value?.email)) {
return;
}
FS.onReady().then(() => {
@@ -93,4 +165,4 @@ const FS = {
};
export default FS;
-export {FSPage};
+export {FSPage, parseFSAttributes, getFSAttributes, getChatFSAttributes};
diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts
index 66ce71451c17..ad3ae653daa1 100644
--- a/src/libs/HttpUtils.ts
+++ b/src/libs/HttpUtils.ts
@@ -10,6 +10,10 @@ import * as UpdateRequired from './actions/UpdateRequired';
import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from './API/types';
import * as ApiUtils from './ApiUtils';
import HttpsError from './Errors/HttpsError';
+import getPlatform from './getPlatform';
+
+const platform = getPlatform();
+const isNativePlatform = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS;
let shouldFailAllRequests = false;
let shouldForceOffline = false;
@@ -160,10 +164,12 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form
function xhr(command: string, data: Record, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise {
const formData = new FormData();
Object.keys(data).forEach((key) => {
- if (typeof data[key] === 'undefined') {
+ const value = data[key];
+ if (value === undefined) {
return;
}
- formData.append(key, data[key] as string | Blob);
+ validateFormDataParameter(command, key, value);
+ formData.append(key, value as string | Blob);
});
const url = ApiUtils.getCommandURL({shouldUseSecure, command});
@@ -172,6 +178,35 @@ function xhr(command: string, data: Record, type: RequestType =
return processHTTPRequest(url, type, formData, abortSignalController?.signal);
}
+/**
+ * Ensures no value of type `object` other than null, Blob, its subclasses, or {uri: string} (native platforms only) is passed to XMLHttpRequest.
+ * Otherwise, it will be incorrectly serialized as `[object Object]` and cause an error on Android.
+ * See https://github.com/Expensify/App/issues/45086
+ */
+function validateFormDataParameter(command: string, key: string, value: unknown) {
+ // eslint-disable-next-line @typescript-eslint/no-shadow
+ const isValid = (value: unknown, isTopLevel: boolean): boolean => {
+ if (value === null || typeof value !== 'object') {
+ return true;
+ }
+ if (Array.isArray(value)) {
+ return value.every((element) => isValid(element, false));
+ }
+ if (isTopLevel) {
+ // Native platforms only require the value to include the `uri` property.
+ // Optionally, it can also have a `name` and `type` props.
+ // On other platforms, the value must be an instance of `Blob`.
+ return isNativePlatform ? 'uri' in value && !!value.uri : value instanceof Blob;
+ }
+ return false;
+ };
+
+ if (!isValid(value, true)) {
+ // eslint-disable-next-line no-console
+ console.warn(`An unsupported value was passed to command '${command}' (parameter: '${key}'). Only Blob and primitive types are allowed.`);
+ }
+}
+
function cancelPendingRequests(command: AbortCommand = ABORT_COMMANDS.All) {
const controller = abortControllerMap.get(command);
diff --git a/src/libs/Middleware/SaveResponseInOnyx.ts b/src/libs/Middleware/SaveResponseInOnyx.ts
index 12c1931b0199..1a092f131e04 100644
--- a/src/libs/Middleware/SaveResponseInOnyx.ts
+++ b/src/libs/Middleware/SaveResponseInOnyx.ts
@@ -25,13 +25,16 @@ const SaveResponseInOnyx: Middleware = (requestResponse, request) =>
const responseToApply = {
type: CONST.ONYX_UPDATE_TYPES.HTTPS,
- lastUpdateID: Number(response?.lastUpdateID ?? 0),
- previousUpdateID: Number(response?.previousUpdateID ?? 0),
+ lastUpdateID: Number(response?.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID),
+ previousUpdateID: Number(response?.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID),
request,
response: response ?? {},
};
- if (requestsToIgnoreLastUpdateID.includes(request.command) || !OnyxUpdates.doesClientNeedToBeUpdated(Number(response?.previousUpdateID ?? 0))) {
+ if (
+ requestsToIgnoreLastUpdateID.includes(request.command) ||
+ !OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: Number(response?.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID)})
+ ) {
return OnyxUpdates.apply(responseToApply);
}
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
index 74fb83aa7e01..80165073fda8 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
@@ -70,7 +70,7 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {activeWorkspaceID} = useActiveWorkspace();
- const {currentReportID} = useCurrentReportID() ?? {currentReportID: null};
+ const {currentReportID = null} = useCurrentReportID() ?? {};
const [user] = useOnyx(ONYXKEYS.USER);
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx
index b19635a77fdb..c044422a7cc7 100644
--- a/src/libs/Navigation/NavigationRoot.tsx
+++ b/src/libs/Navigation/NavigationRoot.tsx
@@ -12,7 +12,7 @@ import useThemePreference from '@hooks/useThemePreference';
import Firebase from '@libs/Firebase';
import {FSPage} from '@libs/Fullstory';
import Log from '@libs/Log';
-import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors';
+import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} from '@libs/onboardingSelectors';
import {getPathFromURL} from '@libs/Url';
import {updateLastVisitedPath} from '@userActions/App';
import * as Session from '@userActions/Session';
@@ -98,6 +98,10 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh
const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
selector: hasCompletedGuidedSetupFlowSelector,
});
+ const [wasInvitedToNewDot = false] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {
+ selector: wasInvitedToNewDotSelector,
+ });
+ const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY);
const initialState = useMemo(() => {
if (!user || user.isFromPublicDomain) {
@@ -105,8 +109,8 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh
}
// If the user haven't completed the flow, we want to always redirect them to the onboarding flow.
- // We also make sure that the user is authenticated.
- if (!NativeModules.HybridAppModule && !isOnboardingCompleted && authenticated && !shouldShowRequire2FAModal) {
+ // We also make sure that the user is authenticated, isn't part of a group workspace, & wasn't invited to NewDot.
+ if (!NativeModules.HybridAppModule && !hasNonPersonalPolicy && !isOnboardingCompleted && !wasInvitedToNewDot && authenticated && !shouldShowRequire2FAModal) {
const {adaptedState} = getAdaptedStateFromPath(getOnboardingInitialPath(isPrivateDomain), linkingConfig.config);
return adaptedState;
}
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 9a03409fb3a2..2c3b060e0835 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -9,7 +9,6 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.CURRENCY,
SCREENS.WORKSPACE.DESCRIPTION,
SCREENS.WORKSPACE.SHARE,
- SCREENS.WORKSPACE.DOWNGRADE,
],
[SCREENS.WORKSPACE.MEMBERS]: [
SCREENS.WORKSPACE.MEMBER_DETAILS,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index ffed2519f775..088f624c7dc3 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -247,13 +247,13 @@ type SettingsNavigatorParamList = {
backTo?: Routes;
};
[SCREENS.WORKSPACE.UPGRADE]: {
- policyID: string;
+ policyID?: string;
featureName?: string;
backTo?: Routes;
categoryId?: string;
};
[SCREENS.WORKSPACE.DOWNGRADE]: {
- policyID: string;
+ policyID?: string;
};
[SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: {
policyID: string;
diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts
index 2a963b8bc6c9..04f45411dce1 100644
--- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts
+++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts
@@ -6,6 +6,7 @@ import * as AppUpdate from '@libs/actions/AppUpdate';
import ModifiedExpenseMessage from '@libs/ModifiedExpenseMessage';
import {getTextFromHtml} from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import playSound, {SOUNDS} from '@libs/Sound';
import type {Report, ReportAction} from '@src/types/onyx';
import focusApp from './focusApp';
import type {LocalNotificationClickHandler, LocalNotificationData} from './types';
@@ -65,9 +66,12 @@ function push(
body,
icon: String(icon),
data,
- silent,
+ silent: true,
tag,
});
+ if (!silent) {
+ playSound(SOUNDS.RECEIVE);
+ }
notificationCache[notificationID].onclick = () => {
onClick();
window.parent.focus();
@@ -122,7 +126,7 @@ export default {
reportID: report.reportID,
};
- push(title, body, icon, data, onClick, true);
+ push(title, body, icon, data, onClick);
},
pushModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) {
diff --git a/src/libs/Notification/PushNotification/NotificationType.ts b/src/libs/Notification/PushNotification/NotificationType.ts
index 0ae8d9cc488e..28d98c7aec42 100644
--- a/src/libs/Notification/PushNotification/NotificationType.ts
+++ b/src/libs/Notification/PushNotification/NotificationType.ts
@@ -19,6 +19,7 @@ type BasePushNotificationData = {
onyxData?: OnyxServerUpdate[];
lastUpdateID?: number;
previousUpdateID?: number;
+ hasPendingOnyxUpdates?: boolean;
};
type ReportActionPushNotificationData = BasePushNotificationData & {
diff --git a/src/libs/Notification/PushNotification/subscribePushNotification/index.ts b/src/libs/Notification/PushNotification/subscribePushNotification/index.ts
index 61af079f9ed1..237a615b570a 100644
--- a/src/libs/Notification/PushNotification/subscribePushNotification/index.ts
+++ b/src/libs/Notification/PushNotification/subscribePushNotification/index.ts
@@ -43,12 +43,12 @@ function getLastUpdateIDAppliedToClient(): Promise {
return new Promise((resolve) => {
Onyx.connect({
key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
- callback: (value) => resolve(value ?? 0),
+ callback: (value) => resolve(value ?? CONST.DEFAULT_NUMBER_ID),
});
});
}
-function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previousUpdateID}: ReportActionPushNotificationData): Promise {
+function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previousUpdateID, hasPendingOnyxUpdates = false}: ReportActionPushNotificationData): Promise {
Log.info(`[PushNotification] Applying onyx data in the ${Visibility.isVisible() ? 'foreground' : 'background'}`, false, {reportID, reportActionID});
if (!ActiveClientManager.isClientTheLeader()) {
@@ -56,30 +56,56 @@ function applyOnyxData({reportID, reportActionID, onyxData, lastUpdateID, previo
return Promise.resolve();
}
- if (!onyxData || !lastUpdateID || !previousUpdateID) {
- Log.hmmm("[PushNotification] didn't apply onyx updates because some data is missing", {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0});
- return Promise.resolve();
- }
+ const logMissingOnyxDataInfo = (isDataMissing: boolean): boolean => {
+ if (isDataMissing) {
+ Log.hmmm("[PushNotification] didn't apply onyx updates because some data is missing", {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0});
+ return false;
+ }
- Log.info('[PushNotification] reliable onyx update received', false, {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0});
- const updates: OnyxUpdatesFromServer = {
- type: CONST.ONYX_UPDATE_TYPES.AIRSHIP,
- lastUpdateID,
- previousUpdateID,
- updates: [
- {
- eventType: '', // This is only needed for Pusher events
- data: onyxData,
- },
- ],
+ Log.info('[PushNotification] reliable onyx update received', false, {lastUpdateID, previousUpdateID, onyxDataCount: onyxData?.length ?? 0});
+ return true;
};
+ let updates: OnyxUpdatesFromServer;
+ if (hasPendingOnyxUpdates) {
+ const isDataMissing = !lastUpdateID;
+ logMissingOnyxDataInfo(isDataMissing);
+ if (isDataMissing) {
+ return Promise.resolve();
+ }
+
+ updates = {
+ type: CONST.ONYX_UPDATE_TYPES.AIRSHIP,
+ lastUpdateID,
+ shouldFetchPendingUpdates: true,
+ updates: [],
+ };
+ } else {
+ const isDataMissing = !lastUpdateID || !onyxData || !previousUpdateID;
+ logMissingOnyxDataInfo(isDataMissing);
+ if (isDataMissing) {
+ return Promise.resolve();
+ }
+
+ updates = {
+ type: CONST.ONYX_UPDATE_TYPES.AIRSHIP,
+ lastUpdateID,
+ previousUpdateID,
+ updates: [
+ {
+ eventType: '', // This is only needed for Pusher events
+ data: onyxData,
+ },
+ ],
+ };
+ }
+
/**
* When this callback runs in the background on Android (via Headless JS), no other Onyx.connect callbacks will run. This means that
* lastUpdateIDAppliedToClient will NOT be populated in other libs. To workaround this, we manually read the value here
* and pass it as a param
*/
- return getLastUpdateIDAppliedToClient().then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, true, lastUpdateIDAppliedToClient));
+ return getLastUpdateIDAppliedToClient().then((lastUpdateIDAppliedToClient) => applyOnyxUpdatesReliably(updates, {shouldRunSync: true, clientLastUpdateID: lastUpdateIDAppliedToClient}));
}
function navigateToReport({reportID, reportActionID}: ReportActionPushNotificationData): Promise {
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 4d1988a53bde..43f601b167aa 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -111,7 +111,7 @@ type GetOptionsConfig = {
};
type GetUserToInviteConfig = {
- searchValue: string;
+ searchValue: string | undefined;
optionsToExclude?: Array>;
reportActions?: ReportActions;
shouldAcceptName?: boolean;
@@ -1078,6 +1078,10 @@ function getUserToInviteOption({
showChatPreviewLine = false,
shouldAcceptName = false,
}: GetUserToInviteConfig): ReportUtils.OptionData | null {
+ if (!searchValue) {
+ return null;
+ }
+
const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchValue)));
const isCurrentUserLogin = isCurrentUser({login: searchValue} as PersonalDetails);
const isInSelectedOption = selectedOptions.some((option) => 'login' in option && option.login === searchValue);
@@ -1086,7 +1090,7 @@ function getUserToInviteOption({
const isInOptionToExclude =
optionsToExclude.findIndex((optionToExclude) => 'login' in optionToExclude && optionToExclude.login === PhoneNumber.addSMSDomainIfPhoneNumber(searchValue).toLowerCase()) !== -1;
- if (!searchValue || isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber && !shouldAcceptName) || isInOptionToExclude) {
+ if (isCurrentUserLogin || isInSelectedOption || (!isValidEmail && !isValidPhoneNumber && !shouldAcceptName) || isInOptionToExclude) {
return null;
}
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index 2a4168a24668..672e79ff335f 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -34,6 +34,7 @@ import type PolicyEmployee from '@src/types/onyx/PolicyEmployee';
import type {SearchPolicy} from '@src/types/onyx/SearchResults';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import {hasSynchronizationErrorMessage} from './actions/connections';
+import {getCurrentUserAccountID} from './actions/Report';
import {getCategoryApproverRule} from './CategoryUtils';
import * as Localize from './Localize';
import Navigation from './Navigation/Navigation';
@@ -687,11 +688,6 @@ function canSendInvoice(policies: OnyxCollection | null, currentUserLogi
return getActiveAdminWorkspaces(policies, currentUserLogin).some((policy) => canSendInvoiceFromWorkspace(policy.id));
}
-function hasWorkspaceWithInvoices(currentUserLogin: string | undefined): boolean {
- const activePolicies = getActivePolicies(allPolicies, currentUserLogin);
- return activePolicies.some((policy) => shouldShowPolicy(policy, NetworkStore.isOffline(), currentUserLogin) && policy.areInvoicesEnabled);
-}
-
function hasDependentTags(policy: OnyxEntry, policyTagList: OnyxEntry) {
if (!policy?.hasMultipleTagLists) {
return false;
@@ -1190,6 +1186,25 @@ function hasOtherControlWorkspaces(currentPolicyID: string) {
return otherControlWorkspaces.length > 0;
}
+// If no policyID is provided, it indicates the workspace upgrade/downgrade URL
+// is being accessed from the Subscriptions page without a specific policyID.
+// In this case, check if the user is an admin on more than one policy.
+// If the user is an admin for multiple policies, we can render the page as it contains a condition
+// to navigate them to the Workspaces page when no policyID is provided, instead of showing the Upgrade/Downgrade button.
+// If the user is not an admin for multiple policies, they are not allowed to perform this action, and the NotFoundPage is displayed.
+
+function canModifyPlan(policyID?: string) {
+ const currentUserAccountID = getCurrentUserAccountID();
+ const ownerPolicies = getOwnedPaidPolicies(allPolicies, currentUserAccountID);
+
+ if (!policyID) {
+ return ownerPolicies.length > 1;
+ }
+ const policy = getPolicy(policyID);
+
+ return !!policy && isPolicyAdmin(policy);
+}
+
export {
canEditTaxRate,
extractPolicyIDFromPath,
@@ -1249,7 +1264,6 @@ export {
canSendInvoiceFromWorkspace,
canSubmitPerDiemExpenseFromWorkspace,
canSendInvoice,
- hasWorkspaceWithInvoices,
hasDependentTags,
hasVBBA,
getXeroTenants,
@@ -1318,6 +1332,7 @@ export {
hasOtherControlWorkspaces,
getManagerAccountEmail,
getRuleApprovers,
+ canModifyPlan,
};
export type {MemberEmailsToAccountIDs};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index ecbce22411b8..67e0828f5c61 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -4957,7 +4957,7 @@ function getRejectedReportMessage() {
function getWorkspaceNameUpdatedMessage(action: ReportAction) {
const {oldName, newName} = ReportActionsUtils.getOriginalMessage(action as ReportAction) ?? {};
const message = oldName && newName ? Localize.translateLocal('workspaceActions.renamedWorkspaceNameAction', {oldName, newName}) : ReportActionsUtils.getReportActionText(action);
- return message;
+ return Str.htmlEncode(message);
}
/**
@@ -6476,7 +6476,7 @@ function isReportNotFound(report: OnyxEntry): boolean {
/**
* Check if the report is the parent report of the currently viewed report or at least one child report has report action
*/
-function shouldHideReport(report: OnyxEntry, currentReportId: string): boolean {
+function shouldHideReport(report: OnyxEntry, currentReportId: string | undefined): boolean {
const currentReport = getReportOrDraftReport(currentReportId);
const parentReport = getParentReport(!isEmptyObject(currentReport) ? currentReport : undefined);
const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? {};
@@ -6646,7 +6646,7 @@ function hasReportErrorsOtherThanFailedReceipt(report: Report, doesReportHaveVio
type ShouldReportBeInOptionListParams = {
report: OnyxEntry;
- currentReportId: string;
+ currentReportId: string | undefined;
isInFocusMode: boolean;
betas: OnyxEntry;
policies: OnyxCollection;
@@ -8349,13 +8349,11 @@ function createDraftTransactionAndNavigateToParticipantSelector(
mccGroup,
} as Transaction);
- const filteredPolicies = Object.values(allPolicies ?? {}).filter(
- (policy) => policy && policy.type !== CONST.POLICY.TYPE.PERSONAL && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
- );
+ const filteredPolicies = Object.values(allPolicies ?? {}).filter((policy) => PolicyUtils.shouldShowPolicy(policy, false, currentUserEmail));
if (actionName === CONST.IOU.ACTION.CATEGORIZE) {
const activePolicy = getPolicy(activePolicyID);
- if (activePolicy && activePolicy?.type !== CONST.POLICY.TYPE.PERSONAL && activePolicy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ if (PolicyUtils.shouldShowPolicy(activePolicy, false, currentUserEmail)) {
const policyExpenseReportID = getPolicyExpenseChat(currentUserAccountID, activePolicyID)?.reportID;
IOU.setMoneyRequestParticipants(transactionID, [
{
@@ -8637,6 +8635,53 @@ function hasInvoiceReports() {
return reports.some((report) => isInvoiceReport(report));
}
+function shouldUnmaskChat(participantsContext: OnyxEntry, report: OnyxInputOrEntry): boolean {
+ if (!report?.participants) {
+ return true;
+ }
+
+ if (isThread(report) && report?.chatType && report?.chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT) {
+ return true;
+ }
+
+ if (isThread(report) && report?.type === CONST.REPORT.TYPE.EXPENSE) {
+ return true;
+ }
+
+ const participantAccountIDs = Object.keys(report.participants);
+
+ if (participantAccountIDs.length > 2) {
+ return false;
+ }
+
+ if (participantsContext) {
+ let teamInChat = false;
+ let userInChat = false;
+
+ for (const participantAccountID of participantAccountIDs) {
+ const id = Number(participantAccountID);
+ const contextAccountData = participantsContext[id];
+
+ if (contextAccountData) {
+ const login = contextAccountData.login ?? '';
+
+ if (login.endsWith(CONST.EMAIL.EXPENSIFY_EMAIL_DOMAIN) || login.endsWith(CONST.EMAIL.EXPENSIFY_TEAM_EMAIL_DOMAIN)) {
+ teamInChat = true;
+ } else {
+ userInChat = true;
+ }
+ }
+ }
+
+ // exclude teamOnly chat
+ if (teamInChat && userInChat) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
function getReportMetadata(reportID: string | undefined) {
return reportID ? allReportMetadataKeyValue[reportID] : undefined;
}
@@ -8963,6 +9008,7 @@ export {
getAllReportErrors,
getAllReportActionsErrorsAndReportActionThatRequiresAttention,
hasInvoiceReports,
+ shouldUnmaskChat,
getReportMetadata,
buildOptimisticSelfDMReport,
isHiddenForCurrentUser,
diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js
index 0b456b5823b1..d8705428bc1f 100644
--- a/src/libs/SearchParser/autocompleteParser.js
+++ b/src/libs/SearchParser/autocompleteParser.js
@@ -212,13 +212,13 @@ function peg$parse(input, options) {
var peg$c34 = ">";
var peg$c35 = "<=";
var peg$c36 = "<";
- var peg$c37 = "\"";
var peg$r0 = /^[:=]/;
- var peg$r1 = /^[^ ,"\t\n\r]/;
- var peg$r2 = /^[^"\r\n]/;
- var peg$r3 = /^[^ ,\t\n\r]/;
- var peg$r4 = /^[ \t\r\n]/;
+ var peg$r1 = /^[^ ,"\u201D\u201C\t\n\r]/;
+ var peg$r2 = /^["\u201C-\u201D]/;
+ var peg$r3 = /^[^"\u201D\u201C\r\n]/;
+ var peg$r4 = /^[^ ,\t\n\r]/;
+ var peg$r5 = /^[ \t\r\n]/;
var peg$e0 = peg$literalExpectation(",", false);
var peg$e1 = peg$otherExpectation("key");
@@ -261,9 +261,9 @@ function peg$parse(input, options) {
var peg$e38 = peg$literalExpectation("<=", false);
var peg$e39 = peg$literalExpectation("<", false);
var peg$e40 = peg$otherExpectation("quote");
- var peg$e41 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false);
- var peg$e42 = peg$literalExpectation("\"", false);
- var peg$e43 = peg$classExpectation(["\"", "\r", "\n"], true, false);
+ var peg$e41 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r"], true, false);
+ var peg$e42 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false);
+ var peg$e43 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false);
var peg$e44 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false);
var peg$e45 = peg$otherExpectation("word");
var peg$e46 = peg$otherExpectation("whitespace");
@@ -1418,8 +1418,8 @@ function peg$parse(input, options) {
if (peg$silentFails === 0) { peg$fail(peg$e41); }
}
}
- if (input.charCodeAt(peg$currPos) === 34) {
- s2 = peg$c37;
+ s2 = input.charAt(peg$currPos);
+ if (peg$r2.test(s2)) {
peg$currPos++;
} else {
s2 = peg$FAILED;
@@ -1428,7 +1428,7 @@ function peg$parse(input, options) {
if (s2 !== peg$FAILED) {
s3 = [];
s4 = input.charAt(peg$currPos);
- if (peg$r2.test(s4)) {
+ if (peg$r3.test(s4)) {
peg$currPos++;
} else {
s4 = peg$FAILED;
@@ -1437,15 +1437,15 @@ function peg$parse(input, options) {
while (s4 !== peg$FAILED) {
s3.push(s4);
s4 = input.charAt(peg$currPos);
- if (peg$r2.test(s4)) {
+ if (peg$r3.test(s4)) {
peg$currPos++;
} else {
s4 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e43); }
}
}
- if (input.charCodeAt(peg$currPos) === 34) {
- s4 = peg$c37;
+ s4 = input.charAt(peg$currPos);
+ if (peg$r2.test(s4)) {
peg$currPos++;
} else {
s4 = peg$FAILED;
@@ -1454,7 +1454,7 @@ function peg$parse(input, options) {
if (s4 !== peg$FAILED) {
s5 = [];
s6 = input.charAt(peg$currPos);
- if (peg$r3.test(s6)) {
+ if (peg$r4.test(s6)) {
peg$currPos++;
} else {
s6 = peg$FAILED;
@@ -1463,7 +1463,7 @@ function peg$parse(input, options) {
while (s6 !== peg$FAILED) {
s5.push(s6);
s6 = input.charAt(peg$currPos);
- if (peg$r3.test(s6)) {
+ if (peg$r4.test(s6)) {
peg$currPos++;
} else {
s6 = peg$FAILED;
@@ -1496,7 +1496,7 @@ function peg$parse(input, options) {
s0 = peg$currPos;
s1 = [];
s2 = input.charAt(peg$currPos);
- if (peg$r3.test(s2)) {
+ if (peg$r4.test(s2)) {
peg$currPos++;
} else {
s2 = peg$FAILED;
@@ -1506,7 +1506,7 @@ function peg$parse(input, options) {
while (s2 !== peg$FAILED) {
s1.push(s2);
s2 = input.charAt(peg$currPos);
- if (peg$r3.test(s2)) {
+ if (peg$r4.test(s2)) {
peg$currPos++;
} else {
s2 = peg$FAILED;
@@ -1548,7 +1548,7 @@ function peg$parse(input, options) {
peg$silentFails++;
s0 = [];
s1 = input.charAt(peg$currPos);
- if (peg$r4.test(s1)) {
+ if (peg$r5.test(s1)) {
peg$currPos++;
} else {
s1 = peg$FAILED;
@@ -1557,7 +1557,7 @@ function peg$parse(input, options) {
while (s1 !== peg$FAILED) {
s0.push(s1);
s1 = input.charAt(peg$currPos);
- if (peg$r4.test(s1)) {
+ if (peg$r5.test(s1)) {
peg$currPos++;
} else {
s1 = peg$FAILED;
diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy
index cc1305adc8b3..ebde336a6ead 100644
--- a/src/libs/SearchParser/baseRules.peggy
+++ b/src/libs/SearchParser/baseRules.peggy
@@ -56,7 +56,7 @@ operator "operator"
/ "<" { return "lt"; }
quotedString "quote"
- = start:[^ ,"\t\n\r]* "\"" inner:[^"\r\n]* "\"" end:[^ ,\t\n\r]* {
+ = start:[^ ,"”“\t\n\r]* ("“" / "\"" / "”") inner:[^"”“\r\n]* ("“" / "\"" / "”") end:[^ ,\t\n\r]* {
return [...start, '"', ...inner, '"', ...end].join("");
}
diff --git a/src/libs/SearchParser/searchParser.js b/src/libs/SearchParser/searchParser.js
index 941ac7f59797..d9baeadb2a14 100644
--- a/src/libs/SearchParser/searchParser.js
+++ b/src/libs/SearchParser/searchParser.js
@@ -217,14 +217,14 @@ function peg$parse(input, options) {
var peg$c34 = ">";
var peg$c35 = "<=";
var peg$c36 = "<";
- var peg$c37 = "\"";
var peg$r0 = /^[^ \t\r\n]/;
var peg$r1 = /^[:=]/;
- var peg$r2 = /^[^ ,"\t\n\r]/;
- var peg$r3 = /^[^"\r\n]/;
- var peg$r4 = /^[^ ,\t\n\r]/;
- var peg$r5 = /^[ \t\r\n]/;
+ var peg$r2 = /^[^ ,"\u201D\u201C\t\n\r]/;
+ var peg$r3 = /^["\u201C-\u201D]/;
+ var peg$r4 = /^[^"\u201D\u201C\r\n]/;
+ var peg$r5 = /^[^ ,\t\n\r]/;
+ var peg$r6 = /^[ \t\r\n]/;
var peg$e0 = peg$classExpectation([" ", "\t", "\r", "\n"], true, false);
var peg$e1 = peg$otherExpectation("key");
@@ -269,9 +269,9 @@ function peg$parse(input, options) {
var peg$e40 = peg$literalExpectation("<=", false);
var peg$e41 = peg$literalExpectation("<", false);
var peg$e42 = peg$otherExpectation("quote");
- var peg$e43 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false);
- var peg$e44 = peg$literalExpectation("\"", false);
- var peg$e45 = peg$classExpectation(["\"", "\r", "\n"], true, false);
+ var peg$e43 = peg$classExpectation([" ", ",", "\"", "\u201D", "\u201C", "\t", "\n", "\r"], true, false);
+ var peg$e44 = peg$classExpectation(["\"", ["\u201C", "\u201D"]], false, false);
+ var peg$e45 = peg$classExpectation(["\"", "\u201D", "\u201C", "\r", "\n"], true, false);
var peg$e46 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false);
var peg$e47 = peg$otherExpectation("word");
var peg$e48 = peg$otherExpectation("whitespace");
@@ -1616,8 +1616,8 @@ function peg$parse(input, options) {
if (peg$silentFails === 0) { peg$fail(peg$e43); }
}
}
- if (input.charCodeAt(peg$currPos) === 34) {
- s2 = peg$c37;
+ s2 = input.charAt(peg$currPos);
+ if (peg$r3.test(s2)) {
peg$currPos++;
} else {
s2 = peg$FAILED;
@@ -1626,7 +1626,7 @@ function peg$parse(input, options) {
if (s2 !== peg$FAILED) {
s3 = [];
s4 = input.charAt(peg$currPos);
- if (peg$r3.test(s4)) {
+ if (peg$r4.test(s4)) {
peg$currPos++;
} else {
s4 = peg$FAILED;
@@ -1635,15 +1635,15 @@ function peg$parse(input, options) {
while (s4 !== peg$FAILED) {
s3.push(s4);
s4 = input.charAt(peg$currPos);
- if (peg$r3.test(s4)) {
+ if (peg$r4.test(s4)) {
peg$currPos++;
} else {
s4 = peg$FAILED;
if (peg$silentFails === 0) { peg$fail(peg$e45); }
}
}
- if (input.charCodeAt(peg$currPos) === 34) {
- s4 = peg$c37;
+ s4 = input.charAt(peg$currPos);
+ if (peg$r3.test(s4)) {
peg$currPos++;
} else {
s4 = peg$FAILED;
@@ -1652,7 +1652,7 @@ function peg$parse(input, options) {
if (s4 !== peg$FAILED) {
s5 = [];
s6 = input.charAt(peg$currPos);
- if (peg$r4.test(s6)) {
+ if (peg$r5.test(s6)) {
peg$currPos++;
} else {
s6 = peg$FAILED;
@@ -1661,7 +1661,7 @@ function peg$parse(input, options) {
while (s6 !== peg$FAILED) {
s5.push(s6);
s6 = input.charAt(peg$currPos);
- if (peg$r4.test(s6)) {
+ if (peg$r5.test(s6)) {
peg$currPos++;
} else {
s6 = peg$FAILED;
@@ -1694,7 +1694,7 @@ function peg$parse(input, options) {
s0 = peg$currPos;
s1 = [];
s2 = input.charAt(peg$currPos);
- if (peg$r4.test(s2)) {
+ if (peg$r5.test(s2)) {
peg$currPos++;
} else {
s2 = peg$FAILED;
@@ -1704,7 +1704,7 @@ function peg$parse(input, options) {
while (s2 !== peg$FAILED) {
s1.push(s2);
s2 = input.charAt(peg$currPos);
- if (peg$r4.test(s2)) {
+ if (peg$r5.test(s2)) {
peg$currPos++;
} else {
s2 = peg$FAILED;
@@ -1746,7 +1746,7 @@ function peg$parse(input, options) {
peg$silentFails++;
s0 = [];
s1 = input.charAt(peg$currPos);
- if (peg$r5.test(s1)) {
+ if (peg$r6.test(s1)) {
peg$currPos++;
} else {
s1 = peg$FAILED;
@@ -1755,7 +1755,7 @@ function peg$parse(input, options) {
while (s1 !== peg$FAILED) {
s0.push(s1);
s1 = input.charAt(peg$currPos);
- if (peg$r5.test(s1)) {
+ if (peg$r6.test(s1)) {
peg$currPos++;
} else {
s1 = peg$FAILED;
diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts
index 73c83cb33b83..3aad00f815ba 100644
--- a/src/libs/SearchQueryUtils.ts
+++ b/src/libs/SearchQueryUtils.ts
@@ -67,11 +67,10 @@ const UserFriendlyKeyMap: Record, policyID: string): boolean {
- return Object.values(reports ?? {}).some((report) => report && report.policyID === policyID && report.reportName === roomName);
+function isExistingRoomName(roomName: string, reports: OnyxCollection, policyID: string | undefined): boolean {
+ return Object.values(reports ?? {}).some((report) => report && policyID && report.policyID === policyID && report.reportName === roomName);
}
/**
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index 931f9e226995..1bc9bde348fb 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -97,6 +97,14 @@ Onyx.connect({
},
});
+let preservedShouldUseStagingServer: boolean | undefined;
+Onyx.connect({
+ key: ONYXKEYS.USER,
+ callback: (value) => {
+ preservedShouldUseStagingServer = value?.shouldUseStagingServer;
+ },
+});
+
const KEYS_TO_PRESERVE: OnyxKey[] = [
ONYXKEYS.ACCOUNT,
ONYXKEYS.IS_CHECKING_PUBLIC_ROOM,
@@ -540,6 +548,7 @@ function setPreservedUserSession(session: OnyxTypes.Session) {
function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) {
// The value of isUsingImportedState will be lost once Onyx is cleared, so we need to store it
const isStateImported = isUsingImportedState;
+ const shouldUseStagingServer = preservedShouldUseStagingServer;
const sequentialQueue = PersistedRequests.getAll();
Onyx.clear(KEYS_TO_PRESERVE).then(() => {
// Network key is preserved, so when using imported state, we should stop forcing offline mode so that the app can re-fetch the network
@@ -556,6 +565,10 @@ function clearOnyxAndResetApp(shouldNavigateToHomepage?: boolean) {
Onyx.set(ONYXKEYS.PRESERVED_USER_SESSION, null);
}
+ if (shouldUseStagingServer) {
+ Onyx.set(ONYXKEYS.USER, {shouldUseStagingServer});
+ }
+
// Requests in a sequential queue should be called even if the Onyx state is reset, so we do not lose any pending data.
// However, the OpenApp request must be called before any other request in a queue to ensure data consistency.
// To do that, sequential queue is cleared together with other keys, and then it's restored once the OpenApp request is resolved.
diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts
index c8dce813c895..271da9b4eada 100644
--- a/src/libs/actions/Card.ts
+++ b/src/libs/actions/Card.ts
@@ -38,7 +38,7 @@ type IssueNewCardFlowData = {
};
function reportVirtualExpensifyCardFraud(card: Card, validateCode: string) {
- const cardID = card?.cardID ?? -1;
+ const cardID = card?.cardID ?? CONST.DEFAULT_NUMBER_ID;
const optimisticData: OnyxUpdate[] = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -57,7 +57,7 @@ function reportVirtualExpensifyCardFraud(card: Card, validateCode: string) {
},
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${card?.fundID ?? '-1'}_${CONST.EXPENSIFY_CARD.BANK}`,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${card?.fundID}_${CONST.EXPENSIFY_CARD.BANK}`,
value: {
[cardID]: null,
},
@@ -94,7 +94,7 @@ function reportVirtualExpensifyCardFraud(card: Card, validateCode: string) {
},
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${card?.fundID ?? '-1'}_${CONST.EXPENSIFY_CARD.BANK}`,
+ key: `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${card?.fundID}_${CONST.EXPENSIFY_CARD.BANK}`,
value: {
[cardID]: {
...card,
@@ -610,7 +610,7 @@ function updateExpensifyCardLimitType(workspaceAccountID: number, cardID: number
function deactivateCard(workspaceAccountID: number, card?: Card) {
const authToken = NetworkStore.getAuthToken();
- const cardID = card?.cardID ?? -1;
+ const cardID = card?.cardID ?? CONST.DEFAULT_NUMBER_ID;
if (!authToken) {
return;
@@ -875,7 +875,11 @@ function toggleContinuousReconciliation(workspaceAccountID: number, shouldUseCon
});
}
-function updateSelectedFeed(feed: CompanyCardFeed, policyID: string) {
+function updateSelectedFeed(feed: CompanyCardFeed, policyID: string | undefined) {
+ if (!policyID) {
+ return;
+ }
+
Onyx.update([
{
onyxMethod: Onyx.METHOD.MERGE,
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index ee49b1c8e803..7f35f781d8d7 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -126,7 +126,6 @@ type CategorizeTrackedExpenseTransactionParams = {
category?: string;
tag?: string;
billable?: boolean;
- receipt?: Receipt;
};
type CategorizeTrackedExpensePolicyParams = {
policyID: string;
@@ -3740,7 +3739,6 @@ function convertTrackedExpenseToRequest(
merchant: string,
created: string,
attendees?: Attendee[],
- receipt?: Receipt,
) {
const {optimisticData, successData, failureData} = onyxData;
@@ -3770,7 +3768,6 @@ function convertTrackedExpenseToRequest(
comment,
created,
merchant,
- receipt,
payerAccountID,
payerEmail,
chatReportID,
@@ -3812,10 +3809,10 @@ function categorizeTrackedExpense(trackedExpenseParams: CategorizeTrackedExpense
successData?.push(...moveTransactionSuccessData);
failureData?.push(...moveTransactionFailureData);
const parameters = {
- onyxData,
...reportInformation,
...policyParams,
...transactionParams,
+ linkedTrackedExpenseReportAction: undefined,
modifiedExpenseReportActionID,
policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID,
policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID,
@@ -3854,7 +3851,6 @@ function shareTrackedExpense(
taxCode = '',
taxAmount = 0,
billable?: boolean,
- receipt?: Receipt,
createdWorkspaceParams?: CreateWorkspaceParams,
) {
const {optimisticData, successData, failureData} = onyxData ?? {};
@@ -3897,7 +3893,6 @@ function shareTrackedExpense(
taxCode,
taxAmount,
billable,
- receipt,
policyExpenseChatReportID: createdWorkspaceParams?.expenseChatReportID,
policyExpenseCreatedReportActionID: createdWorkspaceParams?.expenseCreatedReportActionID,
adminsChatReportID: createdWorkspaceParams?.adminsChatReportID,
@@ -3990,7 +3985,6 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) {
merchant,
created,
attendees,
- receipt,
);
break;
}
@@ -4010,7 +4004,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation) {
createdChatReportActionID,
createdIOUReportActionID,
reportPreviewReportActionID: reportPreviewAction.reportActionID,
- receipt,
+ receipt: receipt instanceof Blob ? receipt : undefined,
receiptState: receipt?.state,
category,
tag,
@@ -4189,7 +4183,7 @@ function trackExpense(
if (!linkedTrackedExpenseReportAction || !actionableWhisperReportActionID || !linkedTrackedExpenseReportID) {
return;
}
- const transactionParams = {
+ const transactionParams: CategorizeTrackedExpenseTransactionParams = {
transactionID: transaction?.transactionID ?? '-1',
amount,
currency,
@@ -4201,13 +4195,12 @@ function trackExpense(
category,
tag,
billable,
- receipt: trackedReceipt,
};
- const policyParams = {
+ const policyParams: CategorizeTrackedExpensePolicyParams = {
policyID: chatReport?.policyID ?? '-1',
isDraftPolicy,
};
- const reportInformation = {
+ const reportInformation: CategorizeTrackedExpenseReportInformation = {
moneyRequestPreviewReportActionID: iouAction?.reportActionID ?? '-1',
moneyRequestReportID: iouReport?.reportID ?? '-1',
moneyRequestCreatedReportActionID: createdIOUReportActionID ?? '-1',
@@ -4217,7 +4210,7 @@ function trackExpense(
transactionThreadReportID: transactionThreadReportID ?? '-1',
reportPreviewReportActionID: reportPreviewAction?.reportActionID ?? '-1',
};
- const trackedExpenseParams = {
+ const trackedExpenseParams: CategorizeTrackedExpenseParams = {
onyxData,
reportInformation,
transactionParams,
@@ -4254,7 +4247,6 @@ function trackExpense(
taxCode,
taxAmount,
billable,
- trackedReceipt,
createdWorkspaceParams,
);
break;
@@ -4273,7 +4265,7 @@ function trackExpense(
createdChatReportActionID: createdChatReportActionID ?? '-1',
createdIOUReportActionID,
reportPreviewReportActionID: reportPreviewAction?.reportActionID,
- receipt: trackedReceipt,
+ receipt: trackedReceipt instanceof Blob ? trackedReceipt : undefined,
receiptState: trackedReceipt?.state,
category,
tag,
diff --git a/src/libs/actions/OnyxUpdateManager/index.ts b/src/libs/actions/OnyxUpdateManager/index.ts
index 085e05b0a449..dad3e4b15f35 100644
--- a/src/libs/actions/OnyxUpdateManager/index.ts
+++ b/src/libs/actions/OnyxUpdateManager/index.ts
@@ -6,6 +6,7 @@ import * as NetworkStore from '@libs/Network/NetworkStore';
import * as SequentialQueue from '@libs/Network/SequentialQueue';
import * as App from '@userActions/App';
import updateSessionAuthTokens from '@userActions/Session/updateSessionAuthTokens';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {OnyxUpdatesFromServer, Session} from '@src/types/onyx';
import {isValidOnyxUpdateFromServer} from '@src/types/onyx/OnyxUpdatesFromServer';
@@ -27,10 +28,10 @@ import * as DeferredOnyxUpdates from './utils/DeferredOnyxUpdates';
// The circular dependency happens because this file calls API.GetMissingOnyxUpdates() which uses the SaveResponseInOnyx.js file (as a middleware).
// Therefore, SaveResponseInOnyx.js can't import and use this file directly.
-let lastUpdateIDAppliedToClient = 0;
+let lastUpdateIDAppliedToClient: number = CONST.DEFAULT_NUMBER_ID;
Onyx.connect({
key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
- callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0),
+ callback: (value) => (lastUpdateIDAppliedToClient = value ?? CONST.DEFAULT_NUMBER_ID),
});
let isLoadingApp = false;
@@ -48,6 +49,7 @@ const createQueryPromiseWrapper = () =>
});
// eslint-disable-next-line import/no-mutable-exports
let queryPromiseWrapper = createQueryPromiseWrapper();
+let isFetchingForPendingUpdates = false;
const resetDeferralLogicVariables = () => {
DeferredOnyxUpdates.clear({shouldUnpauseSequentialQueue: false});
@@ -61,18 +63,19 @@ function finalizeUpdatesAndResumeQueue() {
queryPromiseWrapper = createQueryPromiseWrapper();
DeferredOnyxUpdates.clear();
+ isFetchingForPendingUpdates = false;
}
/**
- *
- * @param onyxUpdatesFromServer
+ * Triggers the fetching process of either pending or missing updates.
+ * @param onyxUpdatesFromServer the current update that is supposed to be applied
* @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient
* @returns
*/
-function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry, clientLastUpdateID?: number) {
+function handleMissingOnyxUpdates(onyxUpdatesFromServer: OnyxEntry, clientLastUpdateID?: number) {
// If isLoadingApp is positive it means that OpenApp command hasn't finished yet, and in that case
- // we don't have base state of the app (reports, policies, etc) setup. If we apply this update,
- // we'll only have them overriten by the openApp response. So let's skip it and return.
+ // we don't have base state of the app (reports, policies, etc.) setup. If we apply this update,
+ // we'll only have them overwritten by the openApp response. So let's skip it and return.
if (isLoadingApp) {
// When ONYX_UPDATES_FROM_SERVER is set, we pause the queue. Let's unpause
// it so the app is not stuck forever without processing requests.
@@ -96,48 +99,81 @@ function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry {
+ // The OnyxUpdateManager can handle different types of re-fetch processes. Either there are pending updates,
+ // that we need to fetch manually, or we detected gaps in the previously fetched updates.
+ // Each of the flows below sets a promise through `DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise`, which we further process.
+ if (shouldFetchPendingUpdates) {
+ // This flow handles the case where the server didn't send updates because the payload was too big.
+ // We need to call the GetMissingOnyxUpdates query to fetch the missing updates up to the pendingLastUpdateID.
+ const pendingUpdateID = Number(lastUpdateIDFromServer);
+
+ isFetchingForPendingUpdates = true;
+
+ // If the pendingUpdateID is not newer than the last locally applied update, we don't need to fetch the missing updates.
+ if (pendingUpdateID <= lastUpdateIDFromClient) {
+ DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(Promise.resolve());
+ return true;
+ }
+
+ console.debug(`[OnyxUpdateManager] Client is fetching pending updates from the server, from updates ${lastUpdateIDFromClient} to ${Number(pendingUpdateID)}`);
+ Log.info('There are pending updates from the server, so fetching incremental updates', true, {
+ pendingUpdateID,
+ lastUpdateIDFromClient,
+ });
+
+ // Get the missing Onyx updates from the server and afterward validate and apply the deferred updates.
+ // This will trigger recursive calls to "validateAndApplyDeferredUpdates" if there are gaps in the deferred updates.
+ DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(
+ App.getMissingOnyxUpdates(lastUpdateIDFromClient, lastUpdateIDFromServer).then(() => OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates(clientLastUpdateID)),
+ );
+
+ return true;
}
- Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process');
+ if (!lastUpdateIDFromClient) {
+ // This is the first time we're receiving an lastUpdateID, so we need to do a final ReconnectApp query before
+ // This flow is setting the promise to a ReconnectApp query.
+
+ // If there is a ReconnectApp query in progress, we should not start another one.
+ if (DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()) {
+ return false;
+ }
+
+ Log.info('Client has not gotten reliable updates before so reconnecting the app to start the process');
+
+ // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request.
+ DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(App.finalReconnectAppAfterActivatingReliableUpdates());
+
+ return true;
+ }
- // Since this is a full reconnectApp, we'll not apply the updates we received - those will come in the reconnect app request.
- DeferredOnyxUpdates.setMissingOnyxUpdatesQueryPromise(App.finalReconnectAppAfterActivatingReliableUpdates());
- } else {
- // The flow below is setting the promise to a getMissingOnyxUpdates to address flow (2) explained above.
+ // This client already has the reliable updates mode enabled, but it's missing some updates and it needs to fetch those.
+ // Therefore, we are calling the GetMissingOnyxUpdates query, to fetch the missing updates.
const areDeferredUpdatesQueued = !DeferredOnyxUpdates.isEmpty();
// Add the new update to the deferred updates
- DeferredOnyxUpdates.enqueue(updateParams, {shouldPauseSequentialQueue: false});
+ DeferredOnyxUpdates.enqueue(onyxUpdatesFromServer, {shouldPauseSequentialQueue: false});
// If there are deferred updates already, we don't need to fetch the missing updates again.
- if (areDeferredUpdatesQueued) {
- return;
+ if (areDeferredUpdatesQueued || isFetchingForPendingUpdates) {
+ return false;
}
- console.debug(`[OnyxUpdateManager] Client is behind the server by ${Number(previousUpdateIDFromServer) - lastUpdateIDFromClient} so fetching incremental updates`);
- Log.info('Gap detected in update IDs from server so fetching incremental updates', true, {
+ console.debug(`[OnyxUpdateManager] Client is fetching missing updates from the server, from updates ${lastUpdateIDFromClient} to ${Number(previousUpdateIDFromServer)}`);
+ Log.info('Gap detected in update IDs from the server so fetching incremental updates', true, {
+ lastUpdateIDFromClient,
lastUpdateIDFromServer,
previousUpdateIDFromServer,
- lastUpdateIDFromClient,
});
// Get the missing Onyx updates from the server and afterwards validate and apply the deferred updates.
@@ -145,9 +181,14 @@ function handleOnyxUpdateGap(onyxUpdatesFromServer: OnyxEntry OnyxUpdateManagerUtils.validateAndApplyDeferredUpdates(clientLastUpdateID)),
);
- }
- DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()?.finally(finalizeUpdatesAndResumeQueue);
+ return true;
+ };
+ const shouldFinalizeAndResume = checkIfClientNeedsToBeUpdated();
+
+ if (shouldFinalizeAndResume) {
+ DeferredOnyxUpdates.getMissingOnyxUpdatesQueryPromise()?.finally(finalizeUpdatesAndResumeQueue);
+ }
}
function updateAuthTokenIfNecessary(onyxUpdatesFromServer: OnyxEntry): void {
@@ -177,8 +218,8 @@ export default () => {
console.debug('[OnyxUpdateManager] Listening for updates from the server');
Onyx.connect({
key: ONYXKEYS.ONYX_UPDATES_FROM_SERVER,
- callback: (value) => handleOnyxUpdateGap(value),
+ callback: (value) => handleMissingOnyxUpdates(value),
});
};
-export {handleOnyxUpdateGap, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables};
+export {handleMissingOnyxUpdates, queryPromiseWrapper as queryPromise, resetDeferralLogicVariables};
diff --git a/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts
index 8a7b67db30c6..8b3bf5d9af86 100644
--- a/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts
+++ b/src/libs/actions/OnyxUpdateManager/utils/DeferredOnyxUpdates.ts
@@ -1,6 +1,7 @@
import Onyx from 'react-native-onyx';
import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types';
import * as SequentialQueue from '@libs/Network/SequentialQueue';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {OnyxUpdatesFromServer, Response} from '@src/types/onyx';
import {isValidOnyxUpdateFromServer} from '@src/types/onyx/OnyxUpdatesFromServer';
@@ -40,7 +41,7 @@ function getUpdates(options?: GetDeferredOnyxUpdatesOptiosn) {
}
return Object.entries(deferredUpdates).reduce((acc, [lastUpdateID, update]) => {
- if (Number(lastUpdateID) > (options.minUpdateID ?? 0)) {
+ if (Number(lastUpdateID) > (options.minUpdateID ?? CONST.DEFAULT_NUMBER_ID)) {
acc[Number(lastUpdateID)] = update;
}
return acc;
diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts
index 5cd66df6b0b0..019821c7f215 100644
--- a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts
+++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/applyUpdates.ts
@@ -1,16 +1,11 @@
-import Onyx from 'react-native-onyx';
import type {DeferredUpdatesDictionary} from '@libs/actions/OnyxUpdateManager/types';
-import ONYXKEYS from '@src/ONYXKEYS';
+import * as OnyxUpdates from '@userActions/OnyxUpdates';
import createProxyForObject from '@src/utils/createProxyForObject';
-let lastUpdateIDAppliedToClient = 0;
-Onyx.connect({
- key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
- callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0),
-});
+jest.mock('@userActions/OnyxUpdates');
type ApplyUpdatesMockValues = {
- onApplyUpdates: ((updates: DeferredUpdatesDictionary) => Promise) | undefined;
+ beforeApplyUpdates: ((updates: DeferredUpdatesDictionary) => Promise) | undefined;
};
type ApplyUpdatesMock = {
@@ -19,15 +14,27 @@ type ApplyUpdatesMock = {
};
const mockValues: ApplyUpdatesMockValues = {
- onApplyUpdates: undefined,
+ beforeApplyUpdates: undefined,
};
const mockValuesProxy = createProxyForObject(mockValues);
const applyUpdates = jest.fn((updates: DeferredUpdatesDictionary) => {
- const lastUpdateIdFromUpdates = Math.max(...Object.keys(updates).map(Number));
- return (mockValuesProxy.onApplyUpdates === undefined ? Promise.resolve() : mockValuesProxy.onApplyUpdates(updates)).then(() =>
- Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Math.max(lastUpdateIDAppliedToClient, lastUpdateIdFromUpdates)),
- );
+ const createChain = () => {
+ let chain = Promise.resolve();
+ Object.values(updates).forEach((update) => {
+ chain = chain.then(() => {
+ return OnyxUpdates.apply(update).then(() => undefined);
+ });
+ });
+
+ return chain;
+ };
+
+ if (mockValuesProxy.beforeApplyUpdates === undefined) {
+ return createChain();
+ }
+
+ return mockValuesProxy.beforeApplyUpdates(updates).then(() => createChain());
});
export {applyUpdates, mockValuesProxy as mockValues};
diff --git a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts
index f66e059ff7f6..a10c265cf569 100644
--- a/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts
+++ b/src/libs/actions/OnyxUpdateManager/utils/__mocks__/index.ts
@@ -6,7 +6,7 @@ import {applyUpdates} from './applyUpdates';
const UtilsImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils');
type OnyxUpdateManagerUtilsMockValues = {
- onValidateAndApplyDeferredUpdates: ((clientLastUpdateID?: number) => Promise) | undefined;
+ beforeValidateAndApplyDeferredUpdates: ((clientLastUpdateID?: number) => Promise) | undefined;
};
type OnyxUpdateManagerUtilsMock = typeof UtilsImplementation & {
@@ -16,17 +16,19 @@ type OnyxUpdateManagerUtilsMock = typeof UtilsImplementation & {
};
const mockValues: OnyxUpdateManagerUtilsMockValues = {
- onValidateAndApplyDeferredUpdates: undefined,
+ beforeValidateAndApplyDeferredUpdates: undefined,
};
const mockValuesProxy = createProxyForObject(mockValues);
const detectGapsAndSplit = jest.fn(UtilsImplementation.detectGapsAndSplit);
-const validateAndApplyDeferredUpdates = jest.fn((clientLastUpdateID?: number) =>
- (mockValuesProxy.onValidateAndApplyDeferredUpdates === undefined ? Promise.resolve() : mockValuesProxy.onValidateAndApplyDeferredUpdates(clientLastUpdateID)).then(() =>
- UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID),
- ),
-);
+const validateAndApplyDeferredUpdates = jest.fn((clientLastUpdateID?: number) => {
+ if (mockValuesProxy.beforeValidateAndApplyDeferredUpdates === undefined) {
+ return UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID);
+ }
+
+ return mockValuesProxy.beforeValidateAndApplyDeferredUpdates(clientLastUpdateID).then(() => UtilsImplementation.validateAndApplyDeferredUpdates(clientLastUpdateID));
+});
export {applyUpdates, detectGapsAndSplit, validateAndApplyDeferredUpdates, mockValuesProxy as mockValues};
export type {OnyxUpdateManagerUtilsMock};
diff --git a/src/libs/actions/OnyxUpdateManager/utils/index.ts b/src/libs/actions/OnyxUpdateManager/utils/index.ts
index 9a527308034e..bf9be862c029 100644
--- a/src/libs/actions/OnyxUpdateManager/utils/index.ts
+++ b/src/libs/actions/OnyxUpdateManager/utils/index.ts
@@ -2,15 +2,16 @@ import Onyx from 'react-native-onyx';
import Log from '@libs/Log';
import * as App from '@userActions/App';
import type {DeferredUpdatesDictionary, DetectGapAndSplitResult} from '@userActions/OnyxUpdateManager/types';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {applyUpdates} from './applyUpdates';
// eslint-disable-next-line import/no-cycle
import * as DeferredOnyxUpdates from './DeferredOnyxUpdates';
-let lastUpdateIDAppliedToClient = 0;
+let lastUpdateIDAppliedToClient: number = CONST.DEFAULT_NUMBER_ID;
Onyx.connect({
key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
- callback: (value) => (lastUpdateIDAppliedToClient = value ?? 0),
+ callback: (value) => (lastUpdateIDAppliedToClient = value ?? CONST.DEFAULT_NUMBER_ID),
});
/**
@@ -114,13 +115,13 @@ function detectGapsAndSplit(lastUpdateIDFromClient: number): DetectGapAndSplitRe
* apply the updates in order after the missing updates are fetched and applied
*/
function validateAndApplyDeferredUpdates(clientLastUpdateID?: number, previousParams?: {newLastUpdateIDFromClient: number; latestMissingUpdateID: number}): Promise {
- const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0;
+ const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? CONST.DEFAULT_NUMBER_ID;
Log.info('[DeferredUpdates] Processing deferred updates', false, {lastUpdateIDFromClient, previousParams});
const {applicableUpdates, updatesAfterGaps, latestMissingUpdateID} = detectGapsAndSplit(lastUpdateIDFromClient);
- // If there are no applicable deferred updates and no missing deferred updates,
+ // If there are no applicably deferred updates and no missing deferred updates,
// we don't need to apply or re-fetch any updates. We can just unpause the queue by resolving.
if (Object.values(applicableUpdates).length === 0 && latestMissingUpdateID === undefined) {
return Promise.resolve();
@@ -138,13 +139,13 @@ function validateAndApplyDeferredUpdates(clientLastUpdateID?: number, previousPa
// After we have applied the applicable updates, there might have been new deferred updates added.
// In the next (recursive) call of "validateAndApplyDeferredUpdates",
// the initial "updatesAfterGaps" and all new deferred updates will be applied in order,
- // as long as there was no new gap detected. Otherwise repeat the process.
+ // as long as there was no new gap detected. Otherwise, repeat the process.
- const newLastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? 0;
+ const newLastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient ?? CONST.DEFAULT_NUMBER_ID;
DeferredOnyxUpdates.enqueue(updatesAfterGaps, {shouldPauseSequentialQueue: false});
- // If lastUpdateIDAppliedToClient got updated, we will just retrigger the validation
+ // If lastUpdateIDAppliedToClient got updated, we will just re-trigger the validation
// and application of the current deferred updates.
if (latestMissingUpdateID <= newLastUpdateIDFromClient) {
validateAndApplyDeferredUpdates(undefined, {newLastUpdateIDFromClient, latestMissingUpdateID})
diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts
index 52dfa9dfd742..a09159993ad8 100644
--- a/src/libs/actions/OnyxUpdates.ts
+++ b/src/libs/actions/OnyxUpdates.ts
@@ -2,7 +2,6 @@ import type {OnyxUpdate} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import type {Merge} from 'type-fest';
import Log from '@libs/Log';
-import * as SequentialQueue from '@libs/Network/SequentialQueue';
import Performance from '@libs/Performance';
import PusherUtils from '@libs/PusherUtils';
import CONST from '@src/CONST';
@@ -154,28 +153,28 @@ function apply({lastUpdateID, type, request, response, updates}: OnyxUpdatesFrom
* @param [updateParams.updates] Exists if updateParams.type === 'pusher'
*/
function saveUpdateInformation(updateParams: OnyxUpdatesFromServer) {
- // If we got here, that means we are missing some updates on our local storage. To
- // guarantee that we're not fetching more updates before our local data is up to date,
- // let's stop the sequential queue from running until we're done catching up.
- SequentialQueue.pause();
-
// Always use set() here so that the updateParams are never merged and always unique to the request that came in
Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, updateParams);
}
+type DoesClientNeedToBeUpdatedParams = {
+ clientLastUpdateID?: number;
+ previousUpdateID?: number;
+};
+
/**
* This function will receive the previousUpdateID from any request/pusher update that has it, compare to our current app state
* and return if an update is needed
* @param previousUpdateID The previousUpdateID contained in the response object
* @param clientLastUpdateID an optional override for the lastUpdateIDAppliedToClient
*/
-function doesClientNeedToBeUpdated(previousUpdateID = 0, clientLastUpdateID = 0): boolean {
+function doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID}: DoesClientNeedToBeUpdatedParams): boolean {
// If no previousUpdateID is sent, this is not a WRITE request so we don't need to update our current state
if (!previousUpdateID) {
return false;
}
- const lastUpdateIDFromClient = clientLastUpdateID || lastUpdateIDAppliedToClient;
+ const lastUpdateIDFromClient = clientLastUpdateID ?? lastUpdateIDAppliedToClient;
// If we don't have any value in lastUpdateIDFromClient, this is the first time we're receiving anything, so we need to do a last reconnectApp
if (!lastUpdateIDFromClient) {
@@ -191,4 +190,5 @@ function doesClientNeedToBeUpdated(previousUpdateID = 0, clientLastUpdateID = 0)
}
// eslint-disable-next-line import/prefer-default-export
-export {apply, doesClientNeedToBeUpdated, saveUpdateInformation};
+export {apply, doesClientNeedToBeUpdated, saveUpdateInformation, applyHTTPSOnyxUpdates as INTERNAL_DO_NOT_USE_applyHTTPSOnyxUpdates};
+export type {DoesClientNeedToBeUpdatedParams as ManualOnyxUpdateCheckIds};
diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts
index f18fac18aca2..c04648c04104 100644
--- a/src/libs/actions/Policy/Policy.ts
+++ b/src/libs/actions/Policy/Policy.ts
@@ -3466,10 +3466,6 @@ function upgradeToCorporate(policyID: string, featureName?: string) {
maxExpenseAmount: CONST.POLICY.DEFAULT_MAX_EXPENSE_AMOUNT,
maxExpenseAmountNoReceipt: CONST.POLICY.DEFAULT_MAX_AMOUNT_NO_RECEIPT,
glCodes: true,
- ...(PolicyUtils.isInstantSubmitEnabled(policy) && {
- autoReporting: true,
- autoReportingFrequency: CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE,
- }),
harvesting: {
enabled: false,
},
@@ -3498,8 +3494,6 @@ function upgradeToCorporate(policyID: string, featureName?: string) {
maxExpenseAmount: policy?.maxExpenseAmount ?? null,
maxExpenseAmountNoReceipt: policy?.maxExpenseAmountNoReceipt ?? null,
glCodes: policy?.glCodes ?? null,
- autoReporting: policy?.autoReporting ?? null,
- autoReportingFrequency: policy?.autoReportingFrequency ?? null,
harvesting: policy?.harvesting ?? null,
},
},
diff --git a/src/libs/actions/Session/AttachmentImageReauthenticator.ts b/src/libs/actions/Session/AttachmentImageReauthenticator.ts
new file mode 100644
index 000000000000..ee4f8f4d8f7a
--- /dev/null
+++ b/src/libs/actions/Session/AttachmentImageReauthenticator.ts
@@ -0,0 +1,72 @@
+import Onyx from 'react-native-onyx';
+import {reauthenticate} from '@libs/Authentication';
+import Log from '@libs/Log';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type Session from '@src/types/onyx/Session';
+
+let isOffline = false;
+let active = false;
+let currentActiveSession: Session = {};
+let timer: NodeJS.Timeout;
+// The delay before requesting a reauthentication once activated
+// When the session is expired we will give it this time to reauthenticate via normal flows, like the Reauthentication middleware, in an attempt to not duplicate authentication requests
+// also, this is an arbitrary number so we may tweak as needed
+const TIMING_BEFORE_REAUTHENTICATION_MS = 3500; // 3.5s
+
+// We subscribe to network's online/offline status
+Onyx.connect({
+ key: ONYXKEYS.NETWORK,
+ callback: (network) => {
+ if (!network) {
+ return;
+ }
+ isOffline = !!network.shouldForceOffline || !!network.isOffline;
+ },
+});
+
+// We subscribe to sessions changes
+Onyx.connect({
+ key: ONYXKEYS.SESSION,
+ callback: (value) => {
+ if (!value || isSameSession(value) || !active) {
+ return;
+ }
+ deactivate();
+ },
+});
+
+function isSameSession(session: Session): boolean {
+ return currentActiveSession.authToken === session.authToken && currentActiveSession.encryptedAuthToken === session.encryptedAuthToken;
+}
+
+function deactivate() {
+ active = false;
+ currentActiveSession = {};
+ clearInterval(timer);
+}
+
+/**
+ * The reauthenticator is currently only used by attachment images and only when the current session is expired.
+ * It will only request reauthentification only once between two receptions of different sessions from Onyx
+ * @param session the current session
+ * @returns
+ */
+function activate(session: Session) {
+ if (!session || isSameSession(session) || isOffline) {
+ return;
+ }
+ currentActiveSession = session;
+ active = true;
+ timer = setTimeout(tryReauthenticate, TIMING_BEFORE_REAUTHENTICATION_MS);
+}
+
+function tryReauthenticate() {
+ if (isOffline || !active) {
+ return;
+ }
+ reauthenticate().catch((error) => {
+ Log.hmmm('Could not reauthenticate attachment image or receipt', {error});
+ });
+}
+
+export default activate;
diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts
index 1dbb01b008dd..0699058b6fdd 100644
--- a/src/libs/actions/Session/index.ts
+++ b/src/libs/actions/Session/index.ts
@@ -66,6 +66,9 @@ Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (value) => {
session = value ?? {};
+ if (!session.creationDate) {
+ session.creationDate = new Date().getTime();
+ }
if (session.authToken && authPromiseResolver) {
authPromiseResolver(true);
authPromiseResolver = null;
@@ -115,6 +118,7 @@ function setSupportAuthToken(supportAuthToken: string, email: string, accountID:
authToken: supportAuthToken,
email,
accountID,
+ creationDate: new Date().getTime(),
}).then(() => {
Log.info('[Supportal] Authtoken set');
});
@@ -212,6 +216,14 @@ function hasAuthToken(): boolean {
return !!session.authToken;
}
+/**
+ * Indicates if the session which creation date is in parameter is expired
+ * @param sessionCreationDate the session creation date timestamp
+ */
+function isExpiredSession(sessionCreationDate: number): boolean {
+ return new Date().getTime() - sessionCreationDate >= CONST.SESSION_EXPIRATION_TIME_MS;
+}
+
function signOutAndRedirectToSignIn(shouldResetToHome?: boolean, shouldStashSession?: boolean, killHybridApp = true) {
Log.info('Redirecting to Sign In because signOut() was called');
hideContextMenu(false);
@@ -510,6 +522,7 @@ function signInAfterTransitionFromOldDot(transitionURL: string) {
autoGeneratedPassword,
clearOnyxOnStart,
completedHybridAppOnboarding,
+ nudgeMigrationTimestamp,
isSingleNewDotEntry,
primaryLogin,
shouldRemoveDelegatedAccess,
@@ -542,7 +555,10 @@ function signInAfterTransitionFromOldDot(transitionURL: string) {
[ONYXKEYS.ACCOUNT]: {primaryLogin},
[ONYXKEYS.CREDENTIALS]: {autoGeneratedLogin, autoGeneratedPassword},
[ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: isSingleNewDotEntry === 'true',
- [ONYXKEYS.NVP_TRYNEWDOT]: {classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'}},
+ [ONYXKEYS.NVP_TRYNEWDOT]: {
+ classicRedirect: {completedHybridAppOnboarding: completedHybridAppOnboarding === 'true'},
+ nudgeMigration: nudgeMigrationTimestamp ? {timestamp: new Date(nudgeMigrationTimestamp)} : undefined,
+ },
}),
)
.then(() => {
@@ -764,7 +780,18 @@ function invalidateCredentials() {
function invalidateAuthToken() {
NetworkStore.setAuthToken('pizza');
- Onyx.merge(ONYXKEYS.SESSION, {authToken: 'pizza'});
+ Onyx.merge(ONYXKEYS.SESSION, {authToken: 'pizza', encryptedAuthToken: 'pizza'});
+}
+
+/**
+ * Send an expired session to FE and invalidate the session in the BE perspective. Action is delayed for 15s
+ */
+function expireSessionWithDelay() {
+ // expires the session after 15s
+ setTimeout(() => {
+ NetworkStore.setAuthToken('pizza');
+ Onyx.merge(ONYXKEYS.SESSION, {authToken: 'pizza', encryptedAuthToken: 'pizza', creationDate: new Date().getTime() - CONST.SESSION_EXPIRATION_TIME_MS});
+ }, 15000);
}
/**
@@ -1226,11 +1253,13 @@ export {
reauthenticatePusher,
invalidateCredentials,
invalidateAuthToken,
+ expireSessionWithDelay,
isAnonymousUser,
toggleTwoFactorAuth,
validateTwoFactorAuth,
waitForUserSignIn,
hasAuthToken,
+ isExpiredSession,
canAnonymousUserAccessRoute,
signInWithSupportAuthToken,
isSupportAuthToken,
diff --git a/src/libs/actions/Session/updateSessionAuthTokens.ts b/src/libs/actions/Session/updateSessionAuthTokens.ts
index 6f90b60ef06d..8f98cf2fc5dd 100644
--- a/src/libs/actions/Session/updateSessionAuthTokens.ts
+++ b/src/libs/actions/Session/updateSessionAuthTokens.ts
@@ -2,5 +2,5 @@ import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
export default function updateSessionAuthTokens(authToken?: string, encryptedAuthToken?: string) {
- Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken});
+ Onyx.merge(ONYXKEYS.SESSION, {authToken, encryptedAuthToken, creationDate: new Date().getTime()});
}
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index 71973a5adbc3..80d04a4617bd 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -58,7 +58,7 @@ let currentEmail = '';
Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (value) => {
- currentUserAccountID = value?.accountID ?? -1;
+ currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID;
currentEmail = value?.email ?? '';
},
});
@@ -910,9 +910,9 @@ function subscribeToUserEvents() {
// Example: {lastUpdateID: 1, previousUpdateID: 0, updates: [{onyxMethod: 'whatever', key: 'foo', value: 'bar'}]}
const updates = {
type: CONST.ONYX_UPDATE_TYPES.PUSHER,
- lastUpdateID: Number(pushJSON.lastUpdateID || 0),
+ lastUpdateID: Number(pushJSON.lastUpdateID ?? CONST.DEFAULT_NUMBER_ID),
updates: pushJSON.updates ?? [],
- previousUpdateID: Number(pushJSON.previousUpdateID || 0),
+ previousUpdateID: Number(pushJSON.previousUpdateID ?? CONST.DEFAULT_NUMBER_ID),
};
applyOnyxUpdatesReliably(updates);
});
diff --git a/src/libs/actions/__mocks__/App.ts b/src/libs/actions/__mocks__/App.ts
index 09fd553a87f3..0b2b098feefb 100644
--- a/src/libs/actions/__mocks__/App.ts
+++ b/src/libs/actions/__mocks__/App.ts
@@ -1,10 +1,11 @@
-import Onyx from 'react-native-onyx';
import type * as AppImport from '@libs/actions/App';
-import type * as ApplyUpdatesImport from '@libs/actions/OnyxUpdateManager/utils/applyUpdates';
-import ONYXKEYS from '@src/ONYXKEYS';
+import * as OnyxUpdates from '@userActions/OnyxUpdates';
import type {OnyxUpdatesFromServer} from '@src/types/onyx';
import createProxyForObject from '@src/utils/createProxyForObject';
+jest.mock('@libs/actions/OnyxUpdates');
+jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates');
+
const AppImplementation = jest.requireActual('@libs/actions/App');
const {
setLocale,
@@ -39,13 +40,30 @@ const mockValues: AppMockValues = {
};
const mockValuesProxy = createProxyForObject(mockValues);
-const ApplyUpdatesImplementation = jest.requireActual('@libs/actions/OnyxUpdateManager/utils/applyUpdates');
-const getMissingOnyxUpdates = jest.fn((_fromID: number, toID: number) => {
- if (mockValuesProxy.missingOnyxUpdatesToBeApplied === undefined) {
- return Onyx.set(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, toID);
+const getMissingOnyxUpdates = jest.fn((updateIDFrom: number, updateIDTo: number) => {
+ const updates = mockValuesProxy.missingOnyxUpdatesToBeApplied ?? [];
+ if (updates.length === 0) {
+ for (let i = updateIDFrom + 1; i <= updateIDTo; i++) {
+ updates.push({
+ lastUpdateID: i,
+ previousUpdateID: i - 1,
+ } as OnyxUpdatesFromServer);
+ }
}
- return ApplyUpdatesImplementation.applyUpdates(mockValuesProxy.missingOnyxUpdatesToBeApplied);
+ let chain = Promise.resolve();
+ updates.forEach((update) => {
+ chain = chain.then(() => {
+ if (!OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID: Number(update.previousUpdateID)})) {
+ return OnyxUpdates.apply(update).then(() => undefined);
+ }
+
+ OnyxUpdates.saveUpdateInformation(update);
+ return Promise.resolve();
+ });
+ });
+
+ return chain;
});
export {
diff --git a/src/libs/actions/__mocks__/OnyxUpdates.ts b/src/libs/actions/__mocks__/OnyxUpdates.ts
new file mode 100644
index 000000000000..3e4cb10d7f9e
--- /dev/null
+++ b/src/libs/actions/__mocks__/OnyxUpdates.ts
@@ -0,0 +1,44 @@
+import Onyx from 'react-native-onyx';
+import type * as OnyxUpdatesImport from '@userActions/OnyxUpdates';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {OnyxUpdatesFromServer, Response} from '@src/types/onyx';
+
+jest.mock('@libs/actions/OnyxUpdateManager/utils/applyUpdates');
+
+const OnyxUpdatesImplementation = jest.requireActual('@libs/actions/OnyxUpdates');
+const {doesClientNeedToBeUpdated, saveUpdateInformation, INTERNAL_DO_NOT_USE_applyHTTPSOnyxUpdates: applyHTTPSOnyxUpdates} = OnyxUpdatesImplementation;
+
+type OnyxUpdatesMock = typeof OnyxUpdatesImport & {
+ apply: jest.Mock, [OnyxUpdatesFromServer]>;
+};
+
+let lastUpdateIDAppliedToClient: number | undefined = 0;
+Onyx.connect({
+ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
+ callback: (val) => (lastUpdateIDAppliedToClient = val),
+});
+
+const apply = jest.fn(({lastUpdateID, request, response}: OnyxUpdatesFromServer): Promise | undefined => {
+ if (lastUpdateID && (lastUpdateIDAppliedToClient === undefined || Number(lastUpdateID) > lastUpdateIDAppliedToClient)) {
+ Onyx.merge(ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, Number(lastUpdateID));
+ }
+
+ if (request && response) {
+ return applyHTTPSOnyxUpdates(request, response).then(() => undefined);
+ }
+
+ return Promise.resolve();
+});
+
+export {
+ // Mocks
+ apply,
+
+ // Actual OnyxUpdates implementation
+ doesClientNeedToBeUpdated,
+ saveUpdateInformation,
+};
+
+type ManualOnyxUpdateCheckIds = OnyxUpdatesImport.ManualOnyxUpdateCheckIds;
+export type {ManualOnyxUpdateCheckIds};
+export type {OnyxUpdatesMock};
diff --git a/src/libs/actions/applyOnyxUpdatesReliably.ts b/src/libs/actions/applyOnyxUpdatesReliably.ts
index 17754712cdc8..d8475c55042a 100644
--- a/src/libs/actions/applyOnyxUpdatesReliably.ts
+++ b/src/libs/actions/applyOnyxUpdatesReliably.ts
@@ -1,7 +1,14 @@
+import * as SequentialQueue from '@libs/Network/SequentialQueue';
+import CONST from '@src/CONST';
import type {OnyxUpdatesFromServer} from '@src/types/onyx';
-import {handleOnyxUpdateGap} from './OnyxUpdateManager';
+import {handleMissingOnyxUpdates} from './OnyxUpdateManager';
import * as OnyxUpdates from './OnyxUpdates';
+type ApplyOnyxUpdatesReliablyOptions = {
+ clientLastUpdateID?: number;
+ shouldRunSync?: boolean;
+};
+
/**
* Checks for and handles gaps of onyx updates between the client and the given server updates before applying them
*
@@ -11,16 +18,32 @@ import * as OnyxUpdates from './OnyxUpdates';
* @param shouldRunSync
* @returns
*/
-export default function applyOnyxUpdatesReliably(updates: OnyxUpdatesFromServer, shouldRunSync = false, clientLastUpdateID = 0) {
- const previousUpdateID = Number(updates.previousUpdateID) || 0;
- if (!OnyxUpdates.doesClientNeedToBeUpdated(previousUpdateID, clientLastUpdateID)) {
- OnyxUpdates.apply(updates);
+export default function applyOnyxUpdatesReliably(updates: OnyxUpdatesFromServer, {shouldRunSync = false, clientLastUpdateID}: ApplyOnyxUpdatesReliablyOptions = {}) {
+ const fetchMissingUpdates = () => {
+ // If we got here, that means we are missing some updates on our local storage. To
+ // guarantee that we're not fetching more updates before our local data is up to date,
+ // let's stop the sequential queue from running until we're done catching up.
+ SequentialQueue.pause();
+
+ if (shouldRunSync) {
+ handleMissingOnyxUpdates(updates, clientLastUpdateID);
+ } else {
+ OnyxUpdates.saveUpdateInformation(updates);
+ }
+ };
+
+ // If a pendingLastUpdateID is was provided, it means that the backend didn't send updates because the payload was too big.
+ // In this case, we need to fetch the missing updates up to the pendingLastUpdateID.
+ if (updates.shouldFetchPendingUpdates) {
+ fetchMissingUpdates();
return;
}
- if (shouldRunSync) {
- handleOnyxUpdateGap(updates, clientLastUpdateID);
- } else {
- OnyxUpdates.saveUpdateInformation(updates);
+ const previousUpdateID = Number(updates.previousUpdateID) ?? CONST.DEFAULT_NUMBER_ID;
+ if (!OnyxUpdates.doesClientNeedToBeUpdated({previousUpdateID, clientLastUpdateID})) {
+ OnyxUpdates.apply(updates);
+ return;
}
+
+ fetchMissingUpdates();
}
diff --git a/src/libs/onboardingSelectors.ts b/src/libs/onboardingSelectors.ts
index b21626cf8a07..b578a6a36942 100644
--- a/src/libs/onboardingSelectors.ts
+++ b/src/libs/onboardingSelectors.ts
@@ -56,4 +56,15 @@ function hasSeenTourSelector(onboarding: OnyxValue): boolean | undefined {
+ return introSelected?.inviteType !== undefined;
+}
+
+export {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector, hasSeenTourSelector, wasInvitedToNewDotSelector};
diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx
index 40e2a6094ac7..c4de1e3b4062 100644
--- a/src/pages/ConciergePage.tsx
+++ b/src/pages/ConciergePage.tsx
@@ -1,4 +1,4 @@
-import {useFocusEffect} from '@react-navigation/native';
+import {useFocusEffect, useRoute} from '@react-navigation/native';
import React, {useCallback, useEffect, useRef} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
@@ -10,6 +10,8 @@ import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
import * as App from '@userActions/App';
import * as Report from '@userActions/Report';
+import * as Task from '@userActions/Task';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
/*
@@ -23,6 +25,11 @@ function ConciergePage() {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [session] = useOnyx(ONYXKEYS.SESSION);
const [isLoadingReportData] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true});
+ const route = useRoute();
+
+ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
+ const viewTourTaskReportID = introSelected?.viewTour;
+ const [viewTourTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${viewTourTaskReportID}`);
useFocusEffect(
useCallback(() => {
@@ -32,12 +39,22 @@ function ConciergePage() {
if (isUnmounted.current || isLoadingReportData === undefined || !!isLoadingReportData) {
return;
}
+
+ // Mark the viewTourTask as complete if we are redirected to Concierge after finishing the Navattic tour
+ const {navattic} = (route.params as {navattic?: string}) ?? {};
+ if (navattic === CONST.NAVATTIC.COMPLETED) {
+ if (viewTourTaskReport) {
+ if (viewTourTaskReport.stateNum !== CONST.REPORT.STATE_NUM.APPROVED || viewTourTaskReport.statusNum !== CONST.REPORT.STATUS_NUM.APPROVED) {
+ Task.completeTask(viewTourTaskReport);
+ }
+ }
+ }
Report.navigateToConciergeChat(true, () => !isUnmounted.current);
});
} else {
Navigation.navigate();
}
- }, [session, isLoadingReportData]),
+ }, [session, isLoadingReportData, route.params, viewTourTaskReport]),
);
useEffect(() => {
diff --git a/src/pages/GroupChatNameEditPage.tsx b/src/pages/GroupChatNameEditPage.tsx
index 69d7f6c6f8af..66cc4b0a2329 100644
--- a/src/pages/GroupChatNameEditPage.tsx
+++ b/src/pages/GroupChatNameEditPage.tsx
@@ -1,6 +1,5 @@
import React, {useCallback, useMemo} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormOnyxValues} from '@components/Form/types';
@@ -22,23 +21,18 @@ import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/NewChatNameForm';
import type {Report as ReportOnyxType} from '@src/types/onyx';
-import type NewGroupChatDraft from '@src/types/onyx/NewGroupChatDraft';
import type {Errors} from '@src/types/onyx/OnyxCommon';
-type GroupChatNameEditPageOnyxProps = {
- groupChatDraft: OnyxEntry;
+type GroupChatNameEditPageProps = Partial> & {
+ report?: ReportOnyxType;
};
-type GroupChatNameEditPageProps = GroupChatNameEditPageOnyxProps &
- Partial> & {
- report?: ReportOnyxType;
- };
-
-function GroupChatNameEditPage({groupChatDraft, report}: GroupChatNameEditPageProps) {
+function GroupChatNameEditPage({report}: GroupChatNameEditPageProps) {
// If we have a report this means we are using this page to update an existing Group Chat name
// In this case its better to use empty string as the reportID if there is no reportID
- const reportID = report?.reportID ?? '';
+ const reportID = report?.reportID;
const isUpdatingExistingReport = !!reportID;
+ const [groupChatDraft] = useOnyx(ONYXKEYS.NEW_GROUP_CHAT_DRAFT);
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -69,13 +63,15 @@ function GroupChatNameEditPage({groupChatDraft, report}: GroupChatNameEditPagePr
if (values[INPUT_IDS.NEW_CHAT_NAME] !== currentChatName) {
Report.updateGroupChatName(reportID, values[INPUT_IDS.NEW_CHAT_NAME] ?? '');
}
- Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID));
+
+ Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID)));
+
return;
}
if (values[INPUT_IDS.NEW_CHAT_NAME] !== currentChatName) {
Report.setGroupDraft({reportName: values[INPUT_IDS.NEW_CHAT_NAME]});
}
- Navigation.goBack(ROUTES.NEW_CHAT_CONFIRM);
+ Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.NEW_CHAT_CONFIRM));
},
[isUpdatingExistingReport, reportID, currentChatName],
);
@@ -117,8 +113,4 @@ function GroupChatNameEditPage({groupChatDraft, report}: GroupChatNameEditPagePr
GroupChatNameEditPage.displayName = 'GroupChatNameEditPage';
-export default withOnyx({
- groupChatDraft: {
- key: ONYXKEYS.NEW_GROUP_CHAT_DRAFT,
- },
-})(GroupChatNameEditPage);
+export default GroupChatNameEditPage;
diff --git a/src/pages/NewChatConfirmPage.tsx b/src/pages/NewChatConfirmPage.tsx
index 85e33cf0c598..360e76738381 100644
--- a/src/pages/NewChatConfirmPage.tsx
+++ b/src/pages/NewChatConfirmPage.tsx
@@ -55,7 +55,7 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP
return [];
}
const options: Participant[] = newGroupDraft.participants.map((participant) =>
- OptionsListUtils.getParticipantsOption({accountID: participant.accountID, login: participant.login, reportID: ''}, allPersonalDetails),
+ OptionsListUtils.getParticipantsOption({accountID: participant.accountID, login: participant?.login, reportID: ''}, allPersonalDetails),
);
return options;
}, [allPersonalDetails, newGroupDraft?.participants]);
@@ -92,7 +92,7 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP
if (!newGroupDraft) {
return;
}
- const newSelectedParticipants = (newGroupDraft.participants ?? []).filter((participant) => participant.login !== option.login);
+ const newSelectedParticipants = (newGroupDraft.participants ?? []).filter((participant) => participant?.login !== option.login);
Report.setGroupDraft({participants: newSelectedParticipants});
},
[newGroupDraft],
@@ -103,7 +103,7 @@ function NewChatConfirmPage({newGroupDraft, allPersonalDetails}: NewChatConfirmP
return;
}
- const logins: string[] = (newGroupDraft.participants ?? []).map((participant) => participant.login);
+ const logins: string[] = (newGroupDraft.participants ?? []).map((participant) => participant.login).filter((login): login is string => !!login);
Report.navigateToAndOpenReport(logins, true, undefined, newGroupDraft.reportName ?? '', newGroupDraft.avatarUri ?? '', avatarFile, optimisticReportID.current, true);
}, [newGroupDraft, avatarFile]);
diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx
index 65aa253ff09d..bcf0c917e80b 100755
--- a/src/pages/NewChatPage.tsx
+++ b/src/pages/NewChatPage.tsx
@@ -1,6 +1,7 @@
import isEmpty from 'lodash/isEmpty';
import reject from 'lodash/reject';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {Keyboard} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
import ImportedStateIndicator from '@components/ImportedStateIndicator';
@@ -102,7 +103,7 @@ function useOptions() {
let participantOption: OptionData | undefined | null = listOptions.personalDetails.find((option) => option.accountID === participant.accountID);
if (!participantOption) {
participantOption = OptionsListUtils.getUserToInviteOption({
- searchValue: participant.login,
+ searchValue: participant?.login,
});
}
if (!participantOption) {
@@ -230,11 +231,13 @@ function NewChatPage() {
if (isOptionInList) {
newSelectedOptions = reject(selectedOptions, (selectedOption) => selectedOption.login === option.login);
} else {
- newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID ?? '-1'}];
+ newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID ?? `${CONST.DEFAULT_NUMBER_ID}`}];
}
selectionListRef?.current?.clearInputAfterSelect?.();
+ selectionListRef.current?.focusTextInput();
+ selectionListRef?.current?.scrollToIndex(Math.max(newSelectedOptions.length - 1, 0), true);
setSelectedOptions(newSelectedOptions);
}
@@ -272,9 +275,13 @@ function NewChatPage() {
if (!personalData || !personalData.login || !personalData.accountID) {
return;
}
- const selectedParticipants: SelectedParticipant[] = selectedOptions.map((option: OptionData) => ({login: option.login ?? '', accountID: option.accountID ?? -1}));
+ const selectedParticipants: SelectedParticipant[] = selectedOptions.map((option: OptionData) => ({
+ login: option?.login,
+ accountID: option.accountID ?? CONST.DEFAULT_NUMBER_ID,
+ }));
const logins = [...selectedParticipants, {login: personalData.login, accountID: personalData.accountID}];
Report.setGroupDraft({participants: logins});
+ Keyboard.dismiss();
Navigation.navigate(ROUTES.NEW_CHAT_CONFIRM);
}, [selectedOptions, personalData]);
const {isDismissed} = useDismissedReferralBanners({referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT});
diff --git a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx
index 4d084cfa924d..deab122e3006 100644
--- a/src/pages/PrivateNotes/PrivateNotesEditPage.tsx
+++ b/src/pages/PrivateNotes/PrivateNotesEditPage.tsx
@@ -96,7 +96,7 @@ function PrivateNotesEditPage({route, report, accountID}: PrivateNotesEditPagePr
if (!Object.values({...report.privateNotes, [route.params.accountID]: {note: editedNote}}).some((item) => item.note)) {
ReportUtils.navigateToDetailsPage(report, backTo);
} else {
- Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID, backTo));
+ Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(ROUTES.PRIVATE_NOTES_LIST.getRoute(report.reportID, backTo)));
}
};
diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx
index 166b12b27751..11f3f3c48efa 100644
--- a/src/pages/ReportDetailsPage.tsx
+++ b/src/pages/ReportDetailsPage.tsx
@@ -875,22 +875,15 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
// Where to navigate back to after deleting the transaction and its report.
const navigateToTargetUrl = useCallback(() => {
- let urlToNavigateBack: string | undefined;
-
+ // If transaction was not deleted (i.e. Cancel was clicked), do nothing
+ // which only dismiss the delete confirmation modal
if (!isTransactionDeleted.current) {
- if (caseID === CASES.DEFAULT) {
- urlToNavigateBack = Task.getNavigationUrlOnTaskDelete(report);
- if (urlToNavigateBack) {
- Report.setDeleteTransactionNavigateBackUrl(urlToNavigateBack);
- Navigation.goBack(urlToNavigateBack as Route);
- } else {
- Navigation.dismissModal();
- }
- return;
- }
return;
}
+ let urlToNavigateBack: string | undefined;
+
+ // Only proceed with navigation logic if transaction was actually deleted
if (!isEmptyObject(requestParentReportAction)) {
const isTrackExpense = ReportActionsUtils.isTrackExpenseAction(requestParentReportAction);
if (isTrackExpense) {
@@ -906,7 +899,7 @@ function ReportDetailsPage({policies, report, route, reportMetadata}: ReportDeta
Report.setDeleteTransactionNavigateBackUrl(urlToNavigateBack);
ReportUtils.navigateBackOnDeleteTransaction(urlToNavigateBack as Route, true);
}
- }, [caseID, iouTransactionID, moneyRequestReport?.reportID, report, requestParentReportAction, isSingleTransactionView, isTransactionDeleted]);
+ }, [iouTransactionID, requestParentReportAction, isSingleTransactionView, isTransactionDeleted, moneyRequestReport?.reportID]);
const mentionReportContextValue = useMemo(() => ({currentReportID: report.reportID, exactlyMatch: true}), [report.reportID]);
diff --git a/src/pages/RoomDescriptionPage.tsx b/src/pages/RoomDescriptionPage.tsx
index 28920f581681..69faef68f766 100644
--- a/src/pages/RoomDescriptionPage.tsx
+++ b/src/pages/RoomDescriptionPage.tsx
@@ -51,7 +51,7 @@ function RoomDescriptionPage({report, policies}: RoomDescriptionPageProps) {
}, []);
const goBack = useCallback(() => {
- Navigation.goBack(backTo ?? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID));
+ Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.goBack(backTo ?? ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)));
}, [report.reportID, backTo]);
const submitForm = useCallback(() => {
diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx
index 10c8401b98aa..ccb4bbfe5da1 100644
--- a/src/pages/Search/AdvancedSearchFilters.tsx
+++ b/src/pages/Search/AdvancedSearchFilters.tsx
@@ -372,7 +372,7 @@ function AdvancedSearchFilters() {
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES);
const [searchAdvancedFilters = {} as SearchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM);
- const policyID = searchAdvancedFilters.policyID ?? '-1';
+ const policyID = searchAdvancedFilters.policyID;
const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
const taxRates = getAllTaxRates();
const personalDetails = usePersonalDetails();
@@ -434,8 +434,8 @@ function AdvancedSearchFilters() {
const onSaveSearch = () => {
const savedSearchKeys = Object.keys(savedSearches ?? {});
if (!queryJSON || (savedSearches && savedSearchKeys.includes(String(queryJSON.hash)))) {
- // If the search is already saved, return early to prevent unnecessary API calls
- Navigation.dismissModal();
+ // If the search is already saved, we only display the results as we don't need to save it.
+ applyFiltersAndNavigate();
return;
}
diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx
index f5687c09bd8d..7d9550c5ec06 100644
--- a/src/pages/Search/EmptySearchView.tsx
+++ b/src/pages/Search/EmptySearchView.tsx
@@ -12,6 +12,7 @@ import MenuItem from '@components/MenuItem';
import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useEnvironment from '@hooks/useEnvironment';
import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
@@ -116,6 +117,9 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
});
const viewTourTaskReportID = introSelected?.viewTour;
const [viewTourTaskReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${viewTourTaskReportID}`);
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
+ const canModifyTask = Task.canModifyTask(viewTourTaskReport, currentUserPersonalDetails.accountID);
+ const canActionTask = Task.canActionTask(viewTourTaskReport, currentUserPersonalDetails.accountID);
const content = useMemo(() => {
switch (type) {
@@ -149,7 +153,9 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
buttonAction: () => {
Link.openExternalLink(navatticURL);
Welcome.setSelfTourViewed();
- Task.completeTask(viewTourTaskReport);
+ if (viewTourTaskReport && canModifyTask && canActionTask) {
+ Task.completeTask(viewTourTaskReport);
+ }
},
},
]
@@ -187,7 +193,9 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
buttonAction: () => {
Link.openExternalLink(navatticURL);
Welcome.setSelfTourViewed();
- Task.completeTask(viewTourTaskReport);
+ if (viewTourTaskReport && canModifyTask && canActionTask) {
+ Task.completeTask(viewTourTaskReport);
+ }
},
},
]
@@ -235,6 +243,8 @@ function EmptySearchView({type, hasResults}: EmptySearchViewProps) {
shouldRedirectToExpensifyClassic,
hasResults,
viewTourTaskReport,
+ canModifyTask,
+ canActionTask,
]);
return (
diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx
index 110005c263f9..3fe047ab3187 100644
--- a/src/pages/Search/SearchTypeMenu.tsx
+++ b/src/pages/Search/SearchTypeMenu.tsx
@@ -21,7 +21,7 @@ import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
import * as SearchActions from '@libs/actions/Search';
import Navigation from '@libs/Navigation/Navigation';
-import {getAllTaxRates, hasWorkspaceWithInvoices} from '@libs/PolicyUtils';
+import {canSendInvoice, getAllTaxRates} from '@libs/PolicyUtils';
import {hasInvoiceReports} from '@libs/ReportUtils';
import * as SearchQueryUtils from '@libs/SearchQueryUtils';
import * as SearchUIUtils from '@libs/SearchUIUtils';
@@ -71,7 +71,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) {
);
const {showDeleteModal, DeleteConfirmModal} = useDeleteSavedSearch();
const [session] = useOnyx(ONYXKEYS.SESSION);
-
+ const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const personalDetails = usePersonalDetails();
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const taxRates = getAllTaxRates();
@@ -97,7 +97,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) {
},
];
- if (hasWorkspaceWithInvoices(session?.email) || hasInvoiceReports()) {
+ if (canSendInvoice(allPolicies, session?.email) || hasInvoiceReports()) {
typeMenuItems.push({
title: translate('workspace.common.invoices'),
type: CONST.SEARCH.DATA_TYPES.INVOICE,
diff --git a/src/pages/Travel/TripDetailsPage.tsx b/src/pages/Travel/TripDetailsPage.tsx
index d7a93e7cdeba..6236fb249001 100644
--- a/src/pages/Travel/TripDetailsPage.tsx
+++ b/src/pages/Travel/TripDetailsPage.tsx
@@ -48,8 +48,8 @@ function TripDetailsPage({route}: TripDetailsPageProps) {
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID}`);
- const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID ?? '-1'}`);
- const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? '-1'}`);
+ const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID ?? CONST.DEFAULT_NUMBER_ID}`);
+ const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID ?? CONST.DEFAULT_NUMBER_ID}`);
const tripID = ReportUtils.getTripIDFromTransactionParentReportID(parentReport?.reportID);
const reservationType = transaction?.receipt?.reservationList?.at(route.params.reservationIndex ?? 0)?.type;
@@ -67,7 +67,7 @@ function TripDetailsPage({route}: TripDetailsPageProps) {
>
{
if (!subtitle) {
return false;
@@ -142,7 +144,6 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
const defaultSubscriptSize = ReportUtils.isExpenseRequest(report) ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT;
const icons = ReportUtils.getIcons(reportHeaderData, personalDetails, null, '', -1, policy, invoiceReceiverPolicy);
const brickRoadIndicator = ReportUtils.hasReportNameError(report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '';
- const shouldShowBorderBottom = !isTaskReport || !shouldUseNarrowLayout;
const shouldDisableDetailPage = ReportUtils.shouldDisableDetailPage(report);
const shouldUseGroupTitle = isGroupChat && (!!report?.reportName || !isMultipleParticipant);
const isLoading = !report?.reportID || !title;
@@ -154,7 +155,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
return (
@@ -258,7 +259,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
{
if (ReportUtils.canEditPolicyDescription(policy)) {
- Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(report.policyID ?? '-1'));
+ Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(report.policyID));
return;
}
Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID, Navigation.getReportRHPActiveRoute()));
@@ -286,7 +287,7 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
{!shouldUseNarrowLayout && isChatUsedForOnboarding && }
- {isTaskReport && !shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && }
+ {!shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && }
{!isParentReportLoading && canJoin && !shouldUseNarrowLayout && joinButton}
{shouldDisplaySearchRouter && }
@@ -315,6 +316,13 @@ function HeaderView({report, parentReportAction, reportID, onNavigationMenuButto
addSpacing
/>
)}
+ {!!report && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && (
+
+
+
+
+
+ )}
);
}
diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx
index 73fed14af87c..b02ccec1a56b 100644
--- a/src/pages/home/ReportScreen.tsx
+++ b/src/pages/home/ReportScreen.tsx
@@ -15,11 +15,10 @@ import MoneyRequestHeader from '@components/MoneyRequestHeader';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
import ScreenWrapper from '@components/ScreenWrapper';
-import TaskHeaderActionButton from '@components/TaskHeaderActionButton';
-import type {CurrentReportIDContextValue} from '@components/withCurrentReportID';
-import withCurrentReportID from '@components/withCurrentReportID';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useAppFocusEvent from '@hooks/useAppFocusEvent';
+import type {CurrentReportIDContextValue} from '@hooks/useCurrentReportID';
+import useCurrentReportID from '@hooks/useCurrentReportID';
import useDeepCompareRef from '@hooks/useDeepCompareRef';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -97,7 +96,7 @@ function getParentReportAction(parentReportActions: OnyxEntry ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action));
const isSingleTransactionView = ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report);
const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID ?? '-1'}`];
- const isTopMostReportId = currentReportID === reportIDFromRoute;
+ const isTopMostReportId = currentReportIDValue?.currentReportID === reportIDFromRoute;
const didSubscribeToReportLeavingEvents = useRef(false);
const [showSoftInputOnFocus, setShowSoftInputOnFocus] = useState(false);
@@ -776,15 +776,6 @@ function ReportScreen({route, currentReportID = '', navigation}: ReportScreenPro
needsOffscreenAlphaCompositing
>
{headerView}
- {!!report && ReportUtils.isTaskReport(report) && shouldUseNarrowLayout && ReportUtils.isOpenTaskReport(report, parentReportAction) && (
-
-
-
-
-
-
-
- )}
{!!accountManagerReportID && ReportUtils.isConciergeChatReport(report) && isBannerVisible && (
prevProps.currentReportID === nextProps.currentReportID && lodashIsEqual(prevProps.route, nextProps.route)));
+export default memo(ReportScreen, (prevProps, nextProps) => lodashIsEqual(prevProps.route, nextProps.route));
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
index b8cdde2ecff3..fadd140f47fd 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.tsx
@@ -412,7 +412,7 @@ const ContextMenuActions: ContextMenuAction[] = [
const logMessage = ReportActionsUtils.getMemberChangeMessageFragment(reportAction).html ?? '';
setClipboardMessage(logMessage);
} else if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_NAME) {
- Clipboard.setString(ReportUtils.getWorkspaceNameUpdatedMessage(reportAction));
+ Clipboard.setString(Str.htmlDecode(ReportUtils.getWorkspaceNameUpdatedMessage(reportAction)));
} else if (ReportActionsUtils.isReimbursementQueuedAction(reportAction)) {
Clipboard.setString(ReportUtils.getReimbursementQueuedActionMessage(reportAction, reportID, false));
} else if (ReportActionsUtils.isActionableMentionWhisper(reportAction)) {
diff --git a/src/pages/home/report/PureReportActionItem.tsx b/src/pages/home/report/PureReportActionItem.tsx
index bb3e04a90b84..dd44558f83bf 100644
--- a/src/pages/home/report/PureReportActionItem.tsx
+++ b/src/pages/home/report/PureReportActionItem.tsx
@@ -1,7 +1,7 @@
import lodashIsEqual from 'lodash/isEqual';
import React, {memo, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import type {GestureResponderEvent, TextInput} from 'react-native';
-import {InteractionManager, View} from 'react-native';
+import {InteractionManager, Keyboard, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {Emoji} from '@assets/emojis/types';
@@ -1075,7 +1075,15 @@ function PureReportActionItem({
return (
{
+ if (draftMessage === undefined) {
+ onPress?.();
+ }
+ if (!Keyboard.isVisible()) {
+ return;
+ }
+ Keyboard.dismiss();
+ }}
style={[action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !isDeletedParentAction ? styles.pointerEventsNone : styles.pointerEventsAuto]}
onPressIn={() => shouldUseNarrowLayout && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={() => ControlSelection.unblock()}
diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx
index 7fd5bb21f57d..eda19dde71a5 100644
--- a/src/pages/home/report/ReportActionsList.tsx
+++ b/src/pages/home/report/ReportActionsList.tsx
@@ -2,7 +2,7 @@ import type {ListRenderItemInfo} from '@react-native/virtualized-lists/Lists/Vir
import {useIsFocused, useRoute} from '@react-navigation/native';
// eslint-disable-next-line lodash/import-scope
import type {DebouncedFunc} from 'lodash';
-import React, {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
+import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, InteractionManager, View} from 'react-native';
import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native';
import {useOnyx} from 'react-native-onyx';
@@ -19,6 +19,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import DateUtils from '@libs/DateUtils';
+import {getChatFSAttributes} from '@libs/Fullstory';
import isReportScreenTopmostCentralPane from '@libs/Navigation/isReportScreenTopmostCentralPane';
import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralPane';
import Navigation from '@libs/Navigation/Navigation';
@@ -29,6 +30,7 @@ import Visibility from '@libs/Visibility';
import type {AuthScreensParamList} from '@navigation/types';
import variables from '@styles/variables';
import * as Report from '@userActions/Report';
+import {PersonalDetailsContext} from '@src/components/OnyxProvider';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -171,6 +173,7 @@ function ReportActionsList({
const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID}`);
const [accountID] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.accountID});
+ const participantsContext = useContext(PersonalDetailsContext);
useEffect(() => {
const unsubscriber = Visibility.onVisibilityChange(() => {
@@ -724,13 +727,19 @@ function ReportActionsList({
// When performing comment linking, initially 25 items are added to the list. Subsequent fetches add 15 items from the cache or 50 items from the server.
// This is to ensure that the user is able to see the 'scroll to newer comments' button when they do comment linking and have not reached the end of the list yet.
const canScrollToNewerComments = !isLoadingInitialReportActions && !hasNewestReportAction && sortedReportActions.length > 25 && !isLastPendingActionIsDelete;
+ const [reportActionsListTestID, reportActionsListFSClass] = getChatFSAttributes(participantsContext, 'ReportActionsList', report);
+
return (
<>
-
+
diff --git a/src/pages/home/report/comment/RenderCommentHTML.tsx b/src/pages/home/report/comment/RenderCommentHTML.tsx
index e730ae061519..fc5679f8a1f1 100644
--- a/src/pages/home/report/comment/RenderCommentHTML.tsx
+++ b/src/pages/home/report/comment/RenderCommentHTML.tsx
@@ -5,10 +5,12 @@ import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage';
type RenderCommentHTMLProps = {
source: OriginalMessageSource;
html: string;
+ containsOnlyEmojis: boolean;
};
-function RenderCommentHTML({html, source}: RenderCommentHTMLProps) {
- const commentHtml = source === 'email' ? `${html}` : `${html}`;
+function RenderCommentHTML({html, source, containsOnlyEmojis}: RenderCommentHTMLProps) {
+ const commentHtml =
+ source === 'email' ? `${html}` : `${html}`;
return ;
}
diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx
index 5dc8c6a85b85..f8ea9b56871f 100644
--- a/src/pages/home/report/comment/TextCommentFragment.tsx
+++ b/src/pages/home/report/comment/TextCommentFragment.tsx
@@ -68,13 +68,12 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so
const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? '');
const containsEmojis = CONST.REGEX.ALL_EMOJIS.test(text ?? '');
if (!shouldRenderAsText(html, text ?? '') && !(containsOnlyEmojis && styleAsDeleted)) {
- const editedTag = fragment?.isEdited ? `` : '';
+ const editedTag = fragment?.isEdited ? `` : '';
const htmlWithDeletedTag = styleAsDeleted ? `${html}` : html;
let htmlContent = htmlWithDeletedTag;
if (containsOnlyEmojis) {
htmlContent = Str.replaceAll(htmlContent, '', '');
- htmlContent = Str.replaceAll(htmlContent, '