diff --git a/.github/workflows/createNewVersion.yml b/.github/workflows/createNewVersion.yml index b0b2d07f990d..29dddbcd3151 100644 --- a/.github/workflows/createNewVersion.yml +++ b/.github/workflows/createNewVersion.yml @@ -106,9 +106,6 @@ jobs: runs-on: macos-latest needs: [validateActor, createNewVersion] if: ${{ fromJSON(needs.validateActor.outputs.HAS_WRITE_ACCESS) }} - defaults: - run: - working-directory: Mobile-Expensify steps: - name: Run turnstyle uses: softprops/turnstyle@49108bdfa571e62371bd2c3094893c547ab3fc03 @@ -121,22 +118,17 @@ jobs: uses: actions/checkout@v4 with: ref: main + submodules: true # The OS_BOTIFY_COMMIT_TOKEN is a personal access token tied to osbotify # This is a workaround to allow pushes to a protected branch token: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} - - name: Check out `Mobile-Expensify` repo - uses: actions/checkout@v4 - with: - repository: 'Expensify/Mobile-Expensify' - submodules: true - path: 'Mobile-Expensify' - token: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} - - - name: Update submodule + - name: Update submodule and checkout the main branch run: | - cd react-native git submodule update --init + cd Mobile-Expensify + git checkout main + git pull origin main - name: Setup git for OSBotify uses: ./.github/actions/composite/setupGitForOSBotify @@ -146,6 +138,7 @@ jobs: - name: Generate HybridApp version run: | + cd Mobile-Expensify # Generate all flavors of the version SHORT_APP_VERSION=$(echo "$NEW_VERSION" | awk -F'-' '{print $1}') BUILD_NUMBER=$(echo "$NEW_VERSION" | awk -F'-' '{print $2}') @@ -178,6 +171,7 @@ jobs: - name: Commit new version run: | + cd Mobile-Expensify git add \ ./Android/AndroidManifest.xml \ ./app/config/config.json \ @@ -186,8 +180,14 @@ jobs: ./iOS/NotificationServiceExtension/Info.plist git commit -m "Update version to ${{ needs.createNewVersion.outputs.NEW_VERSION }}" - - name: Update main branch - run: git push origin main + - name: Update main branch on Mobile-Expensify and App + run: | + cd Mobile-Expensify + git push origin main + cd .. + git add Mobile-Expensify + git commit -m "Update Mobile-Expensify to ${{ needs.createNewVersion.outputs.NEW_VERSION }}" + git push origin main - name: Announce failed workflow in Slack if: ${{ failure() }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d58a81c8d80a..21a8fa73289a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -183,6 +183,11 @@ jobs: id: setup-node uses: ./.github/actions/composite/setupNode + - name: Run grunt build + run: | + cd Mobile-Expensify + npm run grunt:build:shared + - name: Setup Java uses: actions/setup-java@v4 with: @@ -222,7 +227,7 @@ jobs: - name: Get Android native version id: getAndroidVersion - run: echo "VERSION_CODE=$(grep -oP 'android:versionCode="\K[0-9]+' ../Android/AndroidManifest.xml)" >> "$GITHUB_OUTPUT" + run: echo "VERSION_CODE=$(grep -oP 'android:versionCode="\K[0-9]+' Mobile-Expensify/Android/AndroidManifest.xml)" >> "$GITHUB_OUTPUT" - name: Build Android app if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} @@ -239,10 +244,11 @@ jobs: VERSION: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} - name: Get current Android rollout percentage + if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} id: getAndroidRolloutPercentage uses: ./.github/actions/javascript/getAndroidRolloutPercentage with: - GOOGLE_KEY_FILE: Mobile-Expensify/react-native/android-fastlane-json-key.json + GOOGLE_KEY_FILE: ./android-fastlane-json-key.json PACKAGE_NAME: org.me.mobiexpensifyg - name: Submit production build for Google Play review and a slow rollout @@ -507,12 +513,12 @@ jobs: uses: actions/cache@v4 id: pods-cache with: - path: Mobile-Expensify/ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('Mobile-Expensify/ios/Podfile.lock', 'firebase.json') }} + path: Mobile-Expensify/iOS/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock', 'firebase.json') }} - name: Compare Podfile.lock and Manifest.lock id: compare-podfile-and-manifest - run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('Mobile-Expensify/ios/Podfile.lock') == hashFiles('Mobile-Expensify/ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock') == hashFiles('Mobile-Expensify/iOS/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - name: Install cocoapods uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml index 1e79bceb403a..42a5f15f8910 100644 --- a/.github/workflows/testBuildHybrid.yml +++ b/.github/workflows/testBuildHybrid.yml @@ -9,7 +9,6 @@ on: OLD_DOT_COMMIT: description: The branch, tag or SHA to checkout on Old Dot side required: false - default: 'main' pull_request_target: types: [opened, synchronize, labeled] branches: ['*ci-test/**'] @@ -86,38 +85,41 @@ jobs: androidHybrid: name: Build Android HybridApp needs: [validateActor, getBranchRef] + if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} runs-on: ubuntu-latest-xl - defaults: - run: - working-directory: Mobile-Expensify/react-native outputs: S3_APK_PATH: ${{ steps.exportAndroidS3Path.outputs.S3_APK_PATH }} steps: - name: Checkout uses: actions/checkout@v4 with: - repository: 'Expensify/Mobile-Expensify' submodules: true - path: 'Mobile-Expensify' - ref: ${{ env.OLD_DOT_COMMIT }} + ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} token: ${{ secrets.OS_BOTIFY_TOKEN }} # fetch-depth: 0 is required in order to fetch the correct submodule branch fetch-depth: 0 - - name: Update submodule + - name: Update submodule to match main + env: + OLD_DOT_COMMIT: ${{ env.OLD_DOT_COMMIT }} run: | - git submodule update --init - git fetch - git checkout ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} + git submodule update --init --remote + if [[ -z "$OLD_DOT_COMMIT" ]]; then + git fetch + git checkout ${{ env.OLD_DOT_COMMIT }} + fi - name: Configure MapBox SDK run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - uses: actions/setup-node@v4 - with: - node-version-file: 'Mobile-Expensify/react-native/.nvmrc' - cache: npm - cache-dependency-path: 'Mobile-Expensify/react-native' + - name: Setup Node + id: setup-node + uses: ./.github/actions/composite/setupNode + + - name: Run grunt build + run: | + cd Mobile-Expensify + npm run grunt:build:shared - name: Setup dotenv run: | @@ -125,14 +127,6 @@ jobs: sed -i 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc echo "PULL_REQUEST_NUMBER=${{ inputs.pull_request_number }}" >> .env.adhoc - - name: Install node modules - run: | - npm install - cd .. && npm install - - # Fixes https://github.com/Expensify/App/issues/51682 - npm run grunt:build:shared - - name: Setup Java uses: actions/setup-java@v4 with: @@ -143,7 +137,6 @@ jobs: uses: ruby/setup-ruby@v1.190.0 with: bundler-cache: true - working-directory: 'Mobile-Expensify/react-native' - name: Install 1Password CLI uses: 1password/install-cli-action@v1 @@ -155,7 +148,7 @@ jobs: op document get --output ./upload-key.keystore upload-key.keystore op document get --output ./android-fastlane-json-key.json android-fastlane-json-key.json # Copy the keystore to the Android directory for Fullstory - cp ./upload-key.keystore ../Android + cp ./upload-key.keystore Mobile-Expensify/Android - name: Load Android upload keystore credentials from 1Password id: load-credentials @@ -168,10 +161,6 @@ jobs: ANDROID_UPLOAD_KEYSTORE_ALIAS: op://Mobile-Deploy-CI/Repository-Secrets/ANDROID_UPLOAD_KEYSTORE_ALIAS ANDROID_UPLOAD_KEY_PASSWORD: op://Mobile-Deploy-CI/Repository-Secrets/ANDROID_UPLOAD_KEY_PASSWORD - - name: Get Android native version - id: getAndroidVersion - run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_OUTPUT" - - name: Build Android app id: build env: @@ -200,11 +189,120 @@ jobs: run: | # $s3APKPath is set from within the Fastfile, android upload_s3 lane echo "S3_APK_PATH=$s3APKPath" >> "$GITHUB_OUTPUT" + + iosHybrid: + name: Build and deploy iOS for testing + 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 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} + token: ${{ secrets.OS_BOTIFY_TOKEN }} + # fetch-depth: 0 is required in order to fetch the correct submodule branch + fetch-depth: 0 + + - name: Update submodule to match main + env: + OLD_DOT_COMMIT: ${{ env.OLD_DOT_COMMIT }} + run: | + git submodule update --init --remote + if [[ -z "$OLD_DOT_COMMIT" ]]; then + git fetch + git checkout ${{ env.OLD_DOT_COMMIT }} + fi + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + id: setup-node + uses: ./.github/actions/composite/setupNode + + - name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it + run: | + cp .env.staging .env.adhoc + sed -i '' 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc + echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Install New Expensify Gems + run: bundle install + + - name: Cache Pod dependencies + uses: actions/cache@v4 + id: pods-cache + with: + path: Mobile-Expensify/iOS/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock', 'firebase.json') }} + + - name: Compare Podfile.lock and Manifest.lock + id: compare-podfile-and-manifest + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('Mobile-Expensify/iOS/Podfile.lock') == hashFiles('Mobile-Expensify/iOS/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + + - name: Install cocoapods + uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 + if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' + with: + timeout_minutes: 10 + max_attempts: 5 + command: npm run pod-install + + - name: Install 1Password CLI + uses: 1password/install-cli-action@v1 + + - name: Load files from 1Password + env: + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + run: | + op document get --output ./OldApp_AdHoc.mobileprovision OldApp_AdHoc + op document get --output ./OldApp_AdHoc_Share_Extension.mobileprovision OldApp_AdHoc_Share_Extension + op document get --output ./OldApp_AdHoc_Notification_Service.mobileprovision OldApp_AdHoc_Notification_Service + + - name: Decrypt certificate + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Build AdHoc app + run: bundle exec fastlane ios build_adhoc_hybrid + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Upload AdHoc build to S3 + run: bundle exec fastlane ios upload_s3 + env: + S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} + S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + S3_BUCKET: ad-hoc-expensify-cash + S3_REGION: us-east-1 + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: ios + path: ./ios_paths.json + + postGithubComment: runs-on: ubuntu-latest name: Post a GitHub comment with app download links for testing - needs: [validateActor, getBranchRef, androidHybrid] + needs: [validateActor, getBranchRef, androidHybrid, iosHybrid] if: ${{ always() }} steps: - name: Checkout @@ -217,6 +315,17 @@ jobs: uses: actions/download-artifact@v4 if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} + - name: Read JSONs with iOS paths + id: get_ios_path + if: ${{ needs.iosHybrid.result == 'success' }} + run: | + content_ios="$(cat ./ios/ios_paths.json)" + content_ios="${content_ios//'%'/'%25'}" + content_ios="${content_ios//$'\n'/'%0A'}" + content_ios="${content_ios//$'\r'/'%0D'}" + ios_path=$(echo "$content_ios" | jq -r '.html_path') + echo "ios_path=$ios_path" >> "$GITHUB_OUTPUT" + - name: Publish links to apps for download if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} uses: ./.github/actions/javascript/postTestBuildComment @@ -224,4 +333,6 @@ jobs: PR_NUMBER: ${{ env.PULL_REQUEST_NUMBER }} GITHUB_TOKEN: ${{ github.token }} ANDROID: ${{ needs.androidHybrid.result }} - ANDROID_LINK: ${{ needs.androidHybrid.outputs.S3_APK_PATH }} \ No newline at end of file + IOS: ${{ needs.iosHybrid.result }} + ANDROID_LINK: ${{ needs.androidHybrid.outputs.S3_APK_PATH }} + IOS_LINK: ${{ steps.get_ios_path.outputs.ios_path }} diff --git a/Mobile-Expensify b/Mobile-Expensify index 7df7a0a1002d..af549932c171 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 7df7a0a1002d7622fd8b9c59a5dbfcc39164e736 +Subproject commit af549932c17151a57655466e4912e038b21f501a diff --git a/README.md b/README.md index 77b9d509a74d..8f7161b7fe96 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ You can only build HybridApp if you have been granted access to [`Mobile-Expensi ## Getting started with HybridApp 1. If you haven't, please follow [these instructions](https://github.com/Expensify/App?tab=readme-ov-file#getting-started) to setup the NewDot local environment. -2. Run `git submodule update --init` to download the `Mobile-Expensify` sourcecode. +2. Run `git submodule update --init --progress` to download the `Mobile-Expensify` sourcecode. - If you have access to `Mobile-Expensify` and the command fails with a https-related error add this to your `~/.gitconfig` file: ``` diff --git a/android/app/build.gradle b/android/app/build.gradle index 641aa6b5d62b..1391594d72a8 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 1009007401 - versionName "9.0.74-1" + versionCode 1009007506 + versionName "9.0.75-6" // 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/docs/articles/expensify-classic/connections/accelo/Accelo-Troubleshooting.md b/docs/articles/expensify-classic/connections/accelo/Accelo-Troubleshooting.md index fd0a6ca59069..f49ac1ead30e 100644 --- a/docs/articles/expensify-classic/connections/accelo/Accelo-Troubleshooting.md +++ b/docs/articles/expensify-classic/connections/accelo/Accelo-Troubleshooting.md @@ -1,7 +1,42 @@ --- title: Accelo Troubleshooting -description: Accelo Troubleshooting -order: 3 +description: Resources to help you solve issues with your Accelo integration. --- -# Coming soon +# Overview +Most of the Accelo integration with Expensify is managed on the Accelo side. You will find their [help site](https://help.accelo.com/guides/integrations-guide/expensify/) helpful, especially the [FAQs](https://help.accelo.com/guides/integrations-guide/expensify/#faq). + +## Information sync between Expensify and Accelo +The Accelo integration does a one-way sync, bringing expenses from Expensify into Accelo. When this happens, it transfers specific information from Expensify expenses to Accelo: + +| Expensify | Accelo | +|---------------------|-----------------------| +| Description | Title | +| Date | Date Incurred | +| Category | Type | +| Tags | Against (relevant Project, Ticket or Retainer) | +| Distance (mileage) | Quantity | +| Hours (time expenses) | Quantity | +| Amount | Purchase Price and Sale Price | +| Reimbursable? | Reimbursable? | +| Billable? | Billable? | +| Receipt | Attachment | +| Tax Rate | Tax Code | +| Attendees | Submitted By | + +## Expense Status +The status of your expense report in Expensify is also synced in Accelo. + +| Expensify Report Status | Accelo Expense Status | +|-------------------------|-----------------------| +| Open | Submitted | +| Submitted | Submitted | +| Approved | Approved | +| Reimbursed | Approved | +| Rejected | Declined | +| Archived | Approved | +| Closed | Approved | + + +## Can I use an Accelo and an accounting integration in Expensify at the same time? +Yes, you can use Accelo and an accounting system simultaneously. In order to update your Expensify tags with your Accelo Projects, Tickets, or Retainers, you will need to have a special switch enabled that allows you to have non-accounting tags alongside your accounting connection. Please contact Concierge to request that our support team enable the “Indirect Tag Uploads” switch for you. diff --git a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md index 068e4dd5bca9..68bca5228913 100644 --- a/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md +++ b/docs/articles/expensify-classic/connections/netsuite/Configure-Netsuite.md @@ -36,6 +36,12 @@ The three options for the date your report will export with are: - Submitted date: The date the employee submitted the report - Exported date: The date you export the report to NetSuite +## Accounting Method + +This dictates when reimbursable expenses will export, according to your preferred accounting method: +- Accrual: Out of pocket expenses will export immediately when the report is final approved +- Cash: Out of pocket expenses will export when paid via Expensify or marked as Reimbursed + ## Export Settings for Reimbursable Expenses **Expense Reports:** Expensify transactions will export reimbursable expenses as expense reports by default, which will be posted to the payables account designated in NetSuite. diff --git a/docs/articles/new-expensify/expenses-&-payments/Export-download-expenses.md b/docs/articles/new-expensify/expenses-&-payments/Export-download-expenses.md index 1952ba9539cd..6f7292245f00 100644 --- a/docs/articles/new-expensify/expenses-&-payments/Export-download-expenses.md +++ b/docs/articles/new-expensify/expenses-&-payments/Export-download-expenses.md @@ -6,7 +6,7 @@ description: Find expenses and export expense data to a CSV file Expensify allows you to export expense data to a downloaded CSV file, which you can then import into your favorite spreadsheet tool for deeper analysis. -##Search Expenses +## Search Expenses The first step to exporting and downloading expenses is finding the data you need. @@ -15,7 +15,7 @@ The first step to exporting and downloading expenses is finding the data you nee 3. Select your Filters on the top right to filter by credit card used, coding, date range, keyword, expense value and a number of other useful criteria 4. Hit View Results to see all expenses that match your filters - ##Download Expenses + ## Download Expenses 1. Select the checkbox to the left of the expenses or select all with the very top checkbox. 2. Click **# selected** at the top-right and select **Download**. diff --git a/docs/assets/images/ExpensifyHelp-CreateExpense-1.png b/docs/assets/images/ExpensifyHelp-CreateExpense-1.png new file mode 100644 index 000000000000..7b6459440d5e Binary files /dev/null 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 new file mode 100644 index 000000000000..65aaf8017a32 Binary files /dev/null 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 new file mode 100644 index 000000000000..0173de29d68d Binary files /dev/null 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 new file mode 100644 index 000000000000..901d08f1771d Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-CreateExpense-4.png differ diff --git a/docs/assets/images/Tax Exempt - Classic.png b/docs/assets/images/Tax Exempt - Classic.png new file mode 100644 index 000000000000..0987f5e4ca7d Binary files /dev/null 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 new file mode 100644 index 000000000000..9ff6673da6b3 Binary files /dev/null and b/docs/assets/images/Tax Exempt - New Expensify.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9e0ba567ac48..798e328f73fa 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -88,9 +88,9 @@ platform :android do desc "Generate AdHoc HybridApp apk" lane :build_adhoc_hybrid do - ENV["ENVFILE"]="../.env.adhoc.hybridapp" + ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp" gradle( - project_dir: '../Android', + project_dir: 'Mobile-Expensify/Android', task: 'assembleAdhoc', properties: { "android.injected.signing.store.file" => './upload-key.keystore', @@ -406,6 +406,42 @@ platform :ios do setIOSBuildOutputsInEnv() end + desc "Build an iOS HybridApp Adhoc build" + lane :build_adhoc_hybrid do + ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp" + + setupIOSSigningCertificate() + + install_provisioning_profile( + path: "./OldApp_AdHoc.mobileprovision" + ) + + install_provisioning_profile( + path: "./OldApp_AdHoc_Share_Extension.mobileprovision" + ) + + install_provisioning_profile( + path: "./OldApp_AdHoc_Notification_Service.mobileprovision" + ) + + build_app( + workspace: "Mobile-Expensify/iOS/Expensify.xcworkspace", + scheme: "Expensify", + output_name: "Expensify.ipa", + export_method: "app-store", + export_options: { + manageAppVersionAndBuildNumber: false, + provisioningProfiles: { + "com.expensify.expensifylite.adhoc" => "(OldApp) AppStore", + "com.expensify.expensifylite.adhoc.SmartScanExtension" => "(OldApp) AppStore: Share Extension", + "com.expensify.expensifylite.adhoc.NotificationServiceExtension" => "(OldApp) AppStore: Notification Service", + } + } + ) + + setIOSBuildOutputsInEnv() + end + desc "Build an unsigned iOS production build" lane :build_unsigned do ENV["ENVFILE"]=".env.production" diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 11881aaa884c..74d34f52214b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.74 + 9.0.75 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.74.1 + 9.0.75.6 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 9bd26e6f9d25..c594f105f833 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.74 + 9.0.75 CFBundleSignature ???? CFBundleVersion - 9.0.74.1 + 9.0.75.6 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 6fdb96653e16..2b8181d88d5b 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.74 + 9.0.75 CFBundleVersion - 9.0.74.1 + 9.0.75.6 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 18eba3d79c27..0db33e40e7fb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1981,8 +1981,27 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-view-shot (3.8.0): + - react-native-view-shot (4.0.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-webview (13.8.6): - DoubleConversion - glog @@ -3231,7 +3250,7 @@ SPEC CHECKSUMS: react-native-quick-sqlite: 7c793c9f5834e756b336257a8d8b8239b7ceb451 react-native-release-profiler: 131ec5e4145d900b2be2a8d6641e2ce0dd784259 react-native-safe-area-context: 38fdd9b3c5561de7cabae64bd0cd2ce05d2768a1 - react-native-view-shot: 6b7ed61d77d88580fed10954d45fad0eb2d47688 + react-native-view-shot: 6bafd491eb295b5834e05c469a37ecbd796d5b22 react-native-webview: ad29375839c9aa0409ce8e8693291b42bdc067a4 React-nativeconfig: 57781b79e11d5af7573e6f77cbf1143b71802a6d React-NativeModulesApple: 7ff2e2cfb2e5fa5bdedcecf28ce37e696c6ef1e1 diff --git a/package-lock.json b/package-lock.json index 00621db538e2..c9bc9ec2f28e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.74-1", + "version": "9.0.75-6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.74-1", + "version": "9.0.75-6", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -95,7 +95,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.82", + "react-native-onyx": "2.0.86", "react-native-pager-view": "6.5.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -114,7 +114,7 @@ "react-native-svg": "15.9.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "3.8.0", + "react-native-view-shot": "4.0.0", "react-native-vision-camera": "^4.6.1", "react-native-web": "0.19.13", "react-native-webview": "13.8.6", @@ -32150,9 +32150,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.82", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.82.tgz", - "integrity": "sha512-12+NgkC4fOeGu2J6s985NKUuLHP4aijBhpE6Us5IfVL+9dwxr/KqUVgV00OzXtYAABcWcpMC5PrvESqe8T5Iyw==", + "version": "2.0.86", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.86.tgz", + "integrity": "sha512-3pjyzlo8We4tSx/xf+4IRnBMcm5rk0E+aHBUSUxJ5jaFermx0SXZJlnvE5Emkw+iu0bXKkwea6zt2LhxD1JSsg==", "license": "MIT", "dependencies": { "ascii-table": "0.0.9", @@ -32454,7 +32454,9 @@ } }, "node_modules/react-native-view-shot": { - "version": "3.8.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.0.tgz", + "integrity": "sha512-e7wtfdm981DQVqkW+YE9mkemYarI0VZQ7PzRcHzQOmXlVrGKvNVD2MzRXOg+gK8msQIQ95QxATJKzG/QkQ9QHQ==", "license": "MIT", "dependencies": { "html2canvas": "^1.4.1" diff --git a/package.json b/package.json index 6ac8fe17cb14..59602f900e14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.74-1", + "version": "9.0.75-6", "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.", @@ -158,7 +158,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.82", + "react-native-onyx": "2.0.86", "react-native-pager-view": "6.5.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -177,7 +177,7 @@ "react-native-svg": "15.9.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "3.8.0", + "react-native-view-shot": "4.0.0", "react-native-vision-camera": "^4.6.1", "react-native-web": "0.19.13", "react-native-webview": "13.8.6", diff --git a/react-native.config.js b/react-native.config.js index a8c2436688e4..773375378acd 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -1,7 +1,10 @@ +const iosSourceDir = process.env.PROJECT_ROOT_PATH ? process.env.PROJECT_ROOT_PATH + 'ios' : 'ios'; +const androidSourceDir = process.env.PROJECT_ROOT_PATH ? process.env.PROJECT_ROOT_PATH + 'android' : 'android'; + module.exports = { project: { - ios: {sourceDir: process.env.PROJECT_ROOT_PATH + 'ios'}, - android: {sourceDir: process.env.PROJECT_ROOT_PATH + 'android'}, + ios: {sourceDir: iosSourceDir}, + android: {sourceDir: androidSourceDir}, }, assets: ['./assets/fonts/native'], }; diff --git a/src/App.tsx b/src/App.tsx index 52904e0a06c4..cc824b78fa4c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import KeyboardProvider from './components/KeyboardProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; +import {ProductTrainingContextProvider} from './components/ProductTrainingContext'; import SafeArea from './components/SafeArea'; import ScrollOffsetContextProvider from './components/ScrollOffsetContextProvider'; import {SearchRouterContextProvider} from './components/Search/SearchRouter/SearchRouterContext'; @@ -95,6 +96,7 @@ function App({url}: AppProps) { VideoPopoverMenuContextProvider, KeyboardProvider, SearchRouterContextProvider, + ProductTrainingContextProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index 2d38d26d8820..4fcc1cada6ff 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -344,11 +344,14 @@ const CONST = { ANIMATION_GYROSCOPE_VALUE: 0.4, ANIMATION_PAID_DURATION: 200, ANIMATION_PAID_CHECKMARK_DELAY: 300, + ANIMATION_THUMBSUP_DURATION: 250, + ANIMATION_THUMBSUP_DELAY: 200, ANIMATION_PAID_BUTTON_HIDE_DELAY: 1000, BACKGROUND_IMAGE_TRANSITION_DURATION: 1000, SCREEN_TRANSITION_END_TIMEOUT: 1000, ARROW_HIDE_DELAY: 3000, MAX_IMAGE_CANVAS_AREA: 16777216, + CHUNK_LOAD_ERROR: 'ChunkLoadError', API_ATTACHMENT_VALIDATIONS: { // 24 megabytes in bytes, this is limit set on servers, do not update without wider internal discussion @@ -6404,6 +6407,13 @@ const CONST = { }, MIGRATED_USER_WELCOME_MODAL: 'migratedUserWelcomeModal', + + PRODUCT_TRAINING_TOOLTIP_NAMES: { + CONCEIRGE_LHN_GBR: 'conciergeLHNGBR', + RENAME_SAVED_SEARCH: 'renameSavedSearch', + QUICK_ACTION_BUTTON: 'quickActionButton', + WORKSAPCE_CHAT_CREATE: 'workspaceChatCreate', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 45d636c0b1df..2e65b5f372b4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -111,9 +111,6 @@ const ONYXKEYS = { /** NVP keys */ - /** Boolean flag only true when first set */ - NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser', - /** This NVP contains list of at most 5 recent attendees */ NVP_RECENT_ATTENDEES: 'nvp_expensify_recentAttendees', @@ -216,18 +213,9 @@ const ONYXKEYS = { /** The end date (epoch timestamp) of the workspace owner’s grace period after the free trial ends. */ NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END: 'nvp_private_billingGracePeriodEnd', - /** The NVP containing all information related to educational tooltip in workspace chat */ - NVP_WORKSPACE_TOOLTIP: 'workspaceTooltip', - /** The NVP containing the target url to navigate to when deleting a transaction */ NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL: 'nvp_deleteTransactionNavigateBackURL', - /** Whether to show save search rename tooltip */ - SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP: 'shouldShowSavedSearchRenameTooltip', - - /** Whether to hide gbr tooltip */ - NVP_SHOULD_HIDE_GBR_TOOLTIP: 'nvp_should_hide_gbr_tooltip', - /** Does this user have push notifications enabled for this device? */ PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -876,7 +864,6 @@ type OnyxCollectionValuesMapping = { type OnyxValuesMapping = { [ONYXKEYS.ACCOUNT]: OnyxTypes.Account; [ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID]: string; - [ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER]: boolean; // NVP_ONBOARDING is an array for old users. [ONYXKEYS.NVP_ONBOARDING]: Onboarding | []; @@ -1017,9 +1004,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_BILLING_FUND_ID]: number; [ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number; [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; - [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip; [ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL]: string | undefined; - [ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP]: boolean; [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; [ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE]: string; [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx; @@ -1027,7 +1012,6 @@ type OnyxValuesMapping = { [ONYXKEYS.LAST_ROUTE]: string; [ONYXKEYS.IS_SINGLE_NEW_DOT_ENTRY]: boolean | undefined; [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; - [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; [ONYXKEYS.CONCIERGE_REPORT_ID]: string; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx index af77a20b4caa..fc5c77958635 100644 --- a/src/components/ArchivedReportFooter.tsx +++ b/src/components/ArchivedReportFooter.tsx @@ -1,7 +1,6 @@ import lodashEscape from 'lodash/escape'; import React from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {getCurrentUserAccountID} from '@libs/actions/Report'; @@ -10,26 +9,20 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetailsList, Report, ReportAction} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import Banner from './Banner'; -type ArchivedReportFooterOnyxProps = { - /** The reason this report was archived */ - reportClosedAction: OnyxEntry; - - /** Personal details of all users */ - personalDetails: OnyxEntry; -}; - -type ArchivedReportFooterProps = ArchivedReportFooterOnyxProps & { +type ArchivedReportFooterProps = { /** The archived report */ report: Report; }; -function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}}: ArchivedReportFooterProps) { +function ArchivedReportFooter({report}: ArchivedReportFooterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {initialValue: {}}); + const [reportClosedAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false, selector: ReportActionsUtils.getLastClosedReportAction}); const originalMessage = ReportActionsUtils.isClosedAction(reportClosedAction) ? ReportActionsUtils.getOriginalMessage(reportClosedAction) : null; const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; const actorPersonalDetails = personalDetails?.[reportClosedAction?.actorAccountID ?? -1]; @@ -78,13 +71,4 @@ function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}} ArchivedReportFooter.displayName = 'ArchivedReportFooter'; -export default withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - reportClosedAction: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - canEvict: false, - selector: ReportActionsUtils.getLastClosedReportAction, - }, -})(ArchivedReportFooter); +export default ArchivedReportFooter; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 07edd148778d..84767c6347e7 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -122,6 +122,9 @@ type ButtonProps = Partial & { /** Id to use for this button */ id?: string; + /** Used to locate this button in ui tests */ + testID?: string; + /** Accessibility label for the component */ accessibilityLabel?: string; @@ -237,6 +240,7 @@ function Button( shouldShowRightIcon = false, id = '', + testID = undefined, accessibilityLabel = '', isSplitButton = false, link = false, @@ -405,6 +409,7 @@ function Button( ]} disabledStyle={disabledStyle} id={id} + testID={testID} accessibilityLabel={accessibilityLabel} role={CONST.ROLE.BUTTON} hoverDimmingValue={1} diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx index a95cf9bf87d2..7b55f2317d46 100644 --- a/src/components/ConfirmationPage.tsx +++ b/src/components/ConfirmationPage.tsx @@ -82,6 +82,7 @@ function ConfirmationPage({ success large text={buttonText} + testID="confirmation-button" style={styles.mt6} pressOnEnter onPress={onButtonPress} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts index 0d8acd5eef38..336d7043d4ed 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts +++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts @@ -2,6 +2,7 @@ import type {FlashList} from '@shopify/flash-list'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import emojis from '@assets/emojis'; import {useFrequentlyUsedEmojis} from '@components/OnyxProvider'; +import useKeyboardState from '@hooks/useKeyboardState'; import useLocalize from '@hooks/useLocalize'; import usePreferredEmojiSkinTone from '@hooks/usePreferredEmojiSkinTone'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -23,12 +24,15 @@ const useEmojiPickerMenu = () => { const [preferredSkinTone] = usePreferredEmojiSkinTone(); const {windowHeight} = useWindowDimensions(); const StyleUtils = useStyleUtils(); + const {keyboardHeight} = useKeyboardState(); + /** - * At EmojiPicker has set innerContainerStyle with maxHeight: '95%' by styles.popoverInnerContainer - * to avoid the list style to be cut off due to the list height being larger than the container height - * so we need to calculate listStyle based on the height of the window and innerContainerStyle at the EmojiPicker + * The EmojiPicker sets the `innerContainerStyle` with `maxHeight: '95%'` in `styles.popoverInnerContainer` + * to prevent the list from being cut off when the list height exceeds the container's height. + * To calculate the available list height, we subtract the keyboard height from the `windowHeight` + * to ensure the list is properly adjusted when the keyboard is visible. */ - const listStyle = StyleUtils.getEmojiPickerListHeight(isListFiltered, windowHeight * 0.95); + const listStyle = StyleUtils.getEmojiPickerListHeight(isListFiltered, windowHeight * 0.95 - keyboardHeight); useEffect(() => { setFilteredEmojis(allEmojis); diff --git a/src/components/ErrorBoundary/BaseErrorBoundary.tsx b/src/components/ErrorBoundary/BaseErrorBoundary.tsx index f56441316f7c..880c833e18e8 100644 --- a/src/components/ErrorBoundary/BaseErrorBoundary.tsx +++ b/src/components/ErrorBoundary/BaseErrorBoundary.tsx @@ -27,7 +27,7 @@ function BaseErrorBoundary({logError = () => {}, errorMessage, children}: BaseEr return ( : } + FallbackComponent={updateRequired ? UpdateRequiredView : GenericErrorPage} onError={catchError} > {children} diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index c423d3101d92..6b8cf173b0fd 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -11,6 +11,7 @@ import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {useSession} from '@components/OnyxProvider'; import PressableWithSecondaryInteraction from '@components/PressableWithSecondaryInteraction'; +import {useProductTrainingContext} from '@components/ProductTrainingContext'; import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; @@ -22,7 +23,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import DateUtils from '@libs/DateUtils'; import DomUtils from '@libs/DomUtils'; -import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import Performance from '@libs/Performance'; @@ -32,7 +32,6 @@ import * as ReportActionContextMenu from '@pages/home/report/ContextMenu/ReportA import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import variables from '@styles/variables'; import Timing from '@userActions/Timing'; -import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -48,18 +47,19 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${optionItem?.reportID || -1}`); - const [isFirstTimeNewExpensifyUser] = useOnyx(ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER); - const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { - selector: hasCompletedGuidedSetupFlowSelector, - }); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const session = useSession(); // Guides are assigned for the MANAGE_TEAM onboarding action, except for emails that have a '+'. const isOnboardingGuideAssigned = introSelected?.choice === CONST.ONBOARDING_CHOICES.MANAGE_TEAM && !session?.email?.includes('+'); const shouldShowToooltipOnThisReport = isOnboardingGuideAssigned ? ReportUtils.isAdminRoom(report) : ReportUtils.isConciergeChatReport(report); - const [shouldHideGBRTooltip] = useOnyx(ONYXKEYS.NVP_SHOULD_HIDE_GBR_TOOLTIP, {initialValue: true}); + const shouldShowGetStartedTooltip = shouldShowToooltipOnThisReport && isScreenFocused; + const {shouldShowProductTrainingTooltip, renderProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONCEIRGE_LHN_GBR, + shouldShowGetStartedTooltip, + ); const {translate} = useLocalize(); const [isContextMenuActive, setIsContextMenuActive] = useState(false); @@ -72,30 +72,6 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti }, []), ); - const renderGBRTooltip = useCallback( - () => ( - - - {translate('sidebarScreen.tooltip')} - - ), - [ - styles.alignItemsCenter, - styles.flexRow, - styles.justifyContentCenter, - styles.flexWrap, - styles.textAlignCenter, - styles.gap1, - styles.quickActionTooltipSubtitle, - theme.tooltipHighlightText, - translate, - ], - ); - const isInFocusMode = viewMode === CONST.OPTION_MODE.COMPACT; const sidebarInnerRowStyle = StyleSheet.flatten( isInFocusMode @@ -180,17 +156,17 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti needsOffscreenAlphaCompositing > diff --git a/src/components/LHNOptionsList/OptionRowLHNData.tsx b/src/components/LHNOptionsList/OptionRowLHNData.tsx index ccf12aa4ce24..7c992dbeae24 100644 --- a/src/components/LHNOptionsList/OptionRowLHNData.tsx +++ b/src/components/LHNOptionsList/OptionRowLHNData.tsx @@ -40,8 +40,6 @@ function OptionRowLHNData({ const shouldDisplayViolations = ReportUtils.shouldDisplayViolationsRBRInLHN(fullReport, transactionViolations); const isSettled = ReportUtils.isSettled(fullReport); const shouldDisplayReportViolations = !isSettled && ReportUtils.isReportOwner(fullReport) && ReportUtils.hasReportViolations(reportID); - // We only want to show RBR for expense reports with transaction violations not for transaction threads reports. - const doesExpenseReportHasViolations = ReportUtils.isExpenseReport(fullReport) && !isSettled && ReportUtils.hasViolations(reportID, transactionViolations, true); const optionItem = useMemo(() => { // Note: ideally we'd have this as a dependent selector in onyx! @@ -52,7 +50,7 @@ function OptionRowLHNData({ preferredLocale: preferredLocale ?? CONST.LOCALES.DEFAULT, policy, parentReportAction, - hasViolations: !!shouldDisplayViolations || shouldDisplayReportViolations || doesExpenseReportHasViolations, + hasViolations: !!shouldDisplayViolations || shouldDisplayReportViolations, lastMessageTextFromReport, transactionViolations, invoiceReceiverPolicy, diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 6a888a09b60b..19af05a1581b 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -303,8 +303,6 @@ function MoneyRequestConfirmationList({ return false; }; - const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); - useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -320,6 +318,7 @@ function MoneyRequestConfirmationList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run if it's just setFormError that changes }, [isFocused, transaction, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit]); + const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); const isFirstUpdatedDistanceAmount = useRef(false); useEffect(() => { diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 340be8a6c3e1..e32c4eae410f 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -8,7 +8,6 @@ import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -216,7 +215,6 @@ function MoneyRequestConfirmationListFooter({ unit, }: MoneyRequestConfirmationListFooterProps) { const styles = useThemeStyles(); - const theme = useTheme(); const {translate, toLocaleDigit} = useLocalize(); const {isOffline} = useNetwork(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -534,7 +532,6 @@ function MoneyRequestConfirmationListFooter({ onToggle={(isOn) => onToggleBillable?.(isOn)} isActive={iouIsBillable} disabled={isReadOnly} - titleStyle={!iouIsBillable && {color: theme.textSupporting}} wrapperStyle={styles.flex1} /> diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx index 3d6ad9006dc5..ba320a594135 100644 --- a/src/components/ProcessMoneyReportHoldMenu.tsx +++ b/src/components/ProcessMoneyReportHoldMenu.tsx @@ -66,6 +66,9 @@ function ProcessMoneyReportHoldMenu({ const onSubmit = (full: boolean) => { if (isApprove) { + if (startAnimation) { + startAnimation(); + } IOU.approveMoneyRequest(moneyRequestReport, full); if (!full && isLinkedTransactionHeld(Navigation.getTopmostReportActionId() ?? '-1', moneyRequestReport?.reportID ?? '')) { Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport?.reportID ?? '')); diff --git a/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts b/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts new file mode 100644 index 000000000000..d7f2a27d94d2 --- /dev/null +++ b/src/components/ProductTrainingContext/PRODUCT_TRAINING_TOOLTIP_DATA.ts @@ -0,0 +1,67 @@ +import type {ValueOf} from 'type-fest'; +import {dismissProductTraining} from '@libs/actions/Welcome'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +const {CONCEIRGE_LHN_GBR, RENAME_SAVED_SEARCH, WORKSAPCE_CHAT_CREATE, QUICK_ACTION_BUTTON} = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; + +type ProductTrainingTooltipName = ValueOf; + +type ShouldShowConditionProps = { + shouldUseNarrowLayout?: boolean; +}; + +type TooltipData = { + content: Array<{text: TranslationPaths; isBold: boolean}>; + onHideTooltip: () => void; + name: ProductTrainingTooltipName; + priority: number; + shouldShow: (props: ShouldShowConditionProps) => boolean; +}; + +const PRODUCT_TRAINING_TOOLTIP_DATA: Record = { + [CONCEIRGE_LHN_GBR]: { + content: [ + {text: 'productTrainingTooltip.conciergeLHNGBR.part1', isBold: false}, + {text: 'productTrainingTooltip.conciergeLHNGBR.part2', isBold: true}, + ], + onHideTooltip: () => dismissProductTraining(CONCEIRGE_LHN_GBR), + name: CONCEIRGE_LHN_GBR, + priority: 1300, + shouldShow: ({shouldUseNarrowLayout}) => !!shouldUseNarrowLayout, + }, + [RENAME_SAVED_SEARCH]: { + content: [ + {text: 'productTrainingTooltip.saveSearchTooltip.part1', isBold: true}, + {text: 'productTrainingTooltip.saveSearchTooltip.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(RENAME_SAVED_SEARCH), + name: RENAME_SAVED_SEARCH, + priority: 1250, + shouldShow: ({shouldUseNarrowLayout}) => !shouldUseNarrowLayout, + }, + [QUICK_ACTION_BUTTON]: { + content: [ + {text: 'productTrainingTooltip.quickActionButton.part1', isBold: true}, + {text: 'productTrainingTooltip.quickActionButton.part2', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(QUICK_ACTION_BUTTON), + name: QUICK_ACTION_BUTTON, + priority: 1200, + shouldShow: () => true, + }, + [WORKSAPCE_CHAT_CREATE]: { + content: [ + {text: 'productTrainingTooltip.workspaceChatCreate.part1', isBold: false}, + {text: 'productTrainingTooltip.workspaceChatCreate.part2', isBold: true}, + {text: 'productTrainingTooltip.workspaceChatCreate.part3', isBold: false}, + ], + onHideTooltip: () => dismissProductTraining(WORKSAPCE_CHAT_CREATE), + name: WORKSAPCE_CHAT_CREATE, + priority: 1100, + shouldShow: () => true, + }, +}; + +export default PRODUCT_TRAINING_TOOLTIP_DATA; +export type {ProductTrainingTooltipName}; diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx new file mode 100644 index 000000000000..92997fe70af3 --- /dev/null +++ b/src/components/ProductTrainingContext/index.tsx @@ -0,0 +1,214 @@ +import React, {createContext, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {hasCompletedGuidedSetupFlowSelector} from '@libs/onboardingSelectors'; +import Permissions from '@libs/Permissions'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import type {ProductTrainingTooltipName} from './PRODUCT_TRAINING_TOOLTIP_DATA'; +import PRODUCT_TRAINING_TOOLTIP_DATA from './PRODUCT_TRAINING_TOOLTIP_DATA'; + +type ProductTrainingContextType = { + shouldRenderTooltip: (tooltipName: ProductTrainingTooltipName) => boolean; + registerTooltip: (tooltipName: ProductTrainingTooltipName) => void; + unregisterTooltip: (tooltipName: ProductTrainingTooltipName) => void; +}; + +const ProductTrainingContext = createContext({ + shouldRenderTooltip: () => false, + registerTooltip: () => {}, + unregisterTooltip: () => {}, +}); + +function ProductTrainingContextProvider({children}: ChildrenProps) { + const [tryNewDot] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT); + const hasBeenAddedToNudgeMigration = !!tryNewDot?.nudgeMigration?.timestamp; + const [isOnboardingCompleted = true] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { + selector: hasCompletedGuidedSetupFlowSelector, + }); + const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); + const [allBetas] = useOnyx(ONYXKEYS.BETAS); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const [activeTooltips, setActiveTooltips] = useState>(new Set()); + + const unregisterTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + setActiveTooltips((prev) => { + const next = new Set(prev); + next.delete(tooltipName); + return next; + }); + }, + [setActiveTooltips], + ); + + const determineVisibleTooltip = useCallback(() => { + if (activeTooltips.size === 0) { + return null; + } + + const sortedTooltips = Array.from(activeTooltips) + .map((name) => ({ + name, + priority: PRODUCT_TRAINING_TOOLTIP_DATA[name]?.priority ?? 0, + })) + .sort((a, b) => b.priority - a.priority); + + const highestPriorityTooltip = sortedTooltips.at(0); + + if (!highestPriorityTooltip) { + return null; + } + + return highestPriorityTooltip.name; + }, [activeTooltips]); + + const shouldTooltipBeVisible = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + const isDismissed = !!dismissedProductTraining?.[tooltipName]; + + if (isDismissed || !Permissions.shouldShowProductTrainingElements(allBetas)) { + return false; + } + const tooltipConfig = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + + if (!isOnboardingCompleted && !hasBeenAddedToNudgeMigration) { + return false; + } + + return tooltipConfig.shouldShow({ + shouldUseNarrowLayout, + }); + }, + [allBetas, dismissedProductTraining, hasBeenAddedToNudgeMigration, isOnboardingCompleted, shouldUseNarrowLayout], + ); + + const registerTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + const shouldRegister = shouldTooltipBeVisible(tooltipName); + if (!shouldRegister) { + return; + } + setActiveTooltips((prev) => new Set([...prev, tooltipName])); + }, + [shouldTooltipBeVisible], + ); + + const shouldRenderTooltip = useCallback( + (tooltipName: ProductTrainingTooltipName) => { + // First check base conditions + const shouldShow = shouldTooltipBeVisible(tooltipName); + if (!shouldShow) { + return false; + } + const visibleTooltip = determineVisibleTooltip(); + + // If this is the highest priority visible tooltip, show it + if (tooltipName === visibleTooltip) { + return true; + } + + return false; + }, + [shouldTooltipBeVisible, determineVisibleTooltip], + ); + + const contextValue = useMemo( + () => ({ + shouldRenderTooltip, + registerTooltip, + unregisterTooltip, + }), + [shouldRenderTooltip, registerTooltip, unregisterTooltip], + ); + + return {children}; +} + +const useProductTrainingContext = (tooltipName: ProductTrainingTooltipName, shouldShow = true) => { + const context = useContext(ProductTrainingContext); + const styles = useThemeStyles(); + const theme = useTheme(); + const {translate} = useLocalize(); + + if (!context) { + throw new Error('useProductTourContext must be used within a ProductTourProvider'); + } + + const {shouldRenderTooltip, registerTooltip, unregisterTooltip} = context; + + useEffect(() => { + if (shouldShow) { + registerTooltip(tooltipName); + return () => { + unregisterTooltip(tooltipName); + }; + } + return () => {}; + }, [tooltipName, registerTooltip, unregisterTooltip, shouldShow]); + + const renderProductTrainingTooltip = useCallback(() => { + const tooltip = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + return ( + + + + {tooltip.content.map(({text, isBold}) => { + const translatedText = translate(text); + return ( + + {translatedText} + + ); + })} + + + ); + }, [ + styles.alignItemsCenter, + styles.flexRow, + styles.flexWrap, + styles.gap1, + styles.justifyContentCenter, + styles.p2, + styles.quickActionTooltipSubtitle, + styles.textAlignCenter, + styles.textBold, + theme.tooltipHighlightText, + tooltipName, + translate, + ]); + + const shouldShowProductTrainingTooltip = useMemo(() => { + return shouldRenderTooltip(tooltipName); + }, [shouldRenderTooltip, tooltipName]); + + const hideProductTrainingTooltip = useCallback(() => { + const tooltip = PRODUCT_TRAINING_TOOLTIP_DATA[tooltipName]; + tooltip.onHideTooltip(); + unregisterTooltip(tooltipName); + }, [tooltipName, unregisterTooltip]); + + return { + renderProductTrainingTooltip, + hideProductTrainingTooltip, + shouldShowProductTrainingTooltip: shouldShow && shouldShowProductTrainingTooltip, + }; +}; + +export {ProductTrainingContextProvider, useProductTrainingContext}; diff --git a/src/components/RNMarkdownTextInput.tsx b/src/components/RNMarkdownTextInput.tsx index d36af6e13826..11f8a852dbcf 100644 --- a/src/components/RNMarkdownTextInput.tsx +++ b/src/components/RNMarkdownTextInput.tsx @@ -5,13 +5,14 @@ import React from 'react'; import type {TextInput} from 'react-native'; import Animated from 'react-native-reanimated'; import useTheme from '@hooks/useTheme'; +import CONST from '@src/CONST'; // Convert the underlying TextInput into an Animated component so that we can take an animated ref and pass it to a worklet const AnimatedMarkdownTextInput = Animated.createAnimatedComponent(MarkdownTextInput); type AnimatedMarkdownTextInputRef = typeof AnimatedMarkdownTextInput & TextInput & HTMLInputElement; -function RNMarkdownTextInputWithRef(props: MarkdownTextInputProps, ref: ForwardedRef) { +function RNMarkdownTextInputWithRef({maxLength, ...props}: MarkdownTextInputProps, ref: ForwardedRef) { const theme = useTheme(); return ( @@ -27,6 +28,10 @@ function RNMarkdownTextInputWithRef(props: MarkdownTextInputProps, ref: Forwarde }} // eslint-disable-next-line {...props} + /** + * If maxLength is not set, we should set the it to CONST.MAX_COMMENT_LENGTH + 1, to avoid parsing markdown for large text + */ + maxLength={maxLength ?? CONST.MAX_COMMENT_LENGTH + 1} /> ); } diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index de20575aeef4..d1dcdb2f57f5 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -79,8 +79,10 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo const enabledReportFields = sortedPolicyReportFields.filter((reportField) => !ReportUtils.isReportFieldDisabled(report, reportField, policy)); const isOnlyTitleFieldEnabled = enabledReportFields.length === 1 && ReportUtils.isReportFieldOfTypeTitle(enabledReportFields.at(0)); - const shouldShowReportField = - !ReportUtils.isClosedExpenseReportWithNoExpenses(report) && ReportUtils.isPaidGroupPolicyExpenseReport(report) && (!isCombinedReport || !isOnlyTitleFieldEnabled); + const isClosedExpenseReportWithNoExpenses = ReportUtils.isClosedExpenseReportWithNoExpenses(report); + const isPaidGroupPolicyExpenseReport = ReportUtils.isPaidGroupPolicyExpenseReport(report); + const isInvoiceReport = ReportUtils.isInvoiceReport(report); + const shouldShowReportField = !isClosedExpenseReportWithNoExpenses && (isPaidGroupPolicyExpenseReport || isInvoiceReport) && (!isCombinedReport || !isOnlyTitleFieldEnabled); const renderThreadDivider = useMemo( () => @@ -102,9 +104,9 @@ function MoneyReportView({report, policy, isCombinedReport = false, shouldShowTo <> - {!ReportUtils.isClosedExpenseReportWithNoExpenses(report) && ( + {!isClosedExpenseReportWithNoExpenses && ( <> - {ReportUtils.isPaidGroupPolicyExpenseReport(report) && + {(isPaidGroupPolicyExpenseReport || isInvoiceReport) && policy?.areReportFieldsEnabled && (!isCombinedReport || !isOnlyTitleFieldEnabled) && sortedPolicyReportFields.map((reportField) => { diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index ca50e93e536f..e3d4a8d31cf6 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -15,7 +15,6 @@ import ViolationMessages from '@components/ViolationMessages'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useViolations from '@hooks/useViolations'; import type {ViolationField} from '@hooks/useViolations'; @@ -79,7 +78,6 @@ const getTransactionID = (report: OnyxEntry, parentReportActio }; function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) { - const theme = useTheme(); const styles = useThemeStyles(); const session = useSession(); const {isOffline} = useNetwork(); @@ -390,7 +388,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const shouldShowReceiptAudit = isReceiptAllowed && (shouldShowReceiptEmptyState || hasReceipt); const errors = { - ...(transaction?.errorFields?.route ?? transaction?.errors), + ...(transaction?.errorFields?.route ?? transaction?.errorFields?.waypoints ?? transaction?.errors), ...parentReportAction?.errors, }; @@ -468,8 +466,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals return; } if (parentReportAction) { - const urlToNavigateBack = IOU.cleanUpMoneyRequest(transaction?.transactionID ?? linkedTransactionID, parentReportAction, true); - Navigation.goBack(urlToNavigateBack); + IOU.cleanUpMoneyRequest(transaction?.transactionID ?? linkedTransactionID, parentReportAction, true); return; } } @@ -730,7 +727,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals {shouldShowBillable && ( - {translate('common.billable')} + {translate('common.billable')} {!!getErrorForField('billable') && ( (); const [paymentType, setPaymentType] = useState(); const getCanIOUBePaid = useCallback( - (onlyShowPayElsewhere = false) => IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere), + (onlyShowPayElsewhere = false, shouldCheckApprovedState = true) => + IOU.canIOUBePaid(iouReport, chatReport, policy, allTransactions, onlyShowPayElsewhere, undefined, undefined, shouldCheckApprovedState), [iouReport, chatReport, policy, allTransactions], ); const canIOUBePaid = useMemo(() => getCanIOUBePaid(), [getCanIOUBePaid]); + const canIOUBePaidAndApproved = useMemo(() => getCanIOUBePaid(false, false), [getCanIOUBePaid]); const onlyShowPayElsewhere = useMemo(() => !canIOUBePaid && getCanIOUBePaid(true), [canIOUBePaid, getCanIOUBePaid]); const shouldShowPayButton = isPaidAnimationRunning || canIOUBePaid || onlyShowPayElsewhere; + const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]) || isApprovedAnimationRunning; + + const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = ReportUtils.getNonHeldAndFullAmount(iouReport, shouldShowPayButton); const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(iouReport?.reportID ?? ''); @@ -151,12 +157,18 @@ function ReportPreview({ })); const checkMarkScale = useSharedValue(iouSettled ? 1 : 0); + const isApproved = ReportUtils.isReportApproved(iouReport, action); + const thumbsUpScale = useSharedValue(isApproved ? 1 : 0); + const thumbsUpStyle = useAnimatedStyle(() => ({ + ...styles.defaultCheckmarkWrapper, + transform: [{scale: thumbsUpScale.get()}], + })); + const moneyRequestComment = action?.childLastMoneyRequestComment ?? ''; const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); const isInvoiceRoom = ReportUtils.isInvoiceRoom(chatReport); const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); - const isApproved = ReportUtils.isReportApproved(iouReport, action); const canAllowSettlement = ReportUtils.hasUpdatedTotal(iouReport, policy); const numberOfRequests = allTransactions.length; const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(iouReportID); @@ -207,11 +219,19 @@ function ReportPreview({ const {isDelegateAccessRestricted} = useDelegateUserDetails(); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); - const stopAnimation = useCallback(() => setIsPaidAnimationRunning(false), []); + const stopAnimation = useCallback(() => { + setIsPaidAnimationRunning(false); + setIsApprovedAnimationRunning(false); + }, []); const startAnimation = useCallback(() => { setIsPaidAnimationRunning(true); HapticFeedback.longPress(); }, []); + const startApprovedAnimation = useCallback(() => { + setIsApprovedAnimationRunning(true); + HapticFeedback.longPress(); + }, []); + const confirmPayment = useCallback( (type: PaymentMethodType | undefined, payAsBusiness?: boolean) => { if (!type) { @@ -243,6 +263,8 @@ function ReportPreview({ } else if (ReportUtils.hasHeldExpenses(iouReport?.reportID)) { setIsHoldMenuVisible(true); } else { + setIsApprovedAnimationRunning(true); + HapticFeedback.longPress(); IOU.approveMoneyRequest(iouReport, true); } }; @@ -340,9 +362,6 @@ function ReportPreview({ ]); const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); - const shouldShowApproveButton = useMemo(() => IOU.canApproveIOU(iouReport, policy), [iouReport, policy]); - - const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(iouReport); const shouldShowSettlementButton = (shouldShowPayButton || shouldShowApproveButton) && !showRTERViolationMessage && !shouldShowBrokenConnectionViolation; @@ -427,7 +446,7 @@ function ReportPreview({ const shouldShowExportIntegrationButton = !shouldShowPayButton && !shouldShowSubmitButton && connectedIntegration && isAdmin && ReportUtils.canBeExported(iouReport); useEffect(() => { - if (!isPaidAnimationRunning) { + if (!isPaidAnimationRunning || isApprovedAnimationRunning) { return; } @@ -448,6 +467,14 @@ function ReportPreview({ checkMarkScale.set(isPaidAnimationRunning ? withDelay(CONST.ANIMATION_PAID_CHECKMARK_DELAY, withSpring(1, {duration: CONST.ANIMATION_PAID_DURATION})) : 1); }, [isPaidAnimationRunning, iouSettled, checkMarkScale]); + useEffect(() => { + if (!isApproved) { + return; + } + + thumbsUpScale.set(isApprovedAnimationRunning ? withDelay(CONST.ANIMATION_THUMBSUP_DELAY, withSpring(1, {duration: CONST.ANIMATION_THUMBSUP_DURATION})) : 1); + }, [isApproved, isApprovedAnimationRunning, thumbsUpScale]); + const openReportFromPreview = useCallback(() => { Performance.markStart(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); Timing.start(CONST.TIMING.OPEN_REPORT_FROM_PREVIEW); @@ -483,7 +510,7 @@ function ReportPreview({ - {previewMessage} + {previewMessage} {shouldShowRBR && ( )} + {isApproved && ( + + + + )} {shouldShowSubtitle && !!supportText && ( @@ -537,6 +572,8 @@ function ReportPreview({ shouldUseSuccessStyle={!hasHeldExpenses} onlyShowPayElsewhere={onlyShowPayElsewhere} isPaidAnimationRunning={isPaidAnimationRunning} + isApprovedAnimationRunning={isApprovedAnimationRunning} + canIOUBePaid={canIOUBePaidAndApproved || isPaidAnimationRunning} onAnimationFinish={stopAnimation} formattedAmount={getSettlementAmount() ?? ''} currency={iouReport?.currency} @@ -604,7 +641,13 @@ function ReportPreview({ chatReport={chatReport} moneyRequestReport={iouReport} transactionCount={numberOfRequests} - startAnimation={startAnimation} + startAnimation={() => { + if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { + startApprovedAnimation(); + } else { + startAnimation(); + } + }} /> )} diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 09315bfb8a8e..1e3df2a34817 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -177,7 +177,7 @@ function ScreenWrapper( }, [route?.params]); UNSTABLE_usePreventRemove(shouldReturnToOldDot, () => { - NativeModules.HybridAppModule?.closeReactNativeApp(false, false); + NativeModules.HybridAppModule?.closeReactNativeApp({shouldSignOut: false, shouldSetNVP: false}); }); const panResponder = useRef( diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 07f23a96424d..4c08c477f29d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -78,7 +78,10 @@ function mapToItemWithSelectionInfo( shouldAnimateInHighlight: boolean, ) { if (SearchUIUtils.isReportActionListItemType(item)) { - return item; + return { + ...item, + shouldAnimateInHighlight, + }; } return SearchUIUtils.isTransactionListItemType(item) @@ -134,6 +137,8 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo const [currentSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const previousTransactions = usePrevious(transactions); + const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const previousReportActions = usePrevious(reportActions); useEffect(() => { if (!currentSearchResults?.search?.type) { @@ -211,6 +216,8 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo previousTransactions, queryJSON, offset, + reportActions, + previousReportActions, }); // There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded @@ -323,15 +330,20 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo const ListItem = SearchUIUtils.getListItem(type, status); const sortedData = SearchUIUtils.getSortedSections(type, status, data, sortBy, sortOrder); + const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT; const sortedSelectedData = sortedData.map((item) => { - const baseKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${(item as TransactionListItemType).transactionID}`; + const baseKey = isChat + ? `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${(item as ReportActionListItemType).reportActionID}` + : `${ONYXKEYS.COLLECTION.TRANSACTION}${(item as TransactionListItemType).transactionID}`; // Check if the base key matches the newSearchResultKey (TransactionListItemType) const isBaseKeyMatch = baseKey === newSearchResultKey; // Check if any transaction within the transactions array (ReportListItemType) matches the newSearchResultKey - const isAnyTransactionMatch = (item as ReportListItemType)?.transactions?.some((transaction) => { - const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; - return transactionKey === newSearchResultKey; - }); + const isAnyTransactionMatch = + !isChat && + (item as ReportListItemType)?.transactions?.some((transaction) => { + const transactionKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`; + return transactionKey === newSearchResultKey; + }); // Determine if either the base key or any transaction key matches const shouldAnimateInHighlight = isBaseKeyMatch || isAnyTransactionMatch; diff --git a/src/components/SelectionList/ChatListItem.tsx b/src/components/SelectionList/ChatListItem.tsx index a3e04c9088f1..4807aa7760c8 100644 --- a/src/components/SelectionList/ChatListItem.tsx +++ b/src/components/SelectionList/ChatListItem.tsx @@ -5,11 +5,13 @@ import MentionReportContext from '@components/HTMLEngineProvider/HTMLRenderers/M import MultipleAvatars from '@components/MultipleAvatars'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import TextWithTooltip from '@components/TextWithTooltip'; +import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import ReportActionItemDate from '@pages/home/report/ReportActionItemDate'; import ReportActionItemFragment from '@pages/home/report/ReportActionItemFragment'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; import type {ChatListItemProps, ListItem, ReportActionListItemType} from './types'; @@ -56,11 +58,24 @@ function ChatListItem({ const hoveredBackgroundColor = styles.sidebarLinkHover?.backgroundColor ? styles.sidebarLinkHover.backgroundColor : theme.sidebar; const mentionReportContextValue = useMemo(() => ({currentReportID: item?.reportID ?? '-1'}), [item.reportID]); - + const animatedHighlightStyle = useAnimatedHighlightStyle({ + borderRadius: variables.componentBorderRadius, + shouldHighlight: item?.shouldAnimateInHighlight ?? false, + highlightColor: theme.messageHighlightBG, + backgroundColor: theme.highlightBG, + }); + const pressableStyle = [ + styles.selectionListPressableItemWrapper, + styles.textAlignLeft, + // Removing background style because they are added to the parent OpacityView via animatedHighlightStyle + styles.bgTransparent, + item.isSelected && styles.activeComponentBG, + item.cursorStyle, + ]; return ( ({ keyForList={item.keyForList} onFocus={onFocus} shouldSyncFocus={shouldSyncFocus} + pressableWrapperStyle={[styles.mh5, animatedHighlightStyle]} hoverStyle={item.isSelected && styles.activeComponentBG} > {(hovered) => ( diff --git a/src/components/SelectionList/Search/ActionCell.tsx b/src/components/SelectionList/Search/ActionCell.tsx index db5077beca9a..2608e4e2de8c 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import Badge from '@components/Badge'; import Button from '@components/Button'; @@ -36,7 +36,7 @@ type ActionCellProps = { function ActionCell({ action = CONST.SEARCH.ACTION_TYPES.VIEW, - shouldUseSuccessStyle = true, + shouldUseSuccessStyle: shouldUseSuccessStyleProp = true, isLargeScreenWidth = true, isSelected = false, goToItem, @@ -52,6 +52,16 @@ function ActionCell({ const text = translate(actionTranslationsMap[action]); + const getButtonInnerStyles = useCallback( + (shouldUseSuccessStyle: boolean) => { + if (!isSelected) { + return {}; + } + return shouldUseSuccessStyle ? styles.buttonSuccessHovered : styles.buttonDefaultHovered; + }, + [isSelected, styles], + ); + const shouldUseViewAction = action === CONST.SEARCH.ACTION_TYPES.VIEW || (parentAction === CONST.SEARCH.ACTION_TYPES.PAID && action === CONST.SEARCH.ACTION_TYPES.PAID); if ((parentAction !== CONST.SEARCH.ACTION_TYPES.PAID && action === CONST.SEARCH.ACTION_TYPES.PAID) || action === CONST.SEARCH.ACTION_TYPES.DONE) { @@ -77,31 +87,32 @@ function ActionCell({ ); } - if (action === CONST.SEARCH.ACTION_TYPES.VIEW || shouldUseViewAction) { - const buttonInnerStyles = isSelected ? styles.buttonDefaultHovered : {}; + if (action === CONST.SEARCH.ACTION_TYPES.VIEW || action === CONST.SEARCH.ACTION_TYPES.REVIEW || shouldUseViewAction) { return isLargeScreenWidth ? (