diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8f12229707ff..354b78d437a3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -114,51 +114,6 @@ jobs: env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - submitAndroid: - name: Submit Android app for production review - needs: prep - if: ${{ github.ref == 'refs/heads/production' }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - 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: Decrypt json w/ Google Play credentials - run: gpg --batch --yes --decrypt --passphrase="${{ secrets.LARGE_SECRET_PASSPHRASE }}" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg - working-directory: android/app - - - name: Submit Android build for review - run: bundle exec fastlane android upload_google_play_production - env: - VERSION: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} - - - name: Warn deployers if Android production deploy failed - if: ${{ failure() }} - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#deployer', - attachments: [{ - color: "#DB4545", - pretext: ``, - text: `đź’Ą Android production deploy failed. Please manually submit ${{ needs.prep.outputs.APP_VERSION }} in the . đź’Ą`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - android_hybrid: name: Build and deploy Android HybridApp needs: prep @@ -183,6 +138,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: @@ -425,12 +385,6 @@ jobs: APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} - - name: Submit build for App Store review - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane ios submit_for_review - env: - VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} - - name: Upload iOS build to Browser Stack if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/Users/runner/work/App/App/New Expensify.ipa" @@ -508,12 +462,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 @@ -709,7 +663,7 @@ jobs: name: Post a Slack message when any platform fails to build or deploy runs-on: ubuntu-latest if: ${{ failure() }} - needs: [buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] + needs: [buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] steps: - name: Checkout uses: actions/checkout@v4 @@ -724,21 +678,15 @@ jobs: outputs: IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAtLeastOnePlatform.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }} IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAllPlatforms.outputs.IS_ALL_PLATFORMS_DEPLOYED }} - needs: [buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] + needs: [buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web] if: ${{ always() }} steps: - name: Check deployment success on at least one platform id: checkDeploymentSuccessOnAtLeastOnePlatform run: | isAtLeastOnePlatformDeployed="false" - if [ ${{ github.ref }} == 'refs/heads/production' ]; then - if [ "${{ needs.submitAndroid.result }}" == "success" ]; then - isAtLeastOnePlatformDeployed="true" - fi - else - if [ "${{ needs.uploadAndroid.result }}" == "success" ]; then - isAtLeastOnePlatformDeployed="true" - fi + if [ "${{ needs.uploadAndroid.result }}" == "success" ]; then + isAtLeastOnePlatformDeployed="true" fi if [ "${{ needs.iOS.result }}" == "success" ] || \ @@ -763,14 +711,8 @@ jobs: isAllPlatformsDeployed="true" fi - if [ ${{ github.ref }} == 'refs/heads/production' ]; then - if [ "${{ needs.submitAndroid.result }}" != "success" ]; then - isAllPlatformsDeployed="false" - fi - else - if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then - isAllPlatformsDeployed="false" - fi + if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then + isAllPlatformsDeployed="false" fi echo "IS_ALL_PLATFORMS_DEPLOYED=$isAllPlatformsDeployed" >> "$GITHUB_OUTPUT" @@ -918,7 +860,7 @@ jobs: name: Post a Slack message when all platforms deploy successfully runs-on: ubuntu-latest if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} - needs: [prep, buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] steps: - name: 'Announces the deploy in the #announce Slack room' uses: 8398a7/action-slack@v3 @@ -972,11 +914,11 @@ jobs: postGithubComments: uses: ./.github/workflows/postDeployComments.yml if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [prep, buildAndroid, uploadAndroid, submitAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, android_hybrid, desktop, iOS, iOS_hybrid, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] with: version: ${{ needs.prep.outputs.APP_VERSION }} env: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} - android: ${{ github.ref == 'refs/heads/production' && needs.submitAndroid.result || needs.uploadAndroid.result }} + android: ${{ github.ref == 'refs/heads/production' || needs.uploadAndroid.result }} android_hybrid: ${{ needs.android_hybrid.result }} ios: ${{ needs.iOS.result }} ios_hybrid: ${{ needs.iOS_hybrid.result }} diff --git a/.github/workflows/testBuildHybrid.yml b/.github/workflows/testBuildHybrid.yml index b670e9b9cdb3..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/**'] @@ -88,37 +87,39 @@ jobs: 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: | @@ -126,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: @@ -144,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 @@ -156,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 @@ -205,53 +197,43 @@ jobs: env: DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer runs-on: macos-13-xlarge - defaults: - run: - working-directory: Mobile-Expensify/react-native 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 + - name: Setup Node id: setup-node - with: - node-version-file: 'Mobile-Expensify/react-native/.nvmrc' - cache: npm - cache-dependency-path: 'Mobile-Expensify/react-native' - + 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: Install node modules - run: | - npm install - cd .. && npm install - - name: Setup Ruby uses: ruby/setup-ruby@v1.190.0 with: bundler-cache: true - working-directory: 'Mobile-Expensify/react-native' - name: Install New Expensify Gems run: bundle install @@ -260,12 +242,12 @@ jobs: uses: actions/cache@v4 id: pods-cache with: - path: ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('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('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + 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 @@ -273,7 +255,7 @@ jobs: with: timeout_minutes: 10 max_attempts: 5 - command: cd Mobile-Expensify/iOS && bundle exec pod install + command: npm run pod-install - name: Install 1Password CLI uses: 1password/install-cli-action@v1 diff --git a/Mobile-Expensify b/Mobile-Expensify index c0cf9cfe6fd7..3c7145ddc6cf 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit c0cf9cfe6fd7c760a2f8621cf0473f0694bec10f +Subproject commit 3c7145ddc6cf1c5c642f1fb7a9c988ba92dca127 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 23f777896f61..e42639f792af 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 1009007500 - versionName "9.0.75-0" + versionCode 1009007600 + versionName "9.0.76-0" // 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/expensify-billing/Tax-Exempt.md b/docs/articles/expensify-classic/expensify-billing/Tax-Exempt.md index 92c92e4e3a44..a91454b4965b 100644 --- a/docs/articles/expensify-classic/expensify-billing/Tax-Exempt.md +++ b/docs/articles/expensify-classic/expensify-billing/Tax-Exempt.md @@ -11,6 +11,8 @@ If your organization is recognized by the IRS or other local tax authorities as 1. Our team will review your document and let you know if we need any more information. 1. Once everything is verified, we'll update your account accordingly. +![Click the request tax exempt status button]({{site.url}}/assets/images/Tax Exempt - Classic.png){:width="100%"} + Once your account is marked as tax-exempt, the corresponding state tax will no longer be applied to future billing. If you need to remove your tax-exempt status, let your Account Manager know or contact Concierge. 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/fastlane/Fastfile b/fastlane/Fastfile index 880a12023056..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', @@ -408,7 +408,7 @@ platform :ios do desc "Build an iOS HybridApp Adhoc build" lane :build_adhoc_hybrid do - ENV["ENVFILE"]="../.env.adhoc.hybridapp" + ENV["ENVFILE"]="Mobile-Expensify/.env.adhoc.hybridapp" setupIOSSigningCertificate() @@ -425,7 +425,7 @@ platform :ios do ) build_app( - workspace: "../iOS/Expensify.xcworkspace", + workspace: "Mobile-Expensify/iOS/Expensify.xcworkspace", scheme: "Expensify", output_name: "Expensify.ipa", export_method: "app-store", diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2c05d4b504bc..d1850d89aa1b 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.75 + 9.0.76 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.75.0 + 9.0.76.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index bdba334ae098..abaa274392d2 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.75 + 9.0.76 CFBundleSignature ???? CFBundleVersion - 9.0.75.0 + 9.0.76.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 70d8151a90cc..5a6d69aa2b8a 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.75 + 9.0.76 CFBundleVersion - 9.0.75.0 + 9.0.76.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 88faee782979..31ff58598c82 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 @@ -3233,7 +3252,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 4a74c12480ad..55f541c50cca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.75-0", + "version": "9.0.76-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.75-0", + "version": "9.0.76-0", "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", @@ -32174,9 +32174,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", @@ -32478,7 +32478,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 6f606905fc31..3d9379140bf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.75-0", + "version": "9.0.76-0", "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/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 15554719ca9d..d8724d9c72b2 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -344,6 +344,8 @@ 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, @@ -1049,6 +1051,7 @@ const CONST = { MODIFIED_EXPENSE: 'MODIFIEDEXPENSE', MOVED: 'MOVED', OUTDATED_BANK_ACCOUNT: 'OUTDATEDBANKACCOUNT', // OldDot Action + REIMBURSED: 'REIMBURSED', REIMBURSEMENT_ACH_BOUNCE: 'REIMBURSEMENTACHBOUNCE', // OldDot Action REIMBURSEMENT_ACH_CANCELLED: 'REIMBURSEMENTACHCANCELLED', // OldDot Action REIMBURSEMENT_ACCOUNT_CHANGED: 'REIMBURSEMENTACCOUNTCHANGED', // OldDot Action @@ -6103,6 +6106,33 @@ const CONST = { AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion', SEARCH: 'searchItem', }, + SEARCH_USER_FRIENDLY_KEYS: { + TYPE: 'type', + STATUS: 'status', + SORT_BY: 'sort-by', + SORT_ORDER: 'sort-order', + POLICY_ID: 'workspace', + DATE: 'date', + AMOUNT: 'amount', + EXPENSE_TYPE: 'expense-type', + CURRENCY: 'currency', + MERCHANT: 'merchant', + DESCRIPTION: 'description', + FROM: 'from', + TO: 'to', + CATEGORY: 'category', + TAG: 'tag', + TAX_RATE: 'tax-rate', + CARD_ID: 'card', + REPORT_ID: 'reportid', + KEYWORD: 'keyword', + IN: 'in', + SUBMITTED: 'submitted', + APPROVED: 'approved', + PAID: 'paid', + EXPORTED: 'exported', + POSTED: 'posted', + }, DATE_MODIFIERS: { BEFORE: 'Before', AFTER: 'After', @@ -6405,6 +6435,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..e19aa71cce52 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', @@ -464,6 +452,9 @@ const ONYXKEYS = { /** The user's Concierge reportID */ CONCIERGE_REPORT_ID: 'conciergeReportID', + /** The user's session that will be preserved when using imported state */ + PRESERVED_USER_SESSION: 'preservedUserSession', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -876,7 +867,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 +1007,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,9 +1015,9 @@ 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.PRESERVED_USER_SESSION]: OnyxTypes.Session; [ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING]: OnyxTypes.DismissedProductTraining; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/components/AmountTextInput.tsx b/src/components/AmountTextInput.tsx index 12189d22dba0..6be2b43c09d7 100644 --- a/src/components/AmountTextInput.tsx +++ b/src/components/AmountTextInput.tsx @@ -39,7 +39,7 @@ type AmountTextInputProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; -} & Pick; +} & Pick; function AmountTextInput( { 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/ImportOnyxState/index.native.tsx b/src/components/ImportOnyxState/index.native.tsx index 2258da4c8f6c..bdd805241c55 100644 --- a/src/components/ImportOnyxState/index.native.tsx +++ b/src/components/ImportOnyxState/index.native.tsx @@ -1,11 +1,12 @@ import React, {useState} from 'react'; import ReactNativeBlobUtil from 'react-native-blob-util'; -import Onyx from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; @@ -45,8 +46,9 @@ function applyStateInChunks(state: OnyxValues) { return promise; } -export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { +export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [session] = useOnyx(ONYXKEYS.SESSION); const handleFileRead = (file: FileObject) => { if (!file.uri) { @@ -57,6 +59,8 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta readOnyxFile(file.uri) .then((fileContent: string) => { const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; + setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); Onyx.clear(KEYS_TO_PRESERVE).then(() => { applyStateInChunks(transformedState).then(() => { @@ -67,14 +71,7 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta }) .catch(() => { setIsErrorModalVisible(true); - }) - .finally(() => { - setIsLoading(false); }); - - if (isLoading) { - setIsLoading(false); - } }; return ( diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx index 8add2d9172fd..2f9a2b70b65b 100644 --- a/src/components/ImportOnyxState/index.tsx +++ b/src/components/ImportOnyxState/index.tsx @@ -1,17 +1,19 @@ import React, {useState} from 'react'; -import Onyx from 'react-native-onyx'; +import Onyx, {useOnyx} from 'react-native-onyx'; import type {FileObject} from '@components/AttachmentModal'; -import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState, setPreservedUserSession} from '@libs/actions/App'; import {setShouldForceOffline} from '@libs/actions/Network'; import Navigation from '@libs/Navigation/Navigation'; import type {OnyxValues} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import BaseImportOnyxState from './BaseImportOnyxState'; import type ImportOnyxStateProps from './types'; import {cleanAndTransformState} from './utils'; -export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { +export default function ImportOnyxState({setIsLoading}: ImportOnyxStateProps) { const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + const [session] = useOnyx(ONYXKEYS.SESSION); const handleFileRead = (file: FileObject) => { if (!file.uri) { @@ -27,26 +29,20 @@ export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxSta .then((text) => { const fileContent = text; const transformedState = cleanAndTransformState(fileContent); + const currentUserSessionCopy = {...session}; + setPreservedUserSession(currentUserSessionCopy); setShouldForceOffline(true); Onyx.clear(KEYS_TO_PRESERVE).then(() => { - Onyx.multiSet(transformedState) - .then(() => { - setIsUsingImportedState(true); - Navigation.navigate(ROUTES.HOME); - }) - .finally(() => { - setIsLoading(false); - }); + Onyx.multiSet(transformedState).then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }); }); }) .catch(() => { setIsErrorModalVisible(true); setIsLoading(false); }); - - if (isLoading) { - setIsLoading(false); - } }; return ( diff --git a/src/components/ImportOnyxState/types.ts b/src/components/ImportOnyxState/types.ts index 8e504c493529..2b4b56a3b20c 100644 --- a/src/components/ImportOnyxState/types.ts +++ b/src/components/ImportOnyxState/types.ts @@ -1,5 +1,4 @@ type ImportOnyxStateProps = { - isLoading: boolean; setIsLoading: (isLoading: boolean) => void; }; diff --git a/src/components/ImportOnyxState/utils.ts b/src/components/ImportOnyxState/utils.ts index a5f24fa80714..94779868384d 100644 --- a/src/components/ImportOnyxState/utils.ts +++ b/src/components/ImportOnyxState/utils.ts @@ -3,7 +3,7 @@ import type {UnknownRecord} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; // List of Onyx keys from the .txt file we want to keep for the local override -const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.SESSION, ONYXKEYS.PREFERRED_THEME]; +const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.PREFERRED_THEME]; function isRecord(value: unknown): value is Record { return typeof value === 'object' && !Array.isArray(value) && value !== null; diff --git a/src/components/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/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 717659c16fd3..9ef33900bb00 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -12,7 +12,6 @@ import CONST from '@src/CONST'; import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; -import type {TextInputWithCurrencySymbolProps} from './TextInputWithCurrencySymbol/types'; type CurrentMoney = {amount: string; currency: string}; @@ -92,7 +91,7 @@ type MoneyRequestAmountInputProps = { /** The width of inner content */ contentWidth?: number; -} & Pick; +}; type Selection = { start: number; @@ -127,7 +126,6 @@ function MoneyRequestAmountInput( hideFocusedState = true, shouldKeepUserInput = false, autoGrow = true, - autoGrowExtraSpace, contentWidth, ...props }: MoneyRequestAmountInputProps, @@ -291,7 +289,6 @@ function MoneyRequestAmountInput( return ( { - if (!errors.includes(formError)) { - return; - } - - setFormError(''); - }, - [formError, setFormError], - ); - const shouldDisplayFieldError: boolean = useMemo(() => { if (!isEditingSplitBill) { return false; @@ -314,34 +303,6 @@ function MoneyRequestConfirmationList({ return false; }; - const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0); - - useEffect(() => { - // We want this effect to run only when the transaction is moving from Self DM to a workspace chat - if (!isDistanceRequest || !isMovingTransactionFromTrackExpense || !isPolicyExpenseChat) { - return; - } - - const errorKey = 'iou.error.invalidRate'; - const policyRates = DistanceRequestUtils.getMileageRates(policy); - - // If the selected rate belongs to the policy, clear the error - if (Object.keys(policyRates).includes(customUnitRateID)) { - clearFormErrors([errorKey]); - return; - } - - // If there is a distance rate in the policy that matches the rate and unit of the currently selected mileage rate, select it automatically - const matchingRate = Object.values(policyRates).find((policyRate) => policyRate.rate === mileageRate.rate && policyRate.unit === mileageRate.unit); - if (matchingRate?.customUnitRateID) { - IOU.setCustomUnitRateID(transactionID, matchingRate.customUnitRateID); - return; - } - - // If none of the above conditions are met, display the rate error - setFormError(errorKey); - }, [isDistanceRequest, isPolicyExpenseChat, transactionID, mileageRate, customUnitRateID, policy, isMovingTransactionFromTrackExpense, setFormError, clearFormErrors]); - useEffect(() => { if (shouldDisplayFieldError && didConfirmSplit) { setFormError('iou.error.genericSmartscanFailureMessage'); @@ -352,11 +313,12 @@ function MoneyRequestConfirmationList({ return; } // reset the form error whenever the screen gains or loses focus - clearFormErrors(['iou.error.genericSmartscanFailureMessage', 'iou.receiptScanningFailed']); + setFormError(''); // 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(() => { @@ -507,8 +469,8 @@ function MoneyRequestConfirmationList({ return; } - clearFormErrors(['iou.error.invalidSplit', 'iou.error.invalidSplitParticipants', 'iou.error.invalidSplitYourself']); - }, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserPersonalDetails.accountID, iouAmount, iouCurrencyCode, setFormError, translate, clearFormErrors]); + setFormError(''); + }, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserPersonalDetails.accountID, iouAmount, iouCurrencyCode, setFormError, translate]); useEffect(() => { if (!isTypeSplit || !transaction?.splitShares) { @@ -675,9 +637,7 @@ function MoneyRequestConfirmationList({ }, [isTypeSplit, translate, payeePersonalDetails, getSplitSectionHeader, splitParticipants, selectedParticipants]); useEffect(() => { - if (!isDistanceRequest || (isMovingTransactionFromTrackExpense && !isPolicyExpenseChat)) { - // We don't want to recalculate the distance merchant when moving a transaction from Track Expense to a 1:1 chat, because the distance rate will be the same default P2P rate. - // When moving to a policy chat (e.g. sharing with an accountant), we should recalculate the distance merchant with the policy's rate. + if (!isDistanceRequest || isMovingTransactionFromTrackExpense) { return; } @@ -700,7 +660,6 @@ function MoneyRequestConfirmationList({ translate, toLocaleDigit, isDistanceRequest, - isPolicyExpenseChat, transaction, transactionID, action, diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index c391564a6d3d..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); @@ -259,7 +257,6 @@ function MoneyRequestConfirmationListFooter({ const taxRateTitle = TransactionUtils.getTaxName(policy, transaction); // Determine if the merchant error should be displayed const shouldDisplayMerchantError = isMerchantRequired && (shouldDisplayFieldError || formError === 'iou.error.invalidMerchant') && isMerchantEmpty; - const shouldDisplayDistanceRateError = formError === 'iou.error.invalidRate'; // The empty receipt component should only show for IOU Requests of a paid policy ("Team" or "Corporate") const shouldShowReceiptEmptyState = iouType === CONST.IOU.TYPE.SUBMIT && PolicyUtils.isPaidGroupPolicy(policy); const { @@ -370,7 +367,6 @@ function MoneyRequestConfirmationListFooter({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()))} - brickRoadIndicator={shouldDisplayDistanceRateError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} disabled={didConfirm} interactive={!!rate && !isReadOnly && isPolicyExpenseChat} /> @@ -536,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/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 1c2a552db476..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, }; @@ -729,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/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index c90dcb2330e1..d9884b1c1efe 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -160,8 +160,8 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue); onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); - if (item.text && item.autocompleteID) { - const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID}; + if (item.mapKey && item.autocompleteID) { + const substitutions = {...autocompleteSubstitutions, [item.mapKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } @@ -179,11 +179,11 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const updateAutocompleteSubstitutions = useCallback( (item: SearchQueryItem) => { - if (!item.autocompleteID || !item.text) { + if (!item.autocompleteID || !item.mapKey) { return; } - const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID}; + const substitutions = {...autocompleteSubstitutions, [item.mapKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); }, [autocompleteSubstitutions], diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 78230380735a..4b800b637712 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -48,20 +48,20 @@ function getContextualSearchAutocompleteKey(item: SearchQueryItem) { } function getContextualSearchQuery(item: SearchQueryItem) { - const baseQuery = `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${item.roomType}`; + const baseQuery = `${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TYPE}:${item.roomType}`; let additionalQuery = ''; switch (item.roomType) { case CONST.SEARCH.DATA_TYPES.EXPENSE: case CONST.SEARCH.DATA_TYPES.INVOICE: - additionalQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${item.policyID}`; + additionalQuery += ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.POLICY_ID}:${item.policyID}`; if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) { - additionalQuery += ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; + additionalQuery += ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TO}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; } break; case CONST.SEARCH.DATA_TYPES.CHAT: default: - additionalQuery = ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; + additionalQuery = ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; break; } return baseQuery + additionalQuery; @@ -223,8 +223,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue); onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); - if (item.text && item.autocompleteID) { - const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID}; + if (item.mapKey && item.autocompleteID) { + const substitutions = {...autocompleteSubstitutions, [item.mapKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); } @@ -248,11 +248,11 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const updateAutocompleteSubstitutions = useCallback( (item: SearchQueryItem) => { - if (!item.autocompleteID || !item.text) { + if (!item.autocompleteID || !item.mapKey) { return; } - const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID}; + const substitutions = {...autocompleteSubstitutions, [item.mapKey]: item.autocompleteID}; setAutocompleteSubstitutions(substitutions); }, [autocompleteSubstitutions], diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 3fe7cc9e2de4..a53e49374d81 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -5,7 +5,7 @@ import {useOnyx} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; -import type {SearchFilterKey} from '@components/Search/types'; +import type {SearchFilterKey, UserFriendlyKey} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem'; import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem'; @@ -40,9 +40,10 @@ import type PersonalDetails from '@src/types/onyx/PersonalDetails'; import {getSubstitutionMapKey} from './getQueryWithSubstitutions'; type AutocompleteItemData = { - filterKey: SearchFilterKey; + filterKey: UserFriendlyKey; text: string; autocompleteID?: string; + mapKey?: SearchFilterKey; }; type SearchRouterListProps = { @@ -82,6 +83,10 @@ function isSearchQueryListItem(listItem: UserListItemProps | SearchQ return isSearchQueryItem(listItem.item); } +function getAutocompleteDisplayText(filterKey: UserFriendlyKey, value: string) { + return `${filterKey}:${value}`; +} + function getItemHeight(item: OptionData | SearchQueryItem) { if (isSearchQueryItem(item)) { return 44; @@ -223,7 +228,7 @@ function SearchRouterList( .slice(0, 10); return filteredTags.map((tagName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TAG, text: tagName, })); } @@ -235,7 +240,7 @@ function SearchRouterList( .slice(0, 10); return filteredCategories.map((categoryName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.CATEGORY, text: categoryName, })); } @@ -247,7 +252,7 @@ function SearchRouterList( .slice(0, 10); return filteredCurrencies.map((currencyName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.CURRENCY, text: currencyName, })); } @@ -258,9 +263,10 @@ function SearchRouterList( .slice(0, 10); return filteredTaxRates.map((tax) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TAX_RATE, text: tax.taxRateName, autocompleteID: tax.taxRateIds.join(','), + mapKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, })); } case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { @@ -269,9 +275,10 @@ function SearchRouterList( .slice(0, 10); return filteredParticipants.map((participant) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.FROM, text: participant.name, autocompleteID: participant.accountID, + mapKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, })); } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { @@ -280,9 +287,10 @@ function SearchRouterList( .slice(0, 10); return filteredParticipants.map((participant) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TO, text: participant.name, autocompleteID: participant.accountID, + mapKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, })); } case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { @@ -291,9 +299,10 @@ function SearchRouterList( .slice(0, 10); return filteredChats.map((chat) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.IN, text: chat.text ?? '', autocompleteID: chat.reportID, + mapKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, })); } case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { @@ -301,7 +310,7 @@ function SearchRouterList( .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase())) .sort(); - return filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type})); + return filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TYPE, text: type})); } case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { const filteredStatuses = statusAutocompleteList @@ -309,7 +318,7 @@ function SearchRouterList( .sort() .slice(0, 10); - return filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status})); + return filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.STATUS, text: status})); } case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { const filteredExpenseTypes = expenseTypes @@ -317,7 +326,7 @@ function SearchRouterList( .sort(); return filteredExpenseTypes.map((expenseType) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.EXPENSE_TYPE, text: expenseType, })); } @@ -331,9 +340,10 @@ function SearchRouterList( .slice(0, 10); return filteredCards.map((card) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + filterKey: CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.CARD_ID, text: CardUtils.getCardDescription(card.cardID), autocompleteID: card.cardID.toString(), + mapKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, })); } default: { @@ -411,9 +421,10 @@ function SearchRouterList( sections.push({title: translate('search.recentChats'), data: styledRecentReports}); if (autocompleteSuggestions.length > 0) { - const autocompleteData = autocompleteSuggestions.map(({filterKey, text, autocompleteID}) => { + const autocompleteData = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => { return { - text: getSubstitutionMapKey(filterKey, text), + text: getAutocompleteDisplayText(filterKey, text), + mapKey: mapKey ? getSubstitutionMapKey(mapKey, text) : undefined, singleIcon: Expensicons.MagnifyingGlass, searchQuery: text, autocompleteID, 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/Search/types.ts b/src/components/Search/types.ts index b9579ed914ad..0a402358d73e 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -97,6 +97,8 @@ type SearchFilterKey = | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID; +type UserFriendlyKey = ValueOf; + type QueryFilters = Array<{ key: SearchFilterKey; filters: QueryFilter[]; @@ -148,6 +150,7 @@ export type { QueryFilter, QueryFilters, SearchFilterKey, + UserFriendlyKey, ExpenseSearchStatus, InvoiceSearchStatus, TripSearchStatus, 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 c7e7f587769c..2608e4e2de8c 100644 --- a/src/components/SelectionList/Search/ActionCell.tsx +++ b/src/components/SelectionList/Search/ActionCell.tsx @@ -87,16 +87,19 @@ function ActionCell({ ); } - if (action === CONST.SEARCH.ACTION_TYPES.VIEW || shouldUseViewAction) { + if (action === CONST.SEARCH.ACTION_TYPES.VIEW || action === CONST.SEARCH.ACTION_TYPES.REVIEW || shouldUseViewAction) { return isLargeScreenWidth ? ( + )} + {/** + These are the actionable buttons that appear at the bottom of a Concierge message + for example: Invite a user mentioned but not a member of the room + https://github.com/Expensify/App/issues/32741 + */} + {actionableItemButtons.length > 0 && ( + + )} + + ) : ( + + )} + + + + ); + } + const numberOfThreadReplies = action.childVisibleActionCount ?? 0; + + const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(action, isThreadReportParentAction); + const oldestFourAccountIDs = + action.childOldestFourAccountIDs + ?.split(',') + .map((accountID) => Number(accountID)) + .filter((accountID): accountID is number => typeof accountID === 'number') ?? []; + const draftMessageRightAlign = draftMessage !== undefined ? styles.chatItemReactionsDraftRight : {}; + + return ( + <> + {children} + {Permissions.canUseLinkPreviews() && !isHidden && (action.linkMetadata?.length ?? 0) > 0 && ( + + !isEmptyObject(item))} /> + + )} + {!ReportActionsUtils.isMessageDeleted(action) && ( + + { + if (Session.isAnonymousUser()) { + hideContextMenu(false); + + InteractionManager.runAfterInteractions(() => { + Session.signOutAndRedirectToSignIn(); + }); + } else { + toggleReaction(emoji, ignoreSkinToneOnCompare); + } + }} + setIsEmojiPickerActive={setIsEmojiPickerActive} + /> + + )} + + {shouldDisplayThreadReplies && ( + + + + )} + + ); + }; + + /** + * Get ReportActionItem with a proper wrapper + * @param hovered whether the ReportActionItem is hovered + * @param isWhisper whether the ReportActionItem is a whisper + * @param hasErrors whether the report action has any errors + * @returns report action item + */ + + const renderReportActionItem = (hovered: boolean, isWhisper: boolean, hasErrors: boolean): React.JSX.Element => { + const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper, hasErrors); + + if (draftMessage !== undefined) { + return {content}; + } + + if (!displayAsGroup) { + return ( + item === moderationDecision) && + !ReportActionsUtils.isPendingRemove(action) + } + > + {content} + + ); + } + + return {content}; + }; + + if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + const transactionID = ReportActionsUtils.isMoneyRequestAction(parentReportActionForTransactionThread) + ? ReportActionsUtils.getOriginalMessage(parentReportActionForTransactionThread)?.IOUTransactionID + : '-1'; + + return ( + + ); + } + if (ReportActionsUtils.isChronosOOOListAction(action)) { + return ( + + ); + } + + // For the `pay` IOU action on non-pay expense flow, we don't want to render anything if `isWaitingOnBankAccount` is true + // Otherwise, we will see two system messages informing the payee needs to add a bank account or wallet + if ( + ReportActionsUtils.isMoneyRequestAction(action) && + !!report?.isWaitingOnBankAccount && + ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && + !isSendingMoney + ) { + return null; + } + + // If action is actionable whisper and resolved by user, then we don't want to render anything + if (isActionableWhisper && (hasResolutionInOriginalMessage ? originalMessage.resolution : null)) { + return null; + } + + // We currently send whispers to all report participants and hide them in the UI for users that shouldn't see them. + // This is a temporary solution needed for comment-linking. + // The long term solution will leverage end-to-end encryption and only targeted users will be able to decrypt. + if (ReportActionsUtils.isWhisperActionTargetedToOthers(action)) { + return null; + } + + const hasErrors = !isEmptyObject(action.errors); + const whisperedTo = ReportActionsUtils.getWhisperedTo(action); + const isMultipleParticipant = whisperedTo.length > 1; + + const iouReportID = + ReportActionsUtils.isMoneyRequestAction(action) && ReportActionsUtils.getOriginalMessage(action)?.IOUReportID + ? (ReportActionsUtils.getOriginalMessage(action)?.IOUReportID ?? '').toString() + : '-1'; + const transactionsWithReceipts = getTransactionsWithReceipts(iouReportID); + const isWhisper = whisperedTo.length > 0 && transactionsWithReceipts.length === 0; + const whisperedToPersonalDetails = isWhisper + ? (Object.values(personalDetails ?? {}).filter((details) => whisperedTo.includes(details?.accountID ?? -1)) as OnyxTypes.PersonalDetails[]) + : []; + const isWhisperOnlyVisibleByUser = isWhisper && isCurrentUserTheOnlyParticipant(whisperedTo); + const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + + return ( + shouldUseNarrowLayout && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onSecondaryInteraction={showPopover} + preventDefaultContextMenu={draftMessage === undefined && !hasErrors} + withoutFocusOnSecondaryInteraction + accessibilityLabel={translate('accessibilityHints.chatMessage')} + accessible + > + + {(hovered) => ( + + {shouldDisplayNewMarker && (!shouldUseThreadDividerLine || !isFirstVisibleReportAction) && } + {shouldDisplayContextMenu && ( + + )} + + { + const transactionID = ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : undefined; + if (transactionID) { + clearError(transactionID); + } + clearAllRelatedReportActionErrors(reportID, action); + }} + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + pendingAction={ + draftMessage !== undefined ? undefined : action.pendingAction ?? (action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : undefined) + } + shouldHideOnDelete={!isThreadReportParentAction} + errors={linkedTransactionRouteError ?? ErrorUtils.getLatestErrorMessageField(action as ErrorUtils.OnyxDataWithErrors)} + errorRowStyles={[styles.ml10, styles.mr2]} + needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(action)} + shouldDisableStrikeThrough + > + {isWhisper && ( + + + + + + {translate('reportActionContextMenu.onlyVisible')} +   + + + + )} + {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper, hasErrors)} + + + + )} + + + + + + ); +} +export type {PureReportActionItemProps}; +export default memo(PureReportActionItem, (prevProps, nextProps) => { + const prevParentReportAction = prevProps.parentReportAction; + const nextParentReportAction = nextProps.parentReportAction; + return ( + prevProps.displayAsGroup === nextProps.displayAsGroup && + prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && + prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && + lodashIsEqual(prevProps.action, nextProps.action) && + lodashIsEqual(prevProps.report?.pendingFields, nextProps.report?.pendingFields) && + lodashIsEqual(prevProps.report?.isDeletedParentAction, nextProps.report?.isDeletedParentAction) && + lodashIsEqual(prevProps.report?.errorFields, nextProps.report?.errorFields) && + prevProps.report?.statusNum === nextProps.report?.statusNum && + prevProps.report?.stateNum === nextProps.report?.stateNum && + prevProps.report?.parentReportID === nextProps.report?.parentReportID && + prevProps.report?.parentReportActionID === nextProps.report?.parentReportActionID && + // TaskReport's created actions render the TaskView, which updates depending on certain fields in the TaskReport + ReportUtils.isTaskReport(prevProps.report) === ReportUtils.isTaskReport(nextProps.report) && + prevProps.action.actionName === nextProps.action.actionName && + prevProps.report?.reportName === nextProps.report?.reportName && + prevProps.report?.description === nextProps.report?.description && + ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) && + prevProps.report?.managerID === nextProps.report?.managerID && + prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && + prevProps.report?.total === nextProps.report?.total && + prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && + prevProps.report?.policyAvatar === nextProps.report?.policyAvatar && + prevProps.linkedReportActionID === nextProps.linkedReportActionID && + lodashIsEqual(prevProps.report?.fieldList, nextProps.report?.fieldList) && + lodashIsEqual(prevProps.transactionThreadReport, nextProps.transactionThreadReport) && + lodashIsEqual(prevProps.reportActions, nextProps.reportActions) && + lodashIsEqual(prevParentReportAction, nextParentReportAction) && + prevProps.draftMessage === nextProps.draftMessage && + prevProps.iouReport?.reportID === nextProps.iouReport?.reportID && + lodashIsEqual(prevProps.emojiReactions, nextProps.emojiReactions) && + lodashIsEqual(prevProps.linkedTransactionRouteError, nextProps.linkedTransactionRouteError) && + lodashIsEqual(prevProps.reportNameValuePairs, nextProps.reportNameValuePairs) && + prevProps.isUserValidated === nextProps.isUserValidated && + prevProps.parentReport?.reportID === nextProps.parentReport?.reportID && + lodashIsEqual(prevProps.personalDetails, nextProps.personalDetails) && + lodashIsEqual(prevProps.blockedFromConcierge, nextProps.blockedFromConcierge) && + prevProps.originalReportID === nextProps.originalReportID && + prevProps.isArchivedRoom === nextProps.isArchivedRoom && + prevProps.isChronosReport === nextProps.isChronosReport && + prevProps.isClosedExpenseReportWithNoExpenses === nextProps.isClosedExpenseReportWithNoExpenses && + lodashIsEqual(prevProps.missingPaymentMethod, nextProps.missingPaymentMethod) && + prevProps.reimbursementDeQueuedActionMessage === nextProps.reimbursementDeQueuedActionMessage && + prevProps.modifiedExpenseMessage === nextProps.modifiedExpenseMessage && + prevProps.userBillingFundID === nextProps.userBillingFundID && + prevProps.reportAutomaticallyForwardedMessage === nextProps.reportAutomaticallyForwardedMessage + ); +}); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx index 893d2b3060d9..78d3288d05f0 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.tsx @@ -12,14 +12,12 @@ import type {FileObject} from '@components/AttachmentModal'; import AttachmentModal from '@components/AttachmentModal'; import EmojiPickerButton from '@components/EmojiPicker/EmojiPickerButton'; import ExceededCommentLength from '@components/ExceededCommentLength'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; import ImportedStateIndicator from '@components/ImportedStateIndicator'; import type {Mention} from '@components/MentionSuggestions'; import OfflineIndicator from '@components/OfflineIndicator'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxProvider'; -import Text from '@components/Text'; +import {useProductTrainingContext} from '@components/ProductTrainingContext'; import EducationalTooltip from '@components/Tooltip/EducationalTooltip'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebounce from '@hooks/useDebounce'; @@ -28,7 +26,6 @@ import useHandleExceedMaxTaskTitleLength from '@hooks/useHandleExceedMaxTaskTitl import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; @@ -116,7 +113,6 @@ function ReportActionCompose({ onComposerFocus, onComposerBlur, }: ReportActionComposeProps) { - const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -129,6 +125,11 @@ function ReportActionCompose({ const [blockedFromConcierge] = useOnyx(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE); const [shouldShowComposeInput = true] = useOnyx(ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT); + const {renderProductTrainingTooltip, hideProductTrainingTooltip, shouldShowProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.WORKSAPCE_CHAT_CREATE, + shouldShowEducationalTooltip, + ); + /** * Updates the Highlight state of the composer */ @@ -380,34 +381,6 @@ function ReportActionCompose({ return reportActionComposeHeight - emojiOffsetWithComposeBox - CONST.MENU_POSITION_REPORT_ACTION_COMPOSE_BOTTOM; }, [styles]); - const renderWorkspaceChatTooltip = useCallback( - () => ( - - - - {translate('reportActionCompose.tooltip.title')} - {translate('reportActionCompose.tooltip.subtitle')} - - - ), - [ - styles.alignItemsCenter, - styles.flexRow, - styles.justifyContentCenter, - styles.flexWrap, - styles.textAlignCenter, - styles.gap1, - styles.quickActionTooltipTitle, - styles.quickActionTooltipSubtitle, - theme.tooltipHighlightText, - translate, - ], - ); - const validateMaxLength = useCallback( (value: string) => { const taskCommentMatch = value?.match(CONST.REGEX.TASK_TITLE_WITH_OPTONAL_SHORT_MENTION); @@ -448,10 +421,10 @@ function ReportActionCompose({ contentContainerStyle={isComposerFullSize ? styles.flex1 : {}} > ; - - /** The transaction thread report associated with the report for this action, if any */ - transactionThreadReport?: OnyxEntry; - - /** Array of report actions for the report for this action */ - // eslint-disable-next-line react/no-unused-prop-types - reportActions: OnyxTypes.ReportAction[]; - - /** Report action belonging to the report's parent */ - parentReportAction: OnyxEntry; - - /** The transaction thread report's parentReportAction */ - /** It's used by withOnyx HOC */ - // eslint-disable-next-line react/no-unused-prop-types - parentReportActionForTransactionThread?: OnyxEntry; - - /** All the data of the action item */ - action: OnyxTypes.ReportAction; - - /** Should the comment have the appearance of being grouped with the previous comment? */ - displayAsGroup: boolean; - - /** Is this the most recent IOU Action? */ - isMostRecentIOUReportAction: boolean; - - /** Should we display the new marker on top of the comment? */ - shouldDisplayNewMarker: boolean; +import type {ReportAction} from '@src/types/onyx'; +import type {PureReportActionItemProps} from './PureReportActionItem'; +import PureReportActionItem from './PureReportActionItem'; - /** Determines if the avatar is displayed as a subscript (positioned lower than normal) */ - shouldShowSubscriptAvatar?: boolean; - - /** Position index of the report action in the overall report FlatList view */ - index: number; - - /** Flag to show, hide the thread divider line */ - shouldHideThreadDividerLine?: boolean; - - linkedReportActionID?: string; - - /** Callback to be called on onPress */ - onPress?: () => void; - - /** If this is the first visible report action */ - isFirstVisibleReportAction: boolean; - - /** - * Is the action a thread's parent reportAction viewed from within the thread report? - * It will be false if we're viewing the same parent report action from the report it belongs to rather than the thread. - */ - isThreadReportParentAction?: boolean; - - /** IF the thread divider line will be used */ - shouldUseThreadDividerLine?: boolean; - - /** Whether context menu should be displayed */ - shouldDisplayContextMenu?: boolean; -}; - -function ReportActionItem({ - action, - report, - transactionThreadReport, - linkedReportActionID, - displayAsGroup, - index, - isMostRecentIOUReportAction, - parentReportAction, - shouldDisplayNewMarker, - shouldHideThreadDividerLine = false, - shouldShowSubscriptAvatar = false, - onPress = undefined, - isFirstVisibleReportAction = false, - isThreadReportParentAction = false, - shouldUseThreadDividerLine = false, - shouldDisplayContextMenu = true, - parentReportActionForTransactionThread, -}: ReportActionItemProps) { - const {translate} = useLocalize(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const blockedFromConcierge = useBlockedFromConcierge(); +function ReportActionItem({action, report, ...props}: PureReportActionItemProps) { const reportID = report?.reportID ?? ''; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const originalReportID = useMemo(() => ReportUtils.getOriginalReportID(reportID, action) || '-1', [reportID, action]); @@ -181,909 +31,58 @@ function ReportActionItem({ `${ONYXKEYS.COLLECTION.TRANSACTION}${ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID ?? -1 : -1}`, {selector: (transaction) => transaction?.errorFields?.route ?? null}, ); - const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); - const theme = useTheme(); - const styles = useThemeStyles(); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- This is needed to prevent the app from crashing when the app is using imported state. const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID || '-1'}`); - const StyleUtils = useStyleUtils(); - const personalDetails = usePersonalDetails(); - const [isContextMenuActive, setIsContextMenuActive] = useState(() => ReportActionContextMenu.isActiveReportAction(action.reportActionID)); - const [isEmojiPickerActive, setIsEmojiPickerActive] = useState(); - const [isPaymentMethodPopoverActive, setIsPaymentMethodPopoverActive] = useState(); - const [isHidden, setIsHidden] = useState(false); - const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED); - const reactionListRef = useContext(ReactionListContext); - const {updateHiddenAttachments} = useContext(ReportAttachmentsContext); - const textInputRef = useRef(null); - const popoverAnchorRef = useRef>(null); - const downloadedPreviews = useRef([]); - const prevDraftMessage = usePrevious(draftMessage); const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); // The app would crash due to subscribing to the entire report collection if parentReportID is an empty string. So we should have a fallback ID here. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID || -1}`); - const isReportActionLinked = linkedReportActionID && action.reportActionID && linkedReportActionID === action.reportActionID; - const reportScrollManager = useReportScrollManager(); - const isActionableWhisper = - ReportActionsUtils.isActionableMentionWhisper(action) || ReportActionsUtils.isActionableTrackExpense(action) || ReportActionsUtils.isActionableReportMentionWhisper(action); - const originalMessage = ReportActionsUtils.getOriginalMessage(action); - - const highlightedBackgroundColorIfNeeded = useMemo( - () => (isReportActionLinked ? StyleUtils.getBackgroundColorStyle(theme.messageHighlightBG) : {}), - [StyleUtils, isReportActionLinked, theme.messageHighlightBG], - ); - - const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(action); - const isOriginalMessageAnObject = originalMessage && typeof originalMessage === 'object'; - const hasResolutionInOriginalMessage = isOriginalMessageAnObject && 'resolution' in originalMessage; - const prevActionResolution = usePrevious(isActionableWhisper && hasResolutionInOriginalMessage ? originalMessage?.resolution : null); - - // IOUDetails only exists when we are sending money - const isSendingMoney = - ReportActionsUtils.isMoneyRequestAction(action) && - ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && - ReportActionsUtils.getOriginalMessage(action)?.IOUDetails; - - const updateHiddenState = useCallback( - (isHiddenValue: boolean) => { - setIsHidden(isHiddenValue); - const message = Array.isArray(action.message) ? action.message?.at(-1) : action.message; - const isAttachment = ReportUtils.isReportMessageAttachment(message); - if (!isAttachment) { - return; - } - updateHiddenAttachments(action.reportActionID, isHiddenValue); - }, - [action.reportActionID, action.message, updateHiddenAttachments], - ); - - useEffect( - () => () => { - // ReportActionContextMenu, EmojiPicker and PopoverReactionList are global components, - // we should also hide them when the current component is destroyed - if (ReportActionContextMenu.isActiveReportAction(action.reportActionID)) { - ReportActionContextMenu.hideContextMenu(); - ReportActionContextMenu.hideDeleteModal(); - } - if (EmojiPickerAction.isActive(action.reportActionID)) { - EmojiPickerAction.hideEmojiPicker(true); - } - if (reactionListRef?.current?.isActiveReportAction(action.reportActionID)) { - reactionListRef?.current?.hideReactionList(); - } - }, - [action.reportActionID, reactionListRef], - ); - - useEffect(() => { - // We need to hide EmojiPicker when this is a deleted parent action - if (!isDeletedParentAction || !EmojiPickerAction.isActive(action.reportActionID)) { - return; - } - - EmojiPickerAction.hideEmojiPicker(true); - }, [isDeletedParentAction, action.reportActionID]); - - useEffect(() => { - if (prevDraftMessage !== undefined || draftMessage === undefined) { - return; - } - - focusComposerWithDelay(textInputRef.current)(true); - }, [prevDraftMessage, draftMessage]); - - useEffect(() => { - if (!Permissions.canUseLinkPreviews()) { - return; - } - - const urls = ReportActionsUtils.extractLinksFromMessageHtml(action); - if (lodashIsEqual(downloadedPreviews.current, urls) || action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return; - } - - downloadedPreviews.current = urls; - Report.expandURLPreview(reportID, action.reportActionID); - }, [action, reportID]); - - useEffect(() => { - if (draftMessage === undefined || !ReportActionsUtils.isDeletedAction(action)) { - return; - } - Report.deleteReportActionDraft(reportID, action); - }, [draftMessage, action, reportID]); - - // Hide the message if it is being moderated for a higher offense, or is hidden by a moderator - // Removed messages should not be shown anyway and should not need this flow - const latestDecision = ReportActionsUtils.getReportActionMessage(action)?.moderationDecision?.decision ?? ''; - useEffect(() => { - if (action.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT) { - return; - } - - // Hide reveal message button and show the message if latestDecision is changed to empty - if (!latestDecision) { - setModerationDecision(CONST.MODERATION.MODERATOR_DECISION_APPROVED); - setIsHidden(false); - return; - } - - setModerationDecision(latestDecision); - if ( - ![CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING].some((item) => item === latestDecision) && - !ReportActionsUtils.isPendingRemove(action) - ) { - setIsHidden(true); - return; - } - setIsHidden(false); - }, [latestDecision, action]); - - const toggleContextMenuFromActiveReportAction = useCallback(() => { - setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(action.reportActionID)); - }, [action.reportActionID]); - - const isArchivedRoom = ReportUtils.isArchivedRoomWithID(originalReportID); - const disabledActions = useMemo(() => (!ReportUtils.canWriteInReport(report) ? RestrictedReadOnlyContextMenuActions : []), [report]); - const isChronosReport = ReportUtils.chatIncludesChronosWithID(originalReportID); - /** - * Show the ReportActionContextMenu modal popover. - * - * @param [event] - A press event. - */ - const showPopover = useCallback( - (event: GestureResponderEvent | MouseEvent) => { - // Block menu on the message being Edited or if the report action item has errors - if (draftMessage !== undefined || !isEmptyObject(action.errors) || !shouldDisplayContextMenu) { - return; - } - - setIsContextMenuActive(true); - const selection = SelectionScraper.getCurrentSelection(); - ReportActionContextMenu.showContextMenu( - CONST.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - popoverAnchorRef.current, - reportID, - action.reportActionID, - originalReportID, - draftMessage ?? '', - () => setIsContextMenuActive(true), - toggleContextMenuFromActiveReportAction, - isArchivedRoom, - isChronosReport, - false, - false, - disabledActions, - false, - setIsEmojiPickerActive as () => void, - undefined, - isThreadReportParentAction, - ); - }, - [ - draftMessage, - action, - reportID, - toggleContextMenuFromActiveReportAction, - originalReportID, - shouldDisplayContextMenu, - disabledActions, - isArchivedRoom, - isChronosReport, - isThreadReportParentAction, - ], - ); - - // Handles manual scrolling to the bottom of the chat when the last message is an actionable whisper and it's resolved. - // This fixes an issue where InvertedFlatList fails to auto scroll down and results in an empty space at the bottom of the chat in IOS. - useEffect(() => { - if (index !== 0 || !isActionableWhisper) { - return; - } - - if (prevActionResolution !== (hasResolutionInOriginalMessage ? originalMessage.resolution : null)) { - reportScrollManager.scrollToIndex(index); - } - }, [index, originalMessage, prevActionResolution, reportScrollManager, isActionableWhisper, hasResolutionInOriginalMessage]); - - const toggleReaction = useCallback( - (emoji: Emoji, ignoreSkinToneOnCompare?: boolean) => { - Report.toggleEmojiReaction(reportID, action, emoji, emojiReactions, undefined, ignoreSkinToneOnCompare); - }, - [reportID, action, emojiReactions], - ); - - const contextValue = useMemo( - () => ({ - anchor: popoverAnchorRef.current, - report: {...report, reportID: report?.reportID ?? ''}, - reportNameValuePairs, - action, - transactionThreadReport, - checkIfContextMenuActive: toggleContextMenuFromActiveReportAction, - isDisabled: false, - }), - [report, action, toggleContextMenuFromActiveReportAction, transactionThreadReport, reportNameValuePairs], - ); - - const attachmentContextValue = useMemo(() => ({reportID, type: CONST.ATTACHMENT_TYPE.REPORT}), [reportID]); - - const mentionReportContextValue = useMemo(() => ({currentReportID: report?.reportID ?? '-1'}), [report?.reportID]); - - const actionableItemButtons: ActionableItem[] = useMemo(() => { - if (ReportActionsUtils.isActionableAddPaymentCard(action) && userBillingFundID === undefined && shouldRenderAddPaymentCard()) { - return [ - { - text: 'subscription.cardSection.addCardButton', - key: `${action.reportActionID}-actionableAddPaymentCard-submit`, - onPress: () => { - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_ADD_PAYMENT_CARD); - }, - isMediumSized: true, - isPrimary: true, - }, - ]; - } - - if (!isActionableWhisper && (!ReportActionsUtils.isActionableJoinRequest(action) || ReportActionsUtils.getOriginalMessage(action)?.choice !== ('' as JoinWorkspaceResolution))) { - return []; - } - - if (ReportActionsUtils.isActionableTrackExpense(action)) { - const transactionID = ReportActionsUtils.getOriginalMessage(action)?.transactionID; - return [ - { - text: 'actionableMentionTrackExpense.submit', - key: `${action.reportActionID}-actionableMentionTrackExpense-submit`, - onPress: () => { - ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SUBMIT, action.reportActionID); - }, - isMediumSized: true, - }, - { - text: 'actionableMentionTrackExpense.categorize', - key: `${action.reportActionID}-actionableMentionTrackExpense-categorize`, - onPress: () => { - ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.CATEGORIZE, action.reportActionID); - }, - isMediumSized: true, - }, - { - text: 'actionableMentionTrackExpense.share', - key: `${action.reportActionID}-actionableMentionTrackExpense-share`, - onPress: () => { - ReportUtils.createDraftTransactionAndNavigateToParticipantSelector(transactionID ?? '0', reportID, CONST.IOU.ACTION.SHARE, action.reportActionID); - }, - isMediumSized: true, - }, - { - text: 'actionableMentionTrackExpense.nothing', - key: `${action.reportActionID}-actionableMentionTrackExpense-nothing`, - onPress: () => { - Report.dismissTrackExpenseActionableWhisper(reportID, action); - }, - isMediumSized: true, - }, - ]; - } - - if (ReportActionsUtils.isActionableJoinRequest(action)) { - return [ - { - text: 'actionableMentionJoinWorkspaceOptions.accept', - key: `${action.reportActionID}-actionableMentionJoinWorkspace-${CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.ACCEPT}`, - onPress: () => Member.acceptJoinRequest(reportID, action), - isPrimary: true, - }, - { - text: 'actionableMentionJoinWorkspaceOptions.decline', - key: `${action.reportActionID}-actionableMentionJoinWorkspace-${CONST.REPORT.ACTIONABLE_MENTION_JOIN_WORKSPACE_RESOLUTION.DECLINE}`, - onPress: () => Member.declineJoinRequest(reportID, action), - }, - ]; - } - - if (ReportActionsUtils.isActionableReportMentionWhisper(action)) { - return [ - { - text: 'common.yes', - key: `${action.reportActionID}-actionableReportMentionWhisper-${CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.CREATE}`, - onPress: () => Report.resolveActionableReportMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.CREATE), - isPrimary: true, - }, - { - text: 'common.no', - key: `${action.reportActionID}-actionableReportMentionWhisper-${CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.NOTHING}`, - onPress: () => Report.resolveActionableReportMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_REPORT_MENTION_WHISPER_RESOLUTION.NOTHING), - }, - ]; - } - - return [ - { - text: 'actionableMentionWhisperOptions.invite', - key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE}`, - onPress: () => Report.resolveActionableMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.INVITE), - isPrimary: true, - }, - { - text: 'actionableMentionWhisperOptions.nothing', - key: `${action.reportActionID}-actionableMentionWhisper-${CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING}`, - onPress: () => Report.resolveActionableMentionWhisper(reportID, action, CONST.REPORT.ACTIONABLE_MENTION_WHISPER_RESOLUTION.NOTHING), - }, - ]; - }, [action, isActionableWhisper, reportID, userBillingFundID]); - - /** - * Get the content of ReportActionItem - * @param hovered whether the ReportActionItem is hovered - * @param isWhisper whether the report action is a whisper - * @param hasErrors whether the report action has any errors - * @returns child component(s) - */ - const renderItemContent = (hovered = false, isWhisper = false, hasErrors = false): React.JSX.Element => { - let children; - - // Show the MoneyRequestPreview for when expense is present - if ( - ReportActionsUtils.isMoneyRequestAction(action) && - ReportActionsUtils.getOriginalMessage(action) && - // For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message - (ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || - ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || - ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) - ) { - // There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID - const iouReportID = ReportActionsUtils.getOriginalMessage(action)?.IOUReportID ? ReportActionsUtils.getOriginalMessage(action)?.IOUReportID?.toString() ?? '-1' : '-1'; - children = ( - - ); - } else if (ReportActionsUtils.isTripPreview(action)) { - children = ( - - ); - } else if (action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - children = ReportUtils.isClosedExpenseReportWithNoExpenses(iouReport) ? ( - ${translate('parentReportAction.deletedReport')}`} /> - ) : ( - setIsPaymentMethodPopoverActive(true)} - onPaymentOptionsHide={() => setIsPaymentMethodPopoverActive(false)} - isWhisper={isWhisper} - /> - ); - } else if (ReportActionsUtils.isTaskAction(action)) { - children = ; - } else if (ReportActionsUtils.isCreatedTaskReportAction(action)) { - children = ( - - - - ); - } else if (ReportActionsUtils.isReimbursementQueuedAction(action)) { - const linkedReport = ReportUtils.isChatThread(report) ? parentReport : report; - const submitterDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails?.[linkedReport?.ownerAccountID ?? -1]); - const paymentType = ReportActionsUtils.getOriginalMessage(action)?.paymentType ?? ''; - - const missingPaymentMethod = ReportUtils.getIndicatedMissingPaymentMethod(userWallet, linkedReport?.reportID ?? '-1', action); - children = ( - - <> - {missingPaymentMethod === 'bankAccount' && ( - - )} - {/** - These are the actionable buttons that appear at the bottom of a Concierge message - for example: Invite a user mentioned but not a member of the room - https://github.com/Expensify/App/issues/32741 - */} - {actionableItemButtons.length > 0 && ( - - )} - - ) : ( - - )} - - - - ); - } - const numberOfThreadReplies = action.childVisibleActionCount ?? 0; - - const shouldDisplayThreadReplies = ReportUtils.shouldDisplayThreadReplies(action, isThreadReportParentAction); - const oldestFourAccountIDs = - action.childOldestFourAccountIDs - ?.split(',') - .map((accountID) => Number(accountID)) - .filter((accountID): accountID is number => typeof accountID === 'number') ?? []; - const draftMessageRightAlign = draftMessage !== undefined ? styles.chatItemReactionsDraftRight : {}; - - return ( - <> - {children} - {Permissions.canUseLinkPreviews() && !isHidden && (action.linkMetadata?.length ?? 0) > 0 && ( - - !isEmptyObject(item))} /> - - )} - {!ReportActionsUtils.isMessageDeleted(action) && ( - - { - if (Session.isAnonymousUser()) { - hideContextMenu(false); - - InteractionManager.runAfterInteractions(() => { - Session.signOutAndRedirectToSignIn(); - }); - } else { - toggleReaction(emoji, ignoreSkinToneOnCompare); - } - }} - setIsEmojiPickerActive={setIsEmojiPickerActive} - /> - - )} - - {shouldDisplayThreadReplies && ( - - - - )} - - ); - }; - - /** - * Get ReportActionItem with a proper wrapper - * @param hovered whether the ReportActionItem is hovered - * @param isWhisper whether the ReportActionItem is a whisper - * @param hasErrors whether the report action has any errors - * @returns report action item - */ - - const renderReportActionItem = (hovered: boolean, isWhisper: boolean, hasErrors: boolean): React.JSX.Element => { - const content = renderItemContent(hovered || isContextMenuActive || isEmojiPickerActive, isWhisper, hasErrors); - - if (draftMessage !== undefined) { - return {content}; - } - - if (!displayAsGroup) { - return ( - item === moderationDecision) && - !ReportActionsUtils.isPendingRemove(action) - } - > - {content} - - ); - } - - return {content}; - }; - - if (action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - const transactionID = ReportActionsUtils.isMoneyRequestAction(parentReportActionForTransactionThread) - ? ReportActionsUtils.getOriginalMessage(parentReportActionForTransactionThread)?.IOUTransactionID - : '-1'; - - return ( - - ); - } - if (ReportActionsUtils.isChronosOOOListAction(action)) { - return ( - - ); - } - - // For the `pay` IOU action on non-pay expense flow, we don't want to render anything if `isWaitingOnBankAccount` is true - // Otherwise, we will see two system messages informing the payee needs to add a bank account or wallet - if ( - ReportActionsUtils.isMoneyRequestAction(action) && - !!report?.isWaitingOnBankAccount && - ReportActionsUtils.getOriginalMessage(action)?.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && - !isSendingMoney - ) { - return null; - } - - // If action is actionable whisper and resolved by user, then we don't want to render anything - if (isActionableWhisper && (hasResolutionInOriginalMessage ? originalMessage.resolution : null)) { - return null; - } - - // We currently send whispers to all report participants and hide them in the UI for users that shouldn't see them. - // This is a temporary solution needed for comment-linking. - // The long term solution will leverage end-to-end encryption and only targeted users will be able to decrypt. - if (ReportActionsUtils.isWhisperActionTargetedToOthers(action)) { - return null; - } - - const hasErrors = !isEmptyObject(action.errors); - const whisperedTo = ReportActionsUtils.getWhisperedTo(action); - const isMultipleParticipant = whisperedTo.length > 1; - - const iouReportID = - ReportActionsUtils.isMoneyRequestAction(action) && ReportActionsUtils.getOriginalMessage(action)?.IOUReportID - ? (ReportActionsUtils.getOriginalMessage(action)?.IOUReportID ?? '').toString() - : '-1'; - const transactionsWithReceipts = ReportUtils.getTransactionsWithReceipts(iouReportID); - const isWhisper = whisperedTo.length > 0 && transactionsWithReceipts.length === 0; - const whisperedToPersonalDetails = isWhisper - ? (Object.values(personalDetails ?? {}).filter((details) => whisperedTo.includes(details?.accountID ?? -1)) as OnyxTypes.PersonalDetails[]) - : []; - const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedTo); - const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + const personalDetails = usePersonalDetails(); + const blockedFromConcierge = useBlockedFromConcierge(); + const [userBillingFundID] = useOnyx(ONYXKEYS.NVP_BILLING_FUND_ID); + const linkedReport = ReportUtils.isChatThread(report) ? parentReport : report; + const missingPaymentMethod = ReportUtils.getIndicatedMissingPaymentMethod(userWallet, linkedReport?.reportID ?? '-1', action); return ( - shouldUseNarrowLayout && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={() => ControlSelection.unblock()} - onSecondaryInteraction={showPopover} - preventDefaultContextMenu={draftMessage === undefined && !hasErrors} - withoutFocusOnSecondaryInteraction - accessibilityLabel={translate('accessibilityHints.chatMessage')} - accessible - > - - {(hovered) => ( - - {shouldDisplayNewMarker && (!shouldUseThreadDividerLine || !isFirstVisibleReportAction) && } - {shouldDisplayContextMenu && ( - - )} - - { - const transactionID = ReportActionsUtils.isMoneyRequestAction(action) ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : undefined; - if (transactionID) { - Transaction.clearError(transactionID); - } - ReportActions.clearAllRelatedReportActionErrors(reportID, action); - }} - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - pendingAction={ - draftMessage !== undefined ? undefined : action.pendingAction ?? (action.isOptimisticAction ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : undefined) - } - shouldHideOnDelete={!isThreadReportParentAction} - errors={linkedTransactionRouteError ?? ErrorUtils.getLatestErrorMessageField(action as ErrorUtils.OnyxDataWithErrors)} - errorRowStyles={[styles.ml10, styles.mr2]} - needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(action)} - shouldDisableStrikeThrough - > - {isWhisper && ( - - - - - - {translate('reportActionContextMenu.onlyVisible')} -   - - - - )} - {renderReportActionItem(!!hovered || !!isReportActionLinked, isWhisper, hasErrors)} - - - - )} - - - - - + >, + report, + )} + modifiedExpenseMessage={ModifiedExpenseMessage.getForReportAction(reportID, action)} + getTransactionsWithReceipts={ReportUtils.getTransactionsWithReceipts} + clearError={Transaction.clearError} + clearAllRelatedReportActionErrors={ReportActions.clearAllRelatedReportActionErrors} + dismissTrackExpenseActionableWhisper={Report.dismissTrackExpenseActionableWhisper} + userBillingFundID={userBillingFundID} + reportAutomaticallyForwardedMessage={ReportUtils.getReportAutomaticallyForwardedMessage(action as ReportAction, reportID)} + /> ); } -export default memo(ReportActionItem, (prevProps, nextProps) => { - const prevParentReportAction = prevProps.parentReportAction; - const nextParentReportAction = nextProps.parentReportAction; - return ( - prevProps.displayAsGroup === nextProps.displayAsGroup && - prevProps.isMostRecentIOUReportAction === nextProps.isMostRecentIOUReportAction && - prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && - lodashIsEqual(prevProps.action, nextProps.action) && - lodashIsEqual(prevProps.report?.pendingFields, nextProps.report?.pendingFields) && - lodashIsEqual(prevProps.report?.isDeletedParentAction, nextProps.report?.isDeletedParentAction) && - lodashIsEqual(prevProps.report?.errorFields, nextProps.report?.errorFields) && - prevProps.report?.statusNum === nextProps.report?.statusNum && - prevProps.report?.stateNum === nextProps.report?.stateNum && - prevProps.report?.parentReportID === nextProps.report?.parentReportID && - prevProps.report?.parentReportActionID === nextProps.report?.parentReportActionID && - // TaskReport's created actions render the TaskView, which updates depending on certain fields in the TaskReport - ReportUtils.isTaskReport(prevProps.report) === ReportUtils.isTaskReport(nextProps.report) && - prevProps.action.actionName === nextProps.action.actionName && - prevProps.report?.reportName === nextProps.report?.reportName && - prevProps.report?.description === nextProps.report?.description && - ReportUtils.isCompletedTaskReport(prevProps.report) === ReportUtils.isCompletedTaskReport(nextProps.report) && - prevProps.report?.managerID === nextProps.report?.managerID && - prevProps.shouldHideThreadDividerLine === nextProps.shouldHideThreadDividerLine && - prevProps.report?.total === nextProps.report?.total && - prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && - prevProps.report?.policyAvatar === nextProps.report?.policyAvatar && - prevProps.linkedReportActionID === nextProps.linkedReportActionID && - lodashIsEqual(prevProps.report?.fieldList, nextProps.report?.fieldList) && - lodashIsEqual(prevProps.transactionThreadReport, nextProps.transactionThreadReport) && - lodashIsEqual(prevProps.reportActions, nextProps.reportActions) && - lodashIsEqual(prevParentReportAction, nextParentReportAction) - ); -}); +export default ReportActionItem; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 48c05f7dc8e9..3fcdc35ae300 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -23,7 +23,6 @@ import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralP import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import Visibility from '@libs/Visibility'; import type {AuthScreensParamList} from '@navigation/types'; @@ -197,9 +196,7 @@ function ReportActionsList({ ); const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); - const reportLastReadTime = useMemo(() => { - return ReportConnection.getReport(report.reportID)?.lastReadTime ?? report.lastReadTime ?? ''; - }, [report.reportID, report.lastReadTime]); + const reportLastReadTime = report.lastReadTime ?? ''; // In a one-expense report, the report actions from the expense report and transaction thread are combined. // If the transaction thread has a newer action, it will show an unread marker if we compare it with the expense report lastReadTime. diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 9771e357b15f..f46e2a9476b3 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -45,9 +45,6 @@ type ReportFooterProps = { /** The last report action */ lastReportAction?: OnyxEntry; - /** Whether to show educational tooltip in workspace chat for first-time user */ - workspaceTooltip: OnyxEntry; - /** Whether the chat is empty */ isEmptyChat?: boolean; @@ -76,7 +73,6 @@ function ReportFooter({ isEmptyChat = true, isReportReadyForDisplay = true, isComposerFullSize = false, - workspaceTooltip, onComposerBlur, onComposerFocus, }: ReportFooterProps) { @@ -118,7 +114,7 @@ function ReportFooter({ const isSystemChat = ReportUtils.isSystemChat(report); const isAdminsOnlyPostingRoom = ReportUtils.isAdminsOnlyPostingRoom(report); const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); - const shouldShowEducationalTooltip = !!workspaceTooltip?.shouldShow && !isUserPolicyAdmin; + const shouldShowEducationalTooltip = ReportUtils.isPolicyExpenseChat(report) && !!report.isOwnPolicyExpenseChat && !isUserPolicyAdmin; const allPersonalDetails = usePersonalDetails(); @@ -238,7 +234,6 @@ export default memo( prevProps.isEmptyChat === nextProps.isEmptyChat && prevProps.lastReportAction === nextProps.lastReportAction && prevProps.isReportReadyForDisplay === nextProps.isReportReadyForDisplay && - prevProps.workspaceTooltip?.shouldShow === nextProps.workspaceTooltip?.shouldShow && lodashIsEqual(prevProps.reportMetadata, nextProps.reportMetadata) && lodashIsEqual(prevProps.policy?.employeeList, nextProps.policy?.employeeList) && lodashIsEqual(prevProps.policy?.role, nextProps.policy?.role), diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index 2a1d085ddf3c..6d74a910f455 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -11,7 +11,7 @@ import FloatingActionButton from '@components/FloatingActionButton'; import * as Expensicons from '@components/Icon/Expensicons'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import PopoverMenu from '@components/PopoverMenu'; -import Text from '@components/Text'; +import {useProductTrainingContext} from '@components/ProductTrainingContext'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -187,7 +187,6 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl }, [activePolicy, activePolicyID, session?.accountID, allReports]); const [quickActionPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${quickActionReport?.policyID}`); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: (c) => mapOnyxCollectionItems(c, policySelector)}); - const [hasSeenTrackTraining] = useOnyx(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING); const [isCreateMenuActive, setIsCreateMenuActive] = useState(false); const [modalVisible, setModalVisible] = useState(false); @@ -198,7 +197,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl const prevIsFocused = usePrevious(isFocused); const {isOffline} = useNetwork(); - const {canUseSpotnanaTravel, canUseCombinedTrackSubmit} = usePermissions(); + const {canUseSpotnanaTravel} = usePermissions(); const canSendInvoice = useMemo(() => PolicyUtils.canSendInvoice(allPolicies as OnyxCollection, session?.email), [allPolicies, session?.email]); const isValidReport = !(isEmptyObject(quickActionReport) || ReportUtils.isArchivedRoom(quickActionReport, reportNameValuePairs)); const {environment} = useEnvironment(); @@ -207,6 +206,12 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl const [hasSeenTour = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasSeenTourSelector, }); + + const {renderProductTrainingTooltip, hideProductTrainingTooltip, shouldShowProductTrainingTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.QUICK_ACTION_BUTTON, + isCreateMenuActive && (!shouldUseNarrowLayout || isFocused), + ); + /** * There are scenarios where users who have not yet had their group workspace-chats in NewDot (isPolicyExpenseChatEnabled). In those scenarios, things can get confusing if they try to submit/track expenses. To address this, we block them from Creating, Tracking, Submitting expenses from NewDot if they are: * 1. on at least one group policy @@ -233,16 +238,6 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [personalDetails, session?.accountID, quickActionReport, quickActionPolicy, policyChatForActivePolicy]); - const renderQuickActionTooltip = useCallback( - () => ( - - {translate('quickAction.tooltip.title')} - {translate('quickAction.tooltip.subtitle')} - - ), - [styles.quickActionTooltipTitle, styles.quickActionTooltipSubtitle, translate], - ); - const quickActionTitle = useMemo(() => { if (isEmptyObject(quickActionReport)) { return ''; @@ -372,66 +367,11 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl } }; - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - const selfDMReportID = useMemo(() => ReportUtils.findSelfDMReportID(), [isLoading, isCreateMenuActive]); - const expenseMenuItems = useMemo((): PopoverMenuItem[] => { - if (canUseCombinedTrackSubmit) { - return [ - { - icon: getIconForAction(CONST.IOU.TYPE.CREATE), - text: translate('iou.createExpense'), - shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, - onSelected: () => - interceptAnonymousUser(() => { - if (shouldRedirectToExpensifyClassic) { - setModalVisible(true); - return; - } - IOU.startMoneyRequest( - CONST.IOU.TYPE.CREATE, - // When starting to create an expense from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used - // for all of the routes in the creation flow. - ReportUtils.generateReportID(), - ); - }), - }, - ]; - } - return [ - ...(selfDMReportID - ? [ - { - icon: getIconForAction(CONST.IOU.TYPE.TRACK), - text: translate('iou.trackExpense'), - shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, - onSelected: () => { - if (shouldRedirectToExpensifyClassic) { - setModalVisible(true); - return; - } - interceptAnonymousUser(() => { - IOU.startMoneyRequest( - CONST.IOU.TYPE.TRACK, - // When starting to create a track expense from the global FAB, we need to retrieve selfDM reportID. - // If it doesn't exist, we generate a random optimistic reportID and use it for all of the routes in the creation flow. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - ReportUtils.findSelfDMReportID() || ReportUtils.generateReportID(), - ); - }); - if (!hasSeenTrackTraining && !isOffline) { - setTimeout(() => { - Navigation.navigate(ROUTES.TRACK_TRAINING_MODAL); - }, CONST.ANIMATED_TRANSITION); - } - }, - }, - ] - : []), { - icon: getIconForAction(CONST.IOU.TYPE.REQUEST), - text: translate('iou.submitExpense'), + icon: getIconForAction(CONST.IOU.TYPE.CREATE), + text: translate('iou.createExpense'), shouldCallAfterModalHide: shouldRedirectToExpensifyClassic, onSelected: () => interceptAnonymousUser(() => { @@ -439,9 +379,8 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl setModalVisible(true); return; } - IOU.startMoneyRequest( - CONST.IOU.TYPE.SUBMIT, + CONST.IOU.TYPE.CREATE, // When starting to create an expense from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used // for all of the routes in the creation flow. ReportUtils.generateReportID(), @@ -449,7 +388,7 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl }), }, ]; - }, [canUseCombinedTrackSubmit, translate, selfDMReportID, hasSeenTrackTraining, isOffline, shouldRedirectToExpensifyClassic]); + }, [translate, shouldRedirectToExpensifyClassic]); const quickActionMenuItems = useMemo(() => { // Define common properties in baseQuickAction @@ -465,8 +404,10 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl }, tooltipShiftHorizontal: styles.popoverMenuItem.paddingHorizontal, tooltipShiftVertical: styles.popoverMenuItem.paddingVertical / 2, - renderTooltipContent: renderQuickActionTooltip, + renderTooltipContent: renderProductTrainingTooltip, tooltipWrapperStyle: styles.quickActionTooltipWrapper, + onHideTooltip: hideProductTrainingTooltip, + shouldRenderTooltip: shouldShowProductTrainingTooltip, }; if (quickAction?.action) { @@ -482,7 +423,6 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl description: !hideQABSubtitle ? ReportUtils.getReportName(quickActionReport) ?? translate('quickAction.updateDestination') : '', onSelected: () => interceptAnonymousUser(() => navigateToQuickAction()), shouldShowSubscriptRightAvatar: ReportUtils.isPolicyExpenseChat(quickActionReport), - shouldRenderTooltip: quickAction.isFirstQuickAction, }, ]; } @@ -501,7 +441,6 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl }, true); }), shouldShowSubscriptRightAvatar: true, - shouldRenderTooltip: false, }, ]; } @@ -513,13 +452,14 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu}: Fl styles.popoverMenuItem.paddingHorizontal, styles.popoverMenuItem.paddingVertical, styles.quickActionTooltipWrapper, - renderQuickActionTooltip, + renderProductTrainingTooltip, + hideProductTrainingTooltip, quickAction?.action, - quickAction?.isFirstQuickAction, policyChatForActivePolicy, quickActionTitle, hideQABSubtitle, quickActionReport, + shouldShowProductTrainingTooltip, navigateToQuickAction, selectOption, isValidReport, diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 3b4f66c32738..ba406c3ddef6 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -19,7 +19,6 @@ import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; -import variables from '@styles/variables'; import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types'; import CONST from '@src/CONST'; import type {Route} from '@src/ROUTES'; @@ -260,7 +259,6 @@ function MoneyRequestAmountForm( > () => { - if (!isMovingTransactionFromTrackExpense) { - return; - } - - IOU.resetDraftTransactionsCustomUnit(transactionID); - }, - [isMovingTransactionFromTrackExpense, transactionID], - ); - useEffect(() => { const policyExpenseChat = participants?.find((participant) => participant.isPolicyExpenseChat); if (policyExpenseChat?.policyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 9e18a74246e9..461ed6298c84 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -465,6 +465,7 @@ function IOURequestStepDistance({ waypoints, ...(hasRouteChanged ? {routes: transaction?.routes} : {}), policy, + transactionBackup, }); transactionWasSaved.current = true; navigateBack(); diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index df0504a25c01..fb2484ea414f 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -44,7 +44,6 @@ function IOURequestStepParticipants({ const numberOfParticipants = useRef(participants?.length ?? 0); const iouRequestType = TransactionUtils.getRequestType(transaction); const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; - const isMovingTransactionFromTrackExpense = IOUUtils.isMovingTransactionFromTrackExpense(action); const headerTitle = useMemo(() => { if (action === CONST.IOU.ACTION.CATEGORIZE) { return translate('iou.categorize'); @@ -76,27 +75,23 @@ function IOURequestStepParticipants({ // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the expense process. // skip this in case user is moving the transaction as the receipt path will be valid in that case useEffect(() => { - if (isMovingTransactionFromTrackExpense) { + if (IOUUtils.isMovingTransactionFromTrackExpense(action)) { return; } IOU.navigateToStartStepIfScanFileCannotBeRead(receiptFilename ?? '', receiptPath ?? '', () => {}, iouRequestType, iouType, transactionID, reportID, receiptType ?? ''); - }, [receiptType, receiptPath, receiptFilename, iouRequestType, iouType, transactionID, reportID, isMovingTransactionFromTrackExpense]); + }, [receiptType, receiptPath, receiptFilename, iouRequestType, iouType, transactionID, reportID, action]); const addParticipant = useCallback( (val: Participant[]) => { HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS); const firstParticipantReportID = val.at(0)?.reportID ?? ''; + const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID); const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID); numberOfParticipants.current = val.length; - IOU.setMoneyRequestParticipants(transactionID, val); - if (!isMovingTransactionFromTrackExpense) { - // When moving the transaction, keep the original rate and let the user manually change it to the one they want from the workspace. - // Otherwise, select the default one automatically. - const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID); - IOU.setCustomUnitRateID(transactionID, rateID); - } + IOU.setMoneyRequestParticipants(transactionID, val); + IOU.setCustomUnitRateID(transactionID, rateID); // When multiple participants are selected, the reportID is generated at the end of the confirmation step. // So we are resetting selectedReportID ref to the reportID coming from params. @@ -108,7 +103,7 @@ function IOURequestStepParticipants({ // When a participant is selected, the reportID needs to be saved because that's the reportID that will be used in the confirmation step. selectedReportID.current = firstParticipantReportID || reportID; }, - [iouType, reportID, transactionID, isMovingTransactionFromTrackExpense], + [iouType, reportID, transactionID], ); const goToNextStep = useCallback(() => { diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx index 4870591bcad5..e94042b25e68 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -272,7 +272,7 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { setIsValidateCodeActionModalVisible(false); }} sendValidateCode={() => User.requestContactMethodValidateCode(contactMethod)} - descriptionPrimary={translate('contacts.enterMagicCode', {contactMethod})} + descriptionPrimary={translate('contacts.enterMagicCode', {contactMethod: formattedContactMethod})} /> {!isValidateCodeActionModalVisible && getMenuItems()} diff --git a/src/pages/settings/Profile/ProfileAvatar.tsx b/src/pages/settings/Profile/ProfileAvatar.tsx index a80db51580ba..74b88f419e1d 100644 --- a/src/pages/settings/Profile/ProfileAvatar.tsx +++ b/src/pages/settings/Profile/ProfileAvatar.tsx @@ -1,7 +1,7 @@ import React, {useEffect} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import AttachmentModal from '@components/AttachmentModal'; +import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {AuthScreensParamList} from '@libs/Navigation/types'; @@ -11,17 +11,13 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import * as PersonalDetails from '@userActions/PersonalDetails'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type {PersonalDetailsList, PersonalDetailsMetadata} from '@src/types/onyx'; -type ProfileAvatarOnyxProps = { - personalDetails: OnyxEntry; - personalDetailsMetadata: OnyxEntry>; - isLoadingApp: OnyxEntry; -}; +type ProfileAvatarProps = PlatformStackScreenProps; -type ProfileAvatarProps = ProfileAvatarOnyxProps & PlatformStackScreenProps; - -function ProfileAvatar({route, personalDetails, personalDetailsMetadata, isLoadingApp = true}: ProfileAvatarProps) { +function ProfileAvatar({route}: ProfileAvatarProps) { + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [personalDetailsMetadata] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_METADATA); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {initialValue: true}); const personalDetail = personalDetails?.[route.params.accountID]; const avatarURL = personalDetail?.avatar ?? ''; const accountID = Number(route.params.accountID ?? '-1'); @@ -37,7 +33,7 @@ function ProfileAvatar({route, personalDetails, personalDetailsMetadata, isLoadi return ( { @@ -52,14 +48,4 @@ function ProfileAvatar({route, personalDetails, personalDetailsMetadata, isLoadi ProfileAvatar.displayName = 'ProfileAvatar'; -export default withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - personalDetailsMetadata: { - key: ONYXKEYS.PERSONAL_DETAILS_METADATA, - }, - isLoadingApp: { - key: ONYXKEYS.IS_LOADING_APP, - }, -})(ProfileAvatar); +export default ProfileAvatar; diff --git a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx index b43c970ef5ff..097b1ce679af 100644 --- a/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx +++ b/src/pages/settings/Security/AddDelegate/UpdateDelegateRole/UpdateDelegateRolePage.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -22,7 +22,7 @@ type UpdateDelegateRolePageProps = PlatformStackScreenProps ({ @@ -74,7 +74,6 @@ function UpdateDelegateRolePage({route}: UpdateDelegateRolePageProps) { } requestValidationCode(); - setCurrentRole(option.value); Navigation.navigate(ROUTES.SETTINGS_UPDATE_DELEGATE_ROLE_MAGIC_CODE.getRoute(login, option.value)); }} sections={[{data: roleOptions}]} diff --git a/src/pages/settings/Subscription/SubscriptionSettings/index.tsx b/src/pages/settings/Subscription/SubscriptionSettings/index.tsx index 3af05e1ef1cd..a624244bafeb 100644 --- a/src/pages/settings/Subscription/SubscriptionSettings/index.tsx +++ b/src/pages/settings/Subscription/SubscriptionSettings/index.tsx @@ -24,10 +24,11 @@ function SubscriptionSettings() { const {translate} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [privateSubscription] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); const preferredCurrency = usePreferredCurrency(); const possibleCostSavings = useSubscriptionPossibleCostSavings(); - const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => account?.delegatedAccess?.delegate}); + const isActingAsDelegate = !!account?.delegatedAccess?.delegate; const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const autoRenewalDate = formatSubscriptionEndDate(privateSubscription?.endDate); @@ -41,7 +42,11 @@ function SubscriptionSettings() { Subscription.updateSubscriptionAutoRenew(true); return; } - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY); + if (account?.hasPurchases) { + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_DISABLE_AUTO_RENEW_SURVEY); + } else { + Subscription.updateSubscriptionAutoRenew(false); + } }; const handleAutoIncreaseToggle = () => { diff --git a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx index bd0ce596c733..defc5eb941ac 100644 --- a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx +++ b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx @@ -144,10 +144,7 @@ function TroubleshootPage() { /> - + (null); + const qrCodeRef = useRef(null); const {shouldUseNarrowLayout} = useResponsiveLayout(); const session = useSession(); @@ -96,21 +99,14 @@ function WorkspaceProfileSharePage({policy}: WithPolicyProps) { - {/* - Right now QR code download button is not shown anymore - This is a temporary measure because right now it's broken because of the Fabric update. - We need to wait for react-native v0.74 to be released so react-native-view-shot gets fixed. - - Please see https://github.com/Expensify/App/issues/40110 to see if it can be re-enabled. - */} - @@ -126,6 +122,18 @@ function WorkspaceProfileSharePage({policy}: WithPolicyProps) { shouldLimitWidth={false} wrapperStyle={themeStyles.sectionMenuItemTopDescription} /> + {/* Remove this once https://github.com/Expensify/App/issues/19834 is done. + We shouldn't introduce platform specific code in our codebase. + This is a temporary solution while Web is not supported for the QR code download feature */} + {shouldAllowDownloadQRCode && ( + qrCodeRef.current?.download?.()} + wrapperStyle={themeStyles.sectionMenuItemTopDescription} + /> + )} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 56ba8a5440b8..7d9306795be7 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -84,7 +84,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const currentConnectionName = PolicyUtils.getCurrentConnectionName(policy); const isQuickSettingsFlow = !!backTo; - const canSelectMultiple = shouldUseNarrowLayout ? selectionMode?.isEnabled : true; + const canSelectMultiple = isSmallScreenWidth ? selectionMode?.isEnabled : true; const fetchCategories = useCallback(() => { Category.openPolicyCategoriesPage(policyId); @@ -182,7 +182,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const options: Array>> = []; const isThereAnyAccountingConnection = Object.keys(policy?.connections ?? {}).length !== 0; - if (shouldUseNarrowLayout ? canSelectMultiple : selectedCategoriesArray.length > 0) { + if (isSmallScreenWidth ? canSelectMultiple : selectedCategoriesArray.length > 0) { if (!isThereAnyAccountingConnection) { options.push({ icon: Expensicons.Trashcan, @@ -408,7 +408,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { {hasVisibleCategories && !isLoading && ( item && toggleCategory(item)} sections={[{data: categoryList, isDisabled: false}]} onCheckboxPress={toggleCategory} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index 98c4e93d8d4c..bd5edaa43779 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -4,6 +4,7 @@ import {useOnyx} from 'react-native-onyx'; import CategorySelectorModal from '@components/CategorySelector/CategorySelectorModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; import SelectionList from '@components/SelectionList'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; @@ -99,52 +100,54 @@ function WorkspaceCategoriesSettingsPage({policy, route}: WorkspaceCategoriesSet title={translate('common.settings')} onBackButtonPress={() => Navigation.goBack(isQuickSettingsFlow ? ROUTES.SETTINGS_CATEGORIES_ROOT.getRoute(policyID, backTo) : undefined)} /> - Policy.clearPolicyErrorField(policy?.id ?? '-1', 'requiresCategory')} - shouldPlaceSubtitleBelowSwitch - /> - - - {!!currentPolicy && (sections.at(0)?.data?.length ?? 0) > 0 && ( - - {translate('workspace.categories.defaultSpendCategories')} - {translate('workspace.categories.spendCategoriesDescription')} - - } - sections={sections} - ListItem={SpendCategorySelectorListItem} - onSelectRow={(item) => { - if (!item.groupID || !item.categoryID) { - return; + + Policy.clearPolicyErrorField(policy?.id ?? '-1', 'requiresCategory')} + shouldPlaceSubtitleBelowSwitch + /> + + + {!!currentPolicy && (sections.at(0)?.data?.length ?? 0) > 0 && ( + + {translate('workspace.categories.defaultSpendCategories')} + {translate('workspace.categories.spendCategoriesDescription')} + } - setIsSelectorModalVisible(true); - setCategoryID(item.categoryID); - setGroupID(item.groupID); - }} - /> - )} - {!!categoryID && !!groupID && ( - setIsSelectorModalVisible(false)} - onCategorySelected={setNewCategory} - label={groupID[0].toUpperCase() + groupID.slice(1)} - /> - )} - + sections={sections} + ListItem={SpendCategorySelectorListItem} + onSelectRow={(item) => { + if (!item.groupID || !item.categoryID) { + return; + } + setIsSelectorModalVisible(true); + setCategoryID(item.categoryID); + setGroupID(item.groupID); + }} + /> + )} + + + {!!categoryID && !!groupID && ( + setIsSelectorModalVisible(false)} + onCategorySelected={setNewCategory} + label={groupID[0].toUpperCase() + groupID.slice(1)} + /> + )} ); diff --git a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx index c65ae8957dbe..07b888aaa5f0 100644 --- a/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx +++ b/src/pages/workspace/expensifyCard/issueNew/ConfirmationStep.tsx @@ -51,6 +51,7 @@ function ConfirmationStep({policyID, backTo}: ConfirmationStepProps) { useEffect(() => { submitButton.current?.focus(); + User.resetValidateActionCodeSent(); }, []); useEffect(() => { diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 6d667d786d5d..85a5d2372ee9 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -23,6 +23,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import Navigation from '@navigation/Navigation'; @@ -58,7 +59,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const styles = useThemeStyles(); const {isOffline} = useNetwork(); - const {translate} = useLocalize(); + const {formatPhoneNumber, translate} = useLocalize(); const StyleUtils = useStyleUtils(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [cards] = useOnyx(`${ONYXKEYS.CARD_LIST}`); @@ -75,13 +76,13 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const prevMember = usePrevious(member); const details = personalDetails?.[accountID] ?? ({} as PersonalDetails); const fallbackIcon = details.fallbackIcon ?? ''; - const displayName = details.displayName ?? ''; + const displayName = formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)); const isSelectedMemberOwner = policy?.owner === details.login; const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID; const isCurrentUserAdmin = policy?.employeeList?.[personalDetails?.[currentUserPersonalDetails?.accountID]?.login ?? '']?.role === CONST.POLICY.ROLE.ADMIN; const isCurrentUserOwner = policy?.owner === currentUserPersonalDetails?.login; const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? -1] ?? ({} as PersonalDetails); - const policyOwnerDisplayName = ownerDetails.displayName ?? policy?.owner ?? ''; + const policyOwnerDisplayName = formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(ownerDetails)) ?? policy?.owner ?? ''; const hasMultipleFeeds = Object.values(CardUtils.getCompanyFeeds(cardFeeds)).filter((feed) => !feed.pending).length > 0; const paymentAccountID = cardSettings?.paymentBankAccountID ?? 0; diff --git a/src/pages/workspace/upgrade/UpgradeIntro.tsx b/src/pages/workspace/upgrade/UpgradeIntro.tsx index d45e27905c28..029f8e78271f 100644 --- a/src/pages/workspace/upgrade/UpgradeIntro.tsx +++ b/src/pages/workspace/upgrade/UpgradeIntro.tsx @@ -75,6 +75,7 @@ function UpgradeIntro({feature, onUpgrade, buttonDisabled, loading, isCategorizi