diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml
index 69bbfa380234..403a40214b40 100644
--- a/.github/workflows/buildAndroid.yml
+++ b/.github/workflows/buildAndroid.yml
@@ -41,6 +41,7 @@ on:
description: Git ref to checkout and build
required: true
type: string
+
pull_request_number:
description: The pull request number associated with this build, if relevant.
type: number
diff --git a/.github/workflows/buildIOS.yml b/.github/workflows/buildIOS.yml
deleted file mode 100644
index 2386d01da793..000000000000
--- a/.github/workflows/buildIOS.yml
+++ /dev/null
@@ -1,155 +0,0 @@
-name: Build iOS app
-
-on:
- workflow_call:
- inputs:
- type:
- description: 'What type of build to run. Must be one of ["release", "adhoc"]'
- type: string
- required: true
- ref:
- description: Git ref to checkout and build
- type: string
- required: true
- pull_request_number:
- description: The pull request number associated with this build, if relevant.
- type: string
- required: false
- outputs:
- IPA_FILE_NAME:
- value: ${{ jobs.build.outputs.IPA_FILE_NAME }}
- DSYM_FILE_NAME:
- value: ${{ jobs.build.outputs.DSYM_FILE_NAME }}
-
- workflow_dispatch:
- inputs:
- type:
- description: What type of build do you want to run?
- required: true
- type: choice
- options:
- - release
- - adhoc
- ref:
- description: Git ref to checkout and build
- required: true
- type: string
- pull_request_number:
- description: The pull request number associated with this build, if relevant.
- type: number
- required: false
-
-jobs:
- build:
- name: Build iOS app
- runs-on: macos-13-xlarge
- env:
- DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer
- outputs:
- IPA_FILE_NAME: ${{ steps.build.outputs.IPA_FILE_NAME }}
- DSYM_FILE_NAME: ${{ steps.build.outputs.DSYM_FILE_NAME }}
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- ref: ${{ inputs.ref }}
-
- - name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it
- if: ${{ inputs.type == 'adhoc' }}
- run: |
- cp .env.staging .env.adhoc
- sed -i '' 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc
- echo "PULL_REQUEST_NUMBER=${{ inputs.pull_request_number }}" >> .env.adhoc
-
- - name: Configure MapBox SDK
- run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
-
- - name: Setup Node
- id: setup-node
- uses: ./.github/actions/composite/setupNode
-
- - name: Setup Ruby
- uses: ruby/setup-ruby@v1.190.0
- with:
- bundler-cache: true
-
- - name: Cache Pod dependencies
- uses: actions/cache@v4
- id: pods-cache
- with:
- path: ios/Pods
- key: ${{ runner.os }}-pods-cache-${{ hashFiles('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"
-
- - name: Install cocoapods
- uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847
- if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true'
- with:
- timeout_minutes: 10
- max_attempts: 5
- command: scripts/pod-install.sh
-
- - name: Decrypt provisioning profiles
- run: |
- cd ios
- provisioningProfile=''
- if [ '${{ inputs.type }}' == 'release' ]; then
- provisioningProfile='NewApp_AppStore'
- else
- provisioningProfile='NewApp_AdHoc'
- fi
- echo "Using provisioning profile: $provisioningProfile"
- gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output "$provisioningProfile.mobileprovision" "$provisioningProfile.mobileprovision.gpg"
- gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output "${provisioningProfile}_Notification_Service.mobileprovision" "${provisioningProfile}_Notification_Service.mobileprovision.gpg"
- env:
- LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
-
- - name: Decrypt code signing certificate
- run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg
- env:
- LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
-
- - name: Build iOS ${{ inputs.type }} app
- id: build
- run: |
- lane=''
- if [ '${{ inputs.type }}' == 'release' ]; then
- lane='build'
- else
- lane='build_adhoc'
- fi
-
- bundle exec fastlane ios "$lane"
-
- # Reload environment variables from GITHUB_ENV
- # shellcheck disable=SC1090
- source "$GITHUB_ENV"
-
- {
- # ipaPath and dsymPath are environment variables set within the Fastfile
- echo "IPA_PATH=$ipaPath"
- echo "IPA_FILE_NAME=$(basename "$ipaPath")"
- echo "DSYM_PATH=$dsymPath"
- echo "DSYM_FILE_NAME=$(basename "$dsymPath")"
- } >> "$GITHUB_OUTPUT"
-
- - name: Upload iOS build artifact
- uses: actions/upload-artifact@v4
- with:
- name: ios-artifact-ipa
- path: ${{ steps.build.outputs.IPA_PATH }}
-
- - name: Upload iOS debug symbols artifact
- uses: actions/upload-artifact@v4
- with:
- name: ios-artifact-dsym
- path: ${{ steps.build.outputs.DSYM_PATH }}
-
- - name: Upload iOS sourcemaps
- uses: actions/upload-artifact@v4
- with:
- name: ios-artifact-sourcemaps
- path: ./main.jsbundle.map
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 2053ce44aadb..4ff1a2004d8f 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -136,6 +136,10 @@ jobs:
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:
@@ -203,73 +207,61 @@ jobs:
name: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'desktop-build-artifact' || 'desktop-staging-build-artifact' }}
path: ./desktop-build/NewExpensify.dmg
- buildIOS:
- name: Build iOS app
- uses: ./.github/workflows/buildIOS.yml
- if: ${{ github.ref == 'refs/heads/staging' }}
+ iOS:
+ name: Build and deploy iOS
needs: prep
- secrets: inherit
- with:
- type: release
- ref: staging
-
- uploadIOS:
- name: Upload iOS App to TestFlight
- needs: buildIOS
- runs-on: ubuntu-latest
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer
+ runs-on: macos-13-xlarge
steps:
- name: Checkout
uses: actions/checkout@v4
+ - name: Configure MapBox SDK
+ run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
+
+ - name: Setup Node
+ id: setup-node
+ uses: ./.github/actions/composite/setupNode
+
- name: Setup Ruby
uses: ruby/setup-ruby@v1.190.0
with:
bundler-cache: true
- - name: Decrypt App Store Connect API key
- run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg
- env:
- LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
-
- - name: Download iOS build artifacts
- uses: actions/download-artifact@v4
+ - name: Cache Pod dependencies
+ uses: actions/cache@v4
+ id: pods-cache
with:
- path: /tmp/artifacts
- pattern: ios-artifact-*
- merge-multiple: true
+ path: ios/Pods
+ key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }}
- - name: Log downloaded artifact paths
- run: ls -R /tmp/artifacts
+ - 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"
- - name: Upload iOS app to TestFlight
- run: bundle exec fastlane ios upload_testflight
- env:
- APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }}
- APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }}
- APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }}
- APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }}
- ipaPath: /tmp/artifacts/${{ needs.buildIOS.outputs.IPA_FILE_NAME }}
- dsymPath: /tmp/artifacts/${{ needs.buildIOS.outputs.DSYM_FILE_NAME }}
+ - name: Install cocoapods
+ uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847
+ if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true'
+ with:
+ timeout_minutes: 10
+ max_attempts: 5
+ command: scripts/pod-install.sh
- - 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=@/tmp/artifacts/${{ needs.buildIOS.outputs.IPA_FILE_NAME }}"
+ - name: Decrypt AppStore profile
+ run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg
env:
- BROWSERSTACK: ${{ secrets.BROWSERSTACK }}
+ LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- submitIOS:
- name: Submit iOS app for Apple review
- needs: prep
- if: ${{ github.ref == 'refs/heads/production' }}
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
+ - name: Decrypt AppStore Notification Service profile
+ run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg
+ env:
+ LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- - name: Setup Ruby
- uses: ruby/setup-ruby@v1.190.0
- with:
- bundler-cache: true
+ - name: Decrypt certificate
+ run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg
+ env:
+ LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- name: Decrypt App Store Connect API key
run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg
@@ -280,13 +272,47 @@ jobs:
id: getIOSVersion
run: echo "IOS_VERSION=$(echo '${{ needs.prep.outputs.APP_VERSION }}' | tr '-' '.')" >> "$GITHUB_OUTPUT"
+ - name: Build iOS release app
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ run: bundle exec fastlane ios build
+
+ - name: Upload release build to TestFlight
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ run: bundle exec fastlane ios upload_testflight
+ env:
+ APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }}
+ APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }}
+ 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"
+ env:
+ BROWSERSTACK: ${{ secrets.BROWSERSTACK }}
+
+ - name: Upload iOS sourcemaps artifact
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ios-sourcemaps-artifact
+ path: ./main.jsbundle.map
+
+ - name: Upload iOS build artifact
+ if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: ios-build-artifact
+ path: /Users/runner/work/App/App/New\ Expensify.ipa
+
- name: Warn deployers if iOS production deploy failed
- if: ${{ failure() }}
+ if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
uses: 8398a7/action-slack@v3
with:
status: custom
@@ -389,7 +415,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, desktop, buildIOS, uploadIOS, submitIOS, web]
+ needs: [buildAndroid, uploadAndroid, submitAndroid, desktop, iOS, web]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -415,7 +441,7 @@ 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, desktop, buildIOS, uploadIOS, submitIOS, web]
+ needs: [buildAndroid, uploadAndroid, submitAndroid, desktop, iOS, web]
if: ${{ always() }}
steps:
- name: Check deployment success on at least one platform
@@ -423,19 +449,18 @@ jobs:
run: |
isAtLeastOnePlatformDeployed="false"
if [ ${{ github.ref }} == 'refs/heads/production' ]; then
- if [ '${{ needs.submitAndroid.result }}' == 'success' ] || \
- [ '${{ needs.submitIOS.result }}' == 'success' ]; then
+ if [ "${{ needs.submitAndroid.result }}" == "success" ]; then
isAtLeastOnePlatformDeployed="true"
fi
else
- if [ '${{ needs.uploadAndroid.result }}' == 'success' ] || \
- [ '${{ needs.uploadIOS.result }}' == 'success' ]; then
+ if [ "${{ needs.uploadAndroid.result }}" == "success" ]; then
isAtLeastOnePlatformDeployed="true"
fi
fi
-
- if [ '${{ needs.desktop.result }}' == 'success' ] || \
- [ '${{ needs.web.result }}' == 'success' ]; then
+
+ if [ "${{ needs.iOS.result }}" == "success" ] || \
+ [ "${{ needs.desktop.result }}" == "success" ] || \
+ [ "${{ needs.web.result }}" == "success" ]; then
isAtLeastOnePlatformDeployed="true"
fi
echo "IS_AT_LEAST_ONE_PLATFORM_DEPLOYED=$isAtLeastOnePlatformDeployed" >> "$GITHUB_OUTPUT"
@@ -444,20 +469,19 @@ jobs:
- name: Check deployment success on all platforms
id: checkDeploymentSuccessOnAllPlatforms
run: |
- isAllPlatformsDeployed='false'
- if [ '${{ needs.desktop.result }}' == 'success' ] && \
- [ '${{ needs.web.result }}' == 'success' ]; then
+ isAllPlatformsDeployed="false"
+ if [ "${{ needs.iOS.result }}" == "success" ] && \
+ [ "${{ needs.desktop.result }}" == "success" ] && \
+ [ "${{ needs.web.result }}" == "success" ]; then
isAllPlatformsDeployed="true"
fi
if [ ${{ github.ref }} == 'refs/heads/production' ]; then
- if [ '${{ needs.submitAndroid.result }}' != 'success' ] || \
- [ '${{ needs.submitIOS.result }}' != 'success' ]; then
+ if [ "${{ needs.submitAndroid.result }}" != "success" ]; then
isAllPlatformsDeployed="false"
fi
else
- if [ '${{ needs.uploadAndroid.result }}' != 'success' ] || \
- [ '${{ needs.uploadIOS.result }}' != 'success' ]; then
+ if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then
isAllPlatformsDeployed="false"
fi
fi
@@ -468,7 +492,7 @@ jobs:
createPrerelease:
runs-on: ubuntu-latest
if: ${{ always() && github.ref == 'refs/heads/staging' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }}
- needs: [prep, checkDeploymentSuccess, buildAndroid, buildIOS]
+ needs: [prep, checkDeploymentSuccess]
steps:
- name: Download all workflow run artifacts
uses: actions/download-artifact@v4
@@ -495,12 +519,12 @@ jobs:
continue-on-error: true
run: |
gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber \
- ./android-artifact-sourcemaps/index.android.bundle.map#android-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \
- ./android-artifact-aab/${{ needs.buildAndroid.outputs.AAB_FILE_NAME }} \
+ ./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \
+ ./android-build-artifact/app-production-release.aab \
./desktop-staging-sourcemaps-artifact/desktop-staging-merged-source-map.js.map#desktop-staging-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \
./desktop-staging-build-artifact/NewExpensify.dmg#NewExpensifyStaging.dmg \
- ./ios-artifact-sourcemaps/main.jsbundle.map#ios-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \
- ./ios-artifact-ipa/${{ needs.buildIOS.outputs.IPA_FILE_NAME }} \
+ ./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \
+ ./ios-build-artifact/New\ Expensify.ipa \
./web-staging-sourcemaps-artifact/web-staging-merged-source-map.js.map#web-staging-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \
./web-staging-build-tar-gz-artifact/webBuild.tar.gz#stagingWebBuild.tar.gz \
./web-staging-build-zip-artifact/webBuild.zip#stagingWebBuild.zip
@@ -581,7 +605,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, desktop, buildIOS, uploadIOS, submitIOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease]
+ needs: [prep, buildAndroid, uploadAndroid, submitAndroid, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease]
steps:
- name: 'Announces the deploy in the #announce Slack room'
uses: 8398a7/action-slack@v3
@@ -635,11 +659,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, buildIOS, uploadIOS, submitIOS, desktop, web, checkDeploymentSuccess, createPrerelease, finalizeRelease]
+ needs: [prep, buildAndroid, uploadAndroid, submitAndroid, desktop, iOS, 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 }}
- ios: ${{ github.ref == 'refs/heads/production' && needs.submitIOS.result || needs.uploadIOS.result }}
+ ios: ${{ needs.iOS.result }}
web: ${{ needs.web.result }}
desktop: ${{ needs.desktop.result }}
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index ac20a8d09141..672d468ed3b1 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -120,41 +120,73 @@ jobs:
# $s3APKPath is set from within the Fastfile, android upload_s3 lane
echo "S3_APK_PATH=$s3APKPath" >> "$GITHUB_OUTPUT"
- buildIOS:
- name: Build iOS app for testing
- uses: ./.github/workflows/buildIOS.yml
- if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
+ iOS:
+ name: Build and deploy iOS for testing
needs: [validateActor, getBranchRef]
- secrets: inherit
- with:
- type: adhoc
- ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }}
- pull_request_number: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }}
-
- uploadIOS:
- name: Upload IOS app to S3
- needs: buildIOS
- runs-on: ubuntu-latest
- outputs:
- S3_IPA_PATH: ${{ steps.exportS3Path.outputs.S3_IPA_PATH }}
+ if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer
+ runs-on: macos-13-xlarge
steps:
- name: Checkout
uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }}
+
+ - name: Configure MapBox SDK
+ run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
+
+ - name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it
+ run: |
+ cp .env.staging .env.adhoc
+ sed -i '' 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc
+ echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
+
+ - name: Setup Node
+ id: setup-node
+ uses: ./.github/actions/composite/setupNode
+
+ - name: Setup XCode
+ run: sudo xcode-select -switch /Applications/Xcode_15.2.0.app
- name: Setup Ruby
uses: ruby/setup-ruby@v1.190.0
with:
bundler-cache: true
- - name: Download IOS build artifacts
- uses: actions/download-artifact@v4
+ - name: Cache Pod dependencies
+ uses: actions/cache@v4
+ id: pods-cache
with:
- path: /tmp/artifacts
- pattern: ios-artifact-*
- merge-multiple: true
+ path: ios/Pods
+ key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }}
- - name: Log downloaded artifact paths
- run: ls -R /tmp/artifacts
+ - 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"
+
+ - name: Install cocoapods
+ uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847
+ if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true'
+ with:
+ timeout_minutes: 10
+ max_attempts: 5
+ command: scripts/pod-install.sh
+
+ - name: Decrypt AdHoc profile
+ run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AdHoc.mobileprovision NewApp_AdHoc.mobileprovision.gpg
+ env:
+ LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+
+ - name: Decrypt AdHoc Notification Service profile
+ run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AdHoc_Notification_Service.mobileprovision NewApp_AdHoc_Notification_Service.mobileprovision.gpg
+ env:
+ LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
+
+ - name: Decrypt certificate
+ run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg
+ env:
+ LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
@@ -163,20 +195,22 @@ jobs:
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
+ - name: Build AdHoc app
+ run: bundle exec fastlane ios build_adhoc
+
- name: Upload AdHoc build to S3
run: bundle exec fastlane ios upload_s3
env:
- ipaPath: /tmp/artifacts/${{ needs.buildIOS.outputs.IPA_FILE_NAME }}
S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
S3_BUCKET: ad-hoc-expensify-cash
S3_REGION: us-east-1
- - name: Export S3 paths
- id: exportS3Path
- run: |
- # $s3IpaPath is set from within the Fastfile, ios upload_s3 lane
- echo "S3_IPA_PATH=$s3IpaPath" >> "$GITHUB_OUTPUT"
+ - name: Upload Artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: ios
+ path: ./ios_paths.json
desktop:
name: Build and deploy Desktop for testing
@@ -257,27 +291,41 @@ jobs:
postGithubComment:
runs-on: ubuntu-latest
name: Post a GitHub comment with app download links for testing
- needs: [validateActor, getBranchRef, uploadAndroid, uploadIOS, desktop, web]
- if: ${{ always() && fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
+ needs: [validateActor, getBranchRef, uploadAndroid, iOS, desktop, web]
+ if: ${{ always() }}
steps:
- name: Checkout
uses: actions/checkout@v4
+ if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
with:
ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }}
- name: Download Artifact
uses: actions/download-artifact@v4
+ if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
+
+ - name: Read JSONs with iOS paths
+ id: get_ios_path
+ if: ${{ needs.iOS.result == 'success' }}
+ run: |
+ content_ios="$(cat ./ios/ios_paths.json)"
+ content_ios="${content_ios//'%'/'%25'}"
+ content_ios="${content_ios//$'\n'/'%0A'}"
+ content_ios="${content_ios//$'\r'/'%0D'}"
+ ios_path=$(echo "$content_ios" | jq -r '.html_path')
+ echo "ios_path=$ios_path" >> "$GITHUB_OUTPUT"
- name: Publish links to apps for download
+ if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
uses: ./.github/actions/javascript/postTestBuildComment
with:
PR_NUMBER: ${{ env.PULL_REQUEST_NUMBER }}
GITHUB_TOKEN: ${{ github.token }}
ANDROID: ${{ needs.uploadAndroid.result }}
DESKTOP: ${{ needs.desktop.result }}
- IOS: ${{ needs.uploadIOS.result }}
+ IOS: ${{ needs.iOS.result }}
WEB: ${{ needs.web.result }}
ANDROID_LINK: ${{ needs.uploadAndroid.outputs.S3_APK_PATH }}
DESKTOP_LINK: https://ad-hoc-expensify-cash.s3.amazonaws.com/desktop/${{ env.PULL_REQUEST_NUMBER }}/NewExpensify.dmg
- IOS_LINK: ${{ needs.uploadIOS.outputs.S3_IPA_PATH }}
+ IOS_LINK: ${{ steps.get_ios_path.outputs.ios_path }}
WEB_LINK: https://${{ env.PULL_REQUEST_NUMBER }}.pr-testing.expensify.com
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 870f9d81e108..56ae4bd0a873 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 1009004408
- versionName "9.0.44-8"
+ versionCode 1009004502
+ versionName "9.0.45-2"
// 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"
@@ -229,11 +229,11 @@ dependencies {
implementation 'com.facebook.fresco:fresco:2.5.0'
implementation 'com.facebook.fresco:animated-gif:2.5.0'
- // Android support library
- implementation 'com.android.support:support-core-utils:28.0.0'
+ // AndroidX support library
+ implementation 'androidx.legacy:legacy-support-core-utils:1.0.0'
// Multi Dex Support: https://developer.android.com/studio/build/multidex#mdex-gradle
- implementation 'com.android.support:multidex:1.0.3'
+ implementation 'androidx.multidex:multidex:2.0.1'
// Plaid SDK
implementation project(':react-native-plaid-link-sdk')
diff --git a/android/gradle.properties b/android/gradle.properties
index 46cd98554d29..038fb5c392e8 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -21,8 +21,8 @@ org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=512m
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
-# Automatically convert third-party libraries to use AndroidX
-android.enableJetifier=true
+# Disabled Jetifier to improve build performance as we're not using libraries that require Jetifier for AndroidX compatibility.
+android.enableJetifier=false
# Increase storage capacity (the default is 6 MB)
AsyncStorage_db_size_in_MB=10
diff --git a/docs/articles/expensify-classic/settings/General-product-troubleshooting.md b/docs/articles/expensify-classic/settings/General-product-troubleshooting.md
new file mode 100644
index 000000000000..57126628e04f
--- /dev/null
+++ b/docs/articles/expensify-classic/settings/General-product-troubleshooting.md
@@ -0,0 +1,48 @@
+---
+title: General Product Troubleshooting
+description: How to troubleshoot a website issue
+---
+
+
+# Issues with a specific feature
+If you're having issues with a specific feature, please reffer to the corresponding section of the help docs for detailed explinations of common errors and troubleshooting steps. If you cannot find an answer to your question, please reach out to Concierge via in-product chat or by emailing us at concierge@expensify.com.
+
+# Troubleshooting local issues
+Is your webpage not loading? Try these steps:
+- Try clicking [here](https://www.expensify.com/signout.php?clean=true), which will force a clean sign-out from the site, which can be very helpful in removing any stale data that can cause issues.
+- Clear cookies & cache on your browser.
+- Try using an Incognito or Private browsing window.
+- Try on a different browser.
+
+# JavaScript Console
+A developer console is a tool that logs information about the backend operations of the sites you visit and the applications you run. This information can help our developers solve any issue that you may experience.
+
+If you've been asked to provide a screenshot of your developer console, scroll down to find the instructions for the browser or application you're using.
+
+## Chrome
+
+- Keyboard shortcut
+ - Mac: Cmd + Option + J
+ - Windows: Ctrl + Shift + J
+- From the menu: View > Developer > JavaScript Console
+
+## Firefox
+
+- Keyboard shortcut:
+ - Mac: Cmd + Option + K
+ - Windows: Ctrl + Shift + J
+- From the menu: Menu Bar > More Tools > Web Developer Tools > Console tab
+
+## Safari
+
+Before opening the console you will need to enable it in Safari by clicking the Safari Menu > Settings > Advanced > and selecting the "Show features for web developers" checkbox. Once enabled, you can locate the console in the developer menu or open it using the keyboard shortcut:
+
+- Keyboard shortcut: Cmd + Option + C
+- From the menu: Develop Menu > Show JavaScript Console
+
+## Microsoft Edge
+
+- Keyboard shortcut:
+ - Mac: Cmd + Option + J
+ - Windows: Ctrl + Shift + J
+- From the menu: Right-click a webpage > Inspect > Console
diff --git a/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md
index 28cdc71ed80f..d5bc3ee20000 100644
--- a/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md
+++ b/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md
@@ -4,17 +4,16 @@ description: Configure the Import, Export, and Advanced settings for Expensify's
order: 3
---
-# Configure Sage Intacct integration
-## Step 1: Select entity (multi-entity setups only)
+# Step 1: Select entity (multi-entity setups only)
If you have a multi-entity setup in Sage Intacct, you will be able to select in Expensify which Sage Intacct entity to connect each workspace to. Each Expensify workspace can either be connected to a single entity or connected at the Top Level.
To select or change the Sage Intacct entity that your Expensify workspace is connected to, navigate to the Accounting settings for your workspace and click **Entity** under the Sage Intacct connection.
-## Step 2: Configure import settings
+# Step 2: Configure import settings
The following section will help you determine how data will be imported from Sage Intacct into Expensify. To change your import settings, navigate to the Accounting settings for your workspace, then click **Import** under the Sage Intacct connection.
-### Expense Types / Chart of Accounts
+## Expense Types / Chart of Accounts
The categories in Expensify depend on how you choose to export out-of-pocket expenses:
- If you choose to export out-of-pocket expenses as Expense Reports, your categories in Expensify will be imported from your Sage Intacct Expense Types
@@ -22,13 +21,13 @@ The categories in Expensify depend on how you choose to export out-of-pocket exp
You can disable unnecessary categories in Expensify by going to **Settings > Workspaces > [Workspace Name] > Categories**. Note that every expense must be coded with a Category, or it will fail to export.
-### Billable Expenses
+## Billable Expenses
Enabling billable expenses allows you to map your expense types or accounts to items in Sage Intacct. To do this, you’ll need to enable the correct permissions on your Sage Intacct user or role. This may vary based on the modules you use in Sage Intacct, so you should enable read-only permissions for relevant modules such as Projects, Purchasing, Inventory Control, and Order Entry.
Once permissions are set, you can map categories to specific items, which will then export to Sage Intacct. When an expense is marked as Billable in Expensify, users must select the correct billable Category (Item), or there will be an error during export.
-### Standard dimensions: Departments, Classes, and Locations
+## Standard dimensions: Departments, Classes, and Locations
The Sage Intacct integration allows you to import standard dimensions into Expensify as tags, report fields, or using the Sage Intacct employee default.
- **Sage Intacct Employee default:** This option is only available when exporting as expense reports. When this option is selected, nothing will be imported into Expensify - instead, the employee default will be applied to each expense upon export.
@@ -39,7 +38,7 @@ New departments, classes, and locations must be added in Sage Intacct. Once impo
Please note that when importing departments as tags, expense reports may show the tag name as "Tag" instead of "Department."
-### Customers and Projects
+## Customers and Projects
The Sage Intacct integration allows you to import customers and projects into Expensify as Tags or Report Fields.
- **Tags:** Employees can select the customer or project on each individual expense.
@@ -48,12 +47,12 @@ The Sage Intacct integration allows you to import customers and projects into Ex
New customers and projects must be added in Sage Intacct. Once imported, you can turn specific tags on or off under **Settings > Workspaces > [Workspace Name] > Tags**. You can turn specific report fields on or off under **Settings > Workspaces > [Workspace Name] > Report Fields**.
-### Tax
+## Tax
The Sage Intacct integration supports native VAT and GST tax. To enable this feature, go to **Settings > Workspaces > [Workspace Name] > Accounting**, click **Import** under Sage Intacct, and enable Tax. Enabling this option will import your native tax rates from Sage Intacct into Expensify. From there, you can select default rates for each category under **Settings > Workspaces > [Workspace Name] > Categories**.
For older Sage Intacct connections that don't show the Tax option, simply resync the connection by going to **Settings > Workspaces > [Workspace Name] > Accounting** and clicking the three dots next to Sage Intacct, and the tax toggle will appear.
-### User-Defined Dimensions
+## User-Defined Dimensions
You can add User-Defined Dimensions (UDDs) to your workspace by locating the “Integration Name” in Sage Intacct. Please note that you must be logged in as an administrator in Sage Intacct to find the required fields.
To find the Integration Name in Sage Intacct:
@@ -68,23 +67,23 @@ To find the Integration Name in Sage Intacct:
Once imported, you can turn specific tags on or off under **Settings > Workspaces > [Workspace Name] > Tags**. You can turn specific report fields on or off under **Settings > Workspaces > [Workspace Name] > Report Fields**.
-## Step 5: Configure export settings
+# Step 3: Configure export settings
To access export settings, head to **Settings > Workspaces > [Workspace name] > Accounting** and click **Export** under Sage Intacct.
-### Preferred exporter
+## Preferred exporter
Any workspace admin can export reports to Sage Intacct. For auto-export, Concierge will export on behalf of the preferred exporter. The preferred exporter will also be notified of any expense reports that fail to export to Sage Intacct due to an error.
-### Export date
+## Export date
You can choose which date to use for the records created in Sage Intacct. There are three date options:
1. **Date of last expense:** This will use the date of the previous expense on the report
1. **Export date:** The date you export the report to Sage Intacct
1. **Submitted date:** The date the employee submitted the report
-### Export out-of-pocket expenses as
+## Export out-of-pocket expenses as
Out-of-pocket expenses can be exported to Sage Intacct as **expense reports** or as **vendor bills**. If you choose to export as expense reports, you can optionally select a **default vendor**, which will apply to reimbursable expenses that don't have a matching vendor in Sage Intacct.
-### Export company card expenses as
+## Export company card expenses as
Company Card expenses are exported separately from out-of-pocket expenses, and can be exported to Sage Intacct as credit card charges** or as **vendor bills**.
- **Credit card charges:** When exporting as credit card charges, you must select a credit card account. You can optionally select a default vendor, which will apply to company card expenses that don't have a matching vendor in Sage Intacct.
@@ -93,13 +92,13 @@ Company Card expenses are exported separately from out-of-pocket expenses, and c
If you centrally manage your company cards through Domains in Expensify Classic, you can export expenses from each individual card to a specific account in Sage Intacct in the Expensify Company Card settings.
-### 6. Configure advanced settings
+# Step 4: Configure advanced settings
To access the advanced settings of the Sage Intacct integration, head to **Settings > Workspaces > [Workspace name] > Accounting** and click **Advanced** under Sage Intacct.
Let’s review the different advanced settings and how they interact with the integration.
-### Auto-sync
+## Auto-sync
We strongly recommend enabling auto-sync to ensure that the information in Sage Intacct and Expensify is always in sync. The following will occur when auto-sync is enabled:
**Daily sync from Sage Intacct to Expensify:** Once a day, Expensify will sync any changes from Sage Intacct into Expensify. This includes any changes or additions to your Sage Intacct dimensions.
@@ -108,7 +107,7 @@ We strongly recommend enabling auto-sync to ensure that the information in Sage
**Reimbursement-sync:** If Sync Reimbursed Reports (more details below) is enabled, then we will sync the reimbursement status of reports between Expensify and Sage Intacct.
-### Invite employees
+## Invite employees
Enabling this feature will invite all employees from the connected Sage Intacct entity to your Expensify workspace. Once imported, each employee who has not already been invited to that Expensify workspace will receive an email letting them know they’ve been added to the workspace.
In addition to inviting employees, this feature enables a custom set of approval workflow options, which you can manage in Expensify Classic:
@@ -118,7 +117,7 @@ In addition to inviting employees, this feature enables a custom set of approval
- **Configure Manually:** Employees will be imported, but all levels of approval must be manually configured in Expensify. If you enable this setting, you can configure approvals by going to **Settings > Workspaces > [Workspace Name] > People**.
-### Sync reimbursed reports
+## Sync reimbursed reports
When Sync reimbursed reports is enabled, the reimbursement status will be synced between Expensify and Sage Intacct.
**If you reimburse employees through Expensify:** Reimbursing an expense report will trigger auto-export to Sage Intacct. When the expense report is exported to Sage Intacct, a corresponding bill payment will also be created in Sage Intacct in the selected Cash and Cash Equivalents account. If you don't see the account you'd like to select in the dropdown list, please confirm that the account type is Cash and Cash Equivalents.
@@ -127,7 +126,7 @@ When Sync reimbursed reports is enabled, the reimbursement status will be synced
To ensure this feature works properly for expense reports, make sure that the account you choose within the settings matches the default account for Bill Payments in NetSuite. When exporting invoices, once marked as Paid, the payment is marked against the account selected after enabling the Collection Account setting.
-## FAQ
+# FAQ
-### Will enabling auto-sync affect existing approved and reimbursed reports?
+## Will enabling auto-sync affect existing approved and reimbursed reports?
Auto-sync will only export newly approved reports to Sage Intacct. Any reports that were approved or reimbursed before enabling auto-sync will need to be manually exported in order to sync them to Sage Intacct.
diff --git a/docs/articles/new-expensify/settings/General-product-troubleshooting.md b/docs/articles/new-expensify/settings/General-product-troubleshooting.md
new file mode 100644
index 000000000000..57126628e04f
--- /dev/null
+++ b/docs/articles/new-expensify/settings/General-product-troubleshooting.md
@@ -0,0 +1,48 @@
+---
+title: General Product Troubleshooting
+description: How to troubleshoot a website issue
+---
+
+
+# Issues with a specific feature
+If you're having issues with a specific feature, please reffer to the corresponding section of the help docs for detailed explinations of common errors and troubleshooting steps. If you cannot find an answer to your question, please reach out to Concierge via in-product chat or by emailing us at concierge@expensify.com.
+
+# Troubleshooting local issues
+Is your webpage not loading? Try these steps:
+- Try clicking [here](https://www.expensify.com/signout.php?clean=true), which will force a clean sign-out from the site, which can be very helpful in removing any stale data that can cause issues.
+- Clear cookies & cache on your browser.
+- Try using an Incognito or Private browsing window.
+- Try on a different browser.
+
+# JavaScript Console
+A developer console is a tool that logs information about the backend operations of the sites you visit and the applications you run. This information can help our developers solve any issue that you may experience.
+
+If you've been asked to provide a screenshot of your developer console, scroll down to find the instructions for the browser or application you're using.
+
+## Chrome
+
+- Keyboard shortcut
+ - Mac: Cmd + Option + J
+ - Windows: Ctrl + Shift + J
+- From the menu: View > Developer > JavaScript Console
+
+## Firefox
+
+- Keyboard shortcut:
+ - Mac: Cmd + Option + K
+ - Windows: Ctrl + Shift + J
+- From the menu: Menu Bar > More Tools > Web Developer Tools > Console tab
+
+## Safari
+
+Before opening the console you will need to enable it in Safari by clicking the Safari Menu > Settings > Advanced > and selecting the "Show features for web developers" checkbox. Once enabled, you can locate the console in the developer menu or open it using the keyboard shortcut:
+
+- Keyboard shortcut: Cmd + Option + C
+- From the menu: Develop Menu > Show JavaScript Console
+
+## Microsoft Edge
+
+- Keyboard shortcut:
+ - Mac: Cmd + Option + J
+ - Windows: Ctrl + Shift + J
+- From the menu: Right-click a webpage > Inspect > Console
diff --git a/docs/articles/new-expensify/workspaces/Enable-Report-Fields.md b/docs/articles/new-expensify/workspaces/Enable-Report-Fields.md
new file mode 100644
index 000000000000..3ae1af36482b
--- /dev/null
+++ b/docs/articles/new-expensify/workspaces/Enable-Report-Fields.md
@@ -0,0 +1,51 @@
+---
+title: Enable-Report-Fields.md
+description: Enable and create Report Fields for your Workspaces
+---
+
+{% include info.html %}
+Report fields are only available on the Control plan. You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system.
+{% include end-info.html %}
+
+If you are not connected to an accounting integration, workspace Admins can add additional required report fields that allow you to specify header-level details like specific project names, business trip information, locations, and more.
+
+## Enable Report Fields
+To enable report fields on a Workspace:
+
+1. Click Settings in the bottom left menu
+2. Click Workspaces from the left-hand menu
+3. Select the Workspace you want to enable Report Fields for
+4. Go to More Features and toggle on Report Fields
+
+{% include info.html %}
+If you are not already on a Control plan, you will be prompted to upgrade
+{% include end-info.html %}
+
+## Create New Report Fields
+To create new Report Fields:
+
+1. Click Settings in the bottom left menu
+2. Click Workspaces from the left-hand menu
+3. Select the Workspace you want to create Report Fields on
+4. Click Report Fields on the lefthand menu (if you do not see this option, enable Report Fields by following the Enable Report Fields process above this)
+5. Click “Add Field” in the top right corner
+6. Click “Name” and add a name your your Report Field
+7. Click “Type” to select the Report Field type; you will have the following options:
+ - Text - Add a field for free-text input
+ - Date - Add a calendar for date selection
+ - List - Add a list of options to choose from
+ - To create values for your list, click List Vales > Add Values
+8. Once you have added a Name and the Type, click Save at the bottom of the page
+
+## Edit or Delete Existing Report Fields
+To edit or delete existing report fields Report Fields:
+
+1. Click Settings in the bottom left menu
+2. Click Workspaces from the left-hand menu
+3. Select the Workspace you want to edit Report Fields on
+4. Click Report Fields on the lefthand menu
+5. Click the Report Field you wish to edit or delete
+6. Make the required edits in the right-hand panel, or select “Delete”
+
+
+
diff --git a/docs/articles/new-expensify/workspaces/Track-taxes.md b/docs/articles/new-expensify/workspaces/Track-taxes.md
index fb4077679350..a8ea82873b9e 100644
--- a/docs/articles/new-expensify/workspaces/Track-taxes.md
+++ b/docs/articles/new-expensify/workspaces/Track-taxes.md
@@ -4,15 +4,13 @@ description: Set up tax rates in your Expensify workspace
---
-# Track taxes
-
Each Expensify workspace can be configured with one or more tax rates. Once tax rates are enabled on your workspace, all expenses will have a default tax rate applied based on the currency, and employees will be able to select the correct tax rate for each expense.
-Tax rates are only available on the Control plan. Collect plan users will need to upgrade to Control for access to tag tax codes.
+Tax rates are available on Collect and Control plans.
-## Enable taxes on a workspace
+# Enable taxes on a workspace
-Tax codes are only available on the Control plan. Taxes can be enabled on any workspace where the default currency is not USD. Please note that if you have a direct accounting integration, tax rates will be managed through the integration and cannot be manually enabled or disabled using the instructions below.
+Taxes can be enabled on any workspace where the default currency is not USD. Please note that if you have a direct accounting integration, tax rates will be managed through the integration and cannot be manually enabled or disabled using the instructions below.
**To enable taxes on your workspace:**
@@ -24,7 +22,7 @@ Tax codes are only available on the Control plan. Taxes can be enabled on any wo
After toggling on taxes, you will see a new **Taxes** option in the left menu.
-## Manually add, delete, or edit tax rates
+# Manually add, delete, or edit tax rates
**To manually add a tax rate:**
@@ -53,7 +51,7 @@ Please note: The workspace currency default rate cannot be deleted or disabled.
Please note: The workspace currency default rate cannot be deleted or disabled.
-## Change the default tax rates
+# Change the default tax rates
After enabling taxes in your workspace, you can set two default rates:
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 1a7499a2a2c3..eed84acdc916 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -19,7 +19,6 @@ KEY_GRADLE_APK_PATH = "apkPath"
KEY_S3_APK_PATH = "s3APKPath"
KEY_GRADLE_AAB_PATH = "aabPath"
KEY_IPA_PATH = "ipaPath"
-KEY_S3_IPA_PATH = "s3IpaPath"
KEY_DSYM_PATH = "dsymPath"
# Export environment variables to GITHUB_ENV
@@ -216,7 +215,7 @@ platform :ios do
build_app(
workspace: "./ios/NewExpensify.xcworkspace",
scheme: "New Expensify",
- output_name: "NewExpensify.ipa",
+ output_name: "New Expensify.ipa",
export_options: {
provisioningProfiles: {
"com.chat.expensify.chat" => "(NewApp) AppStore",
@@ -257,7 +256,6 @@ platform :ios do
workspace: "./ios/NewExpensify.xcworkspace",
skip_profile_detection: true,
scheme: "New Expensify AdHoc",
- output_name: "NewExpensify_AdHoc.ipa",
export_method: "ad-hoc",
export_options: {
method: "ad-hoc",
@@ -282,16 +280,12 @@ platform :ios do
ipa: ENV[KEY_IPA_PATH],
app_directory: "ios/#{ENV['PULL_REQUEST_NUMBER']}",
)
- puts "Saving S3 outputs in env..."
- exportEnvVars({
- KEY_S3_IPA_PATH => lane_context[SharedValues::S3_HTML_OUTPUT_PATH],
- })
+ sh("echo '{\"ipa_path\": \"#{lane_context[SharedValues::S3_IPA_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../ios_paths.json")
end
desc "Upload app to TestFlight"
lane :upload_testflight do
upload_to_testflight(
- ipa: ENV[KEY_IPA_PATH],
api_key_path: "./ios/ios-fastlane-json-key.json",
distribute_external: true,
notify_external_testers: true,
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 51caecb7c81a..9b44440ea8ce 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 9.0.44
+ 9.0.45CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 9.0.44.8
+ 9.0.45.2FullStoryOrgId
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index c2ee6978f322..f3fe791cc8a1 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 9.0.44
+ 9.0.45CFBundleSignature????CFBundleVersion
- 9.0.44.8
+ 9.0.45.2
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 1f812f0eff46..747676c49fc0 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName$(PRODUCT_NAME)CFBundleShortVersionString
- 9.0.44
+ 9.0.45CFBundleVersion
- 9.0.44.8
+ 9.0.45.2NSExtensionNSExtensionPointIdentifier
diff --git a/package-lock.json b/package-lock.json
index cf9978bae510..22385023374c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.0.44-8",
+ "version": "9.0.45-2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.0.44-8",
+ "version": "9.0.45-2",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index baf05e92111b..1387bda002d6 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.0.44-8",
+ "version": "9.0.45-2",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/CONST.ts b/src/CONST.ts
index 0b130ae7564b..533cd0fdb380 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -480,6 +480,7 @@ const CONST = {
NEW_DOT_COPILOT: 'newDotCopilot',
WORKSPACE_RULES: 'workspaceRules',
COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit',
+ NEW_DOT_QBD: 'quickbooksDesktopOnNewDot',
},
BUTTON_STATES: {
DEFAULT: 'default',
@@ -2300,12 +2301,14 @@ const CONST = {
XERO: 'xero',
NETSUITE: 'netsuite',
SAGE_INTACCT: 'intacct',
+ QBD: 'quickbooksDesktop',
},
ROUTE: {
QBO: 'quickbooks-online',
XERO: 'xero',
NETSUITE: 'netsuite',
SAGE_INTACCT: 'sage-intacct',
+ QBD: 'quickbooks-desktop',
},
NAME_USER_FRIENDLY: {
netsuite: 'NetSuite',
@@ -5452,6 +5455,7 @@ const CONST = {
INITIAL_URL: 'INITIAL_URL',
ACTIVE_WORKSPACE_ID: 'ACTIVE_WORKSPACE_ID',
RETRY_LAZY_REFRESHED: 'RETRY_LAZY_REFRESHED',
+ LAST_REFRESH_TIMESTAMP: 'LAST_REFRESH_TIMESTAMP',
},
RESERVATION_TYPE: {
@@ -5785,6 +5789,9 @@ const CONST = {
TAGS_ARTICLE_LINK: 'https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags#import-a-spreadsheet-1',
},
+ // The timeout duration (1 minute) (in milliseconds) before the window reloads due to an error.
+ ERROR_WINDOW_RELOAD_TIMEOUT: 60000,
+
DEBUG: {
DETAILS: 'details',
JSON: 'json',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index d7e6b37a60fd..326c6dfe1e62 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -649,6 +649,10 @@ const ROUTES = {
route: 'settings/workspaces/:policyID/accounting/quickbooks-online/export/date-select',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-online/export/date-select` as const,
},
+ POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT: {
+ route: 'settings/workspaces/:policyID/accounting/quickbooks-desktop/export',
+ getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/quickbooks-desktop/export` as const,
+ },
WORKSPACE_PROFILE_NAME: {
route: 'settings/workspaces/:policyID/profile/name',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/profile/name` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 8d363e6317c1..47fe8db82810 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -302,6 +302,7 @@ const SCREENS = {
QUICKBOOKS_ONLINE_ADVANCED: 'Policy_Accounting_Quickbooks_Online_Advanced',
QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Account_Selector',
QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR: 'Policy_Accounting_Quickbooks_Online_Invoice_Account_Selector',
+ QUICKBOOKS_DESKTOP_EXPORT: 'Workspace_Accounting_Quickbooks_Desktop_Export',
XERO_IMPORT: 'Policy_Accounting_Xero_Import',
XERO_ORGANIZATION: 'Policy_Accounting_Xero_Customers',
XERO_CHART_OF_ACCOUNTS: 'Policy_Accounting_Xero_Import_Chart_Of_Accounts',
diff --git a/src/components/DeeplinkWrapper/index.website.tsx b/src/components/DeeplinkWrapper/index.website.tsx
index b395eb12c5fe..6f6562f97c17 100644
--- a/src/components/DeeplinkWrapper/index.website.tsx
+++ b/src/components/DeeplinkWrapper/index.website.tsx
@@ -22,6 +22,11 @@ function promptToOpenInDesktopApp(initialUrl = '') {
// 2. There may be non-idempotent operations (e.g. create a new workspace), which obviously should not be executed again in the desktop app.
// So we need to wait until after sign-in and navigation are complete before starting the deeplink redirect.
if (Str.startsWith(window.location.pathname, Str.normalizeUrl(ROUTES.TRANSITION_BETWEEN_APPS))) {
+ const params = new URLSearchParams(window.location.search);
+ // If the user is redirected from the desktop app, don't prompt the user to open in desktop.
+ if (params.get('referrer') === 'desktop') {
+ return;
+ }
App.beginDeepLinkRedirectAfterTransition();
} else {
// Match any magic link (/v//<6 digit code>)
diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx
index d947e13fa708..3e723b66828d 100644
--- a/src/components/MultipleAvatars.tsx
+++ b/src/components/MultipleAvatars.tsx
@@ -54,6 +54,9 @@ type MultipleAvatarsProps = {
/** Prop to limit the amount of avatars displayed horizontally */
maxAvatarsInRow?: number;
+
+ /** Prop to limit the amount of avatars displayed horizontally */
+ overlapDivider?: number;
};
type AvatarStyles = {
@@ -79,6 +82,7 @@ function MultipleAvatars({
shouldShowTooltip = true,
shouldUseCardBackground = false,
maxAvatarsInRow = CONST.AVATAR_ROW_SIZE.DEFAULT,
+ overlapDivider = 3,
}: MultipleAvatarsProps) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -167,7 +171,7 @@ function MultipleAvatars({
const oneAvatarSize = StyleUtils.getAvatarStyle(size);
const oneAvatarBorderWidth = StyleUtils.getAvatarBorderWidth(size).borderWidth ?? 0;
- const overlapSize = oneAvatarSize.width / 3;
+ const overlapSize = oneAvatarSize.width / overlapDivider;
if (shouldStackHorizontally) {
// Height of one avatar + border space
diff --git a/src/components/ProcessMoneyReportHoldMenu.tsx b/src/components/ProcessMoneyReportHoldMenu.tsx
index da572e4b1a79..d140e71bceae 100644
--- a/src/components/ProcessMoneyReportHoldMenu.tsx
+++ b/src/components/ProcessMoneyReportHoldMenu.tsx
@@ -41,6 +41,9 @@ type ProcessMoneyReportHoldMenuProps = {
/** Number of transaction of a money request */
transactionCount: number;
+
+ /** Callback for displaying payment animation on IOU preview component */
+ startAnimation?: () => void;
};
function ProcessMoneyReportHoldMenu({
@@ -53,6 +56,7 @@ function ProcessMoneyReportHoldMenu({
chatReport,
moneyRequestReport,
transactionCount,
+ startAnimation,
}: ProcessMoneyReportHoldMenuProps) {
const {translate} = useLocalize();
const isApprove = requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE;
@@ -66,6 +70,9 @@ function ProcessMoneyReportHoldMenu({
Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(moneyRequestReport?.reportID ?? ''));
}
} else if (chatReport && paymentType) {
+ if (startAnimation) {
+ startAnimation();
+ }
IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport, full);
}
onClose();
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
index 420d3eaf46a8..9329558d6531 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
+++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx
@@ -51,25 +51,19 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {MoneyRequestPreviewProps, PendingMessageProps} from './types';
function MoneyRequestPreviewContent({
- iouReport,
isBillSplit,
- session,
action,
- personalDetails,
- chatReport,
- transaction,
contextMenuAnchor,
chatReportID,
reportID,
onPreviewPressed,
containerStyles,
- walletTerms,
checkIfContextMenuActive = () => {},
shouldShowPendingConversionMessage = false,
isHovered = false,
isWhisper = false,
- transactionViolations,
shouldDisplayContextMenu = true,
+ iouReportID,
}: MoneyRequestPreviewProps) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -78,6 +72,16 @@ function MoneyRequestPreviewContent({
const {windowWidth} = useWindowDimensions();
const route = useRoute>();
const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID || '-1'}`);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID || '-1'}`);
+
+ const isMoneyRequestAction = ReportActionsUtils.isMoneyRequestAction(action);
+ const transactionID = isMoneyRequestAction ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : '-1';
+ const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`);
+ const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS);
+ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const sessionAccountID = session?.accountID;
const managerID = iouReport?.managerID ?? -1;
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestPreview/index.tsx
index c01206f83f55..f902948b2cb5 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/index.tsx
+++ b/src/components/ReportActionItem/MoneyRequestPreview/index.tsx
@@ -1,44 +1,18 @@
import lodashIsEmpty from 'lodash/isEmpty';
import React from 'react';
-import {withOnyx} from 'react-native-onyx';
-import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import {useOnyx} from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
import MoneyRequestPreviewContent from './MoneyRequestPreviewContent';
-import type {MoneyRequestPreviewOnyxProps, MoneyRequestPreviewProps} from './types';
+import type {MoneyRequestPreviewProps} from './types';
function MoneyRequestPreview(props: MoneyRequestPreviewProps) {
+ const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${props.iouReportID || '-1'}`);
// We should not render the component if there is no iouReport and it's not a split or track expense.
// Moved outside of the component scope to allow for easier use of hooks in the main component.
// eslint-disable-next-line react/jsx-props-no-spreading
- return lodashIsEmpty(props.iouReport) && !(props.isBillSplit || props.isTrackExpense) ? null : ;
+ return lodashIsEmpty(iouReport) && !(props.isBillSplit || props.isTrackExpense) ? null : ;
}
MoneyRequestPreview.displayName = 'MoneyRequestPreview';
-export default withOnyx({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- chatReport: {
- key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`,
- },
- iouReport: {
- key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`,
- },
- session: {
- key: ONYXKEYS.SESSION,
- },
- transaction: {
- key: ({action}) => {
- const isMoneyRequestAction = ReportActionsUtils.isMoneyRequestAction(action);
- const transactionID = isMoneyRequestAction ? ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID : 0;
- return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
- },
- },
- walletTerms: {
- key: ONYXKEYS.WALLET_TERMS,
- },
- transactionViolations: {
- key: ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS,
- },
-})(MoneyRequestPreview);
+export default MoneyRequestPreview;
diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts
index 021ae5d188d9..c40b45c6d2bd 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts
+++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts
@@ -1,33 +1,9 @@
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ContextMenuAnchor} from '@pages/home/report/ContextMenu/ReportActionContextMenu';
import type * as OnyxTypes from '@src/types/onyx';
import type IconAsset from '@src/types/utils/IconAsset';
-type MoneyRequestPreviewOnyxProps = {
- /** All of the personal details for everyone */
- personalDetails: OnyxEntry;
-
- /** Chat report associated with iouReport */
- chatReport: OnyxEntry;
-
- /** IOU report data object */
- iouReport: OnyxEntry;
-
- /** Session info for the currently logged in user. */
- session: OnyxEntry;
-
- /** The transaction attached to the action.message.iouTransactionID */
- transaction: OnyxEntry;
-
- /** The transaction violations attached to the action.message.iouTransactionID */
- transactionViolations: OnyxCollection;
-
- /** Information about the user accepting the terms for payments */
- walletTerms: OnyxEntry;
-};
-
-type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & {
+type MoneyRequestPreviewProps = {
/** The active IOUReport, used for Onyx subscription */
// The iouReportID is used inside withOnyx HOC
// eslint-disable-next-line react/no-unused-prop-types
@@ -90,4 +66,4 @@ type PendingProps = {
type PendingMessageProps = PendingProps | NoPendingProps;
-export type {MoneyRequestPreviewProps, MoneyRequestPreviewOnyxProps, PendingMessageProps};
+export type {MoneyRequestPreviewProps, PendingMessageProps};
diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx
index cadfe5786079..f10951f2b1a0 100644
--- a/src/components/ReportActionItem/ReportPreview.tsx
+++ b/src/components/ReportActionItem/ReportPreview.tsx
@@ -192,6 +192,10 @@ function ReportPreview({
const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false);
const stopAnimation = useCallback(() => setIsPaidAnimationRunning(false), []);
+ const startAnimation = useCallback(() => {
+ setIsPaidAnimationRunning(true);
+ HapticFeedback.longPress();
+ }, []);
const confirmPayment = useCallback(
(type: PaymentMethodType | undefined, payAsBusiness?: boolean) => {
if (!type) {
@@ -590,6 +594,7 @@ function ReportPreview({
chatReport={chatReport}
moneyRequestReport={iouReport}
transactionCount={numberOfRequests}
+ startAnimation={startAnimation}
/>
)}
diff --git a/src/components/ReportActionItem/TaskPreview.tsx b/src/components/ReportActionItem/TaskPreview.tsx
index 95528286d1e1..187dfbafa5c4 100644
--- a/src/components/ReportActionItem/TaskPreview.tsx
+++ b/src/components/ReportActionItem/TaskPreview.tsx
@@ -1,18 +1,17 @@
import {Str} from 'expensify-common';
import React from 'react';
import {View} from 'react-native';
-import type {StyleProp, ViewStyle} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import Avatar from '@components/Avatar';
import Checkbox from '@components/Checkbox';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
+import iconWrapperStyle from '@components/Icon/IconWrapperStyles';
import {usePersonalDetails} from '@components/OnyxProvider';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import RenderHTML from '@components/RenderHTML';
import {showContextMenuForReport} from '@components/ShowContextMenuContext';
-import Text from '@components/Text';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
@@ -55,12 +54,9 @@ type TaskPreviewProps = WithCurrentUserPersonalDetailsProps & {
/** Callback for updating context menu active state, used for showing context menu */
checkIfContextMenuActive: () => void;
-
- /** Style for the task preview container */
- style: StyleProp;
};
-function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false, style}: TaskPreviewProps) {
+function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, checkIfContextMenuActive, currentUserPersonalDetails, isHovered = false}: TaskPreviewProps) {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
@@ -75,33 +71,29 @@ function TaskPreview({taskReportID, action, contextMenuAnchor, chatReportID, che
: action?.childStateNum === CONST.REPORT.STATE_NUM.APPROVED && action?.childStatusNum === CONST.REPORT.STATUS_NUM.APPROVED;
const taskTitle = Str.htmlEncode(TaskUtils.getTaskTitle(taskReportID, action?.childReportName ?? ''));
const taskAssigneeAccountID = Task.getTaskAssigneeAccountID(taskReport) ?? action?.childManagerAccountID ?? -1;
- const hasAssignee = taskAssigneeAccountID > 0;
const personalDetails = usePersonalDetails();
const avatar = personalDetails?.[taskAssigneeAccountID]?.avatar ?? Expensicons.FallbackAvatar;
- const avatarSize = CONST.AVATAR_SIZE.SMALL;
+ const htmlForTaskPreview = `${taskTitle}`;
const isDeletedParentAction = ReportUtils.isCanceledTaskReport(taskReport, action);
- const iconWrapperStyle = StyleUtils.getTaskPreviewIconWrapper(hasAssignee ? avatarSize : undefined);
- const titleStyle = StyleUtils.getTaskPreviewTitleStyle(iconWrapperStyle.height, isTaskCompleted);
-
const shouldShowGreenDotIndicator = ReportUtils.isOpenTaskReport(taskReport, action) && ReportUtils.isReportManager(taskReport);
if (isDeletedParentAction) {
return ${translate('parentReportAction.deletedTask')}`} />;
}
return (
-
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(taskReportID))}
onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()}
onPressOut={() => ControlSelection.unblock()}
onLongPress={(event) => showContextMenuForReport(event, contextMenuAnchor, chatReportID, action, checkIfContextMenuActive)}
shouldUseHapticsOnLongPress
- style={[styles.flexRow, styles.justifyContentBetween, style]}
+ style={[styles.flexRow, styles.justifyContentBetween]}
role={CONST.ROLE.BUTTON}
accessibilityLabel={translate('task.task')}
>
-
-
+
+
- {hasAssignee && (
+ {taskAssigneeAccountID > 0 && (
)}
- {taskTitle}
+
+ ${htmlForTaskPreview}` : htmlForTaskPreview} />
+
{shouldShowGreenDotIndicator && (
-
+
diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx
index 97e611f0bafe..12708a7acbfa 100644
--- a/src/components/ReportWelcomeText.tsx
+++ b/src/components/ReportWelcomeText.tsx
@@ -1,29 +1,25 @@
import React, {useMemo} from 'react';
import {View} from 'react-native';
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 Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
+import {getPolicy} from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import SidebarUtils from '@libs/SidebarUtils';
import CONST from '@src/CONST';
import type {IOUType} from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {PersonalDetailsList, Policy, Report} from '@src/types/onyx';
+import type {Policy, Report} from '@src/types/onyx';
import {PressableWithoutFeedback} from './Pressable';
import RenderHTML from './RenderHTML';
import Text from './Text';
import UserDetailsTooltip from './UserDetailsTooltip';
-type ReportWelcomeTextOnyxProps = {
- /** All of the personal details for everyone */
- personalDetails: OnyxEntry;
-};
-
-type ReportWelcomeTextProps = ReportWelcomeTextOnyxProps & {
+type ReportWelcomeTextProps = {
/** The report currently being looked at */
report: OnyxEntry;
@@ -31,29 +27,39 @@ type ReportWelcomeTextProps = ReportWelcomeTextOnyxProps & {
policy: OnyxEntry;
};
-function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextProps) {
+function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID || -1}`);
+ const isArchivedRoom = ReportUtils.isArchivedRoom(report, reportNameValuePairs);
const isChatRoom = ReportUtils.isChatRoom(report);
const isSelfDM = ReportUtils.isSelfDM(report);
const isInvoiceRoom = ReportUtils.isInvoiceRoom(report);
const isSystemChat = ReportUtils.isSystemChat(report);
const isDefault = !(isChatRoom || isPolicyExpenseChat || isSelfDM || isInvoiceRoom || isSystemChat);
- const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report);
+ const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, undefined, undefined, true);
const isMultipleParticipant = participantAccountIDs.length > 1;
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant);
const welcomeMessage = SidebarUtils.getWelcomeMessage(report, policy);
const moneyRequestOptions = ReportUtils.temporary_getMoneyRequestOptions(report, policy, participantAccountIDs);
- const additionalText = moneyRequestOptions
- .filter(
- (item): item is Exclude =>
- item !== CONST.IOU.TYPE.INVOICE,
- )
- .map((item) => translate(`reportActionsView.iouTypes.${item}`))
+ const filteredOptions = moneyRequestOptions.filter(
+ (item): item is Exclude =>
+ item !== CONST.IOU.TYPE.INVOICE,
+ );
+ const additionalText = filteredOptions
+ .map((item, index) => `${index === filteredOptions.length - 1 ? `${translate('common.or')} ` : ''}${translate(`reportActionsView.iouTypes.${item}`)}`)
.join(', ');
const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy);
const reportName = ReportUtils.getReportName(report);
+ const shouldShowUsePlusButtonText =
+ (moneyRequestOptions.includes(CONST.IOU.TYPE.PAY) ||
+ moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT) ||
+ moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK) ||
+ moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)) &&
+ !isPolicyExpenseChat;
const navigateToReport = () => {
if (!report?.reportID) {
@@ -112,7 +118,38 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP
{welcomeMessage.phrase3}
))}
+ {isInvoiceRoom &&
+ !isArchivedRoom &&
+ (welcomeMessage?.messageHtml ? (
+ {
+ if (!canEditPolicyDescription) {
+ return;
+ }
+ Navigation.navigate(ROUTES.WORKSPACE_PROFILE_DESCRIPTION.getRoute(policy?.id ?? '-1'));
+ }}
+ style={[styles.renderHTML, canEditPolicyDescription ? styles.cursorPointer : styles.cursorText]}
+ accessibilityLabel={translate('reportDescriptionPage.roomDescription')}
+ >
+
+
+ ) : (
+
+ {welcomeMessage.phrase1}
+
+ {report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL ? (
+ {ReportUtils.getDisplayNameForParticipant(report?.invoiceReceiver?.accountID)}
+ ) : (
+ {getPolicy(report?.invoiceReceiver?.policyID)?.name}
+ )}
+
+ {` ${translate('common.and')} `}
+ {ReportUtils.getPolicyName(report)}
+ {welcomeMessage.phrase2}
+
+ ))}
{isChatRoom &&
+ (!isInvoiceRoom || isArchivedRoom) &&
(welcomeMessage?.messageHtml ? (
{
@@ -179,9 +216,8 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP
))}
)}
- {(moneyRequestOptions.includes(CONST.IOU.TYPE.PAY) || moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT) || moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK)) && (
- {translate('reportActionsView.usePlusButton', {additionalText})}
- )}
+ {shouldShowUsePlusButtonText && {translate('reportActionsView.usePlusButton', {additionalText})}}
+ {ReportUtils.isConciergeChatReport(report) && {translate('reportActionsView.askConcierge')}}
>
);
@@ -189,8 +225,4 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP
ReportWelcomeText.displayName = 'ReportWelcomeText';
-export default withOnyx({
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
-})(ReportWelcomeText);
+export default ReportWelcomeText;
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index e459be1815bc..3556ff068b55 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -180,12 +180,34 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr
const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0;
const isSearchResultsEmpty = !searchResults?.data || SearchUtils.isSearchResultsEmpty(searchResults);
const prevIsSearchResultEmpty = usePrevious(isSearchResultsEmpty);
+ const data = searchResults === undefined ? [] : SearchUtils.getSections(type, status, searchResults.data, searchResults.search);
useEffect(() => {
/** We only want to display the skeleton for the status filters the first time we load them for a specific data type */
setShouldShowStatusBarLoading(shouldShowLoadingState && searchResults?.search?.type !== type);
}, [searchResults?.search?.type, setShouldShowStatusBarLoading, shouldShowLoadingState, type]);
+ useEffect(() => {
+ const newTransactionList: SelectedTransactions = {};
+ data.forEach((report) => {
+ const transactionsData: TransactionListItemType[] = Object.hasOwn(report, 'transactions') && 'transactions' in report ? report.transactions : [];
+ transactionsData.forEach((transaction) => {
+ if (!Object.keys(selectedTransactions).includes(transaction.transactionID)) {
+ return;
+ }
+ newTransactionList[transaction.transactionID] = {
+ action: transaction.action,
+ canHold: transaction.canHold,
+ canUnhold: transaction.canUnhold,
+ isSelected: selectedTransactions[transaction.transactionID].isSelected,
+ canDelete: transaction.canDelete,
+ };
+ });
+ });
+ setSelectedTransactions(newTransactionList, data);
+ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
+ }, [data, setSelectedTransactions]);
+
useEffect(() => {
if (!isSearchResultsEmpty || prevIsSearchResultEmpty) {
return;
@@ -208,7 +230,6 @@ function Search({queryJSON, onSearchListScroll, contentContainerStyle}: SearchPr
}
const ListItem = SearchUtils.getListItem(type, status);
- const data = SearchUtils.getSections(type, status, searchResults.data, searchResults.search);
const sortedData = SearchUtils.getSortedSections(type, status, data, sortBy, sortOrder);
const sortedSelectedData = sortedData.map((item) => {
const baseKey = `${ONYXKEYS.COLLECTION.TRANSACTION}${(item as TransactionListItemType).transactionID}`;
diff --git a/src/hooks/usePageRefresh/index.native.ts b/src/hooks/usePageRefresh/index.native.ts
new file mode 100644
index 000000000000..eb6a229ad65c
--- /dev/null
+++ b/src/hooks/usePageRefresh/index.native.ts
@@ -0,0 +1,10 @@
+import {useErrorBoundary} from 'react-error-boundary';
+import type UsePageRefresh from './type';
+
+const usePageRefresh: UsePageRefresh = () => {
+ const {resetBoundary} = useErrorBoundary();
+
+ return resetBoundary;
+};
+
+export default usePageRefresh;
diff --git a/src/hooks/usePageRefresh/index.ts b/src/hooks/usePageRefresh/index.ts
new file mode 100644
index 000000000000..3708fe29e966
--- /dev/null
+++ b/src/hooks/usePageRefresh/index.ts
@@ -0,0 +1,24 @@
+import differenceInMilliseconds from 'date-fns/differenceInMilliseconds';
+import {useErrorBoundary} from 'react-error-boundary';
+import CONST from '@src/CONST';
+import type UsePageRefresh from './type';
+
+const usePageRefresh: UsePageRefresh = () => {
+ const {resetBoundary} = useErrorBoundary();
+
+ return () => {
+ const lastRefreshTimestamp = JSON.parse(sessionStorage.getItem(CONST.SESSION_STORAGE_KEYS.LAST_REFRESH_TIMESTAMP) ?? 'null') as string;
+
+ if (lastRefreshTimestamp === null || differenceInMilliseconds(Date.now(), Number(lastRefreshTimestamp)) > CONST.ERROR_WINDOW_RELOAD_TIMEOUT) {
+ resetBoundary();
+ sessionStorage.setItem(CONST.SESSION_STORAGE_KEYS.LAST_REFRESH_TIMESTAMP, Date.now().toString());
+
+ return;
+ }
+
+ window.location.reload();
+ sessionStorage.removeItem(CONST.SESSION_STORAGE_KEYS.LAST_REFRESH_TIMESTAMP);
+ };
+};
+
+export default usePageRefresh;
diff --git a/src/hooks/usePageRefresh/type.ts b/src/hooks/usePageRefresh/type.ts
new file mode 100644
index 000000000000..f800cc0224a9
--- /dev/null
+++ b/src/hooks/usePageRefresh/type.ts
@@ -0,0 +1,3 @@
+type UsePageRefresh = () => () => void;
+
+export default UsePageRefresh;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index f15e5c97d037..e359870cbad6 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -19,7 +19,6 @@ import type {
BadgeFreeTrialParams,
BeginningOfChatHistoryAdminRoomPartOneParams,
BeginningOfChatHistoryAnnounceRoomPartOneParams,
- BeginningOfChatHistoryAnnounceRoomPartTwo,
BeginningOfChatHistoryDomainRoomPartOneParams,
BillingBannerCardAuthenticationRequiredParams,
BillingBannerCardExpiredParams,
@@ -265,6 +264,7 @@ const translations = {
phoneNumberPlaceholder: '(xxx) xxx-xxxx',
email: 'Email',
and: 'and',
+ or: 'or',
details: 'Details',
privacy: 'Privacy',
privacyPolicy: 'Privacy Policy',
@@ -658,33 +658,34 @@ const translations = {
reportActionsView: {
beginningOfArchivedRoomPartOne: 'You missed the party in ',
beginningOfArchivedRoomPartTwo: ", there's nothing to see here.",
- beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `Collaboration with everyone at ${domainRoom} starts here! 🎉\nUse `,
- beginningOfChatHistoryDomainRoomPartTwo: ' to chat with colleagues, share tips, and ask questions.',
- beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => `Collaboration among ${workspaceName} admins starts here! 🎉\nUse `,
- beginningOfChatHistoryAdminRoomPartTwo: ' to chat about topics such as workspace configurations and more.',
- beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) =>
- `Collaboration between all ${workspaceName} members starts here! 🎉\nUse `,
- beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` to chat about anything ${workspaceName} related.`,
- beginningOfChatHistoryUserRoomPartOne: 'Collaboration starts here! 🎉\nUse this space to chat about anything ',
+ beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `This chat is with all Expensify members on the ${domainRoom} domain.`,
+ beginningOfChatHistoryDomainRoomPartTwo: ' Use it to chat with colleagues, share tips, and ask questions.',
+ beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => `This chat is with ${workspaceName} admins.`,
+ beginningOfChatHistoryAdminRoomPartTwo: ' Use it to chat about workspace setup and more.',
+ beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => `This chat is with everyone in ${workspaceName} workspace.`,
+ beginningOfChatHistoryAnnounceRoomPartTwo: ` Use it for the most important announcements.`,
+ beginningOfChatHistoryUserRoomPartOne: 'This chat room is for anything ',
beginningOfChatHistoryUserRoomPartTwo: ' related.',
- beginningOfChatHistoryInvoiceRoom: 'Collaboration starts here! 🎉 Use this room to view, discuss, and pay invoices.',
- beginningOfChatHistory: 'This is the beginning of your chat with ',
- beginningOfChatHistoryPolicyExpenseChatPartOne: 'Collaboration between ',
- beginningOfChatHistoryPolicyExpenseChatPartTwo: ' and ',
- beginningOfChatHistoryPolicyExpenseChatPartThree: ' starts here! 🎉 This is the place to chat, submit expenses and settle up.',
+ beginningOfChatHistoryInvoiceRoomPartOne: `This chat is for invoices between `,
+ beginningOfChatHistoryInvoiceRoomPartTwo: `. Use the + button to send an invoice.`,
+ beginningOfChatHistory: 'This chat is with ',
+ beginningOfChatHistoryPolicyExpenseChatPartOne: 'This is where ',
+ beginningOfChatHistoryPolicyExpenseChatPartTwo: ' will submit expenses to ',
+ beginningOfChatHistoryPolicyExpenseChatPartThree: ' workspace. Just use the + button.',
beginningOfChatHistorySelfDM: 'This is your personal space. Use it for notes, tasks, drafts, and reminders.',
beginningOfChatHistorySystemDM: "Welcome! Let's get you set up.",
chatWithAccountManager: 'Chat with your account manager here',
sayHello: 'Say hello!',
yourSpace: 'Your space',
welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `Welcome to ${roomName}!`,
- usePlusButton: ({additionalText}: UsePlusButtonParams) => `\nYou can also use the + button to ${additionalText}, or assign a task!`,
+ usePlusButton: ({additionalText}: UsePlusButtonParams) => `\nUse the + button to ${additionalText} an expense.`,
+ askConcierge: '\nAsk questions and get 24/7 realtime support.',
iouTypes: {
- pay: 'pay expenses',
- split: 'split an expense',
- submit: 'submit an expense',
- track: 'track an expense',
- invoice: 'invoice an expense',
+ pay: 'pay',
+ split: 'split',
+ submit: 'submit',
+ track: 'track',
+ invoice: 'invoice',
},
},
adminOnlyCanPost: 'Only admins can send messages in this room.',
@@ -2351,6 +2352,9 @@ const translations = {
}
},
},
+ qbd: {
+ exportDescription: 'Configure how Expensify data exports to QuickBooks Desktop.',
+ },
qbo: {
importDescription: 'Choose which coding configurations to import from QuickBooks Online to Expensify.',
classes: 'Classes',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 413084aa8286..7a2ce98b6bdd 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -17,7 +17,6 @@ import type {
BadgeFreeTrialParams,
BeginningOfChatHistoryAdminRoomPartOneParams,
BeginningOfChatHistoryAnnounceRoomPartOneParams,
- BeginningOfChatHistoryAnnounceRoomPartTwo,
BeginningOfChatHistoryDomainRoomPartOneParams,
BillingBannerCardAuthenticationRequiredParams,
BillingBannerCardExpiredParams,
@@ -256,6 +255,7 @@ const translations = {
phoneNumberPlaceholder: '(xxx) xxx-xxxx',
email: 'Email',
and: 'y',
+ or: 'o',
details: 'Detalles',
privacy: 'Privacidad',
hidden: 'Oculto',
@@ -650,34 +650,36 @@ const translations = {
reportActionsView: {
beginningOfArchivedRoomPartOne: 'Te perdiste la fiesta en ',
beginningOfArchivedRoomPartTwo: ', no hay nada que ver aquí.',
- beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) => `¡Colabora aquí con todos los participantes de ${domainRoom}! 🎉\nUtiliza `,
- beginningOfChatHistoryDomainRoomPartTwo: ' para chatear con compañeros, compartir consejos o hacer una pregunta.',
+ beginningOfChatHistoryDomainRoomPartOne: ({domainRoom}: BeginningOfChatHistoryDomainRoomPartOneParams) =>
+ `Este chat es con todos los miembros de Expensify en el dominio ${domainRoom}.`,
+ beginningOfChatHistoryDomainRoomPartTwo: ' Úsalo para chatear con colegas, compartir consejos y hacer preguntas.',
beginningOfChatHistoryAdminRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) =>
- `¡Este es el lugar para que los administradores de ${workspaceName} colaboren! 🎉\nUsa `,
- beginningOfChatHistoryAdminRoomPartTwo: ' para chatear sobre temas como la configuración del espacio de trabajo y mas.',
- beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) =>
- `¡Este es el lugar para que todos los miembros de ${workspaceName} colaboren! 🎉\nUsa `,
- beginningOfChatHistoryAnnounceRoomPartTwo: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartTwo) => ` para chatear sobre cualquier cosa relacionada con ${workspaceName}.`,
- beginningOfChatHistoryUserRoomPartOne: '¡Este es el lugar para colaborar! 🎉\nUsa este espacio para chatear sobre cualquier cosa relacionada con ',
- beginningOfChatHistoryUserRoomPartTwo: '.',
- beginningOfChatHistoryInvoiceRoom: '¡Este es el lugar para colaborar! 🎉 Utilice esta sala para ver, discutir y pagar facturas.',
- beginningOfChatHistory: 'Aquí comienzan tus conversaciones con ',
- beginningOfChatHistoryPolicyExpenseChatPartOne: '¡La colaboración entre ',
- beginningOfChatHistoryPolicyExpenseChatPartTwo: ' y ',
- beginningOfChatHistoryPolicyExpenseChatPartThree: ' empieza aquí! 🎉 Este es el lugar donde chatear y presentar o pagar gastos.',
+ `Este chat es con los administradores del espacio de trabajo ${workspaceName}.`,
+ beginningOfChatHistoryAdminRoomPartTwo: ' Use it to chat about workspace setup and more.',
+ beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => `Este chat es con todos en el espacio de trabajo ${workspaceName}.`,
+ beginningOfChatHistoryAnnounceRoomPartTwo: ` Úsalo para hablar sobre la configuración del espacio de trabajo y más.`,
+ beginningOfChatHistoryUserRoomPartOne: 'ste chat es para todo lo relacionado con ',
+ beginningOfChatHistoryUserRoomPartTwo: ' Fue creado por.',
+ beginningOfChatHistoryInvoiceRoomPartOne: `Este chat es para facturas entre `,
+ beginningOfChatHistoryInvoiceRoomPartTwo: `. Usa el botón + para enviar una factura.`,
+ beginningOfChatHistory: 'Este chat es con',
+ beginningOfChatHistoryPolicyExpenseChatPartOne: 'Aquí es donde ',
+ beginningOfChatHistoryPolicyExpenseChatPartTwo: ' enviará los gastos al espacio de trabajo ',
+ beginningOfChatHistoryPolicyExpenseChatPartThree: '. Solo usa el botón +.',
beginningOfChatHistorySelfDM: 'Este es tu espacio personal. Úsalo para notas, tareas, borradores y recordatorios.',
beginningOfChatHistorySystemDM: '¡Bienvenido! Vamos a configurar tu cuenta.',
chatWithAccountManager: 'Chatea con tu gestor de cuenta aquí',
sayHello: '¡Saluda!',
yourSpace: 'Tu espacio',
welcomeToRoom: ({roomName}: WelcomeToRoomParams) => `¡Bienvenido a ${roomName}!`,
- usePlusButton: ({additionalText}: UsePlusButtonParams) => `\n¡También puedes usar el botón + de abajo para ${additionalText}, o asignar una tarea!`,
+ usePlusButton: ({additionalText}: UsePlusButtonParams) => `\nUsa el botón + para ${additionalText} un gasto`,
+ askConcierge: 'Haz preguntas y obtén soporte en tiempo real las 24/7.',
iouTypes: {
- pay: 'pagar gastos',
- split: 'dividir un gasto',
- submit: 'presentar un gasto',
- track: 'rastrear un gasto',
- invoice: 'facturar un gasto',
+ pay: 'pagar',
+ split: 'dividir',
+ submit: 'presentar',
+ track: 'rastrear',
+ invoice: 'facturar',
},
},
adminOnlyCanPost: 'Solo los administradores pueden enviar mensajes en esta sala.',
@@ -2372,6 +2374,9 @@ const translations = {
}
},
},
+ qbd: {
+ exportDescription: 'Configura cómo se exportan los datos de Expensify a QuickBooks Desktop.',
+ },
qbo: {
importDescription: 'Elige que configuraciónes de codificación son importadas desde QuickBooks Online a Expensify.',
classes: 'Clases',
diff --git a/src/libs/API/parameters/ResolveDuplicatesParams.ts b/src/libs/API/parameters/ResolveDuplicatesParams.ts
new file mode 100644
index 000000000000..d225f227c0d7
--- /dev/null
+++ b/src/libs/API/parameters/ResolveDuplicatesParams.ts
@@ -0,0 +1,24 @@
+type ResolveDuplicatesParams = {
+ /** The ID of the transaction that we want to keep */
+ transactionID: string;
+
+ /** The list of other duplicated transactions */
+ transactionIDList: string[];
+ created: string;
+ merchant: string;
+ amount: number;
+ currency: string;
+ category: string;
+ comment: string;
+ billable: boolean;
+ reimbursable: boolean;
+ tag: string;
+
+ /** The reportActionID of the dismissed violation action in the kept transaction thread report */
+ dismissedViolationReportActionID: string;
+
+ /** The ID list of the hold report actions corresponding to the transactionIDList */
+ reportActionIDList: string[];
+};
+
+export default ResolveDuplicatesParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 1b4e89342f5f..0ad5c9644e9f 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -239,6 +239,7 @@ export type {default as SendInvoiceParams} from './SendInvoiceParams';
export type {default as PayInvoiceParams} from './PayInvoiceParams';
export type {default as MarkAsCashParams} from './MarkAsCashParams';
export type {default as TransactionMergeParams} from './TransactionMergeParams';
+export type {default as ResolveDuplicatesParams} from './ResolveDuplicatesParams';
export type {default as UpdateSubscriptionTypeParams} from './UpdateSubscriptionTypeParams';
export type {default as SignUpUserParams} from './SignUpUserParams';
export type {default as UpdateSubscriptionAutoRenewParams} from './UpdateSubscriptionAutoRenewParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 30c563e1ae2b..7adcebbe2872 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -286,6 +286,7 @@ const WRITE_COMMANDS = {
PAY_INVOICE: 'PayInvoice',
MARK_AS_CASH: 'MarkAsCash',
TRANSACTION_MERGE: 'Transaction_Merge',
+ RESOLVE_DUPLICATES: 'ResolveDuplicates',
UPDATE_SUBSCRIPTION_TYPE: 'UpdateSubscriptionType',
SIGN_UP_USER: 'SignUpUser',
UPDATE_SUBSCRIPTION_AUTO_RENEW: 'UpdateSubscriptionAutoRenew',
@@ -706,6 +707,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.PAY_INVOICE]: Parameters.PayInvoiceParams;
[WRITE_COMMANDS.MARK_AS_CASH]: Parameters.MarkAsCashParams;
[WRITE_COMMANDS.TRANSACTION_MERGE]: Parameters.TransactionMergeParams;
+ [WRITE_COMMANDS.RESOLVE_DUPLICATES]: Parameters.ResolveDuplicatesParams;
[WRITE_COMMANDS.UPDATE_SUBSCRIPTION_TYPE]: Parameters.UpdateSubscriptionTypeParams;
[WRITE_COMMANDS.SIGN_UP_USER]: Parameters.SignUpUserParams;
[WRITE_COMMANDS.UPDATE_SUBSCRIPTION_AUTO_RENEW]: Parameters.UpdateSubscriptionAutoRenewParams;
diff --git a/src/libs/AccountingUtils.ts b/src/libs/AccountingUtils.ts
index fe472752978a..7516048241d6 100644
--- a/src/libs/AccountingUtils.ts
+++ b/src/libs/AccountingUtils.ts
@@ -7,6 +7,7 @@ const ROUTE_NAME_MAPPING = {
[CONST.POLICY.CONNECTIONS.ROUTE.XERO]: CONST.POLICY.CONNECTIONS.NAME.XERO,
[CONST.POLICY.CONNECTIONS.ROUTE.SAGE_INTACCT]: CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT,
[CONST.POLICY.CONNECTIONS.ROUTE.NETSUITE]: CONST.POLICY.CONNECTIONS.NAME.NETSUITE,
+ [CONST.POLICY.CONNECTIONS.ROUTE.QBD]: CONST.POLICY.CONNECTIONS.NAME.QBD,
};
const NAME_ROUTE_MAPPING = {
@@ -14,6 +15,7 @@ const NAME_ROUTE_MAPPING = {
[CONST.POLICY.CONNECTIONS.NAME.XERO]: CONST.POLICY.CONNECTIONS.ROUTE.XERO,
[CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT]: CONST.POLICY.CONNECTIONS.ROUTE.SAGE_INTACCT,
[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]: CONST.POLICY.CONNECTIONS.ROUTE.NETSUITE,
+ [CONST.POLICY.CONNECTIONS.NAME.QBD]: CONST.POLICY.CONNECTIONS.ROUTE.QBD,
};
function getConnectionNameFromRouteParam(routeParam: ValueOf) {
diff --git a/src/libs/Authentication.ts b/src/libs/Authentication.ts
index dd1003591701..1ab7083b2d8e 100644
--- a/src/libs/Authentication.ts
+++ b/src/libs/Authentication.ts
@@ -1,6 +1,7 @@
import CONFIG from '@src/CONFIG';
import CONST from '@src/CONST';
import type Response from '@src/types/onyx/Response';
+import * as Delegate from './actions/Delegate';
import updateSessionAuthTokens from './actions/Session/updateSessionAuthTokens';
import redirectToSignIn from './actions/SignInRedirect';
import * as ErrorUtils from './ErrorUtils';
@@ -84,6 +85,14 @@ function reauthenticate(command = ''): Promise {
return;
}
+ // If we reauthenticated due to an expired delegate token, restore the delegate's original account.
+ // This is because the credentials used to reauthenticate were for the delegate's original account, and not for the account they were connected as.
+ if (Delegate.isConnectedAsDelegate()) {
+ Log.info('Reauthenticated while connected as a delegate. Restoring original account.');
+ Delegate.restoreDelegateSession(response);
+ return;
+ }
+
// Update authToken in Onyx and in our local variables so that API requests will use the new authToken
updateSessionAuthTokens(response.authToken, response.encryptedAuthToken);
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 2be410ad8803..5cdfa302ec97 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -300,6 +300,7 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/workspace/accounting/qbo/export/QuickbooksCompanyCardExpenseAccountPage').default,
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: () =>
require('../../../../pages/workspace/accounting/qbo/export/QuickbooksPreferredExporterConfigurationPage').default,
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT]: () => require('../../../../pages/workspace/accounting/qbd/export/QuickbooksDesktopExportPage').default,
[SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../../pages/ReimbursementAccount/ReimbursementAccountPage').default,
[SCREENS.GET_ASSISTANCE]: () => require('../../../../pages/GetAssistancePage').default,
[SCREENS.SETTINGS.TWO_FACTOR_AUTH]: () => require('../../../../pages/settings/Security/TwoFactorAuth/TwoFactorAuthPage').default,
diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
index 6d8871e5a38a..5a6e949ea6ec 100755
--- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
+++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts
@@ -45,6 +45,7 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = {
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ADVANCED,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ACCOUNT_SELECTOR,
SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR,
+ SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT,
SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT,
SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS,
SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index 980b28c3d635..5c94ef8ec41d 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -376,6 +376,7 @@ const config: LinkingOptions['config'] = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR]: {
path: ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_INVOICE_ACCOUNT_SELECTOR.route,
},
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_XERO_IMPORT.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_XERO_CHART_OF_ACCOUNTS.route},
[SCREENS.WORKSPACE.ACCOUNTING.XERO_ORGANIZATION]: {path: ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.route},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index ecd2895053f1..2d56c849546b 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -436,6 +436,9 @@ type SettingsNavigatorParamList = {
[SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_EXPORT_PREFERRED_EXPORTER]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_DESKTOP_EXPORT]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.ACCOUNTING.XERO_IMPORT]: {
policyID: string;
};
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index 8c47100e465b..22a6c72933de 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -49,6 +49,10 @@ function canUseCombinedTrackSubmit(betas: OnyxEntry): boolean {
return !!betas?.includes(CONST.BETAS.COMBINED_TRACK_SUBMIT);
}
+function canUseNewDotQBD(betas: OnyxEntry): boolean {
+ return !!betas?.includes(CONST.BETAS.NEW_DOT_QBD) || canUseAllBetas(betas);
+}
+
/**
* Link previews are temporarily disabled.
*/
@@ -68,4 +72,5 @@ export default {
canUseNewDotCopilot,
canUseWorkspaceRules,
canUseCombinedTrackSubmit,
+ canUseNewDotQBD,
};
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index a50bdd7a10ba..3d016fab713d 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -2099,7 +2099,7 @@ function getDisplayNameForParticipant(
return shouldUseShortForm ? shortName : longName;
}
-function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldExcludeHidden = false, shouldExcludeDeleted = false): number[] {
+function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldExcludeHidden = false, shouldExcludeDeleted = false, shouldForceExcludeCurrentUser = false): number[] {
const reportParticipants = report?.participants ?? {};
let participantsEntries = Object.entries(reportParticipants);
@@ -2126,7 +2126,7 @@ function getParticipantsAccountIDsForDisplay(report: OnyxEntry, shouldEx
// For 1:1 chat, we don't want to include the current user as a participant in order to not mark 1:1 chats as having multiple participants
// For system chat, we want to display Expensify as the only participant
- const shouldExcludeCurrentUser = isOneOnOneChat(report) || isSystemChat(report);
+ const shouldExcludeCurrentUser = isOneOnOneChat(report) || isSystemChat(report) || shouldForceExcludeCurrentUser;
if (shouldExcludeCurrentUser || shouldExcludeHidden || shouldExcludeDeleted) {
participantsIds = participantsIds.filter((accountID) => {
@@ -3755,12 +3755,11 @@ function getReportName(
parentReportActionParam?: OnyxInputOrEntry,
personalDetails?: Partial,
invoiceReceiverPolicy?: OnyxEntry,
- shouldIncludePolicyName = false,
): string {
const reportID = report?.reportID;
const cacheKey = getCacheKey(report);
- if (reportID && !isUserCreatedPolicyRoom(report) && !isDefaultRoom(report)) {
+ if (reportID) {
const reportNameFromCache = reportNameCache.get(cacheKey);
if (reportNameFromCache?.reportName && reportNameFromCache.reportName === report?.reportName && reportNameFromCache.reportName !== CONST.REPORT.DEFAULT_REPORT_NAME) {
@@ -3874,11 +3873,6 @@ function getReportName(
formattedName = getInvoicesChatName(report, invoiceReceiverPolicy);
}
- if (shouldIncludePolicyName && (isUserCreatedPolicyRoom(report) || isDefaultRoom(report))) {
- const policyName = getPolicyName(report, true);
- formattedName = policyName ? `${policyName} • ${report?.reportName}` : report?.reportName;
- }
-
if (isArchivedRoom(report, getReportNameValuePairs(report?.reportID))) {
formattedName += ` (${Localize.translateLocal('common.archived')})`;
}
@@ -3935,8 +3929,8 @@ function getPayeeName(report: OnyxEntry): string | undefined {
/**
* Get either the policyName or domainName the chat is tied to
*/
-function getChatRoomSubtitle(report: OnyxEntry, isTitleIncludePolicyName = false): string | undefined {
- if (isChatThread(report) || ((isUserCreatedPolicyRoom(report) || isDefaultRoom(report)) && isTitleIncludePolicyName)) {
+function getChatRoomSubtitle(report: OnyxEntry): string | undefined {
+ if (isChatThread(report)) {
return '';
}
if (isSelfDM(report)) {
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 0496bc66fe5b..dd2902c91bfe 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -488,7 +488,7 @@ function getOptionData({
result.phoneNumber = personalDetail?.phoneNumber ?? '';
}
- const reportName = ReportUtils.getReportName(report, policy, undefined, undefined, invoiceReceiverPolicy, true);
+ const reportName = ReportUtils.getReportName(report, policy, undefined, undefined, invoiceReceiverPolicy);
result.text = reportName;
result.subtitle = subtitle;
@@ -543,7 +543,7 @@ function getWelcomeMessage(report: OnyxEntry, policy: OnyxEntry)
}
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistory');
- const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report);
+ const participantAccountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(report, undefined, undefined, true);
const isMultipleParticipant = participantAccountIDs.length > 1;
const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(
OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, allPersonalDetails),
@@ -588,17 +588,28 @@ function getRoomWelcomeMessage(report: OnyxEntry): WelcomeMessage {
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfArchivedRoomPartOne');
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfArchivedRoomPartTwo');
} else if (ReportUtils.isDomainRoom(report)) {
+ welcomeMessage.showReportName = false;
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryDomainRoomPartOne', {domainRoom: report?.reportName ?? ''});
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryDomainRoomPartTwo');
} else if (ReportUtils.isAdminRoom(report)) {
+ welcomeMessage.showReportName = false;
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartOne', {workspaceName});
welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartTwo');
} else if (ReportUtils.isAnnounceRoom(report)) {
+ welcomeMessage.showReportName = false;
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartOne', {workspaceName});
- welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartTwo', {workspaceName});
+ welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartTwo');
} else if (ReportUtils.isInvoiceRoom(report)) {
welcomeMessage.showReportName = false;
- welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryInvoiceRoom');
+ welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryInvoiceRoomPartOne');
+ welcomeMessage.phrase2 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryInvoiceRoomPartTwo');
+ const payer =
+ report?.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL
+ ? ReportUtils.getDisplayNameForParticipant(report?.invoiceReceiver?.accountID)
+ : PolicyUtils.getPolicy(report?.invoiceReceiver?.policyID)?.name;
+ const receiver = ReportUtils.getPolicyName(report);
+ welcomeMessage.messageText = `${welcomeMessage.phrase1}${payer} ${Localize.translateLocal('common.and')} ${receiver}${welcomeMessage.phrase2}`;
+ return welcomeMessage;
} else {
// Message for user created rooms or other room types.
welcomeMessage.phrase1 = Localize.translateLocal('reportActionsView.beginningOfChatHistoryUserRoomPartOne');
diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts
index 573a8ca2ca63..2d6469dfb8a5 100644
--- a/src/libs/actions/Delegate.ts
+++ b/src/libs/actions/Delegate.ts
@@ -10,8 +10,10 @@ import * as SequentialQueue from '@libs/Network/SequentialQueue';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Delegate, DelegatedAccess, DelegateRole} from '@src/types/onyx/Account';
+import type Response from '@src/types/onyx/Response';
import {confirmReadyToOpenApp, openApp} from './App';
import updateSessionAuthTokens from './Session/updateSessionAuthTokens';
+import updateSessionUser from './Session/updateSessionUser';
let delegatedAccess: DelegatedAccess;
Onyx.connect({
@@ -21,7 +23,18 @@ Onyx.connect({
},
});
-const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.SESSION, ONYXKEYS.IS_LOADING_APP];
+const KEYS_TO_PRESERVE_DELEGATE_ACCESS = [
+ ONYXKEYS.NVP_TRY_FOCUS_MODE,
+ ONYXKEYS.PREFERRED_THEME,
+ ONYXKEYS.NVP_PREFERRED_LOCALE,
+ ONYXKEYS.SESSION,
+ ONYXKEYS.IS_LOADING_APP,
+ ONYXKEYS.CREDENTIALS,
+
+ // We need to preserve the sidebar loaded state since we never unrender the sidebar when connecting as a delegate
+ // This allows the report screen to load correctly when the delegate token expires and the delegate is returned to their original account.
+ ONYXKEYS.IS_SIDEBAR_LOADED,
+];
function connect(email: string) {
if (!delegatedAccess?.delegators) {
@@ -313,6 +326,10 @@ function clearAddDelegateErrors(email: string, fieldName: string) {
});
}
+function isConnectedAsDelegate() {
+ return !!delegatedAccess?.delegate;
+}
+
function removePendingDelegate(email: string) {
if (!delegatedAccess?.delegates) {
return;
@@ -325,4 +342,17 @@ function removePendingDelegate(email: string) {
});
}
-export {connect, disconnect, clearDelegatorErrors, addDelegate, requestValidationCode, clearAddDelegateErrors, removePendingDelegate};
+function restoreDelegateSession(authenticateResponse: Response) {
+ Onyx.clear(KEYS_TO_PRESERVE_DELEGATE_ACCESS).then(() => {
+ updateSessionAuthTokens(authenticateResponse?.authToken, authenticateResponse?.encryptedAuthToken);
+ updateSessionUser(authenticateResponse?.accountID, authenticateResponse?.email);
+
+ NetworkStore.setAuthToken(authenticateResponse.authToken ?? null);
+ NetworkStore.setIsAuthenticating(false);
+
+ confirmReadyToOpenApp();
+ openApp();
+ });
+}
+
+export {connect, disconnect, clearDelegatorErrors, addDelegate, requestValidationCode, clearAddDelegateErrors, removePendingDelegate, restoreDelegateSession, isConnectedAsDelegate};
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 6681388c26dd..8d8e25a3ffb6 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -17,6 +17,7 @@ import type {
PayMoneyRequestParams,
ReplaceReceiptParams,
RequestMoneyParams,
+ ResolveDuplicatesParams,
SendInvoiceParams,
SendMoneyParams,
SetNameValuePairParams,
@@ -8124,6 +8125,21 @@ function getIOURequestPolicyID(transaction: OnyxEntry, re
return workspaceSender?.policyID ?? report?.policyID ?? '-1';
}
+function getIOUActionForTransactions(transactionIDList: string[], iouReportID: string): Array> {
+ return Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`] ?? {})?.filter(
+ (reportAction): reportAction is ReportAction => {
+ if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) {
+ return false;
+ }
+ const message = ReportActionsUtils.getOriginalMessage(reportAction);
+ if (!message?.IOUTransactionID) {
+ return false;
+ }
+ return transactionIDList.includes(message.IOUTransactionID);
+ },
+ );
+}
+
/** Merge several transactions into one by updating the fields of the one we want to keep and deleting the rest */
function mergeDuplicates(params: TransactionMergeParams) {
const originalSelectedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`];
@@ -8208,18 +8224,7 @@ function mergeDuplicates(params: TransactionMergeParams) {
},
};
- const iouActionsToDelete = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${params.reportID}`] ?? {})?.filter(
- (reportAction): reportAction is ReportAction => {
- if (!ReportActionsUtils.isMoneyRequestAction(reportAction)) {
- return false;
- }
- const message = ReportActionsUtils.getOriginalMessage(reportAction);
- if (!message?.IOUTransactionID) {
- return false;
- }
- return params.transactionIDList.includes(message.IOUTransactionID);
- },
- );
+ const iouActionsToDelete = getIOUActionForTransactions(params.transactionIDList, params.reportID);
const deletedTime = DateUtils.getDBTime();
const expenseReportActionsOptimisticData: OnyxUpdate = {
@@ -8280,6 +8285,125 @@ function mergeDuplicates(params: TransactionMergeParams) {
API.write(WRITE_COMMANDS.TRANSACTION_MERGE, params, {optimisticData, failureData});
}
+/** Instead of merging the duplicates, it updates the transaction we want to keep and puts the others on hold without deleting them */
+function resolveDuplicates(params: TransactionMergeParams) {
+ const originalSelectedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`];
+
+ const optimisticTransactionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`,
+ value: {
+ ...originalSelectedTransaction,
+ billable: params.billable,
+ comment: {
+ comment: params.comment,
+ },
+ category: params.category,
+ created: params.created,
+ currency: params.currency,
+ modifiedMerchant: params.merchant,
+ reimbursable: params.reimbursable,
+ tag: params.tag,
+ },
+ };
+
+ const failureTransactionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${params.transactionID}`,
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ value: originalSelectedTransaction as OnyxTypes.Transaction,
+ };
+
+ const optimisticTransactionViolations: OnyxUpdate[] = [...params.transactionIDList, params.transactionID].map((id) => {
+ const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? [];
+ const newViolation = {name: CONST.VIOLATIONS.HOLD, type: CONST.VIOLATION_TYPES.VIOLATION};
+ const updatedViolations = id === params.transactionID ? violations : [...violations, newViolation];
+ return {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
+ value: updatedViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION),
+ };
+ });
+
+ const failureTransactionViolations: OnyxUpdate[] = [...params.transactionIDList, params.transactionID].map((id) => {
+ const violations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`] ?? [];
+ return {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`,
+ value: violations,
+ };
+ });
+
+ const iouActionList = getIOUActionForTransactions(params.transactionIDList, params.reportID);
+ const transactionThreadReportIDList = iouActionList.map((action) => action?.childReportID);
+ const orderedTransactionIDList = iouActionList.map((action) => {
+ const message = ReportActionsUtils.getOriginalMessage(action);
+ return message?.IOUTransactionID ?? '';
+ });
+
+ const optimisticHoldActions: OnyxUpdate[] = [];
+ const failureHoldActions: OnyxUpdate[] = [];
+ const reportActionIDList: string[] = [];
+ transactionThreadReportIDList.forEach((transactionThreadReportID) => {
+ const createdReportAction = ReportUtils.buildOptimisticHoldReportAction();
+ reportActionIDList.push(createdReportAction.reportActionID);
+ optimisticHoldActions.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
+ value: {
+ [createdReportAction.reportActionID]: createdReportAction,
+ },
+ });
+ failureHoldActions.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
+ value: {
+ [createdReportAction.reportActionID]: {
+ errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericHoldExpenseFailureMessage'),
+ },
+ },
+ });
+ });
+
+ const transactionThreadReportID = getIOUActionForTransactions([params.transactionID], params.reportID).at(0)?.childReportID;
+ const optimisticReportAction = ReportUtils.buildOptimisticDismissedViolationReportAction({
+ reason: 'manual',
+ violationName: CONST.VIOLATIONS.DUPLICATED_TRANSACTION,
+ });
+
+ const optimisticReportActionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
+ value: {
+ [optimisticReportAction.reportActionID]: optimisticReportAction,
+ },
+ };
+
+ const failureReportActionData: OnyxUpdate = {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
+ value: {
+ [optimisticReportAction.reportActionID]: null,
+ },
+ };
+
+ const optimisticData: OnyxUpdate[] = [];
+ const failureData: OnyxUpdate[] = [];
+
+ optimisticData.push(optimisticTransactionData, ...optimisticTransactionViolations, ...optimisticHoldActions, optimisticReportActionData);
+ failureData.push(failureTransactionData, ...failureTransactionViolations, ...failureHoldActions, failureReportActionData);
+ const {reportID, transactionIDList, receiptID, ...otherParams} = params;
+
+ const parameters: ResolveDuplicatesParams = {
+ ...otherParams,
+ reportActionIDList,
+ transactionIDList: orderedTransactionIDList,
+ dismissedViolationReportActionID: optimisticReportAction.reportActionID,
+ };
+
+ API.write(WRITE_COMMANDS.RESOLVE_DUPLICATES, parameters, {optimisticData, failureData});
+}
+
export {
adjustRemainingSplitShares,
getNextApproverAccountID,
@@ -8351,5 +8475,6 @@ export {
updateMoneyRequestTaxAmount,
updateMoneyRequestTaxRate,
mergeDuplicates,
+ resolveDuplicates,
};
export type {GPSPoint as GpsPoint, IOURequestType};
diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts
index 886f8b06fc6f..13fcea0df85d 100644
--- a/src/libs/actions/Link.ts
+++ b/src/libs/actions/Link.ts
@@ -195,7 +195,8 @@ function buildURLWithAuthToken(url: string, shortLivedAuthToken?: string) {
const emailParam = `email=${encodeURIComponent(currentUserEmail)}`;
const exitTo = `exitTo=${url}`;
const accountID = `accountID=${currentUserAccountID}`;
- const paramsArray = [accountID, emailParam, authTokenParam, exitTo];
+ const referrer = 'referrer=desktop';
+ const paramsArray = [accountID, emailParam, authTokenParam, exitTo, referrer];
const params = paramsArray.filter(Boolean).join('&');
return `${CONFIG.EXPENSIFY.NEW_EXPENSIFY_URL}transition?${params}`;
diff --git a/src/libs/actions/Policy/Category.ts b/src/libs/actions/Policy/Category.ts
index e237ed80e293..a623989cfb6c 100644
--- a/src/libs/actions/Policy/Category.ts
+++ b/src/libs/actions/Policy/Category.ts
@@ -846,7 +846,7 @@ function deleteWorkspaceCategories(policyID: string, categoryNamesToDelete: stri
const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {};
const optimisticPolicyCategoriesData = categoryNamesToDelete.reduce>>((acc, categoryName) => {
- acc[categoryName] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE};
+ acc[categoryName] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false};
return acc;
}, {});
const shouldDisableRequiresCategory = !OptionsListUtils.hasEnabledOptions(
@@ -878,6 +878,7 @@ function deleteWorkspaceCategories(policyID: string, categoryNamesToDelete: stri
acc[categoryName] = {
pendingAction: null,
errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.categories.deleteFailureMessage'),
+ enabled: !!policyCategories?.[categoryName]?.enabled,
};
return acc;
}, {}),
diff --git a/src/libs/actions/Policy/Tag.ts b/src/libs/actions/Policy/Tag.ts
index 2a3080fc810f..7708921f57b5 100644
--- a/src/libs/actions/Policy/Tag.ts
+++ b/src/libs/actions/Policy/Tag.ts
@@ -356,7 +356,7 @@ function deletePolicyTags(policyID: string, tagsToDelete: string[]) {
[policyTag.name]: {
tags: {
...tagsToDelete.reduce>>>((acc, tagName) => {
- acc[tagName] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE};
+ acc[tagName] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, enabled: false};
return acc;
}, {}),
},
@@ -388,7 +388,11 @@ function deletePolicyTags(policyID: string, tagsToDelete: string[]) {
[policyTag.name]: {
tags: {
...tagsToDelete.reduce>>>((acc, tagName) => {
- acc[tagName] = {pendingAction: null, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.deleteFailureMessage')};
+ acc[tagName] = {
+ pendingAction: null,
+ errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.tags.deleteFailureMessage'),
+ enabled: !!policyTag?.tags[tagName]?.enabled,
+ };
return acc;
}, {}),
},
diff --git a/src/libs/actions/Session/updateSessionUser.ts b/src/libs/actions/Session/updateSessionUser.ts
new file mode 100644
index 000000000000..75e888469bec
--- /dev/null
+++ b/src/libs/actions/Session/updateSessionUser.ts
@@ -0,0 +1,6 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+export default function updateSessionUser(accountID?: number, email?: string) {
+ Onyx.merge(ONYXKEYS.SESSION, {accountID, email});
+}
diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts
index abe2f68d2a83..52a8b8e143b8 100644
--- a/src/libs/actions/TaxRate.ts
+++ b/src/libs/actions/TaxRate.ts
@@ -275,7 +275,7 @@ function setPolicyTaxesEnabled(policyID: string, taxesIDsToUpdate: string[], isE
type TaxRateDeleteMap = Record<
string,
- | (Pick & {
+ | (Pick & {
errors: OnyxCommon.Errors | null;
})
| null
@@ -306,7 +306,7 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) {
pendingFields: {foreignTaxDefault: isForeignTaxRemoved ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null},
foreignTaxDefault: isForeignTaxRemoved ? firstTaxID : foreignTaxDefault,
taxes: taxesToDelete.reduce((acc, taxID) => {
- acc[taxID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null};
+ acc[taxID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null, isDisabled: true};
return acc;
}, {}),
},
@@ -339,6 +339,7 @@ function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) {
acc[taxID] = {
pendingAction: null,
errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.taxes.error.deleteFailureMessage'),
+ isDisabled: !!policyTaxRates?.[taxID]?.isDisabled,
};
return acc;
}, {}),
diff --git a/src/libs/memoize/stats.ts b/src/libs/memoize/stats.ts
index 59d563100ec4..6020371154ee 100644
--- a/src/libs/memoize/stats.ts
+++ b/src/libs/memoize/stats.ts
@@ -13,14 +13,29 @@ function isMemoizeStatsEntry(entry: any): entry is MemoizeStatsEntry {
}
class MemoizeStats {
+ /**
+ * Number of calls to the memoized function. Both cache hits and misses are counted.
+ */
private calls = 0;
+ /**
+ * Number of cache hits. This is the number of times the cache returned a value instead of calling the original function.
+ */
private hits = 0;
+ /**
+ * Average time of cache retrieval. This is the time it takes to retrieve a value from the cache, without calling the original function.
+ */
private avgCacheTime = 0;
+ /**
+ * Average time of original function execution. This is the time it takes to execute the original function when the cache does not have a value.
+ */
private avgFnTime = 0;
+ /**
+ * Current cache size. This is the number of entries in the cache.
+ */
private cacheSize = 0;
isEnabled = false;
diff --git a/src/libs/memoize/types.ts b/src/libs/memoize/types.ts
index 80a6b4c55507..9ee48c9dc790 100644
--- a/src/libs/memoize/types.ts
+++ b/src/libs/memoize/types.ts
@@ -16,21 +16,40 @@ type IsomorphicReturnType = Fn extends Callable ? Retur
type KeyComparator = (k1: Key, k2: Key) => boolean;
type InternalOptions = {
+ /**
+ * Type of cache to use. Currently only `array` is supported.
+ */
cache: 'array';
};
type Options = {
+ /**
+ * Maximum number of entries in the cache. If the cache exceeds this number, the oldest entries will be removed.
+ */
maxSize: number;
+ /**
+ * Equality comparator to use for comparing keys in the cache. Can be either:
+ * - `deep` - default comparator that uses [DeepEqual](https://github.com/planttheidea/fast-equals?tab=readme-ov-file#deepequal)
+ * - `shallow` - comparator that uses [ShallowEqual](https://github.com/planttheidea/fast-equals?tab=readme-ov-file#shallowequal)
+ * - a custom comparator - a function that takes two keys and returns a boolean.
+ */
equality: 'deep' | 'shallow' | KeyComparator;
+ /**
+ * If set to `true`, memoized function stats will be collected. It can be overridden by global `Memoize` config. See `MemoizeStats` for more information.
+ */
monitor: boolean;
+ /**
+ * Maximum number of arguments to use for caching. If set, only the first `maxArgs` arguments will be used to generate the cache key.
+ */
maxArgs?: MaxArgs;
+ /**
+ * Name of the monitoring entry. If not provided, the function name will be used.
+ */
monitoringName?: string;
/**
- * Function to transform the arguments into a key, which is used to reference the result in the cache.
- * When called with constructable (e.g. class, `new` keyword) functions, it won't get proper types for `truncatedArgs`
- * Any viable fixes are welcome!
- * @param truncatedArgs - Tuple of arguments passed to the memoized function (truncated to `maxArgs`). Does not work with constructable (see description).
- * @returns - Key to use for caching
+ * Transforms arguments into a cache key. If set, `maxArgs` will be applied to arguments first.
+ * @param truncatedArgs Tuple of arguments passed to the memoized function (truncated to `maxArgs`). Does not work with constructable (see description).
+ * @returns Key to use for caching
*/
transformKey?: (truncatedArgs: TakeFirst, MaxArgs>) => Key;
} & InternalOptions;
diff --git a/src/pages/ErrorPage/GenericErrorPage.tsx b/src/pages/ErrorPage/GenericErrorPage.tsx
index 9f4186bc354f..0357cdc0204b 100644
--- a/src/pages/ErrorPage/GenericErrorPage.tsx
+++ b/src/pages/ErrorPage/GenericErrorPage.tsx
@@ -1,5 +1,4 @@
import React from 'react';
-import {useErrorBoundary} from 'react-error-boundary';
import {View} from 'react-native';
import LogoWordmark from '@assets/images/expensify-wordmark.svg';
import Button from '@components/Button';
@@ -10,6 +9,7 @@ import SafeAreaConsumer from '@components/SafeAreaConsumer';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
+import usePageRefresh from '@hooks/usePageRefresh';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -23,8 +23,7 @@ function GenericErrorPage() {
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
-
- const {resetBoundary} = useErrorBoundary();
+ const refreshPage = usePageRefresh();
return (
@@ -59,16 +58,16 @@ function GenericErrorPage() {
diff --git a/src/pages/PrivateNotes/PrivateNotesListPage.tsx b/src/pages/PrivateNotes/PrivateNotesListPage.tsx
index cc7ee9f54daa..424cc3e14683 100644
--- a/src/pages/PrivateNotes/PrivateNotesListPage.tsx
+++ b/src/pages/PrivateNotes/PrivateNotesListPage.tsx
@@ -1,8 +1,7 @@
import type {RouteProp} from '@react-navigation/native';
import {useRoute} from '@react-navigation/native';
import React, {useCallback, useMemo} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {withOnyx} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import {AttachmentContext} from '@components/AttachmentContext';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
@@ -20,19 +19,13 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type {PersonalDetailsList, Report} from '@src/types/onyx';
+import type {Report} from '@src/types/onyx';
-type PrivateNotesListPageOnyxProps = {
- /** All of the personal details for everyone */
- personalDetailsList: OnyxEntry;
+type PrivateNotesListPageProps = WithReportAndPrivateNotesOrNotFoundProps & {
+ /** The report currently being looked at */
+ report: Report;
};
-type PrivateNotesListPageProps = WithReportAndPrivateNotesOrNotFoundProps &
- PrivateNotesListPageOnyxProps & {
- /** The report currently being looked at */
- report: Report;
- };
-
type NoteListItem = {
title: string;
action: () => void;
@@ -43,9 +36,10 @@ type NoteListItem = {
accountID: string;
};
-function PrivateNotesListPage({report, personalDetailsList, session}: PrivateNotesListPageProps) {
+function PrivateNotesListPage({report, session}: PrivateNotesListPageProps) {
const route = useRoute>();
const backTo = route.params.backTo;
+ const [personalDetailsList] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const styles = useThemeStyles();
const {translate} = useLocalize();
const getAttachmentValue = useCallback((item: NoteListItem) => ({reportID: item.reportID, accountID: Number(item.accountID), type: CONST.ATTACHMENT_TYPE.NOTE}), []);
@@ -102,11 +96,11 @@ function PrivateNotesListPage({report, personalDetailsList, session}: PrivateNot
onBackButtonPress={() => Navigation.goBack(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID, backTo))}
onCloseButtonPress={() => Navigation.dismissModal()}
/>
- {translate('privateNotes.personalNoteMessage')}
+ {translate('privateNotes.personalNoteMessage')}
{privateNotes.map((item) => getMenuItem(item))}
@@ -115,10 +109,4 @@ function PrivateNotesListPage({report, personalDetailsList, session}: PrivateNot
PrivateNotesListPage.displayName = 'PrivateNotesListPage';
-export default withReportAndPrivateNotesOrNotFound('privateNotes.title')(
- withOnyx({
- personalDetailsList: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- })(PrivateNotesListPage),
-);
+export default withReportAndPrivateNotesOrNotFound('privateNotes.title')(PrivateNotesListPage);
diff --git a/src/pages/Search/SavedSearchRenamePage.tsx b/src/pages/Search/SavedSearchRenamePage.tsx
index d2643591ebbf..00180cd5de7f 100644
--- a/src/pages/Search/SavedSearchRenamePage.tsx
+++ b/src/pages/Search/SavedSearchRenamePage.tsx
@@ -68,6 +68,7 @@ function SavedSearchRenamePage({route}: {route: {params: {q: string; name: strin
onChangeText={(renamedName) => setNewName(renamedName)}
ref={inputCallbackRef}
defaultValue={name}
+ shouldShowClearButton
/>
diff --git a/src/pages/Search/SearchPageBottomTab.tsx b/src/pages/Search/SearchPageBottomTab.tsx
index b4fb2aac61ff..8fce3a950445 100644
--- a/src/pages/Search/SearchPageBottomTab.tsx
+++ b/src/pages/Search/SearchPageBottomTab.tsx
@@ -103,12 +103,12 @@ function SearchPageBottomTab() {
shouldDisplayCancelSearch={shouldDisplayCancelSearch}
/>
-
-
- {shouldUseNarrowLayout && (
+ {shouldUseNarrowLayout ? (
+
+
- )}
-
+
+ ) : (
+
+ )}
>
) : (
diff --git a/src/pages/TransactionDuplicate/Confirmation.tsx b/src/pages/TransactionDuplicate/Confirmation.tsx
index 15217e215ad4..b26ae615b465 100644
--- a/src/pages/TransactionDuplicate/Confirmation.tsx
+++ b/src/pages/TransactionDuplicate/Confirmation.tsx
@@ -14,6 +14,7 @@ import ScreenWrapper from '@components/ScreenWrapper';
import ScrollView from '@components/ScrollView';
import {ShowContextMenuContext} from '@components/ShowContextMenuContext';
import Text from '@components/Text';
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useReviewDuplicatesNavigation from '@hooks/useReviewDuplicatesNavigation';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -35,23 +36,32 @@ function Confirmation() {
const styles = useThemeStyles();
const {translate} = useLocalize();
const route = useRoute>();
+ const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const [reviewDuplicates, reviewDuplicatesResult] = useOnyx(ONYXKEYS.REVIEW_DUPLICATES);
const transaction = useMemo(() => TransactionUtils.buildNewTransactionAfterReviewingDuplicates(reviewDuplicates), [reviewDuplicates]);
const transactionID = TransactionUtils.getTransactionID(route.params.threadReportID ?? '');
const compareResult = TransactionUtils.compareDuplicateTransactionFields(transactionID);
const {goBack} = useReviewDuplicatesNavigation(Object.keys(compareResult.change ?? {}), 'confirmation', route.params.threadReportID, route.params.backTo);
const [report, reportResult] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`);
+ const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transaction?.reportID}`);
const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction?.reportID}`);
const reportAction = Object.values(reportActions ?? {}).find(
(action) => ReportActionsUtils.isMoneyRequestAction(action) && ReportActionsUtils.getOriginalMessage(action)?.IOUTransactionID === reviewDuplicates?.transactionID,
);
const transactionsMergeParams = useMemo(() => TransactionUtils.buildTransactionsMergeParams(reviewDuplicates, transaction), [reviewDuplicates, transaction]);
+ const isReportOwner = iouReport?.ownerAccountID === currentUserPersonalDetails?.accountID;
+
const mergeDuplicates = useCallback(() => {
IOU.mergeDuplicates(transactionsMergeParams);
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportAction?.childReportID ?? '-1'));
}, [reportAction?.childReportID, transactionsMergeParams]);
+ const resolveDuplicates = useCallback(() => {
+ IOU.resolveDuplicates(transactionsMergeParams);
+ Navigation.dismissModal(reportAction?.childReportID ?? '-1');
+ }, [transactionsMergeParams, reportAction?.childReportID]);
+
const contextValue = useMemo(
() => ({
transactionThreadReport: report,
@@ -116,7 +126,13 @@ function Confirmation() {