diff --git a/.github/workflows/verifyHybridApp.yml b/.github/workflows/verifyHybridApp.yml index c69487a1b4e4..876d3b1834fb 100644 --- a/.github/workflows/verifyHybridApp.yml +++ b/.github/workflows/verifyHybridApp.yml @@ -17,6 +17,26 @@ on: - 'android/AndroidManifest.xml' - 'ios/Podfile.lock' - 'ios/project.pbxproj' + pull_request_target: + types: [opened, synchronize] + branches-ignore: [staging, production] + paths: + - '**.kt' + - '**.java' + - '**.swift' + - '**.mm' + - '**.h' + - '**.cpp' + - 'package.json' + - 'patches/**' + - 'android/build.gradle' + - 'android/AndroidManifest.xml' + - 'ios/Podfile.lock' + - 'ios/project.pbxproj' + +permissions: + pull-requests: write + contents: read concurrency: group: ${{ github.ref == 'refs/heads/main' && format('{0}-{1}', github.ref, github.sha) || github.ref }}-verify-main @@ -26,7 +46,7 @@ jobs: comment_on_fork: name: Comment on all PRs that are forks # Only run on pull requests that *are* a fork - if: ${{ github.event.pull_request.head.repo.fork }} + if: ${{ github.event.pull_request.head.repo.fork && github.event_name == 'pull_request_target' }} runs-on: ubuntu-latest steps: - name: Comment on forks @@ -39,7 +59,7 @@ jobs: name: Verify Android HybridApp builds on main runs-on: ubuntu-latest-xl # Only run on pull requests that are *not* on a fork - if: ${{ !github.event.pull_request.head.repo.fork }} + if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' }} steps: - name: Checkout uses: actions/checkout@v4 @@ -80,7 +100,7 @@ jobs: name: Verify iOS HybridApp builds on main runs-on: macos-15-xlarge # Only run on pull requests that are *not* on a fork - if: ${{ !github.event.pull_request.head.repo.fork }} + if: ${{ !github.event.pull_request.head.repo.fork && github.event_name == 'pull_request' }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/Mobile-Expensify b/Mobile-Expensify index 9435072ed182..5cdb2418ac3b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 9435072ed1829c25a22627b78ce8072ea63f5740 +Subproject commit 5cdb2418ac3b258bd94c47d2f4cb9af07b27e46d diff --git a/__mocks__/@pusher/pusher-websocket-react-native/index.ts b/__mocks__/@pusher/pusher-websocket-react-native/index.ts new file mode 100644 index 000000000000..b2b59d303251 --- /dev/null +++ b/__mocks__/@pusher/pusher-websocket-react-native/index.ts @@ -0,0 +1,85 @@ +import type {PusherEvent} from '@pusher/pusher-websocket-react-native'; +import CONST from '@src/CONST'; + +type OnSubscriptionSucceeded = () => void; + +type OnEvent = (event: PusherEvent) => void; + +type ChannelCallbacks = { + onSubscriptionSucceeded: OnSubscriptionSucceeded; + onEvent: OnEvent; +}; + +type InitProps = { + onConnectionStateChange: (currentState: string, previousState: string) => void; +}; + +type SubscribeProps = { + channelName: string; + onEvent: OnEvent; + onSubscriptionSucceeded: OnSubscriptionSucceeded; +}; + +type UnsubscribeProps = { + channelName: string; +}; + +class MockedPusher { + static instance: MockedPusher | null = null; + + channels = new Map(); + + socketId = 'mock-socket-id'; + + connectionState: string = CONST.PUSHER.STATE.DISCONNECTED; + + static getInstance() { + if (!MockedPusher.instance) { + MockedPusher.instance = new MockedPusher(); + } + return MockedPusher.instance; + } + + init({onConnectionStateChange}: InitProps) { + onConnectionStateChange(CONST.PUSHER.STATE.CONNECTED, CONST.PUSHER.STATE.DISCONNECTED); + return Promise.resolve(); + } + + connect() { + this.connectionState = CONST.PUSHER.STATE.CONNECTED; + return Promise.resolve(); + } + + disconnect() { + this.connectionState = CONST.PUSHER.STATE.DISCONNECTED; + this.channels.clear(); + return Promise.resolve(); + } + + subscribe({channelName, onEvent, onSubscriptionSucceeded}: SubscribeProps) { + if (!this.channels.has(channelName)) { + this.channels.set(channelName, {onEvent, onSubscriptionSucceeded}); + onSubscriptionSucceeded(); + } + return Promise.resolve(); + } + + unsubscribe({channelName}: UnsubscribeProps) { + this.channels.delete(channelName); + } + + trigger({channelName, eventName, data}: PusherEvent) { + this.channels.get(channelName)?.onEvent({channelName, eventName, data: data as Record}); + } + + getChannel(channelName: string) { + return this.channels.get(channelName); + } + + getSocketId() { + return Promise.resolve(this.socketId); + } +} + +// eslint-disable-next-line import/prefer-default-export +export {MockedPusher as Pusher}; diff --git a/__mocks__/pusher-js/react-native.ts b/__mocks__/pusher-js/react-native.ts deleted file mode 100644 index 1edec34ffb14..000000000000 --- a/__mocks__/pusher-js/react-native.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {PusherMock} from 'pusher-js-mock'; - -class PusherMockWithDisconnect extends PusherMock { - disconnect() { - return jest.fn(); - } -} - -export default PusherMockWithDisconnect; diff --git a/android/app/build.gradle b/android/app/build.gradle index 1a048dcf02d4..9f36777d3131 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -114,8 +114,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009010200 - versionName "9.1.2-0" + versionCode 1009010300 + versionName "9.1.3-0" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" diff --git a/docs/articles/expensify-classic/expenses/Edit-expenses.md b/docs/articles/expensify-classic/expenses/Edit-expenses.md new file mode 100644 index 000000000000..a1a5af751261 --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Edit-expenses.md @@ -0,0 +1,77 @@ +--- +title: Edit Expenses +description: Learn how to edit expenses in Expensify, including restrictions and permissions. +--- + +You can edit expenses in Expensify to update details like category, description, or attendees. However, some fields have restrictions based on the expense type and report status. + +# Edit an Expense + +{% include selector.html values="desktop, mobile" %} + +{% include option.html value="desktop" %} + +1. Click the **Expenses** tab. +2. Select the expense you want to edit. +3. Click the field you want to change (e.g., category, description, attendees). +4. Make your changes and click **Save**. + +{% include end-option.html %} + +{% include option.html value="mobile" %} + +1. Tap the **Expenses** tab. +2. Select the expense you want to edit. +3. Tap **More Options**. +4. Update the relevant fields and tap **Save**. + +{% include end-option.html %} + +{% include end-selector.html %} + +# Expense Editing Rules + +Editing restrictions apply based on expense type and report status. + +## General Editing Rules +- **Category, description, attendees, and report assignment** can be edited by the expense owner, approvers, and Workspace Admins. +- **Amount** can be edited for most manually entered expenses, except for company card transactions. +- **Tag and billable status** can be updated as long as the report is in an editable state. + +## Company Card Expenses +- **Amount cannot be edited** for expenses imported from a company card. +- **Category, tag, and billable status** can be edited if the report is in the Open or Processing state. +- **Receipt images** can be added or replaced at any time. + +## Submitted and Approved Expenses +- **Submitted expenses** can only be edited by an approver or Workspace Admin. +- **Approved expenses** cannot be edited unless they are reopened. +- **Expenses in a Closed report** cannot be edited. + +# Delete an Expense + +Expenses can only be deleted by the submitter, and the report must be in the Open state. + +1. Navigate to the **Expenses** tab. +2. Select the expense you want to delete. +3. Click **Delete** and confirm. + +{% include info.html %} +If the report has been submitted, you must retract it before deleting an expense. +{% include end-info.html %} + +# FAQ + +## Who can edit an expense? +- **Expense owner**: Can edit expenses if the report is Open. +- **Approvers and Workspace Admins**: Can edit submitted expenses before final approval. +- **Finance teams**: May have additional permissions based on workspace settings. + +## Why can’t I edit my expense amount? +Company card expenses have a fixed amount based on imported transaction data and cannot be changed. + +## Can I edit an expense after it has been approved? +No, approved expenses cannot be edited unless the report is reopened. + +## How do I update an expense in a submitted report? +If you need to edit an expense in a submitted report, contact an approver or Workspace Admin to reopen the report. diff --git a/docs/articles/new-expensify/connect-credit-cards/company-cards/Commercial-feeds.md b/docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md similarity index 100% rename from docs/articles/new-expensify/connect-credit-cards/company-cards/Commercial-feeds.md rename to docs/articles/new-expensify/connect-credit-cards/Commercial-feeds.md diff --git a/docs/articles/new-expensify/connect-credit-cards/company-cards/Company-Card-Settings.md b/docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md similarity index 100% rename from docs/articles/new-expensify/connect-credit-cards/company-cards/Company-Card-Settings.md rename to docs/articles/new-expensify/connect-credit-cards/Company-Card-Settings.md diff --git a/docs/articles/new-expensify/connect-credit-cards/company-cards/Direct-feeds.md b/docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md similarity index 100% rename from docs/articles/new-expensify/connect-credit-cards/company-cards/Direct-feeds.md rename to docs/articles/new-expensify/connect-credit-cards/Direct-feeds.md diff --git a/docs/articles/new-expensify/expensify-card/Manage-Expensify-Cards.md b/docs/articles/new-expensify/expensify-card/Manage-Expensify-Cards.md index 47962f81a5a7..26219db3667d 100644 --- a/docs/articles/new-expensify/expensify-card/Manage-Expensify-Cards.md +++ b/docs/articles/new-expensify/expensify-card/Manage-Expensify-Cards.md @@ -10,7 +10,7 @@ You must be a Workspace Admin to manage this feature. Once your Expensify Cards have been issued, you can monitor them and check your card’s current balance, remaining limit, and earned cash back. -1. Click your profile image or icon in the bottom left menu. +1. Click on **Settings** in the bottom left menu. 2. Scroll down and click **Workspaces** in the left menu. 3. Select the workspace that contains the desired Expensify Cards. 4. Click **Expensify Card** in the left menu. Here, you’ll see a list of all of the issued cards. diff --git a/docs/new-expensify/hubs/connect-credit-cards/company-cards.html b/docs/new-expensify/hubs/connect-credit-cards/company-cards.html deleted file mode 100644 index 86641ee60b7d..000000000000 --- a/docs/new-expensify/hubs/connect-credit-cards/company-cards.html +++ /dev/null @@ -1,5 +0,0 @@ ---- -layout: default ---- - -{% include section.html %} diff --git a/docs/redirects.csv b/docs/redirects.csv index 371c346e6803..7d13a27a1fe6 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -642,4 +642,7 @@ https://help.expensify.com/articles/expensify-classic/travel/Book-with-Expensify https://help.expensify.com/articles/expensify-classic/travel/Configure-travel-policy-and-preferences.md,https://help.expensify.com/articles/new-expensify/travel/Configure-travel-policy-and-preferences https://help.expensify.com/articles/expensify-classic/travel/Track-Travel-Analytics.md,https://help.expensify.com/articles/new-expensify/travel/Track-Travel-Analytics https://help.expensify.com/articles/expensify-classic/connections/ADP,https://help.expensify.com/articles/expensify-classic/connections/Connect-to-ADP +https://help.expensify.com/articles/new-expensify/connect-credit-cards/company-cards/Commercial-feeds,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Commercial-feeds +https://help.expensify.com/articles/new-expensify/connect-credit-cards/company-cards/Company-Card-Settings,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Company-Card-Settings +https://help.expensify.com/articles/new-expensify/connect-credit-cards/company-cards/Direct-feeds,https://help.expensify.com/articles/new-expensify/connect-credit-cards/Direct-feeds https://help.expensify.com/articles/expensify-classic/travel,https://help.expensify.com/new-expensify/hubs/travel/ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index bdad051a3489..0294d9c178d8 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -23,7 +23,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.1.2 + 9.1.3 CFBundleSignature ???? CFBundleURLTypes @@ -44,7 +44,7 @@ CFBundleVersion - 9.1.2.0 + 9.1.3.0 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 368e92295a1d..f53a4167d2b1 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.1.2 + 9.1.3 CFBundleSignature ???? CFBundleVersion - 9.1.2.0 + 9.1.3.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 10bb86be5b90..f3fe1163beaf 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.1.2 + 9.1.3 CFBundleVersion - 9.1.2.0 + 9.1.3.0 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 30fd5587224a..1b9153c3d861 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -310,6 +310,7 @@ PODS: - nanopb/encode (= 2.30908.0) - nanopb/decode (2.30908.0) - nanopb/encode (2.30908.0) + - NWWebSocket (0.5.4) - Onfido (29.7.2) - onfido-react-native-sdk (10.6.0): - DoubleConversion @@ -335,6 +336,12 @@ PODS: - Yoga - Plaid (5.6.0) - PromisesObjC (2.4.0) + - pusher-websocket-react-native (1.3.1): + - PusherSwift (~> 10.1.5) + - React + - PusherSwift (10.1.5): + - NWWebSocket (~> 0.5.4) + - TweetNacl (~> 1.0.0) - RCT-Folly (2024.01.01.00): - boost - DoubleConversion @@ -2842,6 +2849,7 @@ PODS: - SDWebImage/Core (~> 5.17) - SocketRocket (0.7.1) - Turf (2.8.0) + - TweetNacl (1.0.2) - VisionCamera (4.6.1): - VisionCamera/Core (= 4.6.1) - VisionCamera/React (= 4.6.1) @@ -2872,6 +2880,7 @@ DEPENDENCIES: - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - lottie-react-native (from `../node_modules/lottie-react-native`) - "onfido-react-native-sdk (from `../node_modules/@onfido/react-native-sdk`)" + - "pusher-websocket-react-native (from `../node_modules/@pusher/pusher-websocket-react-native`)" - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) @@ -3008,15 +3017,18 @@ SPEC REPOS: - MapboxMaps - MapboxMobileEvents - nanopb + - NWWebSocket - Onfido - Plaid - PromisesObjC + - PusherSwift - SDWebImage - SDWebImageAVIFCoder - SDWebImageSVGCoder - SDWebImageWebPCoder - SocketRocket - Turf + - TweetNacl EXTERNAL SOURCES: AppLogs: @@ -3060,6 +3072,8 @@ EXTERNAL SOURCES: :path: "../node_modules/lottie-react-native" onfido-react-native-sdk: :path: "../node_modules/@onfido/react-native-sdk" + pusher-websocket-react-native: + :path: "../node_modules/@pusher/pusher-websocket-react-native" RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: @@ -3275,8 +3289,8 @@ SPEC CHECKSUMS: AirshipServiceExtension: 9c73369f426396d9fb9ff222d86d842fac76ba46 AppAuth: 501c04eda8a8d11f179dbe8637b7a91bb7e5d2fa AppLogs: 3bc4e9b141dbf265b9464409caaa40416a9ee0e0 - boost: d7090b1a93a9798c029277a8288114f2948f471c - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + boost: 26992d1adf73c1c7676360643e687aee6dda994b + DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 EXAV: 9773c9799767c9925547b05e41a26a0240bb8ef2 EXImageLoader: 759063a65ab016b836f73972d3bb25404888713d expensify-react-native-background-task: 6f797cf470b627912c246514b1631a205794775d @@ -3296,11 +3310,11 @@ SPEC CHECKSUMS: FirebaseInstallations: 40bd9054049b2eae9a2c38ef1c3dd213df3605cd FirebasePerformance: 0c01a7a496657d7cea86d40c0b1725259d164c6c FirebaseRemoteConfig: 2d6e2cfdb49af79535c8af8a80a4a5009038ec2b - fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be + fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120 ForkInputMask: 55e3fbab504b22da98483e9f9a6514b98fdd2f3c FullStory: c8a10b2358c0d33c57be84d16e4c440b0434b33d fullstory_react-native: 63a803cca04b0447a71daa73e4df3f7b56e1919d - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + glog: 69ef571f3de08433d766d614c73a9838a06bf7eb GoogleAppMeasurement: 5ba1164e3c844ba84272555e916d0a6d3d977e91 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db @@ -3318,11 +3332,14 @@ SPEC CHECKSUMS: MapboxMaps: 05822ab0ee74f7d626e6471572439afe35c1c116 MapboxMobileEvents: d044b9edbe0ec7df60f6c2c9634fe9a7f449266b nanopb: a0ba3315591a9ae0a16a309ee504766e90db0c96 + NWWebSocket: 040d22f23438cc09aaeabf537beff67699c3c76d Onfido: f3af62ea1c9a419589c133e3e511e5d2c4f3f8af onfido-react-native-sdk: 4ccfdeb10f9ccb4a5799d2555cdbc2a068a42c0d Plaid: c32f22ffce5ec67c9e6147eaf6c4d7d5f8086d89 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 - RCT-Folly: bf5c0376ffe4dd2cf438dcf86db385df9fdce648 + pusher-websocket-react-native: e40c49a1e4ec96d4157375aebcf44943f0f8f62f + PusherSwift: cad631bad86cfff4b8458dce1310a7774e469b1f + RCT-Folly: 4464f4d875961fce86008d45f4ecf6cef6de0740 RCTDeprecation: 2c5e1000b04ab70b53956aa498bf7442c3c6e497 RCTRequired: 5f785a001cf68a551c5f5040fb4c415672dbb481 RCTTypeSafety: 6b98db8965005d32449605c0d005ecb4fee8a0f7 @@ -3428,6 +3445,7 @@ SPEC CHECKSUMS: SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Turf: aa2ede4298009639d10db36aba1a7ebaad072a5e + TweetNacl: 3abf4d1d2082b0114e7a67410e300892448951e6 VisionCamera: c95a8ad535f527562be1fb05fb2fd324578e769c Yoga: 3deb2471faa9916c8a82dda2a22d3fba2620ad37 diff --git a/package-lock.json b/package-lock.json index 0768d2c49609..74b9c136c4f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.1.2-0", + "version": "9.1.3-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.1.2-0", + "version": "9.1.3-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -26,6 +26,7 @@ "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@onfido/react-native-sdk": "10.6.0", + "@pusher/pusher-websocket-react-native": "^1.3.1", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.15.0", "@react-native-community/geolocation": "3.3.0", @@ -67,7 +68,7 @@ "lodash-es": "4.17.21", "lottie-react-native": "6.5.1", "mapbox-gl": "^2.15.0", - "onfido-sdk-ui": "14.15.0", + "onfido-sdk-ui": "14.42.0", "process": "^0.11.10", "pusher-js": "8.3.0", "react": "18.3.1", @@ -254,7 +255,6 @@ "peggy": "^4.0.3", "portfinder": "^1.0.28", "prettier": "^2.8.8", - "pusher-js-mock": "^0.3.3", "react-compiler-healthcheck": "^19.0.0-beta-8a03594-20241020", "react-compiler-runtime": "^19.0.0-beta-8a03594-20241020", "react-is": "^18.3.1", @@ -7569,6 +7569,16 @@ "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", "dev": true }, + "node_modules/@pusher/pusher-websocket-react-native": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@pusher/pusher-websocket-react-native/-/pusher-websocket-react-native-1.3.1.tgz", + "integrity": "sha512-NUarJuOW79b9DBjH/ena0pOutBR0uXnWLg5mIbzUYIl0A7gASC1dgBd8fJ7s5fJXQgQQXNlrqQ9E1SKz6pIvuA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.0", "dev": true, @@ -30369,7 +30379,9 @@ } }, "node_modules/onfido-sdk-ui": { - "version": "14.15.0", + "version": "14.42.0", + "resolved": "https://registry.npmjs.org/onfido-sdk-ui/-/onfido-sdk-ui-14.42.0.tgz", + "integrity": "sha512-KBcKWtvMw2VhNIlmlZ/DmlIoqv7j3+E6l/3j5ISp6TpSc9+C30VlhPsUIwMb2EnD4u5uiBmhSiCm/UZsj+iJjA==", "license": "SEE LICENSE in LICENSE" }, "node_modules/open": { @@ -31583,14 +31595,6 @@ "tweetnacl": "^1.0.3" } }, - "node_modules/pusher-js-mock": { - "version": "0.3.8", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.2.4" - } - }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", diff --git a/package.json b/package.json index 291bd0a7bb07..183060968b03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.1.2-0", + "version": "9.1.3-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -93,6 +93,7 @@ "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.2.2", "@onfido/react-native-sdk": "10.6.0", + "@pusher/pusher-websocket-react-native": "^1.3.1", "@react-native-camera-roll/camera-roll": "7.4.0", "@react-native-clipboard/clipboard": "^1.15.0", "@react-native-community/geolocation": "3.3.0", @@ -134,7 +135,7 @@ "lodash-es": "4.17.21", "lottie-react-native": "6.5.1", "mapbox-gl": "^2.15.0", - "onfido-sdk-ui": "14.15.0", + "onfido-sdk-ui": "14.42.0", "process": "^0.11.10", "pusher-js": "8.3.0", "react": "18.3.1", @@ -321,7 +322,6 @@ "peggy": "^4.0.3", "portfinder": "^1.0.28", "prettier": "^2.8.8", - "pusher-js-mock": "^0.3.3", "react-compiler-healthcheck": "^19.0.0-beta-8a03594-20241020", "react-compiler-runtime": "^19.0.0-beta-8a03594-20241020", "react-is": "^18.3.1", diff --git a/src/CONST.ts b/src/CONST.ts index 1d95cc72940e..38af1507160a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -480,6 +480,12 @@ const CONST = { // Regex to read violation value from string given by backend VIOLATION_LIMIT_REGEX: /[^0-9]+/g, + // Validates phone numbers with digits, '+', '-', '()', '.', and spaces + ACCEPTED_PHONE_CHARACTER_REGEX: /^[0-9+\-().\s]+$/, + + // Prevents consecutive special characters or spaces like '--', '..', '((', '))', or ' '. + REPEATED_SPECIAL_CHAR_PATTERN: /([-\s().])\1+/, + MERCHANT_NAME_MAX_LENGTH: 255, MASKED_PAN_PREFIX: 'XXXXXXXXXXXX', @@ -1628,6 +1634,17 @@ const CONST = { PRIVATE_USER_CHANNEL_PREFIX: 'private-encrypted-user-accountID-', PRIVATE_REPORT_CHANNEL_PREFIX: 'private-report-reportID-', PRESENCE_ACTIVE_GUIDES: 'presence-activeGuides', + STATE: { + CONNECTING: 'CONNECTING', + CONNECTED: 'CONNECTED', + DISCONNECTING: 'DISCONNECTING', + DISCONNECTED: 'DISCONNECTED', + RECONNECTING: 'RECONNECTING', + }, + CHANNEL_STATUS: { + SUBSCRIBING: 'SUBSCRIBING', + SUBSCRIBED: 'SUBSCRIBED', + }, }, EMOJI_SPACER: 'SPACER', @@ -2238,7 +2255,7 @@ const CONST = { }, EXPORTER: 'exporter', EXPORT_DATE: 'exportDate', - APPROVAL_ACCOUNT: 'approvalAccount', + PAYMENT_ACCOUNT: 'paymentAccount', }, QUICKBOOKS_EXPORT_DATE: { @@ -6707,6 +6724,12 @@ const CONST = { ERROR_PERMISSION_DENIED: 'permissionDenied', }, }, + LAST_PAYMENT_METHOD: { + LAST_USED: 'lastUsed', + IOU: 'Iou', + EXPENSE: 'Expense', + INVOICE: 'Invoice', + }, SKIPPABLE_COLLECTION_MEMBER_IDS: [String(DEFAULT_NUMBER_ID), '-1', 'undefined', 'null', 'NaN'] as string[], SETUP_SPECIALIST_LOGIN: 'Setup Specialist', } as const; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 937448fdbdfb..da768117cf9e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2013,14 +2013,14 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/nsqs/export/date', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/nsqs/export/date` as const, }, + POLICY_ACCOUNTING_NSQS_EXPORT_PAYMENT_ACCOUNT: { + route: 'settings/workspaces/:policyID/accounting/nsqs/export/payment-account', + getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/nsqs/export/payment-account` as const, + }, POLICY_ACCOUNTING_NSQS_ADVANCED: { route: 'settings/workspaces/:policyID/accounting/nsqs/advanced', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/nsqs/advanced` as const, }, - POLICY_ACCOUNTING_NSQS_ADVANCED_APPROVAL_ACCOUNT: { - route: 'settings/workspaces/:policyID/accounting/nsqs/advanced/approval-account', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/nsqs/advanced/approval-account` as const, - }, POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES: { route: 'settings/workspaces/:policyID/accounting/sage-intacct/prerequisites', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/prerequisites` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 8fc6dfca0972..d950fb1cd5db 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -440,8 +440,8 @@ const SCREENS = { NSQS_EXPORT: 'Policy_Accounting_NSQS_Export', NSQS_EXPORT_PREFERRED_EXPORTER: 'Policy_Accounting_NSQS_Export_Preferred_Exporter', NSQS_EXPORT_DATE: 'Policy_Accounting_NSQS_Export_Date', + NSQS_EXPORT_PAYMENT_ACCOUNT: 'Policy_Accounting_NSQS_Export_Payment_Account', NSQS_ADVANCED: 'Policy_Accounting_NSQS_Advanced', - NSQS_ADVANCED_APPROVAL_ACCOUNT: 'Policy_Accounting_NSQS_Advanced_Approval_Account', SAGE_INTACCT_PREREQUISITES: 'Policy_Accounting_Sage_Intacct_Prerequisites', ENTER_SAGE_INTACCT_CREDENTIALS: 'Policy_Enter_Sage_Intacct_Credentials', EXISTING_SAGE_INTACCT_CONNECTIONS: 'Policy_Existing_Sage_Intacct_Connections', diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx index 5800e92cc4f4..fbfdfddd64aa 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.tsx @@ -25,9 +25,12 @@ type CarouselItemProps = { /** Whether the attachment is currently being viewed in the carousel */ isFocused: boolean; + + /** The reportID related to the attachment */ + reportID?: string; }; -function CarouselItem({item, onPress, isFocused, isModalHovered}: CarouselItemProps) { +function CarouselItem({item, onPress, isFocused, isModalHovered, reportID}: CarouselItemProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {isAttachmentHidden} = useContext(ReportAttachmentsContext); @@ -85,6 +88,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}: CarouselItemPr isFocused={isFocused} duration={item.duration} fallbackSource={Expensicons.AttachmentNotFound} + reportID={reportID} /> diff --git a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx index f02867376c7e..384f0b63fa8c 100644 --- a/src/components/Attachments/AttachmentCarousel/Pager/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/Pager/index.tsx @@ -52,10 +52,13 @@ type AttachmentCarouselPagerProps = { /** Sets the visibility of the arrows. */ setShouldShowArrows: (show?: SetStateAction) => void; + + /** The reportID related to the attachment */ + reportID?: string; }; function AttachmentCarouselPager( - {items, activeSource, initialPage, setShouldShowArrows, onPageSelected, onClose}: AttachmentCarouselPagerProps, + {items, activeSource, initialPage, setShouldShowArrows, onPageSelected, onClose, reportID}: AttachmentCarouselPagerProps, ref: ForwardedRef, ) { const {handleTap, handleScaleChange, isScrollEnabled} = useCarouselContextEvents(setShouldShowArrows); @@ -127,6 +130,7 @@ function AttachmentCarouselPager( )); diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index 68668ccc6ab0..6156654486f2 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -147,6 +147,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi onPageSelected={({nativeEvent: {position: newPage}}) => updatePage(newPage)} onClose={onClose} ref={pagerRef} + reportID={report.reportID} /> )} diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 50caaac3dd81..6fb20e66df4f 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -16,7 +16,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -63,7 +63,7 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi const pagerRef = useRef(null); const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false}); const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false}); - const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); + const canUseTouchScreen = canUseTouchScreenUtil(); const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true); const cellWidth = useMemo( @@ -230,10 +230,11 @@ function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibi isFocused={activeSource === item.source} onPress={canUseTouchScreen ? handleTap : undefined} isModalHovered={shouldShowArrows} + reportID={report.reportID} /> ), - [activeSource, canUseTouchScreen, cellWidth, handleTap, shouldShowArrows, styles.h100], + [activeSource, canUseTouchScreen, cellWidth, handleTap, report.reportID, shouldShowArrows, styles.h100], ); /** Pan gesture handing swiping through attachments on touch screen devices */ const pan = useMemo( diff --git a/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx b/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx index e42c1e3e2fb8..b4181ee12cc6 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewVideo/index.tsx @@ -9,9 +9,12 @@ type AttachmentViewVideoProps = Pick ); } diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index f6d6ba447af3..2c07863ea2e8 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -1,9 +1,8 @@ import {Str} from 'expensify-common'; -import React, {memo, useContext, useEffect, useState} from 'react'; +import React, {memo, useEffect, useState} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import AttachmentCarouselPagerContext from '@components/Attachments/AttachmentCarousel/Pager/AttachmentCarouselPagerContext'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import DistanceEReceipt from '@components/DistanceEReceipt'; import EReceipt from '@components/EReceipt'; @@ -25,6 +24,7 @@ import {getFileResolution, isHighResolutionImage} from '@libs/fileDownload/FileU import {hasEReceipt, hasReceiptSource, isDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils'; import type {ColorValue} from '@styles/utils/types'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentViewImage from './AttachmentViewImage'; import AttachmentViewPdf from './AttachmentViewPdf'; @@ -80,6 +80,9 @@ type AttachmentViewProps = Attachment & { /** Flag indicating if the attachment is being uploaded. */ isUploading?: boolean; + + /** The reportID related to the attachment */ + reportID?: string; }; function AttachmentView({ @@ -106,11 +109,11 @@ function AttachmentView({ isUploaded = true, isDeleted, isUploading = false, + reportID, }: AttachmentViewProps) { const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); const {translate} = useLocalize(); const {updateCurrentlyPlayingURL} = usePlaybackContext(); - const attachmentCarouselPagerContext = useContext(AttachmentCarouselPagerContext); const theme = useTheme(); const {safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets(); @@ -120,7 +123,6 @@ function AttachmentView({ const [isHighResolution, setIsHighResolution] = useState(false); const [hasPDFFailedToLoad, setHasPDFFailedToLoad] = useState(false); const isVideo = (typeof source === 'string' && Str.isVideo(source)) || (file?.name && Str.isVideo(file.name)); - const isUsedInCarousel = !!attachmentCarouselPagerContext?.pagerRef; useEffect(() => { if (!isFocused && !(file && isUsedInAttachmentModal)) { @@ -297,9 +299,10 @@ function AttachmentView({ return ( source.startsWith(prefix))} isHovered={isHovered} duration={duration} + reportID={reportID} /> ); } diff --git a/src/components/Composer/implementation/index.native.tsx b/src/components/Composer/implementation/index.native.tsx index 46895f518da8..b494e38fb571 100644 --- a/src/components/Composer/implementation/index.native.tsx +++ b/src/components/Composer/implementation/index.native.tsx @@ -8,15 +8,12 @@ import type {FileObject} from '@components/AttachmentModal'; import type {ComposerProps} from '@components/Composer/types'; import type {AnimatedMarkdownTextInputRef} from '@components/RNMarkdownTextInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; -import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useMarkdownStyle from '@hooks/useMarkdownStyle'; -import useResetComposerFocus from '@hooks/useResetComposerFocus'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {containsOnlyEmojis} from '@libs/EmojiUtils'; import {splitExtensionFromFileName} from '@libs/fileDownload/FileUtils'; -import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; const excludeNoStyles: Array = []; @@ -29,7 +26,6 @@ function Composer( isDisabled = false, maxLines, isComposerFullSize = false, - autoFocus = false, style, // On native layers we like to have the Text Input not focused so the // user can read new chats without the keyboard in the way of the view. @@ -42,22 +38,12 @@ function Composer( ref: ForwardedRef, ) { const textInput = useRef(null); - const {isFocused, shouldResetFocusRef} = useResetComposerFocus(textInput); const textContainsOnlyEmojis = useMemo(() => containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {inputCallbackRef, inputRef: autoFocusInputRef} = useAutoFocusInput(); - - useEffect(() => { - if (autoFocus === !!autoFocusInputRef.current) { - return; - } - inputCallbackRef(autoFocus ? textInput.current : null); - }, [autoFocus, inputCallbackRef, autoFocusInputRef]); - useEffect(() => { if (!textInput.current || !textInput.current.setSelection || !selection || isComposerFullSize) { return; @@ -88,10 +74,6 @@ function Composer( return; } - if (autoFocus) { - inputCallbackRef(el); - } - // This callback prop is used by the parent component using the constructor to // get a ref to the inner textInput element e.g. if we do // this.textInput = el} /> this will not @@ -141,22 +123,10 @@ function Composer( textAlignVertical="center" style={[composerStyle, maxHeightStyle]} markdownStyle={markdownStyle} - // /* - // There are cases in hybird app on android that screen goes up when there is autofocus on keyboard. (e.g. https://github.com/Expensify/App/issues/53185) - // Workaround for this issue is to maunally focus keyboard after it's acutally rendered which is done by useAutoFocusInput hook. - // */ - autoFocus={getPlatform() !== 'android' ? autoFocus : false} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...props} readOnly={isDisabled} onPaste={pasteFile} - onBlur={(e) => { - if (!isFocused) { - // eslint-disable-next-line react-compiler/react-compiler - shouldResetFocusRef.current = true; // detect the input is blurred when the page is hidden - } - props?.onBlur?.(e); - }} onClear={onClear} /> ); diff --git a/src/components/CurrencyPicker.tsx b/src/components/CurrencyPicker.tsx index 6d670b0d63d8..2bb4eb3e0c86 100644 --- a/src/components/CurrencyPicker.tsx +++ b/src/components/CurrencyPicker.tsx @@ -72,6 +72,7 @@ function CurrencyPicker({label, value, errorText, headerContent, excludeCurrenci onClose={hidePickerModal} onModalHide={hidePickerModal} hideModalContentWhileAnimating + shouldEnableNewFocusManagement useNativeDriver onBackdropPress={Navigation.dismissModal} > diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx index ad7ea87f4c9b..e3383dc52d61 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/VideoRenderer.tsx @@ -4,8 +4,7 @@ import {AttachmentContext} from '@components/AttachmentContext'; import {isDeletedNode} from '@components/HTMLEngineProvider/htmlEngineUtils'; import {ShowContextMenuContext} from '@components/ShowContextMenuContext'; import VideoPlayerPreview from '@components/VideoPlayerPreview'; -import useCurrentReportID from '@hooks/useCurrentReportID'; -import * as FileUtils from '@libs/fileDownload/FileUtils'; +import {getFileName} from '@libs/fileDownload/FileUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; @@ -20,12 +19,11 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { const htmlAttribs = tnode.attributes; const attrHref = htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || htmlAttribs.src || htmlAttribs.href || ''; const sourceURL = tryResolveUrlFromApiRoot(attrHref); - const fileName = FileUtils.getFileName(`${sourceURL}`); + const fileName = getFileName(`${sourceURL}`); const thumbnailUrl = tryResolveUrlFromApiRoot(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_URL_ATTRIBUTE]); const width = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_WIDTH_ATTRIBUTE]); const height = Number(htmlAttribs[CONST.ATTACHMENT_THUMBNAIL_HEIGHT_ATTRIBUTE]); const duration = Number(htmlAttribs[CONST.ATTACHMENT_DURATION_ATTRIBUTE]); - const currentReportIDValue = useCurrentReportID(); const isDeleted = isDeletedNode(tnode); return ( @@ -36,7 +34,7 @@ function VideoRenderer({tnode, key}: VideoRendererProps) { diff --git a/src/components/Onfido/BaseOnfidoWeb.tsx b/src/components/Onfido/BaseOnfidoWeb.tsx index 2a2882c155eb..c5ccf2e36929 100644 --- a/src/components/Onfido/BaseOnfidoWeb.tsx +++ b/src/components/Onfido/BaseOnfidoWeb.tsx @@ -11,7 +11,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import './index.css'; -import type {OnfidoElement, OnfidoError, OnfidoProps} from './types'; +import type {OnfidoElement, OnfidoProps} from './types'; type InitializeOnfidoProps = OnfidoProps & Pick & { @@ -94,7 +94,7 @@ function initializeOnfido({sdkToken, onSuccess, onError, onUserExit, preferredLo } onSuccess(data); }, - onError: (error: OnfidoError) => { + onError: (error) => { const errorType = error.type; const errorMessage: string = error.message ?? CONST.ERROR.UNKNOWN_ERROR; Log.hmmm('Onfido error', {errorType, errorMessage}); diff --git a/src/components/Onfido/types.ts b/src/components/Onfido/types.ts index 3e88ce3b2dda..f08cdbb566b0 100644 --- a/src/components/Onfido/types.ts +++ b/src/components/Onfido/types.ts @@ -1,6 +1,6 @@ import type {OnfidoResult} from '@onfido/react-native-sdk'; import type {Handle} from 'onfido-sdk-ui/types/Onfido'; -import type {CompleteData} from 'onfido-sdk-ui/types/Types'; +import type {CompleteData} from 'onfido-sdk-ui/types/shared/SdkParameters'; import type {OnyxEntry} from 'react-native-onyx'; type OnfidoData = CompleteData | OnfidoResult; diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index 3c96dfd28752..ee57943873cd 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -13,6 +13,7 @@ import { getParticipantsAccountIDsForDisplay, getPolicyName, getReportName, + isAdminRoom as isAdminRoomReportUtils, isArchivedNonExpenseReport, isChatRoom as isChatRoomReportUtils, isConciergeChatReport, @@ -53,6 +54,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { const isSelfDM = isSelfDMReportUtils(report); const isInvoiceRoom = isInvoiceRoomReportUtils(report); const isSystemChat = isSystemChatReportUtils(report); + const isAdminRoom = isAdminRoomReportUtils(report); const isDefault = !(isChatRoom || isPolicyExpenseChat || isSelfDM || isInvoiceRoom || isSystemChat); const participantAccountIDs = getParticipantsAccountIDsForDisplay(report, undefined, true, true); const isMultipleParticipant = participantAccountIDs.length > 1; @@ -78,7 +80,8 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { moneyRequestOptions.includes(CONST.IOU.TYPE.SUBMIT) || moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK) || moneyRequestOptions.includes(CONST.IOU.TYPE.SPLIT)) && - !isPolicyExpenseChat; + !isPolicyExpenseChat && + !isAdminRoom; const navigateToReport = () => { if (!report?.reportID) { @@ -151,6 +154,7 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { ))} {isChatRoom && (!isInvoiceRoom || isArchivedRoom) && + !isAdminRoom && (welcomeMessage?.messageHtml ? ( @@ -170,6 +174,14 @@ function ReportWelcomeText({report, policy}: ReportWelcomeTextProps) { {welcomeMessage.phrase2 !== undefined && {welcomeMessage.phrase2}} ))} + {isChatRoom && isAdminRoom && ( + + {welcomeMessage.phrase1} + {welcomeMessage.phrase2 !== undefined && {welcomeMessage.phrase2}} + {welcomeMessage.phrase3 !== undefined && {welcomeMessage.phrase3}} + {welcomeMessage.phrase4 !== undefined && {welcomeMessage.phrase4}} + + )} {isSelfDM && ( {welcomeMessage.phrase1} diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 2cd3659b6e80..4448189c77c2 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -67,6 +67,9 @@ type SearchAutocompleteListProps = { /** Callback to call when the list of autocomplete substitutions should be updated */ updateAutocompleteSubstitutions: (item: SearchQueryItem) => void; + + /** Whether to subscribe to KeyboardShortcut arrow keys events */ + shouldSubscribeToArrowKeyEvents?: boolean; }; const defaultListOptions = { @@ -118,7 +121,15 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchAutocompleteList( - {autocompleteQueryValue, searchQueryItem, getAdditionalSections, onListItemPress, setTextQuery, updateAutocompleteSubstitutions}: SearchAutocompleteListProps, + { + autocompleteQueryValue, + searchQueryItem, + getAdditionalSections, + onListItemPress, + setTextQuery, + updateAutocompleteSubstitutions, + shouldSubscribeToArrowKeyEvents, + }: SearchAutocompleteListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -490,6 +501,7 @@ function SearchAutocompleteList( ref={ref} initiallyFocusedOptionKey={!shouldUseNarrowLayout ? styledRecentReports.at(0)?.keyForList : undefined} shouldScrollToFocusedIndex={!isInitialRender} + shouldSubscribeToArrowKeyEvents={shouldSubscribeToArrowKeyEvents} /> ); } diff --git a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx index 8eaffa65fd18..a3b814650a23 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx @@ -23,6 +23,7 @@ import { approveMoneyRequestOnSearch, deleteMoneyRequestOnSearch, exportSearchItemsToCSV, + getLastPolicyPaymentMethod, payMoneyRequestOnSearch, unholdMoneyRequestOnSearch, updateAdvancedFilters, @@ -149,9 +150,12 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS !isOffline && !isAnyTransactionOnHold && (selectedReports.length - ? selectedReports.every((report) => report.action === CONST.SEARCH.ACTION_TYPES.PAY && report.policyID && lastPaymentMethods[report.policyID]) + ? selectedReports.every((report) => report.action === CONST.SEARCH.ACTION_TYPES.PAY && report.policyID && getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods)) : selectedTransactionsKeys.every( - (id) => selectedTransactions[id].action === CONST.SEARCH.ACTION_TYPES.PAY && selectedTransactions[id].policyID && lastPaymentMethods[selectedTransactions[id].policyID], + (id) => + selectedTransactions[id].action === CONST.SEARCH.ACTION_TYPES.PAY && + selectedTransactions[id].policyID && + getLastPolicyPaymentMethod(selectedTransactions[id].policyID, lastPaymentMethods), )); if (shouldShowPayOption) { @@ -172,7 +176,7 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS for (const item of items) { const policyID = item.policyID; - const lastPolicyPaymentMethod = policyID ? lastPaymentMethods?.[policyID] : null; + const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(policyID, lastPaymentMethods); if (!lastPolicyPaymentMethod) { Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: activeRoute})); @@ -189,11 +193,15 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS const paymentData = ( selectedReports.length - ? selectedReports.map((report) => ({reportID: report.reportID, amount: report.total, paymentType: lastPaymentMethods[`${report.policyID}`]})) + ? selectedReports.map((report) => ({ + reportID: report.reportID, + amount: report.total, + paymentType: getLastPolicyPaymentMethod(report.policyID, lastPaymentMethods), + })) : Object.values(selectedTransactions).map((transaction) => ({ reportID: transaction.reportID, amount: transaction.amount, - paymentType: lastPaymentMethods[transaction.policyID], + paymentType: getLastPolicyPaymentMethod(transaction.policyID, lastPaymentMethods), })) ) as PaymentData[]; @@ -344,7 +352,7 @@ function SearchPageHeader({queryJSON, searchName, searchRouterListVisible, hideS diff --git a/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx b/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx index e111fe9c47bb..852d6c3748bc 100644 --- a/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx +++ b/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx @@ -24,6 +24,7 @@ import {createBaseSavedSearchMenuItem, createTypeMenuItems, getOverflowMenu as g import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; type SavedSearchMenuItem = MenuItemWithLink & { @@ -94,7 +95,10 @@ function SearchTypeMenuPopover({queryJSON, searchName, shouldGroupByReports}: Se return { ...baseMenuItem, - onSelected: baseMenuItem.onPress, + onSelected: () => { + clearAllFilters(); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); + }, rightComponent: ( { const selectedKeys = Object.keys(selectedTransactions).filter((key) => selectedTransactions[key]); if (selectedKeys.length === 0 && selectionMode?.isEnabled && shouldTurnOffSelectionMode) { @@ -191,9 +193,13 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo } return; } - if (selectedKeys.length > 0 && !selectionMode?.isEnabled) { + if (selectedKeys.length > 0 && !selectionMode?.isEnabled && !isSearchResultsEmpty) { turnOnMobileSelectionMode(); } + + // We don't need to run the effect on change of isSearchResultsEmpty. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSmallScreenWidth, selectedTransactions, selectionMode?.isEnabled]); useEffect(() => { @@ -233,8 +239,6 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo }, }); - const searchResults = currentSearchResults?.data ? currentSearchResults : lastNonEmptySearchResults; - const {newSearchResultKey, handleSelectionListScroll} = useSearchHighlightAndScroll({ searchResults, transactions, @@ -255,7 +259,6 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo const shouldShowLoadingState = !isOffline && !isDataLoaded; const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; - const isSearchResultsEmpty = !searchResults?.data || isSearchResultsEmptyUtil(searchResults); const prevIsSearchResultEmpty = usePrevious(isSearchResultsEmpty); const data = useMemo(() => { @@ -275,7 +278,7 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo return; } const newTransactionList: SelectedTransactions = {}; - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE && !shouldGroupByReports) { + if (!shouldGroupByReports) { data.forEach((transaction) => { if (!Object.hasOwn(transaction, 'transactionID') || !('transactionID' in transaction)) { return; @@ -479,7 +482,7 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo }; const shouldShowYear = shouldShowYearUtil(searchResults?.data); - const shouldShowSorting = !Array.isArray(status) && sortableSearchStatuses.includes(status); + const shouldShowSorting = !Array.isArray(status) && !shouldGroupByReports; return ( ref={handleSelectionListScroll(sortedSelectedData)} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index a1fa7c15e6cf..7ec18f288a76 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -125,6 +125,7 @@ function BaseSelectionList( initialNumToRender = 12, listItemTitleContainerStyles, isScreenFocused = false, + shouldSubscribeToArrowKeyEvents = true, }: BaseSelectionListProps, ref: ForwardedRef, ) { @@ -337,7 +338,7 @@ function BaseSelectionList( initialFocusedIndex: flattenedSections.allOptions.findIndex((option) => option.keyForList === initiallyFocusedOptionKey), maxIndex: Math.min(flattenedSections.allOptions.length - 1, CONST.MAX_SELECTION_LIST_PAGE_LENGTH * currentPage - 1), disabledIndexes: disabledArrowKeyIndexes, - isActive: isFocused, + isActive: shouldSubscribeToArrowKeyEvents && isFocused, onFocusedIndexChange: (index: number) => { const focusedItem = flattenedSections.allOptions.at(index); if (focusedItem) { diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index fc3dd2758ab7..3eb63ae97242 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -523,6 +523,9 @@ type BaseSelectionListProps = Partial & { /** Whether to prevent default focusing of options and focus the textinput when selecting an option */ shouldPreventDefaultFocusOnSelectRow?: boolean; + /** Whether to subscribe to KeyboardShortcut arrow keys events */ + shouldSubscribeToArrowKeyEvents?: boolean; + /** Custom content to display in the header */ headerContent?: ReactNode; diff --git a/src/components/SettlementButton/index.tsx b/src/components/SettlementButton/index.tsx index 667826d29f8b..bca6765d099f 100644 --- a/src/components/SettlementButton/index.tsx +++ b/src/components/SettlementButton/index.tsx @@ -21,6 +21,7 @@ import {approveMoneyRequest, savePreferredPaymentMethod as savePreferredPaymentM import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {LastPaymentMethodType} from '@src/types/onyx'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -74,7 +75,14 @@ function SettlementButton({ const policyEmployeeAccountIDs = policyID ? getPolicyEmployeeAccountIDs(policyID) : []; const reportBelongsToWorkspace = policyID ? doesReportBelongToWorkspace(chatReport, policyEmployeeAccountIDs, policyID) : false; const policyIDKey = reportBelongsToWorkspace ? policyID : CONST.POLICY.ID_FAKE; - const [lastPaymentMethod = '-1', lastPaymentMethodResult] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {selector: (paymentMethod) => paymentMethod?.[policyIDKey]}); + const [lastPaymentMethod, lastPaymentMethodResult] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, { + selector: (paymentMethod) => { + if (typeof paymentMethod?.[policyIDKey] === 'string') { + return paymentMethod?.[policyIDKey]; + } + return (paymentMethod?.[policyIDKey] as LastPaymentMethodType)?.lastUsed; + }, + }); const isLoadingLastPaymentMethod = isLoadingOnyxValue(lastPaymentMethodResult); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); @@ -221,7 +229,7 @@ function SettlementButton({ }; const savePreferredPaymentMethod = (id: string, value: PaymentMethodType) => { - savePreferredPaymentMethodIOU(id, value); + savePreferredPaymentMethodIOU(id, value, undefined); }; return ( diff --git a/src/components/VideoPlayer/BaseVideoPlayer.tsx b/src/components/VideoPlayer/BaseVideoPlayer.tsx index 1fd0cd657e85..dd3eab3d633f 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.tsx +++ b/src/components/VideoPlayer/BaseVideoPlayer.tsx @@ -51,6 +51,7 @@ function BaseVideoPlayer({ // eslint-disable-next-line @typescript-eslint/no-unused-vars isVideoHovered = false, isPreview, + reportID, }: VideoPlayerProps) { const styles = useThemeStyles(); const { @@ -64,6 +65,7 @@ function BaseVideoPlayer({ updateCurrentlyPlayingURL, videoResumeTryNumberRef, setCurrentlyPlayingURL, + currentlyPlayingURLReportID, } = usePlaybackContext(); const {isFullScreenRef} = useFullScreenContext(); const {isOffline} = useNetwork(); @@ -311,7 +313,9 @@ function BaseVideoPlayer({ if (shouldUseSharedVideoElement || videoPlayerRef.current !== currentVideoPlayerRef.current) { return; } - currentVideoPlayerRef.current = null; + currentVideoPlayerRef.current?.setStatusAsync?.({shouldPlay: false, positionMillis: 0}).then(() => { + currentVideoPlayerRef.current = null; + }); }, [currentVideoPlayerRef, shouldUseSharedVideoElement], ); @@ -341,13 +345,14 @@ function BaseVideoPlayer({ }, [setCurrentlyPlayingURL, shouldUseSharedVideoElement], ); + // update shared video elements useEffect(() => { - if (shouldUseSharedVideoElement || url !== currentlyPlayingURL) { + if (shouldUseSharedVideoElement || url !== currentlyPlayingURL || reportID !== currentlyPlayingURLReportID) { return; } shareVideoPlayerElements(videoPlayerRef.current, videoPlayerElementParentRef.current, videoPlayerElementRef.current, isUploading || isFullScreenRef.current); - }, [currentlyPlayingURL, shouldUseSharedVideoElement, shareVideoPlayerElements, url, isUploading, isFullScreenRef]); + }, [currentlyPlayingURL, shouldUseSharedVideoElement, shareVideoPlayerElements, url, isUploading, isFullScreenRef, reportID, currentlyPlayingURLReportID]); // Call bindFunctions() through the refs to avoid adding it to the dependency array of the DOM mutation effect, as doing so would change the DOM when the functions update. const bindFunctionsRef = useRef<(() => void) | null>(null); @@ -394,7 +399,7 @@ function BaseVideoPlayer({ } newParentRef.childNodes[0]?.remove(); }; - }, [currentVideoPlayerRef, currentlyPlayingURL, isFullScreenRef, originalParent, sharedElement, shouldUseSharedVideoElement, url]); + }, [currentVideoPlayerRef, currentlyPlayingURL, currentlyPlayingURLReportID, isFullScreenRef, originalParent, reportID, sharedElement, shouldUseSharedVideoElement, url]); useEffect(() => { if (!shouldPlay) { diff --git a/src/components/VideoPlayer/types.ts b/src/components/VideoPlayer/types.ts index 3dd3884534ec..f5aac9106675 100644 --- a/src/components/VideoPlayer/types.ts +++ b/src/components/VideoPlayer/types.ts @@ -30,6 +30,7 @@ type VideoPlayerProps = { controlsStatus?: ValueOf; shouldPlay?: boolean; isPreview?: boolean; + reportID?: string; }; export type {VideoPlayerProps, VideoWithOnFullScreenUpdate}; diff --git a/src/components/VideoPlayerContexts/PlaybackContext.tsx b/src/components/VideoPlayerContexts/PlaybackContext.tsx index 6a971deacbc1..c66ca951f054 100644 --- a/src/components/VideoPlayerContexts/PlaybackContext.tsx +++ b/src/components/VideoPlayerContexts/PlaybackContext.tsx @@ -1,9 +1,11 @@ +import type {NavigationState} from '@react-navigation/native'; import type {AVPlaybackStatus, AVPlaybackStatusToSet} from 'expo-av'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {View} from 'react-native'; import type {VideoWithOnFullScreenUpdate} from '@components/VideoPlayer/types'; -import useCurrentReportID from '@hooks/useCurrentReportID'; import usePrevious from '@hooks/usePrevious'; +import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; +import Navigation from '@libs/Navigation/Navigation'; import Visibility from '@libs/Visibility'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {PlaybackContext, StatusCallback} from './types'; @@ -16,7 +18,7 @@ function PlaybackContextProvider({children}: ChildrenProps) { const [sharedElement, setSharedElement] = useState(null); const [originalParent, setOriginalParent] = useState(null); const currentVideoPlayerRef = useRef(null); - const {currentReportID} = useCurrentReportID() ?? {}; + const [currentReportID, setCurrentReportID] = useState(); const prevCurrentReportID = usePrevious(currentReportID); const videoResumeTryNumberRef = useRef(0); const playVideoPromiseRef = useRef>(); @@ -48,6 +50,22 @@ function PlaybackContextProvider({children}: ChildrenProps) { currentVideoPlayerRef.current?.unloadAsync?.(); }, [currentVideoPlayerRef]); + /** + * This function is used to update the currentReportID + * @param state root navigation state + */ + const updateCurrentPlayingReportID = useCallback( + (state: NavigationState) => { + if (!isReportTopmostSplitNavigator()) { + setCurrentReportID(undefined); + return; + } + const reportID = Navigation.getTopmostReportId(state); + setCurrentReportID(reportID); + }, + [setCurrentReportID], + ); + const updateCurrentlyPlayingURL = useCallback( (url: string | null) => { if (currentlyPlayingURL && url !== currentlyPlayingURL) { @@ -89,8 +107,9 @@ function PlaybackContextProvider({children}: ChildrenProps) { setCurrentlyPlayingURL(null); setSharedElement(null); setOriginalParent(null); - currentVideoPlayerRef.current = null; + setCurrentlyPlayingURLReportID(undefined); unloadVideo(); + currentVideoPlayerRef.current = null; }); }, [stopVideo, unloadVideo]); @@ -100,10 +119,15 @@ function PlaybackContextProvider({children}: ChildrenProps) { // This prevents the video that plays when the app opens from being interrupted when currentReportID // is initially empty or '-1', or when it changes from empty/'-1' to another value // after the report screen in the central pane is mounted on the large screen. - if (!currentReportID || !prevCurrentReportID || currentReportID === '-1' || prevCurrentReportID === '-1' || currentReportID === prevCurrentReportID) { + if ((!currentReportID && isReportTopmostSplitNavigator()) || (!prevCurrentReportID && !isReportTopmostSplitNavigator()) || currentReportID === prevCurrentReportID) { return; } - resetVideoPlayerData(); + + // We call another setStatusAsync inside useLayoutEffect on the video component, + // so we add a delay here to prevent the error from appearing. + setTimeout(() => { + resetVideoPlayerData(); + }, 0); }, [currentReportID, prevCurrentReportID, resetVideoPlayerData]); useEffect(() => { @@ -131,6 +155,7 @@ function PlaybackContextProvider({children}: ChildrenProps) { pauseVideo, checkVideoPlaying, videoResumeTryNumberRef, + updateCurrentPlayingReportID, }), [ updateCurrentlyPlayingURL, @@ -143,6 +168,7 @@ function PlaybackContextProvider({children}: ChildrenProps) { pauseVideo, checkVideoPlaying, setCurrentlyPlayingURL, + updateCurrentPlayingReportID, ], ); return {children}; diff --git a/src/components/VideoPlayerContexts/types.ts b/src/components/VideoPlayerContexts/types.ts index 532d3a0131d3..057170b335a9 100644 --- a/src/components/VideoPlayerContexts/types.ts +++ b/src/components/VideoPlayerContexts/types.ts @@ -1,3 +1,4 @@ +import type {NavigationState} from '@react-navigation/native'; import type {MutableRefObject} from 'react'; import type {View} from 'react-native'; import type {SharedValue} from 'react-native-reanimated'; @@ -20,6 +21,7 @@ type PlaybackContext = { pauseVideo: () => void; checkVideoPlaying: (statusCallback: StatusCallback) => void; setCurrentlyPlayingURL: React.Dispatch>; + updateCurrentPlayingReportID: (state: NavigationState) => void; }; type VolumeContext = { diff --git a/src/components/VideoPlayerPreview/index.tsx b/src/components/VideoPlayerPreview/index.tsx index fb188e593949..3831bb117fa3 100644 --- a/src/components/VideoPlayerPreview/index.tsx +++ b/src/components/VideoPlayerPreview/index.tsx @@ -22,7 +22,7 @@ type VideoPlayerPreviewProps = { videoUrl: string; /** reportID of the video */ - reportID: string; + reportID: string | undefined; /** Dimension of a video. */ videoDimensions: VideoDimensions; @@ -91,6 +91,7 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, reportID, fileName, videoDi style={[styles.w100, styles.h100]} isPreview videoPlayerStyle={styles.videoPlayerPreview} + reportID={reportID} /> ) { - const isFocused = useIsFocused(); - const shouldResetFocusRef = useRef(false); - - useEffect(() => { - if (!isFocused || !shouldResetFocusRef.current) { - return; - } - const interactionTask = InteractionManager.runAfterInteractions(() => { - inputRef.current?.focus(); // focus input again - shouldResetFocusRef.current = false; - }); - return () => { - interactionTask.cancel(); - }; - }, [isFocused, inputRef]); - - return {isFocused, shouldResetFocusRef}; -} diff --git a/src/languages/en.ts b/src/languages/en.ts index 1354567189dd..a414d3a94dd1 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -720,7 +720,9 @@ const translations = { beginningOfArchivedRoomPartTwo: ", there's nothing to see here.", 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.`, + beginningOfChatHistoryAdminRoomPartOneFirst: 'This chat is with', + beginningOfChatHistoryAdminRoomPartOneLast: 'admin.', + beginningOfChatHistoryAdminRoomWorkspaceName: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => ` ${workspaceName} `, beginningOfChatHistoryAdminRoomPartTwo: ' Use it to chat about workspace setup and more.', beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => `This chat is with everyone in ${workspaceName}.`, beginningOfChatHistoryAnnounceRoomPartTwo: ` Use it for the most important announcements.`, @@ -3472,13 +3474,12 @@ const translations = { expense: 'Expense', reimbursableExpenses: 'Export reimbursable expenses as', nonReimbursableExpenses: 'Export non-reimbursable expenses as', + defaultPaymentAccount: 'NSQS default', + paymentAccount: 'Payment account', + paymentAccountDescription: 'Choose the account that will be used as the payment account for transactions NSQS.', }, advanced: { autoSyncDescription: 'Sync NSQS and Expensify automatically, every day. Export finalized report in realtime', - defaultApprovalAccount: 'NSQS default', - approvalAccount: 'A/P approval account', - approvalAccountDescription: - 'Choose the account that transactions will be approved against in NSQS. If you’re syncing reimbursed reports, this is also the account that bill payments will be created against.', }, }, intacct: { diff --git a/src/languages/es.ts b/src/languages/es.ts index d6a3ac853a40..633bff1248a5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -714,8 +714,9 @@ const translations = { 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 chat es con los administradores del espacio de trabajo ${workspaceName}.`, + beginningOfChatHistoryAdminRoomPartOneFirst: 'Este chat es con los administradores del espacio de trabajo', + beginningOfChatHistoryAdminRoomWorkspaceName: ({workspaceName}: BeginningOfChatHistoryAdminRoomPartOneParams) => ` ${workspaceName}`, + beginningOfChatHistoryAdminRoomPartOneLast: '.', beginningOfChatHistoryAdminRoomPartTwo: ' Úsalo para hablar sobre la configuración del espacio de trabajo y más.', beginningOfChatHistoryAnnounceRoomPartOne: ({workspaceName}: BeginningOfChatHistoryAnnounceRoomPartOneParams) => `Este chat es con todos en ${workspaceName}.`, beginningOfChatHistoryAnnounceRoomPartTwo: ` Úsalo para hablar sobre la configuración del espacio de trabajo y más.`, @@ -3513,13 +3514,12 @@ const translations = { expense: 'Gasto', reimbursableExpenses: 'Exportar gastos reembolsables como', nonReimbursableExpenses: 'Exportar gastos no reembolsables como', + defaultPaymentAccount: 'Preferencia predeterminada de NSQS', + paymentAccount: 'Cuenta de pago', + paymentAccountDescription: 'Elige la cuenta que se utilizará como cuenta de pago para las transacciones NSQS.', }, advanced: { autoSyncDescription: 'Sincroniza NSQS y Expensify automáticamente, todos los días. Exporta el informe finalizado en tiempo real', - defaultApprovalAccount: 'Preferencia predeterminada de NSQS', - approvalAccount: 'Cuenta de aprobación de cuentas por pagar', - approvalAccountDescription: - 'Elija la cuenta con la que se aprobarán las transacciones en NSQS. Si está sincronizando informes reembolsados, esta es también la cuenta con la que se crearán los pagos de facturas.', }, }, intacct: { diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts index 1723d5cd55a0..92f23260cc19 100644 --- a/src/libs/API/index.ts +++ b/src/libs/API/index.ts @@ -5,7 +5,7 @@ import Log from '@libs/Log'; import {HandleUnusedOptimisticID, Logging, Pagination, Reauthentication, RecheckConnection, SaveResponseInOnyx} from '@libs/Middleware'; import {isOffline} from '@libs/Network/NetworkStore'; import {push as pushToSequentialQueue, waitForIdle as waitForSequentialQueueIdle} from '@libs/Network/SequentialQueue'; -import {getPusherSocketID} from '@libs/Pusher/pusher'; +import Pusher from '@libs/Pusher'; import {processWithMiddleware, use} from '@libs/Request'; import {getLength as getPersistedRequestsLength} from '@userActions/PersistedRequests'; import CONST from '@src/CONST'; @@ -70,7 +70,7 @@ function prepareRequest( // We send the pusherSocketID with all write requests so that the api can include it in push events to prevent Pusher from sending the events to the requesting client. The push event // is sent back to the requesting client in the response data instead, which prevents a replay effect in the UI. See https://github.com/Expensify/App/issues/12775. - pusherSocketID: isWriteRequest ? getPusherSocketID() : undefined, + pusherSocketID: isWriteRequest ? Pusher.getPusherSocketID() : undefined, }; // Assemble all request metadata (used by middlewares, and for persisted requests stored in Onyx) diff --git a/src/libs/API/parameters/UpdateNSQSApprovalAccountParams.ts b/src/libs/API/parameters/UpdateNSQSApprovalAccountParams.ts deleted file mode 100644 index 3712e782e143..000000000000 --- a/src/libs/API/parameters/UpdateNSQSApprovalAccountParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -type UpdateNSQSApprovalAccountParams = { - policyID: string; - value: string; -}; - -export default UpdateNSQSApprovalAccountParams; diff --git a/src/libs/API/parameters/UpdateNSQSPaymentAccountParams.ts b/src/libs/API/parameters/UpdateNSQSPaymentAccountParams.ts new file mode 100644 index 000000000000..101b54cdb96f --- /dev/null +++ b/src/libs/API/parameters/UpdateNSQSPaymentAccountParams.ts @@ -0,0 +1,6 @@ +type UpdateNSQSPaymentAccountParams = { + policyID: string; + value: string; +}; + +export default UpdateNSQSPaymentAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index a84b8b0796af..67c5a3627a5d 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -282,7 +282,7 @@ export type {default as UpdateNSQSProjectsMappingParams} from './UpdateNSQSProje export type {default as UpdateNSQSExporterParams} from './UpdateNSQSExporterParams'; export type {default as UpdateNSQSExportDateParams} from './UpdateNSQSExportDateParams'; export type {default as UpdateNSQSAutoSyncParams} from './UpdateNSQSAutoSyncParams'; -export type {default as UpdateNSQSApprovalAccountParams} from './UpdateNSQSApprovalAccountParams'; +export type {default as UpdateNSQSPaymentAccountParams} from './UpdateNSQSPaymentAccountParams'; export type {default as UpdateSageIntacctGenericTypeParams} from './UpdateSageIntacctGenericTypeParams'; export type {default as UpdateNetSuiteCustomersJobsParams} from './UpdateNetSuiteCustomersJobsParams'; export type {default as CopyExistingPolicyConnectionParams} from './CopyExistingPolicyConnectionParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index e17e2c4032cd..522537058cbf 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -377,7 +377,7 @@ const WRITE_COMMANDS = { UPDATE_NSQS_EXPORTER: 'UpdateNSQSExporter', UPDATE_NSQS_EXPORT_DATE: 'UpdateNSQSExportDate', UPDATE_NSQS_AUTO_SYNC: 'UpdateNSQSAutoSync', - UPDATE_NSQS_APPROVAL_ACCOUNT: 'UpdateNSQSApprovalAccount', + UPDATE_NSQS_PAYMENT_ACCOUNT: 'UpdateNSQSPaymentAccount', REQUEST_EXPENSIFY_CARD_LIMIT_INCREASE: 'RequestExpensifyCardLimitIncrease', CONNECT_POLICY_TO_SAGE_INTACCT: 'ConnectPolicyToSageIntacct', COPY_EXISTING_POLICY_CONNECTION: 'CopyExistingPolicyConnection', @@ -878,7 +878,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_NSQS_EXPORTER]: Parameters.UpdateNSQSExporterParams; [WRITE_COMMANDS.UPDATE_NSQS_EXPORT_DATE]: Parameters.UpdateNSQSExportDateParams; [WRITE_COMMANDS.UPDATE_NSQS_AUTO_SYNC]: Parameters.UpdateNSQSAutoSyncParams; - [WRITE_COMMANDS.UPDATE_NSQS_APPROVAL_ACCOUNT]: Parameters.UpdateNSQSApprovalAccountParams; + [WRITE_COMMANDS.UPDATE_NSQS_PAYMENT_ACCOUNT]: Parameters.UpdateNSQSPaymentAccountParams; [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_ENTITY]: Parameters.UpdateSageIntacctGenericTypeParams<'entity', string>; [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_BILLABLE]: Parameters.UpdateSageIntacctGenericTypeParams<'enabled', boolean>; [WRITE_COMMANDS.UPDATE_SAGE_INTACCT_DEPARTMENT_MAPPING]: Parameters.UpdateSageIntacctGenericTypeParams<'mapping', SageIntacctMappingValue>; diff --git a/src/libs/LoginUtils.ts b/src/libs/LoginUtils.ts index 191fd72db4e9..30cd1b9fb020 100644 --- a/src/libs/LoginUtils.ts +++ b/src/libs/LoginUtils.ts @@ -75,4 +75,11 @@ function areEmailsFromSamePrivateDomain(email1: string, email2: string): boolean return Str.extractEmailDomain(email1).toLowerCase() === Str.extractEmailDomain(email2).toLowerCase(); } -export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain}; +function formatE164PhoneNumber(phoneNumber: string) { + const phoneNumberWithCountryCode = appendCountryCode(phoneNumber); + const parsedPhoneNumber = parsePhoneNumber(phoneNumberWithCountryCode); + + return parsedPhoneNumber.number?.e164; +} + +export {getPhoneNumberWithoutSpecialChars, appendCountryCode, isEmailPublicDomain, validateNumber, getPhoneLogin, areEmailsFromSamePrivateDomain, formatE164PhoneNumber}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index ec1c06b2bbaf..4ed6f22f3f3b 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -31,7 +31,7 @@ import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOpt import type {AuthScreensParamList} from '@libs/Navigation/types'; import NetworkConnection from '@libs/NetworkConnection'; import onyxSubscribe from '@libs/onyxSubscribe'; -import * as Pusher from '@libs/Pusher/pusher'; +import Pusher from '@libs/Pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import * as SessionUtils from '@libs/SessionUtils'; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index c16fc4e164db..2058a317aa2b 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -507,9 +507,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/accounting/nsqs/export/NSQSPreferredExporterPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NSQS_EXPORT_DATE]: () => require('../../../../pages/workspace/accounting/nsqs/export/NSQSDatePage').default, + [SCREENS.WORKSPACE.ACCOUNTING.NSQS_EXPORT_PAYMENT_ACCOUNT]: () => require('../../../../pages/workspace/accounting/nsqs/export/NSQSPaymentAccountPage').default, [SCREENS.WORKSPACE.ACCOUNTING.NSQS_ADVANCED]: () => require('../../../../pages/workspace/accounting/nsqs/advanced/NSQSAdvancedPage').default, - [SCREENS.WORKSPACE.ACCOUNTING.NSQS_ADVANCED_APPROVAL_ACCOUNT]: () => - require('../../../../pages/workspace/accounting/nsqs/advanced/NSQSApprovalAccountPage').default, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES]: () => require('../../../../pages/workspace/accounting/intacct/SageIntacctPrerequisitesPage').default, [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: () => require('../../../../pages/workspace/accounting/intacct/EnterSageIntacctCredentialsPage').default, diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 93c6fc355aea..38ae0f2f3ea4 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -4,6 +4,7 @@ import React, {useContext, useEffect, useMemo, useRef} from 'react'; import {NativeModules} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; +import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useCurrentReportID from '@hooks/useCurrentReportID'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -89,6 +90,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh const {cleanStaleScrollOffsets} = useContext(ScrollOffsetContext); const currentReportIDValue = useCurrentReportID(); + const {updateCurrentPlayingReportID} = usePlaybackContext(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [user] = useOnyx(ONYXKEYS.USER); const isPrivateDomain = Session.isUserOnPrivateDomain(); @@ -200,6 +202,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady, sh // Performance optimization to avoid context consumers to delay first render setTimeout(() => { currentReportIDValue?.updateCurrentReportID(state); + updateCurrentPlayingReportID(state); }, 0); parseAndLogRoute(state); diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 3a063ab1f3ee..7d38c1f16b55 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -135,8 +135,8 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.NSQS_EXPORT_DATE]: { path: ROUTES.POLICY_ACCOUNTING_NSQS_EXPORT_DATE.route, }, + [SCREENS.WORKSPACE.ACCOUNTING.NSQS_EXPORT_PAYMENT_ACCOUNT]: { + path: ROUTES.POLICY_ACCOUNTING_NSQS_EXPORT_PAYMENT_ACCOUNT.route, + }, [SCREENS.WORKSPACE.ACCOUNTING.NSQS_ADVANCED]: { path: ROUTES.POLICY_ACCOUNTING_NSQS_ADVANCED.route, }, - [SCREENS.WORKSPACE.ACCOUNTING.NSQS_ADVANCED_APPROVAL_ACCOUNT]: { - path: ROUTES.POLICY_ACCOUNTING_NSQS_ADVANCED_APPROVAL_ACCOUNT.route, - }, [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_PREREQUISITES]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_PREREQUISITES.route}, [SCREENS.WORKSPACE.ACCOUNTING.ENTER_SAGE_INTACCT_CREDENTIALS]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.route}, [SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS]: {path: ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.route}, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1f10d9701c07..ce8fdb83a8e5 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -683,10 +683,10 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.ACCOUNTING.NSQS_EXPORT_DATE]: { policyID: string; }; - [SCREENS.WORKSPACE.ACCOUNTING.NSQS_ADVANCED]: { + [SCREENS.WORKSPACE.ACCOUNTING.NSQS_EXPORT_PAYMENT_ACCOUNT]: { policyID: string; }; - [SCREENS.WORKSPACE.ACCOUNTING.NSQS_ADVANCED_APPROVAL_ACCOUNT]: { + [SCREENS.WORKSPACE.ACCOUNTING.NSQS_ADVANCED]: { policyID: string; }; [SCREENS.WORKSPACE.ACCOUNTING.SAGE_INTACCT_IMPORT]: { diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index 97e62ee4d129..4f5c6c15e47c 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -228,7 +228,10 @@ function buildNextStep(report: OnyxEntry, predictedNextStatus: ValueOf, predictedNextStatus: ValueOf { + if (!network) { + return; + } + shouldForceOffline = !!network.shouldForceOffline; + }, +}); + +let socket: Pusher | null; +let pusherSocketID: string | undefined; +const socketEventCallbacks: SocketEventCallback[] = []; + +let resolveInitPromise: () => void; +let initPromise = new Promise((resolve) => { + resolveInitPromise = resolve; +}); + +const eventsBoundToChannels = new Map) => void>>(); +let channels: Record> = {}; + +/** + * Trigger each of the socket event callbacks with the event information + */ +function callSocketEventCallbacks(eventName: SocketEventName, data?: EventCallbackError | States) { + socketEventCallbacks.forEach((cb) => cb(eventName, data)); +} + +/** + * Initialize our pusher lib + * @returns resolves when Pusher has connected + */ +function init(args: Args): Promise { + return new Promise((resolve) => { + if (socket) { + resolve(); + return; + } + + socket = Pusher.getInstance(); + socket.init({ + apiKey: args.appKey, + cluster: args.cluster, + onConnectionStateChange: (currentState: string, previousState: string) => { + if (currentState === CONST.PUSHER.STATE.CONNECTED) { + socket?.getSocketId().then((id: string) => { + pusherSocketID = id; + callSocketEventCallbacks('connected'); + resolve(); + }); + } + if (currentState === CONST.PUSHER.STATE.DISCONNECTED) { + callSocketEventCallbacks('disconnected'); + } + callSocketEventCallbacks('state_change', {previous: previousState, current: currentState}); + }, + onError: (message: string) => callSocketEventCallbacks('error', {data: {message}}), + onAuthorizer: (channelName: string, socketId: string) => authenticatePusher(socketId, channelName) as Promise, + }); + socket.connect(); + }).then(resolveInitPromise); +} + +/** + * Returns a Pusher channel for a channel name + */ +function getChannel(channelName: string): PusherChannel | undefined { + if (!socket) { + return; + } + + return socket.getChannel(channelName); +} + +/** + * Binds an event callback to a channel + eventName + */ +function bindEventToChannel(channel: string, eventName?: EventName, eventCallback: (data: EventData) => void = () => {}) { + if (!eventName) { + return; + } + + const chunkedDataEvents: Record = {}; + const callback = (eventData: EventData) => { + if (shouldForceOffline) { + Log.info('[Pusher] Ignoring a Push event because shouldForceOffline = true'); + return; + } + + let data: EventData; + try { + data = isObject(eventData) ? eventData : (JSON.parse(eventData) as EventData); + } catch (err) { + Log.alert('[Pusher] Unable to parse single JSON event data from Pusher', {error: err, eventData}); + return; + } + if (data.id === undefined || data.chunk === undefined || data.final === undefined) { + eventCallback(data); + return; + } + + // If we are chunking the requests, we need to construct a rolling list of all packets that have come through + // Pusher. If we've completed one of these full packets, we'll combine the data and act on the event that it's + // assigned to. + + // If we haven't seen this eventID yet, initialize it into our rolling list of packets. + if (!chunkedDataEvents[data.id]) { + chunkedDataEvents[data.id] = {chunks: [], receivedFinal: false}; + } + + // Add it to the rolling list. + const chunkedEvent = chunkedDataEvents[data.id]; + if (data.index !== undefined) { + chunkedEvent.chunks[data.index] = data.chunk; + } + + // If this is the last packet, mark that we've hit the end. + if (data.final) { + chunkedEvent.receivedFinal = true; + } + + // Only call the event callback if we've received the last packet and we don't have any holes in the complete + // packet. + if (chunkedEvent.receivedFinal && chunkedEvent.chunks.length === Object.keys(chunkedEvent.chunks).length) { + try { + eventCallback(JSON.parse(chunkedEvent.chunks.join('')) as EventData); + } catch (err) { + Log.alert('[Pusher] Unable to parse chunked JSON response from Pusher', { + error: err, + eventData: chunkedEvent.chunks.join(''), + }); + + // Using console.error is helpful here because it will print a usable stack trace to the console to debug where the error comes from + console.error(err); + } + + delete chunkedDataEvents[data.id]; + } + }; + + if (!eventsBoundToChannels.has(channel)) { + eventsBoundToChannels.set(channel, new Map()); + } + + eventsBoundToChannels.get(channel)?.set(eventName, callback as (eventData: EventData) => void); +} + +/** + * Subscribe to a channel and an event + */ +function subscribe( + channelName: string, + eventName?: EventName, + eventCallback: (data: EventData) => void = () => {}, + onResubscribe = () => {}, +): Promise { + return initPromise.then( + () => + new Promise((resolve, reject) => { + InteractionManager.runAfterInteractions(() => { + // We cannot call subscribe() before init(). Prevent any attempt to do this on dev. + if (!socket) { + throw new Error(`[Pusher] instance not found. Pusher.subscribe() + most likely has been called before Pusher.init()`); + } + + Log.info('[Pusher] Attempting to subscribe to channel', false, {channelName, eventName}); + + if (!channels[channelName]) { + channels[channelName] = CONST.PUSHER.CHANNEL_STATUS.SUBSCRIBING; + socket.subscribe({ + channelName, + onEvent: (event) => { + const callback = eventsBoundToChannels.get(event.channelName)?.get(event.eventName); + callback?.(event.data as EventData); + }, + onSubscriptionSucceeded: () => { + channels[channelName] = CONST.PUSHER.CHANNEL_STATUS.SUBSCRIBED; + bindEventToChannel(channelName, eventName, eventCallback); + resolve(); + // When subscribing for the first time we register a success callback that can be + // called multiple times when the subscription succeeds again in the future + // e.g. as a result of Pusher disconnecting and reconnecting. This callback does + // not fire on the first subscription_succeeded event. + onResubscribe(); + }, + onSubscriptionError: (name: string, message: string) => { + delete channels[channelName]; + Log.hmmm('[Pusher] Issue authenticating with Pusher during subscribe attempt.', { + channelName, + message, + }); + reject(message); + }, + }); + } else { + bindEventToChannel(channelName, eventName, eventCallback); + resolve(); + } + }); + }), + ); +} + +/** + * Unsubscribe from a channel and optionally a specific event + */ +function unsubscribe(channelName: string, eventName: PusherEventName = '') { + const channel = getChannel(channelName); + + if (!channel) { + Log.hmmm('[Pusher] Attempted to unsubscribe or unbind from a channel, but Pusher-JS has no knowledge of it', {channelName, eventName}); + return; + } + + if (eventName) { + Log.info('[Pusher] Unbinding event', false, {eventName, channelName}); + eventsBoundToChannels.get(channelName)?.delete(eventName); + if (eventsBoundToChannels.get(channelName)?.size === 0) { + Log.info(`[Pusher] After unbinding ${eventName} from channel ${channelName}, no other events were bound to that channel. Unsubscribing...`, false); + eventsBoundToChannels.delete(channelName); + delete channels[channelName]; + socket?.unsubscribe({channelName}); + } + } else { + Log.info('[Pusher] Unsubscribing from channel', false, {channelName}); + eventsBoundToChannels.delete(channelName); + delete channels[channelName]; + socket?.unsubscribe({channelName}); + } +} + +/** + * Are we already in the process of subscribing to this channel? + */ +function isAlreadySubscribing(channelName: string): boolean { + if (!socket) { + return false; + } + + return channels[channelName] === CONST.PUSHER.CHANNEL_STATUS.SUBSCRIBING; +} + +/** + * Are we already subscribed to this channel? + */ +function isSubscribed(channelName: string): boolean { + if (!socket) { + return false; + } + + return channels[channelName] === CONST.PUSHER.CHANNEL_STATUS.SUBSCRIBED; +} + +/** + * Sends an event over a specific event/channel in pusher. + */ +function sendEvent(channelName: string, eventName: EventName, payload: EventData) { + // Check to see if we are subscribed to this channel before sending the event. Sending client events over channels + // we are not subscribed too will throw errors and cause reconnection attempts. Subscriptions are not instant and + // can happen later than we expect. + if (!isSubscribed(channelName)) { + return; + } + + if (shouldForceOffline) { + Log.info('[Pusher] Ignoring a Send event because shouldForceOffline = true'); + return; + } + + socket?.trigger({channelName, eventName, data: JSON.stringify(payload)}); +} + +/** + * Register a method that will be triggered when a socket event happens (like disconnecting) + */ +function registerSocketEventCallback(cb: SocketEventCallback) { + socketEventCallbacks.push(cb); +} + +/** + * Disconnect from Pusher + */ +function disconnect() { + if (!socket) { + Log.info('[Pusher] Attempting to disconnect from Pusher before initialisation has occurred, ignoring.'); + return; + } + + socket.disconnect(); + socket = null; + pusherSocketID = ''; + channels = {}; + eventsBoundToChannels.clear(); + initPromise = new Promise((resolve) => { + resolveInitPromise = resolve; + }); +} + +/** + * Disconnect and Re-Connect Pusher + */ +function reconnect() { + if (!socket) { + Log.info('[Pusher] Unable to reconnect since Pusher instance does not yet exist.'); + return; + } + + Log.info('[Pusher] Reconnecting to Pusher'); + socket.disconnect(); + socket.connect(); +} + +function getPusherSocketID(): string | undefined { + return pusherSocketID; +} + +if (window) { + /** + * Pusher socket for debugging purposes + */ + window.getPusherInstance = () => socket; +} + +const MobilePusher: PusherModule = { + init, + subscribe, + unsubscribe, + getChannel, + isSubscribed, + isAlreadySubscribing, + sendEvent, + disconnect, + reconnect, + registerSocketEventCallback, + TYPE, + getPusherSocketID, +}; + +export default MobilePusher; diff --git a/src/libs/Pusher/pusher.ts b/src/libs/Pusher/index.ts similarity index 84% rename from src/libs/Pusher/pusher.ts rename to src/libs/Pusher/index.ts index cb18bc7f63db..282ac54b6f1b 100644 --- a/src/libs/Pusher/pusher.ts +++ b/src/libs/Pusher/index.ts @@ -1,68 +1,24 @@ import isObject from 'lodash/isObject'; import type {Channel, ChannelAuthorizerGenerator, Options} from 'pusher-js/with-encryption'; +import Pusher from 'pusher-js/with-encryption'; import {InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; -import type {LiteralUnion, ValueOf} from 'type-fest'; import Log from '@libs/Log'; -import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OnyxUpdatesFromServer, ReportUserIsTyping} from '@src/types/onyx'; -import type DeepValueOf from '@src/types/utils/DeepValueOf'; import TYPE from './EventType'; -import Pusher from './library'; -import type {SocketEventName} from './library/types'; - -type States = { - previous: string; - current: string; -}; - -type Args = { - appKey: string; - cluster: string; - authEndpoint: string; -}; - -type UserIsTypingEvent = ReportUserIsTyping & { - userLogin?: string; -}; - -type UserIsLeavingRoomEvent = Record & { - userLogin?: string; -}; - -type PingPongEvent = Record & { - pingID: string; - pingTimestamp: number; -}; - -type PusherEventMap = { - [TYPE.USER_IS_TYPING]: UserIsTypingEvent; - [TYPE.USER_IS_LEAVING_ROOM]: UserIsLeavingRoomEvent; - [TYPE.PONG]: PingPongEvent; -}; - -type EventData = {chunk?: string; id?: string; index?: number; final?: boolean} & (EventName extends keyof PusherEventMap - ? PusherEventMap[EventName] - : OnyxUpdatesFromServer); - -type EventCallbackError = {type: ValueOf; data: {code: number; message?: string}}; - -type ChunkedDataEvents = {chunks: unknown[]; receivedFinal: boolean}; - -type SocketEventCallback = (eventName: SocketEventName, data?: States | EventCallbackError) => void; - -type PusherWithAuthParams = InstanceType & { - config: { - auth?: { - params?: unknown; - }; - }; -}; - -type PusherEventName = LiteralUnion, string>; - -type PusherSubscribtionErrorData = {type?: string; error?: string; status?: string}; +import type { + Args, + ChunkedDataEvents, + EventCallbackError, + EventData, + PusherEventName, + PusherSubscribtionErrorData, + PusherWithAuthParams, + SocketEventCallback, + SocketEventName, + States, +} from './types'; +import type PusherModule from './types'; let shouldForceOffline = false; Onyx.connect({ @@ -98,7 +54,7 @@ function callSocketEventCallbacks(eventName: SocketEventName, data?: EventCallba * Initialize our pusher lib * @returns resolves when Pusher has connected */ -function init(args: Args, params?: unknown): Promise { +function init(args: Args): Promise { return new Promise((resolve) => { if (socket) { resolve(); @@ -122,15 +78,6 @@ function init(args: Args, params?: unknown): Promise { } socket = new Pusher(args.appKey, options); - // If we want to pass params in our requests to api.php we'll need to add it to socket.config.auth.params - // as per the documentation - // (https://pusher.com/docs/channels/using_channels/connection#channels-options-parameter). - // Any param mentioned here will show up in $_REQUEST when we call "AuthenticatePusher". Params passed here need - // to pass our inputRules to show up in the request. - if (params) { - socket.config.auth = {}; - socket.config.auth.params = params; - } // Listen for connection errors and log them socket?.connection.bind('error', (error: EventCallbackError) => { @@ -431,7 +378,7 @@ if (window) { window.getPusherInstance = () => socket; } -export { +const WebPusher: PusherModule = { init, subscribe, unsubscribe, @@ -447,4 +394,4 @@ export { getPusherSocketID, }; -export type {EventCallbackError, States, UserIsTypingEvent, UserIsLeavingRoomEvent, PingPongEvent}; +export default WebPusher; diff --git a/src/libs/Pusher/library/index.native.ts b/src/libs/Pusher/library/index.native.ts deleted file mode 100644 index 4f11506f10fa..000000000000 --- a/src/libs/Pusher/library/index.native.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * We use the pusher-js/react-native module to support pusher on native environments. - * @see: https://github.com/pusher/pusher-js - */ -import PusherImplementation from 'pusher-js/react-native'; -import type Pusher from './types'; - -const PusherNative: Pusher = PusherImplementation; - -export default PusherNative; diff --git a/src/libs/Pusher/library/index.ts b/src/libs/Pusher/library/index.ts deleted file mode 100644 index 6a7104a1d2a5..000000000000 --- a/src/libs/Pusher/library/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * We use the standard pusher-js module to support pusher on web environments. - * @see: https://github.com/pusher/pusher-js - */ -import PusherImplementation from 'pusher-js/with-encryption'; -import type Pusher from './types'; - -const PusherWeb: Pusher = PusherImplementation; - -export default PusherWeb; diff --git a/src/libs/Pusher/library/types.ts b/src/libs/Pusher/library/types.ts deleted file mode 100644 index 566fd8a72774..000000000000 --- a/src/libs/Pusher/library/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type PusherClass from 'pusher-js/with-encryption'; -import type {LiteralUnion} from 'type-fest'; - -type Pusher = typeof PusherClass; - -type SocketEventName = LiteralUnion<'error' | 'connected' | 'disconnected' | 'state_change', string>; - -export default Pusher; - -export type {SocketEventName}; diff --git a/src/libs/Pusher/types.ts b/src/libs/Pusher/types.ts new file mode 100644 index 000000000000..d6d1da3297df --- /dev/null +++ b/src/libs/Pusher/types.ts @@ -0,0 +1,102 @@ +import type {PusherChannel} from '@pusher/pusher-websocket-react-native'; +import type PusherClass from 'pusher-js/with-encryption'; +import type {Channel, ChannelAuthorizerGenerator} from 'pusher-js/with-encryption'; +import type {LiteralUnion, ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; +import type {OnyxUpdatesFromServer, ReportUserIsTyping} from '@src/types/onyx'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import type TYPE from './EventType'; + +type SocketEventName = LiteralUnion<'error' | 'connected' | 'disconnected' | 'state_change', string>; + +type States = { + previous: string; + current: string; +}; + +type Args = { + appKey: string; + cluster: string; + authEndpoint: string; +}; + +type UserIsTypingEvent = ReportUserIsTyping & { + userLogin?: string; +}; + +type UserIsLeavingRoomEvent = Record & { + userLogin?: string; +}; + +type PingPongEvent = Record & { + pingID: string; + pingTimestamp: number; +}; + +type PusherEventMap = { + [TYPE.USER_IS_TYPING]: UserIsTypingEvent; + [TYPE.USER_IS_LEAVING_ROOM]: UserIsLeavingRoomEvent; + [TYPE.PONG]: PingPongEvent; +}; + +type EventData = {chunk?: string; id?: string; index?: number; final?: boolean} & (EventName extends keyof PusherEventMap + ? PusherEventMap[EventName] + : OnyxUpdatesFromServer); + +type EventCallbackError = {type?: ValueOf; data: {code?: number; message?: string}}; + +type ChunkedDataEvents = {chunks: unknown[]; receivedFinal: boolean}; + +type SocketEventCallback = (eventName: SocketEventName, data?: States | EventCallbackError) => void; + +type PusherWithAuthParams = InstanceType & { + config: { + auth?: { + params?: unknown; + }; + }; +}; + +type PusherEventName = LiteralUnion, string>; + +type PusherSubscribtionErrorData = {type?: string; error?: string; status?: string}; + +type PusherModule = { + init: (args: Args) => Promise; + subscribe: ( + channelName: string, + eventName?: EventName, + eventCallback?: (data: EventData) => void, + onResubscribe?: () => void, + ) => Promise; + unsubscribe: (channelName: string, eventName?: PusherEventName) => void; + getChannel: (channelName: string) => Channel | PusherChannel | undefined; + isSubscribed: (channelName: string) => boolean; + isAlreadySubscribing: (channelName: string) => boolean; + sendEvent: (channelName: string, eventName: EventName, payload: EventData) => void; + disconnect: () => void; + reconnect: () => void; + registerSocketEventCallback: (cb: SocketEventCallback) => void; + registerCustomAuthorizer?: (authorizer: ChannelAuthorizerGenerator) => void; + getPusherSocketID: () => string | undefined; + TYPE: typeof TYPE; +}; + +export default PusherModule; + +export type { + SocketEventName, + States, + Args, + UserIsTypingEvent, + UserIsLeavingRoomEvent, + PingPongEvent, + PusherEventMap, + EventData, + EventCallbackError, + ChunkedDataEvents, + SocketEventCallback, + PusherWithAuthParams, + PusherEventName, + PusherSubscribtionErrorData, +}; diff --git a/src/libs/PusherConnectionManager.ts b/src/libs/PusherConnectionManager.ts index 69ffa8339f5c..bc3ef45d8592 100644 --- a/src/libs/PusherConnectionManager.ts +++ b/src/libs/PusherConnectionManager.ts @@ -2,9 +2,8 @@ import type {ChannelAuthorizationCallback} from 'pusher-js/with-encryption'; import CONST from '@src/CONST'; import {authenticatePusher} from './actions/Session'; import Log from './Log'; -import type {SocketEventName} from './Pusher/library/types'; -import {reconnect, registerCustomAuthorizer, registerSocketEventCallback} from './Pusher/pusher'; -import type {EventCallbackError, States} from './Pusher/pusher'; +import Pusher from './Pusher'; +import type {EventCallbackError, SocketEventName, States} from './Pusher/types'; function init() { /** @@ -13,13 +12,13 @@ function init() { * current valid token to generate the signed auth response * needed to subscribe to Pusher channels. */ - registerCustomAuthorizer((channel) => ({ + Pusher.registerCustomAuthorizer?.((channel) => ({ authorize: (socketId: string, callback: ChannelAuthorizationCallback) => { authenticatePusher(socketId, channel.name, callback); }, })); - registerSocketEventCallback((eventName: SocketEventName, error?: EventCallbackError | States) => { + Pusher.registerSocketEventCallback((eventName: SocketEventName, error?: EventCallbackError | States) => { switch (eventName) { case 'error': { if (error && 'type' in error) { @@ -35,7 +34,7 @@ function init() { // On the advice from Pusher directly, they suggested to manually reconnect in those scenarios. if (errorMessage) { Log.hmmm('[PusherConnectionManager] Channels Error 1006 message', {errorMessage}); - reconnect(); + Pusher.reconnect(); } } else if (errorType === CONST.ERROR.PUSHER_ERROR && code === 4201) { // This means the connection was closed because Pusher did not receive a reply from the client when it pinged them for a response diff --git a/src/libs/PusherUtils.ts b/src/libs/PusherUtils.ts index 547aa06e770e..703b5bc4a44c 100644 --- a/src/libs/PusherUtils.ts +++ b/src/libs/PusherUtils.ts @@ -4,8 +4,8 @@ import CONST from '@src/CONST'; import type {OnyxUpdatesFromServer} from '@src/types/onyx'; import Log from './Log'; import NetworkConnection from './NetworkConnection'; -import {subscribe} from './Pusher/pusher'; -import type {PingPongEvent} from './Pusher/pusher'; +import Pusher from './Pusher'; +import type {PingPongEvent} from './Pusher/types'; type Callback = (data: OnyxUpdate[]) => Promise; @@ -50,7 +50,7 @@ function subscribeToPrivateUserChannelEvent(eventName: string, accountID: string function onSubscriptionFailed(error: Error) { Log.hmmm('Failed to subscribe to Pusher channel', {error, pusherChannelName, eventName}); } - subscribe(pusherChannelName, eventName, onEventPush, onPusherResubscribeToPrivateUserChannel).catch(onSubscriptionFailed); + Pusher.subscribe(pusherChannelName, eventName, onEventPush, onPusherResubscribeToPrivateUserChannel).catch(onSubscriptionFailed); } export default { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 366b1e97f2be..edfe8546cf13 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -7270,11 +7270,20 @@ function shouldReportBeInOptionList(params: ShouldReportBeInOptionListParams) { /** * Attempts to find a report in onyx with the provided list of participants. Does not include threads, task, expense, room, and policy expense chat. */ -function getChatByParticipants(newParticipantList: number[], reports: OnyxCollection = allReports, shouldIncludeGroupChats = false): OnyxEntry { +function getChatByParticipants( + newParticipantList: number[], + reports: OnyxCollection = allReports, + shouldIncludeGroupChats = false, + shouldExcludeClosedReports = false, +): OnyxEntry { const sortedNewParticipantList = newParticipantList.sort(); return Object.values(reports ?? {}).find((report) => { const participantAccountIDs = Object.keys(report?.participants ?? {}); + if (shouldExcludeClosedReports && isArchivedReportWithID(report?.reportID)) { + return false; + } + // Skip if it's not a 1:1 chat if (!shouldIncludeGroupChats && !isOneOnOneChat(report) && !isSystemChat(report)) { return false; @@ -8375,7 +8384,7 @@ function hasUpdatedTotal(report: OnyxInputOrEntry, policy: OnyxInputOrEn const hasDifferentWorkspaceCurrency = report.pendingFields?.createChat && isExpenseReport(report) && report.currency !== policy?.outputCurrency; const hasOptimisticHeldExpense = hasHeldExpenses(report.reportID) && report?.unheldTotal === undefined; - return !(hasPendingTransaction && (hasTransactionWithDifferentCurrency || hasDifferentWorkspaceCurrency)) && !hasOptimisticHeldExpense; + return !(hasPendingTransaction && (hasTransactionWithDifferentCurrency || hasDifferentWorkspaceCurrency)) && !hasOptimisticHeldExpense && !report.pendingFields?.total; } /** diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 0744ab0494a6..3ca08277b912 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -28,7 +28,6 @@ import type { } from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; import {canApproveIOU, canIOUBePaid, canSubmitReport} from './actions/IOU'; -import {clearAllFilters} from './actions/Search'; import {convertToDisplayString} from './CurrencyUtils'; import DateUtils from './DateUtils'; import {translateLocal} from './Localize'; @@ -524,7 +523,7 @@ function getListItem(type: SearchDataTypes, status: SearchStatus, shouldGroupByR if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return ChatListItem; } - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE && !shouldGroupByReports) { + if (!shouldGroupByReports) { return TransactionListItem; } return ReportListItem; @@ -537,7 +536,7 @@ function getSections(type: SearchDataTypes, status: SearchStatus, data: OnyxType if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return getReportActionsSections(data); } - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE && !shouldGroupByReports) { + if (!shouldGroupByReports) { return getTransactionsSections(data, metadata); } return getReportSections(data, metadata); @@ -557,7 +556,7 @@ function getSortedSections( if (type === CONST.SEARCH.DATA_TYPES.CHAT) { return getSortedReportActionData(data as ReportActionListItemType[]); } - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE && !shouldGroupByReports) { + if (!shouldGroupByReports) { return getSortedTransactionData(data as TransactionListItemType[], sortBy, sortOrder); } return getSortedReportData(data as ReportListItemType[]); @@ -768,10 +767,6 @@ function createBaseSavedSearchMenuItem(item: SaveSearchItem, key: string, index: query: item.query, shouldShowRightComponent: true, focused: Number(key) === hash, - onPress: () => { - clearAllFilters(); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); - }, pendingAction: item.pendingAction, disabled: item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, shouldIconUseAutoWidthStyle: true, diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 6e9a1711f57d..d1b5a7bc14db 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -112,7 +112,7 @@ import { } from './ReportUtils'; import {getTaskReportActionMessage} from './TaskUtils'; -type WelcomeMessage = {showReportName: boolean; phrase1?: string; phrase2?: string; phrase3?: string; messageText?: string; messageHtml?: string}; +type WelcomeMessage = {showReportName: boolean; phrase1?: string; phrase2?: string; phrase3?: string; phrase4?: string; messageText?: string; messageHtml?: string}; const visibleReportActionItems: ReportActions = {}; let allPersonalDetails: OnyxEntry; @@ -738,9 +738,11 @@ function getRoomWelcomeMessage(report: OnyxEntry): WelcomeMessage { welcomeMessage.phrase1 = translateLocal('reportActionsView.beginningOfChatHistoryDomainRoomPartOne', {domainRoom: report?.reportName ?? ''}); welcomeMessage.phrase2 = translateLocal('reportActionsView.beginningOfChatHistoryDomainRoomPartTwo'); } else if (isAdminRoom(report)) { - welcomeMessage.showReportName = false; - welcomeMessage.phrase1 = translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartOne', {workspaceName}); - welcomeMessage.phrase2 = translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartTwo'); + welcomeMessage.showReportName = true; + welcomeMessage.phrase1 = translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartOneFirst'); + welcomeMessage.phrase2 = translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomWorkspaceName', {workspaceName}); + welcomeMessage.phrase3 = translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartOneLast'); + welcomeMessage.phrase4 = translateLocal('reportActionsView.beginningOfChatHistoryAdminRoomPartTwo'); } else if (isAnnounceRoom(report)) { welcomeMessage.showReportName = false; welcomeMessage.phrase1 = translateLocal('reportActionsView.beginningOfChatHistoryAnnounceRoomPartOne', {workspaceName}); diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 90e7004239c9..68c37916ebed 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -307,6 +307,14 @@ function isValidUSPhone(phoneNumber = '', isCountryCodeOptional?: boolean): bool return parsedPhoneNumber.possible && parsedPhoneNumber.regionCode === CONST.COUNTRY.US; } +function isValidPhoneNumber(phoneNumber: string): boolean { + if (!CONST.ACCEPTED_PHONE_CHARACTER_REGEX.test(phoneNumber) || CONST.REPEATED_SPECIAL_CHAR_PATTERN.test(phoneNumber)) { + return false; + } + const parsedPhoneNumber = parsePhoneNumber(phoneNumber); + return parsedPhoneNumber.possible; +} + function isValidValidateCode(validateCode: string): boolean { return !!validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING); } @@ -660,6 +668,7 @@ export { isRequiredFulfilled, getFieldRequiredErrors, isValidUSPhone, + isValidPhoneNumber, isValidWebsite, validateIdentity, isValidTwoFactorCode, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 52466398b80f..acf9a2e39590 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3299,7 +3299,7 @@ function calculateDiffAmount( iouReport: OnyxTypes.OnyxInputOrEntry, updatedTransaction: OnyxTypes.OnyxInputOrEntry, transaction: OnyxEntry, -): number { +): number | null { if (!iouReport) { return 0; } @@ -3310,17 +3310,12 @@ function calculateDiffAmount( const currentAmount = getAmount(transaction, isExpenseReportLocal); const updatedAmount = getAmount(updatedTransaction, isExpenseReportLocal); - if (updatedCurrency === iouReport?.currency && currentCurrency !== iouReport?.currency) { - // Add the diff to the total if we change the currency from a different currency to the currency of the IOU report - return updatedAmount; - } - - if (updatedCurrency === iouReport?.currency && updatedAmount !== currentAmount) { - // Calculate the diff between the updated amount and the current amount if we change the amount and the currency of the transaction is the currency of the report + if (updatedCurrency === iouReport.currency && currentCurrency === iouReport.currency) { + // Calculate the diff between the updated amount and the current amount if the currency of the updated and current transactions have the same currency as the report return updatedAmount - currentAmount; } - return 0; + return null; } /** @@ -3460,7 +3455,10 @@ function getUpdateMoneyRequestParams( } // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. - const diff = calculateDiffAmount(iouReport, updatedTransaction, transaction); + const calculatedDiffAmount = calculateDiffAmount(iouReport, updatedTransaction, transaction); + // If calculatedDiffAmount is null it means we cannot calculate the new iou report total from front-end due to currency differences. + const isTotalIndeterministic = calculatedDiffAmount === null; + const diff = calculatedDiffAmount ?? 0; let updatedMoneyRequestReport: OnyxTypes.OnyxInputOrEntry; if (!iouReport) { @@ -3498,7 +3496,7 @@ function getUpdateMoneyRequestParams( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: updatedMoneyRequestReport, + value: {...updatedMoneyRequestReport, ...(isTotalIndeterministic && {pendingFields: {total: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}})}, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -3509,7 +3507,7 @@ function getUpdateMoneyRequestParams( successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, - value: {pendingAction: null}, + value: {pendingAction: null, ...(isTotalIndeterministic && {pendingFields: {total: null}})}, }); // Optimistically modify the transaction and the transaction thread @@ -3640,7 +3638,7 @@ function getUpdateMoneyRequestParams( failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, - value: iouReport, + value: {...iouReport, ...(isTotalIndeterministic && {pendingFields: {total: null}})}, }); } @@ -4982,8 +4980,9 @@ function trackExpense(params: CreateTrackExpenseParams) { if (action === CONST.IOU.ACTION.SHARE) { if (isSearchTopmostFullScreenRoute() && activeReportID) { - Navigation.goBack(); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(activeReportID)); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(activeReportID), {forceReplace: true}); + }); } Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(activeReportID, CONST.IOU.SHARE.ROLE.ACCOUNTANT))); } @@ -8667,7 +8666,7 @@ function cancelPayment(expenseReport: OnyxEntry, chatReport: O const policy = getPolicy(chatReport.policyID); const approvalMode = policy?.approvalMode ?? CONST.POLICY.APPROVAL_MODE.BASIC; const stateNum: ValueOf = approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL ? CONST.REPORT.STATE_NUM.SUBMITTED : CONST.REPORT.STATE_NUM.APPROVED; - const statusNum: ValueOf = approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL ? CONST.REPORT.STATUS_NUM.CLOSED : CONST.REPORT.STATUS_NUM.APPROVED; + const statusNum: ValueOf = approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL ? CONST.REPORT.STATUS_NUM.SUBMITTED : CONST.REPORT.STATUS_NUM.APPROVED; const optimisticNextStep = buildNextStep(expenseReport, statusNum); const iouReportActions = getAllReportActions(chatReport.iouReportID); const expenseReportActions = getAllReportActions(expenseReport.reportID); @@ -9545,8 +9544,8 @@ function navigateToStartStepIfScanFileCannotBeRead( } /** Save the preferred payment method for a policy */ -function savePreferredPaymentMethod(policyID: string, paymentMethod: PaymentMethodType) { - Onyx.merge(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {[policyID]: paymentMethod}); +function savePreferredPaymentMethod(policyID: string, paymentMethod: PaymentMethodType, type: ValueOf | undefined) { + Onyx.merge(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {[policyID]: type ? {[type]: paymentMethod, [CONST.LAST_PAYMENT_METHOD.LAST_USED]: paymentMethod} : paymentMethod}); } /** Get report policy id of IOU request */ diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index d9a5cf8f2bc6..d11663228f40 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -79,7 +79,8 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PhoneNumber from '@libs/PhoneNumber'; import {extractPolicyIDFromPath, getPolicy} from '@libs/PolicyUtils'; import processReportIDDeeplink from '@libs/processReportIDDeeplink'; -import * as Pusher from '@libs/Pusher/pusher'; +import Pusher from '@libs/Pusher'; +import type {UserIsLeavingRoomEvent, UserIsTypingEvent} from '@libs/Pusher/types'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import type {OptimisticAddCommentReportAction, OptimisticChatReport} from '@libs/ReportUtils'; import { @@ -397,7 +398,7 @@ function getReportChannelName(reportID: string): string { * * This method makes sure that no matter which we get, we return the "new" format */ -function getNormalizedStatus(typingStatus: Pusher.UserIsTypingEvent | Pusher.UserIsLeavingRoomEvent): ReportUserIsTyping { +function getNormalizedStatus(typingStatus: UserIsTypingEvent | UserIsLeavingRoomEvent): ReportUserIsTyping { let normalizedStatus: ReportUserIsTyping; if (typingStatus.userLogin) { @@ -462,7 +463,7 @@ function subscribeToReportLeavingEvents(reportID: string | undefined) { Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${reportID}`, false); const pusherChannelName = getReportChannelName(reportID); - Pusher.subscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, (leavingStatus: Pusher.UserIsLeavingRoomEvent) => { + Pusher.subscribe(pusherChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, (leavingStatus: UserIsLeavingRoomEvent) => { // If the pusher message comes from OldDot, we expect the leaving status to be keyed by user // login OR by 'Concierge'. If the pusher message comes from NewDot, it is keyed by accountID // since personal details are keyed by accountID. @@ -1499,7 +1500,7 @@ function saveReportDraftComment(reportID: string, comment: string | null, callba /** Broadcasts whether or not a user is typing on a report over the report's private pusher channel. */ function broadcastUserIsTyping(reportID: string) { const privateReportChannelName = getReportChannelName(reportID); - const typingStatus: Pusher.UserIsTypingEvent = { + const typingStatus: UserIsTypingEvent = { [currentUserAccountID]: true, }; Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_TYPING, typingStatus); @@ -1508,7 +1509,7 @@ function broadcastUserIsTyping(reportID: string) { /** Broadcasts to the report's private pusher channel whether a user is leaving a report */ function broadcastUserIsLeavingRoom(reportID: string) { const privateReportChannelName = getReportChannelName(reportID); - const leavingStatus: Pusher.UserIsLeavingRoomEvent = { + const leavingStatus: UserIsLeavingRoomEvent = { [currentUserAccountID]: true, }; Pusher.sendEvent(privateReportChannelName, Pusher.TYPE.USER_IS_LEAVING_ROOM, leavingStatus); @@ -3658,7 +3659,7 @@ function prepareOnboardingOnyxData( const adminsChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`]; const targetChatReport = shouldPostTasksInAdminsRoom ? adminsChatReport ?? {reportID: adminsChatReportID, policyID: onboardingPolicyID} - : getChatByParticipants([CONST.ACCOUNT_ID.CONCIERGE, currentUserAccountID]); + : getChatByParticipants([CONST.ACCOUNT_ID.CONCIERGE, currentUserAccountID], allReports, false, true); const {reportID: targetChatReportID = '', policyID: targetChatPolicyID = ''} = targetChatReport ?? {}; if (!targetChatReportID) { diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 39cb9bc63e34..ef1ddb998f63 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -19,7 +19,7 @@ import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; -import type {LastPaymentMethod, SearchResults} from '@src/types/onyx'; +import type {LastPaymentMethod, LastPaymentMethodType, SearchResults} from '@src/types/onyx'; import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; import {openReport} from './Report'; @@ -77,8 +77,22 @@ function handleActionButtonPress(hash: number, item: TransactionListItemType | R } } +function getLastPolicyPaymentMethod(policyID: string | undefined, lastPaymentMethods: OnyxEntry) { + if (!policyID) { + return null; + } + let lastPolicyPaymentMethod = null; + if (typeof lastPaymentMethods?.[policyID] === 'string') { + lastPolicyPaymentMethod = lastPaymentMethods?.[policyID] as ValueOf; + } else { + lastPolicyPaymentMethod = (lastPaymentMethods?.[policyID] as LastPaymentMethodType)?.lastUsed as ValueOf; + } + + return lastPolicyPaymentMethod; +} + function getPayActionCallback(hash: number, item: TransactionListItemType | ReportListItemType, goToItem: () => void) { - const lastPolicyPaymentMethod = item.policyID ? (lastPaymentMethod?.[item.policyID] as ValueOf) : null; + const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(item.policyID, lastPaymentMethod); if (!lastPolicyPaymentMethod) { goToItem(); @@ -421,4 +435,5 @@ export { handleActionButtonPress, submitMoneyRequestOnSearch, openSearchFiltersCardPage, + getLastPolicyPaymentMethod, }; diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 093208cbc2a7..98f643b385e5 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -36,7 +36,7 @@ import * as MainQueue from '@libs/Network/MainQueue'; import * as NetworkStore from '@libs/Network/NetworkStore'; import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; import NetworkConnection from '@libs/NetworkConnection'; -import * as Pusher from '@libs/Pusher/pusher'; +import Pusher from '@libs/Pusher'; import {getReportIDFromLink, parseReportRouteParams as parseReportRouteParamsReportUtils} from '@libs/ReportUtils'; import * as SessionUtils from '@libs/SessionUtils'; import {clearSoundAssetsCache} from '@libs/Sound'; @@ -895,7 +895,7 @@ const reauthenticatePusher = throttle( {trailing: false}, ); -function authenticatePusher(socketID: string, channelName: string, callback: ChannelAuthorizationCallback) { +function authenticatePusher(socketID: string, channelName: string, callback?: ChannelAuthorizationCallback) { Log.info('[PusherAuthorizer] Attempting to authorize Pusher', false, {channelName}); const params: AuthenticatePusherParams = { @@ -909,11 +909,11 @@ function authenticatePusher(socketID: string, channelName: string, callback: Cha // We use makeRequestWithSideEffects here because we need to authorize to Pusher (an external service) each time a user connects to any channel. // eslint-disable-next-line rulesdir/no-api-side-effects-method - API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.AUTHENTICATE_PUSHER, params) + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.AUTHENTICATE_PUSHER, params) .then((response) => { if (response?.jsonCode === CONST.JSON_CODE.NOT_AUTHENTICATED) { Log.hmmm('[PusherAuthorizer] Unable to authenticate Pusher because authToken is expired'); - callback(new Error('Pusher failed to authenticate because authToken is expired'), {auth: ''}); + callback?.(new Error('Pusher failed to authenticate because authToken is expired'), {auth: ''}); // Attempt to refresh the authToken then reconnect to Pusher reauthenticatePusher(); @@ -922,16 +922,24 @@ function authenticatePusher(socketID: string, channelName: string, callback: Cha if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { Log.hmmm('[PusherAuthorizer] Unable to authenticate Pusher for reason other than expired session'); - callback(new Error(`Pusher failed to authenticate because code: ${response?.jsonCode} message: ${response?.message}`), {auth: ''}); + callback?.(new Error(`Pusher failed to authenticate because code: ${response?.jsonCode} message: ${response?.message}`), {auth: ''}); return; } Log.info('[PusherAuthorizer] Pusher authenticated successfully', false, {channelName}); - callback(null, response as ChannelAuthorizationData); + if (callback) { + callback(null, response as ChannelAuthorizationData); + } else { + return { + auth: response.auth, + // eslint-disable-next-line @typescript-eslint/naming-convention + shared_secret: response.shared_secret, + }; + } }) .catch((error: unknown) => { Log.hmmm('[PusherAuthorizer] Unhandled error: ', {channelName, error}); - callback(new Error('AuthenticatePusher request failed'), {auth: ''}); + callback?.(new Error('AuthenticatePusher request failed'), {auth: ''}); }); } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 1b151f47fde1..574225b20f38 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -32,7 +32,8 @@ import {isOffline} from '@libs/Network/NetworkStore'; import * as SequentialQueue from '@libs/Network/SequentialQueue'; import * as NumberUtils from '@libs/NumberUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; -import * as Pusher from '@libs/Pusher/pusher'; +import Pusher from '@libs/Pusher'; +import type {PingPongEvent} from '@libs/Pusher/types'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -906,7 +907,7 @@ function subscribeToPusherPong() { lastPongReceivedTimestamp = Date.now(); // Calculate the latency between the client and the server - const pongEvent = pushJSON as Pusher.PingPongEvent; + const pongEvent = pushJSON as PingPongEvent; const latency = Date.now() - Number(pongEvent.pingTimestamp); Log.info(`[Pusher PINGPONG] The event took ${latency} ms`); diff --git a/src/libs/actions/connections/NSQS.ts b/src/libs/actions/connections/NSQS.ts index 740be3ddb01f..6a471cdb5c2e 100644 --- a/src/libs/actions/connections/NSQS.ts +++ b/src/libs/actions/connections/NSQS.ts @@ -173,15 +173,15 @@ function updateNSQSAutoSync(policyID: string, enabled: boolean) { API.write(WRITE_COMMANDS.UPDATE_NSQS_AUTO_SYNC, params, onyxData); } -function updateNSQSApprovalAccount(policyID: string, value: string, oldValue: string) { - const onyxData = buildOnyxDataForNSQSConfiguration(policyID, 'approvalAccount', value, oldValue, CONST.NSQS_CONFIG.APPROVAL_ACCOUNT); +function updateNSQSPaymentAccount(policyID: string, value: string, oldValue: string) { + const onyxData = buildOnyxDataForNSQSConfiguration(policyID, 'paymentAccount', value, oldValue, CONST.NSQS_CONFIG.PAYMENT_ACCOUNT); const params = { policyID, value, }; - API.write(WRITE_COMMANDS.UPDATE_NSQS_APPROVAL_ACCOUNT, params, onyxData); + API.write(WRITE_COMMANDS.UPDATE_NSQS_PAYMENT_ACCOUNT, params, onyxData); } -export {connectPolicyToNSQS, updateNSQSCustomersMapping, updateNSQSProjectsMapping, updateNSQSExporter, updateNSQSExportDate, updateNSQSAutoSync, updateNSQSApprovalAccount}; +export {connectPolicyToNSQS, updateNSQSCustomersMapping, updateNSQSProjectsMapping, updateNSQSExporter, updateNSQSExportDate, updateNSQSAutoSync, updateNSQSPaymentAccount}; diff --git a/src/pages/EnablePayments/PersonalInfo/substeps/PhoneNumberStep.tsx b/src/pages/EnablePayments/PersonalInfo/substeps/PhoneNumberStep.tsx index 675869a682af..d4f010b42dfc 100644 --- a/src/pages/EnablePayments/PersonalInfo/substeps/PhoneNumberStep.tsx +++ b/src/pages/EnablePayments/PersonalInfo/substeps/PhoneNumberStep.tsx @@ -5,7 +5,8 @@ import SingleFieldStep from '@components/SubStepForms/SingleFieldStep'; import useLocalize from '@hooks/useLocalize'; import type {SubStepProps} from '@hooks/useSubStep/types'; import useWalletAdditionalDetailsStepFormSubmit from '@hooks/useWalletAdditionalDetailsStepFormSubmit'; -import {getFieldRequiredErrors, isValidUSPhone} from '@libs/ValidationUtils'; +import {appendCountryCode, formatE164PhoneNumber} from '@libs/LoginUtils'; +import {getFieldRequiredErrors, isValidPhoneNumber, isValidUSPhone} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/WalletAdditionalDetailsForm'; @@ -23,9 +24,15 @@ function PhoneNumberStep({onNext, onMove, isEditing}: SubStepProps) { (values: FormOnyxValues): FormInputErrors => { const errors = getFieldRequiredErrors(values, STEP_FIELDS); - if (values.phoneNumber && !isValidUSPhone(values.phoneNumber, true)) { - errors.phoneNumber = translate('bankAccount.error.phoneNumber'); + if (values.phoneNumber) { + const phoneNumberWithCountryCode = appendCountryCode(values.phoneNumber); + const e164FormattedPhoneNumber = formatE164PhoneNumber(values.phoneNumber); + + if (!isValidPhoneNumber(phoneNumberWithCountryCode) || !isValidUSPhone(e164FormattedPhoneNumber)) { + errors.phoneNumber = translate('common.error.phoneNumber'); + } } + return errors; }, [translate], @@ -46,7 +53,9 @@ function PhoneNumberStep({onNext, onMove, isEditing}: SubStepProps) { formTitle={translate('personalInfoStep.whatsYourPhoneNumber')} formDisclaimer={translate('personalInfoStep.weNeedThisToVerify')} validate={validate} - onSubmit={handleSubmit} + onSubmit={(values) => { + handleSubmit({...values, phoneNumber: formatE164PhoneNumber(values.phoneNumber) ?? ''}); + }} inputId={PERSONAL_INFO_STEP_KEY.PHONE_NUMBER} inputLabel={translate('common.phoneNumber')} inputMode={CONST.INPUT_MODE.TEL} diff --git a/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx b/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx index 81f359f403d1..33b7f125f5ba 100644 --- a/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx +++ b/src/pages/MissingPersonalDetails/substeps/PhoneNumber.tsx @@ -1,12 +1,10 @@ -import {Str} from 'expensify-common'; import React, {useCallback} from 'react'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import SingleFieldStep from '@components/SubStepForms/SingleFieldStep'; import useLocalize from '@hooks/useLocalize'; import usePersonalDetailsFormSubmit from '@hooks/usePersonalDetailsFormSubmit'; -import * as LoginUtils from '@libs/LoginUtils'; -import * as PhoneNumberUtils from '@libs/PhoneNumber'; -import * as ValidationUtils from '@libs/ValidationUtils'; +import {appendCountryCode, formatE164PhoneNumber} from '@libs/LoginUtils'; +import {isRequiredFulfilled, isValidPhoneNumber} from '@libs/ValidationUtils'; import type {CustomSubStepProps} from '@pages/MissingPersonalDetails/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -20,14 +18,18 @@ function PhoneNumberStep({isEditing, onNext, onMove, personalDetailsValues}: Cus const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.PHONE_NUMBER])) { + const phoneNumber = values[INPUT_IDS.PHONE_NUMBER]; + const phoneNumberWithCountryCode = appendCountryCode(phoneNumber); + + if (!isRequiredFulfilled(phoneNumber)) { errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); + return errors; } - const phoneNumber = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]); - const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(phoneNumber); - if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumber.slice(0))) { - errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber'); + + if (!isValidPhoneNumber(phoneNumberWithCountryCode)) { + errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.phoneNumber'); } + return errors; }, [translate], @@ -47,7 +49,9 @@ function PhoneNumberStep({isEditing, onNext, onMove, personalDetailsValues}: Cus formID={ONYXKEYS.FORMS.PERSONAL_DETAILS_FORM} formTitle={translate('privatePersonalDetails.enterPhoneNumber')} validate={validate} - onSubmit={handleSubmit} + onSubmit={(values) => { + handleSubmit({...values, phoneNumber: formatE164PhoneNumber(values[INPUT_IDS.PHONE_NUMBER]) ?? ''}); + }} inputId={INPUT_IDS.PHONE_NUMBER} inputLabel={translate('common.phoneNumber')} inputMode={CONST.INPUT_MODE.TEL} diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index a3182d3e4940..e4b8ddc8b45c 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -30,6 +30,7 @@ import variables from '@styles/variables'; import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type {SaveSearchItem} from '@src/types/onyx/SaveSearch'; import SavedSearchItemThreeDotMenu from './SavedSearchItemThreeDotMenu'; @@ -75,6 +76,10 @@ function SearchTypeMenu({queryJSON, shouldGroupByReports}: SearchTypeMenuProps) const baseMenuItem: SavedSearchMenuItem = createBaseSavedSearchMenuItem(item, key, index, title, hash); return { ...baseMenuItem, + onPress: () => { + clearAllFilters(); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: item?.query ?? '', name: item?.name})); + }, rightComponent: ( { - const appStateSubscription = AppState.addEventListener('change', (nextAppState) => { - if (!nextAppState.match(/inactive|background/)) { - focus(true); - return; - } - - Keyboard.dismiss(); - }); - - return () => { - appStateSubscription.remove(); - }; - }, [focus]); - useFocusedInputHandler( { onSelectionChange: (event) => { diff --git a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx index c13c1bd757dc..79f80e804607 100644 --- a/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/FloatingActionButtonAndPopover.tsx @@ -1,12 +1,13 @@ import {useIsFocused} from '@react-navigation/native'; import type {ImageContentFit} from 'expo-image'; import type {ForwardedRef} from 'react'; -import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; +import React, {forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import {NativeModules, View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import type {SvgProps} from 'react-native-svg'; import ConfirmModal from '@components/ConfirmModal'; +import CustomStatusBarAndBackgroundContext from '@components/CustomStatusBarAndBackground/CustomStatusBarAndBackgroundContext'; import FloatingActionButton from '@components/FloatingActionButton'; import * as Expensicons from '@components/Icon/Expensicons'; import type {PopoverMenuItem} from '@components/PopoverMenu'; @@ -202,6 +203,8 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT selector: hasSeenTourSelector, }); + const {setRootStatusBarEnabled} = useContext(CustomStatusBarAndBackgroundContext); + const {renderProductTrainingTooltip, hideProductTrainingTooltip, shouldShowProductTrainingTooltip} = useProductTrainingContext( CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.QUICK_ACTION_BUTTON, isCreateMenuActive && (!shouldUseNarrowLayout || isFocused), @@ -563,6 +566,11 @@ function FloatingActionButtonAndPopover({onHideCreateMenu, onShowCreateMenu, isT isVisible={modalVisible} onConfirm={() => { setModalVisible(false); + if (NativeModules.HybridAppModule) { + NativeModules.HybridAppModule.closeReactNativeApp(false, true); + setRootStatusBarEnabled(false); + return; + } openOldDotLink(CONST.OLDDOT_URLS.INBOX); }} onCancel={() => setModalVisible(false)} diff --git a/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx b/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx index 15704221f259..0075a13d8133 100644 --- a/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/PhoneNumberPage.tsx @@ -1,4 +1,3 @@ -import {Str} from 'expensify-common'; import React, {useCallback} from 'react'; import {useOnyx} from 'react-native-onyx'; import DelegateNoAccessWrapper from '@components/DelegateNoAccessWrapper'; @@ -13,12 +12,11 @@ import TextInput from '@components/TextInput'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as LoginUtils from '@libs/LoginUtils'; +import {getEarliestErrorField} from '@libs/ErrorUtils'; +import {appendCountryCode, formatE164PhoneNumber} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; -import * as PhoneNumberUtils from '@libs/PhoneNumber'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import * as PersonalDetails from '@userActions/PersonalDetails'; +import {isRequiredFulfilled, isValidPhoneNumber} from '@libs/ValidationUtils'; +import {clearPhoneNumberError, updatePhoneNumber as updatePhone} from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; @@ -32,18 +30,18 @@ function PhoneNumberPage() { const {inputCallbackRef} = useAutoFocusInput(); const phoneNumber = privatePersonalDetails?.phoneNumber ?? ''; - const validateLoginError = ErrorUtils.getEarliestErrorField(privatePersonalDetails, 'phoneNumber'); + const validateLoginError = getEarliestErrorField(privatePersonalDetails, 'phoneNumber'); const currenPhoneNumber = privatePersonalDetails?.phoneNumber ?? ''; const updatePhoneNumber = (values: PrivatePersonalDetails) => { // Clear the error when the user tries to submit the form if (validateLoginError) { - PersonalDetails.clearPhoneNumberError(); + clearPhoneNumberError(); } // Only call the API if the user has changed their phone number - if (phoneNumber !== values?.phoneNumber) { - PersonalDetails.updatePhoneNumber(values?.phoneNumber ?? '', currenPhoneNumber); + if (values?.phoneNumber && phoneNumber !== values.phoneNumber) { + updatePhone(formatE164PhoneNumber(values.phoneNumber) ?? '', currenPhoneNumber); } Navigation.goBack(); @@ -52,19 +50,24 @@ function PhoneNumberPage() { const validate = useCallback( (values: FormOnyxValues): FormInputErrors => { const errors: FormInputErrors = {}; - if (!ValidationUtils.isRequiredFulfilled(values[INPUT_IDS.PHONE_NUMBER])) { + const phoneNumberValue = values[INPUT_IDS.PHONE_NUMBER]; + + if (!isRequiredFulfilled(phoneNumberValue)) { errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.fieldRequired'); + return errors; } - const phoneNumberWithCountryCode = LoginUtils.appendCountryCode(values[INPUT_IDS.PHONE_NUMBER]); - const parsedPhoneNumber = PhoneNumberUtils.parsePhoneNumber(values[INPUT_IDS.PHONE_NUMBER]); - if (!parsedPhoneNumber.possible || !Str.isValidE164Phone(phoneNumberWithCountryCode.slice(0))) { - errors[INPUT_IDS.PHONE_NUMBER] = translate('bankAccount.error.phoneNumber'); + + const phoneNumberWithCountryCode = appendCountryCode(phoneNumberValue); + + if (!isValidPhoneNumber(phoneNumberWithCountryCode)) { + errors[INPUT_IDS.PHONE_NUMBER] = translate('common.error.phoneNumber'); + return errors; } - // Clear the error when the user tries to validate the form and there are errors - if (validateLoginError && !!errors) { - PersonalDetails.clearPhoneNumberError(); + if (validateLoginError && Object.keys(errors).length > 0) { + clearPhoneNumberError(); } + return errors; }, [translate, validateLoginError], @@ -95,7 +98,7 @@ function PhoneNumberPage() { PersonalDetails.clearPhoneNumberError()} + onClose={() => clearPhoneNumberError()} > diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx index 6075d58353f9..72b165821c61 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage.tsx @@ -1,5 +1,4 @@ -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; @@ -438,13 +437,13 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro openPolicyMoreFeaturesPage(route.params.policyID); }, [route.params.policyID]); - useNetwork({onReconnect: fetchFeatures}); + useEffect(() => { + fetchFeatures(); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - useFocusEffect( - useCallback(() => { - fetchFeatures(); - }, [fetchFeatures]), - ); + useNetwork({onReconnect: fetchFeatures}); return ( { - fetchData(policyID, shouldSkipVBBACall); - }, [policyID, shouldSkipVBBACall]), - ); - + useEffect(() => { + fetchData(policyID, shouldSkipVBBACall); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const shouldShowPolicy = useMemo(() => shouldShowPolicyUtil(policy, isOffline, currentUserLogin), [policy, isOffline, currentUserLogin]); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); diff --git a/src/pages/workspace/accounting/nsqs/advanced/NSQSAdvancedPage.tsx b/src/pages/workspace/accounting/nsqs/advanced/NSQSAdvancedPage.tsx index bff1d646f6a0..0363e8da258a 100644 --- a/src/pages/workspace/accounting/nsqs/advanced/NSQSAdvancedPage.tsx +++ b/src/pages/workspace/accounting/nsqs/advanced/NSQSAdvancedPage.tsx @@ -1,21 +1,16 @@ -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; import ConnectionLayout from '@components/ConnectionLayout'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateNSQSAutoSync} from '@libs/actions/connections/NSQS'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; +import {settingsPendingAction} from '@libs/PolicyUtils'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {clearNSQSErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; -import type {NSQSPayableAccount} from '@src/types/onyx/Policy'; function NSQSAdvancedPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); @@ -23,21 +18,6 @@ function NSQSAdvancedPage({policy}: WithPolicyProps) { const policyID = policy?.id; const nsqsConfig = policy?.connections?.netsuiteQuickStart?.config; const isAutoSyncEnabled = nsqsConfig?.autoSync?.enabled ?? false; - const approvalAccount = nsqsConfig?.approvalAccount ?? ''; - const nsqsData = policy?.connections?.netsuiteQuickStart?.data; - const payableAccounts: NSQSPayableAccount[] = useMemo(() => nsqsData?.payableAccounts ?? [], [nsqsData?.payableAccounts]); - - const defaultApprovalAccount: NSQSPayableAccount = useMemo( - () => ({ - id: '', - name: translate(`workspace.nsqs.advanced.defaultApprovalAccount`), - displayName: translate(`workspace.nsqs.advanced.defaultApprovalAccount`), - number: '', - type: '', - }), - [translate], - ); - const selectedApprovalAccount = [defaultApprovalAccount, ...payableAccounts].find((account) => account.id === approvalAccount); const toggleAutoSync = useCallback(() => { if (!policyID) { @@ -69,16 +49,6 @@ function NSQSAdvancedPage({policy}: WithPolicyProps) { errors={getLatestErrorField(nsqsConfig, CONST.NSQS_CONFIG.AUTO_SYNC)} onCloseError={policyID ? () => clearNSQSErrorField(policyID, CONST.NSQS_CONFIG.AUTO_SYNC) : undefined} /> - - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NSQS_ADVANCED_APPROVAL_ACCOUNT.getRoute(policyID)) : undefined} - brickRoadIndicator={areSettingsInErrorFields([CONST.NSQS_CONFIG.APPROVAL_ACCOUNT], nsqsConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - /> - ); diff --git a/src/pages/workspace/accounting/nsqs/export/NSQSDatePage.tsx b/src/pages/workspace/accounting/nsqs/export/NSQSDatePage.tsx index 78b14c4aeabd..72c428de1fe7 100644 --- a/src/pages/workspace/accounting/nsqs/export/NSQSDatePage.tsx +++ b/src/pages/workspace/accounting/nsqs/export/NSQSDatePage.tsx @@ -53,7 +53,7 @@ function NSQSDatePage({policy}: WithPolicyProps) { return ( policyID={policyID} - accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} displayName={NSQSDatePage.displayName} headerContent={headerContent} diff --git a/src/pages/workspace/accounting/nsqs/export/NSQSExportPage.tsx b/src/pages/workspace/accounting/nsqs/export/NSQSExportPage.tsx index 652b90600115..3eb1ea5a0f94 100644 --- a/src/pages/workspace/accounting/nsqs/export/NSQSExportPage.tsx +++ b/src/pages/workspace/accounting/nsqs/export/NSQSExportPage.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, {useMemo} from 'react'; import {View} from 'react-native'; import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -11,6 +11,7 @@ import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import type {NSQSPaymentAccount} from '@src/types/onyx/Policy'; function NSQSExportPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); @@ -20,6 +21,21 @@ function NSQSExportPage({policy}: WithPolicyProps) { const nsqsConfig = policy?.connections?.netsuiteQuickStart?.config; const exporter = nsqsConfig?.exporter ?? policyOwner; const exportDate = nsqsConfig?.exportDate ?? CONST.NSQS_EXPORT_DATE.LAST_EXPENSE; + const paymentAccount = nsqsConfig?.paymentAccount ?? ''; + const nsqsData = policy?.connections?.netsuiteQuickStart?.data; + const paymentAccounts: NSQSPaymentAccount[] = useMemo(() => nsqsData?.paymentAccounts ?? [], [nsqsData?.paymentAccounts]); + + const defaultPaymentAccount: NSQSPaymentAccount = useMemo( + () => ({ + id: '', + name: translate(`workspace.nsqs.export.defaultPaymentAccount`), + displayName: translate(`workspace.nsqs.export.defaultPaymentAccount`), + number: '', + type: '', + }), + [translate], + ); + const selectedPaymentAccount = [defaultPaymentAccount, ...paymentAccounts].find((account) => account.id === paymentAccount); return ( + + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NSQS_EXPORT_PAYMENT_ACCOUNT.getRoute(policyID)) : undefined} + brickRoadIndicator={areSettingsInErrorFields([CONST.NSQS_CONFIG.PAYMENT_ACCOUNT], nsqsConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + ); } diff --git a/src/pages/workspace/accounting/nsqs/advanced/NSQSApprovalAccountPage.tsx b/src/pages/workspace/accounting/nsqs/export/NSQSPaymentAccountPage.tsx similarity index 57% rename from src/pages/workspace/accounting/nsqs/advanced/NSQSApprovalAccountPage.tsx rename to src/pages/workspace/accounting/nsqs/export/NSQSPaymentAccountPage.tsx index f1b4fde7e8db..4cd371b51854 100644 --- a/src/pages/workspace/accounting/nsqs/advanced/NSQSApprovalAccountPage.tsx +++ b/src/pages/workspace/accounting/nsqs/export/NSQSPaymentAccountPage.tsx @@ -5,7 +5,7 @@ import type {SelectorType} from '@components/SelectionScreen'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateNSQSApprovalAccount} from '@libs/actions/connections/NSQS'; +import {updateNSQSPaymentAccount} from '@libs/actions/connections/NSQS'; import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import {settingsPendingAction} from '@libs/PolicyUtils'; @@ -14,74 +14,74 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {clearNSQSErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; -import type {NSQSPayableAccount} from '@src/types/onyx/Policy'; +import type {NSQSPaymentAccount} from '@src/types/onyx/Policy'; -function NSQSApprovalAccountPage({policy}: WithPolicyProps) { +function NSQSPaymentAccountPage({policy}: WithPolicyProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policyID = policy?.id; const nsqsConfig = policy?.connections?.netsuiteQuickStart?.config; - const approvalAccount = nsqsConfig?.approvalAccount ?? ''; + const paymentAccount = nsqsConfig?.paymentAccount ?? ''; const nsqsData = policy?.connections?.netsuiteQuickStart?.data; - const payableAccounts: NSQSPayableAccount[] = useMemo(() => nsqsData?.payableAccounts ?? [], [nsqsData?.payableAccounts]); + const paymentAccounts: NSQSPaymentAccount[] = useMemo(() => nsqsData?.paymentAccounts ?? [], [nsqsData?.paymentAccounts]); - const defaultApprovalAccount: NSQSPayableAccount = useMemo( + const defaultPaymentAccount: NSQSPaymentAccount = useMemo( () => ({ id: '', - name: translate(`workspace.nsqs.advanced.defaultApprovalAccount`), - displayName: translate(`workspace.nsqs.advanced.defaultApprovalAccount`), + name: translate(`workspace.nsqs.export.defaultPaymentAccount`), + displayName: translate(`workspace.nsqs.export.defaultPaymentAccount`), number: '', type: '', }), [translate], ); - const sectionData: SelectorType[] = [defaultApprovalAccount, ...payableAccounts].map((option) => ({ + const sectionData: SelectorType[] = [defaultPaymentAccount, ...paymentAccounts].map((option) => ({ keyForList: option.id, text: option.displayName, - isSelected: option.id === approvalAccount, + isSelected: option.id === paymentAccount, value: option.id, })); - const updateApprovalAccount = useCallback( + const updatePaymentAccount = useCallback( ({value}: SelectorType) => { if (!policyID) { return; } - if (value !== approvalAccount) { - updateNSQSApprovalAccount(policyID, value, approvalAccount); + if (value !== paymentAccount) { + updateNSQSPaymentAccount(policyID, value, paymentAccount); } - Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NSQS_ADVANCED.getRoute(policyID)); + Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NSQS_EXPORT.getRoute(policyID)); }, - [policyID, approvalAccount], + [policyID, paymentAccount], ); - const headerContent = useMemo(() => {translate('workspace.nsqs.advanced.approvalAccountDescription')}, [translate, styles.pb5, styles.ph5]); + const headerContent = useMemo(() => {translate('workspace.nsqs.export.paymentAccountDescription')}, [translate, styles.pb5, styles.ph5]); return ( option.isSelected)?.keyForList} - onBackButtonPress={policyID ? () => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NSQS_ADVANCED.getRoute(policyID)) : undefined} - title="workspace.nsqs.advanced.approvalAccount" - pendingAction={settingsPendingAction([CONST.NSQS_CONFIG.APPROVAL_ACCOUNT], nsqsConfig?.pendingFields)} - errors={getLatestErrorField(nsqsConfig, CONST.NSQS_CONFIG.APPROVAL_ACCOUNT)} + onBackButtonPress={policyID ? () => Navigation.goBack(ROUTES.POLICY_ACCOUNTING_NSQS_EXPORT.getRoute(policyID)) : undefined} + title="workspace.nsqs.export.paymentAccount" + pendingAction={settingsPendingAction([CONST.NSQS_CONFIG.PAYMENT_ACCOUNT], nsqsConfig?.pendingFields)} + errors={getLatestErrorField(nsqsConfig, CONST.NSQS_CONFIG.PAYMENT_ACCOUNT)} errorRowStyles={[styles.ph5, styles.pv3]} - onClose={policyID ? () => clearNSQSErrorField(policyID, CONST.NSQS_CONFIG.APPROVAL_ACCOUNT) : undefined} + onClose={policyID ? () => clearNSQSErrorField(policyID, CONST.NSQS_CONFIG.PAYMENT_ACCOUNT) : undefined} /> ); } -NSQSApprovalAccountPage.displayName = 'NSQSApprovalAccountPage'; +NSQSPaymentAccountPage.displayName = 'NSQSPaymentAccountPage'; -export default withPolicyConnections(NSQSApprovalAccountPage); +export default withPolicyConnections(NSQSPaymentAccountPage); diff --git a/src/pages/workspace/accounting/nsqs/export/NSQSPreferredExporterPage.tsx b/src/pages/workspace/accounting/nsqs/export/NSQSPreferredExporterPage.tsx index 524f631b2806..c56339d6ff8d 100644 --- a/src/pages/workspace/accounting/nsqs/export/NSQSPreferredExporterPage.tsx +++ b/src/pages/workspace/accounting/nsqs/export/NSQSPreferredExporterPage.tsx @@ -77,7 +77,7 @@ function NSQSPreferredExporterPage({policy}: WithPolicyProps) { return ( policyID={policyID} - accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} displayName={NSQSCustomersDisplayedAsPage.displayName} sections={[{data: sectionData}]} diff --git a/src/pages/workspace/accounting/nsqs/import/NSQSProjectsDisplayedAsPage.tsx b/src/pages/workspace/accounting/nsqs/import/NSQSProjectsDisplayedAsPage.tsx index 6c3ace0143fa..dfc7c65f873a 100644 --- a/src/pages/workspace/accounting/nsqs/import/NSQSProjectsDisplayedAsPage.tsx +++ b/src/pages/workspace/accounting/nsqs/import/NSQSProjectsDisplayedAsPage.tsx @@ -50,7 +50,7 @@ function NSQSProjectsDisplayedAsPage({policy}: WithPolicyProps) { return ( policyID={policyID} - accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.CONTROL]} + accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} displayName={NSQSProjectsDisplayedAsPage.displayName} sections={[{data: sectionData}]} diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index 5a0c83976c81..bc1aa438bec6 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -225,9 +225,9 @@ function getAccountingIntegrationData( onImportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NSQS_IMPORT.getRoute(policyID)), subscribedImportSettings: [CONST.NSQS_CONFIG.SYNC_OPTIONS.MAPPING.CUSTOMERS, CONST.NSQS_CONFIG.SYNC_OPTIONS.MAPPING.PROJECTS], onExportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NSQS_EXPORT.getRoute(policyID)), - subscribedExportSettings: [CONST.NSQS_CONFIG.EXPORTER, CONST.NSQS_CONFIG.EXPORT_DATE], + subscribedExportSettings: [CONST.NSQS_CONFIG.EXPORTER, CONST.NSQS_CONFIG.EXPORT_DATE, CONST.NSQS_CONFIG.PAYMENT_ACCOUNT], onAdvancedPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NSQS_ADVANCED.getRoute(policyID)), - subscribedAdvancedSettings: [CONST.NSQS_CONFIG.AUTO_SYNC, CONST.NSQS_CONFIG.APPROVAL_ACCOUNT], + subscribedAdvancedSettings: [CONST.NSQS_CONFIG.AUTO_SYNC], onCardReconciliationPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, CONST.POLICY.CONNECTIONS.ROUTE.NSQS)), pendingFields: policy?.connections?.netsuiteQuickStart?.config?.pendingFields, errorFields: policy?.connections?.netsuiteQuickStart?.config?.errorFields, diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 603a6902869e..4b645b27a43e 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -1,4 +1,3 @@ -import {useFocusEffect} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; @@ -92,11 +91,11 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const {isOffline} = useNetwork({onReconnect: fetchCategories}); - useFocusEffect( - useCallback(() => { - fetchCategories(); - }, [fetchCategories]), - ); + useEffect(() => { + fetchCategories(); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const cleanupSelectedOption = useCallback(() => setSelectedCategories({}), []); useCleanupSelectedOptions(cleanupSelectedOption); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx index 44311f64944f..53e325bfb6a8 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsPage.tsx @@ -65,7 +65,7 @@ function WorkspaceCompanyCardPage({route}: WorkspaceCompanyCardPageProps) { }, [policyID, workspaceAccountID]); const {isOffline} = useNetwork({onReconnect: fetchCompanyCards}); - const isLoading = !isOffline && (!cardFeeds || (!!cardFeeds.isLoading && !cardsList)); + const isLoading = !isOffline && (!cardFeeds || (!!cardFeeds.isLoading && isEmptyObject(cardsList))); useFocusEffect(fetchCompanyCards); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 91860e382357..0d511e225251 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -1,4 +1,4 @@ -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import Button from '@components/Button'; @@ -96,11 +96,11 @@ function PolicyDistanceRatesPage({ const {isOffline} = useNetwork({onReconnect: fetchDistanceRates}); - useFocusEffect( - useCallback(() => { - fetchDistanceRates(); - }, [fetchDistanceRates]), - ); + useEffect(() => { + fetchDistanceRates(); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); useEffect(() => { if (isFocused) { diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx index 03212428f3e6..5b5bf3a19ea4 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -1,4 +1,4 @@ -import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import {useFocusEffect} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; @@ -20,6 +20,7 @@ import SelectionListWithModal from '@components/SelectionListWithModal'; import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; @@ -123,7 +124,6 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { const [selectedPerDiem, setSelectedPerDiem] = useState([]); const [deletePerDiemConfirmModalVisible, setDeletePerDiemConfirmModalVisible] = useState(false); const [isDownloadFailureModalVisible, setIsDownloadFailureModalVisible] = useState(false); - const isFocused = useIsFocused(); const policyID = route.params.policyID; const backTo = route.params?.backTo; const policy = usePolicy(policyID); @@ -154,12 +154,8 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { }, [fetchPerDiem]), ); - useEffect(() => { - if (isFocused) { - return; - } - setSelectedPerDiem([]); - }, [isFocused]); + const cleanupSelectedOption = useCallback(() => setSelectedPerDiem([]), []); + useCleanupSelectedOptions(cleanupSelectedOption); const subRatesList = useMemo( () => @@ -237,6 +233,10 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { }; const openSubRateDetails = (rate: PolicyOption) => { + if (isSmallScreenWidth && selectionMode?.isEnabled) { + toggleSubRate(rate); + return; + } Navigation.navigate(ROUTES.WORKSPACE_PER_DIEM_DETAILS.getRoute(policyID, rate.rateID, rate.subRateID)); }; diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 7e61667d6b45..93e64e6dd02f 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -1,6 +1,5 @@ -import {useFocusEffect} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -93,7 +92,11 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const {isOffline} = useNetwork({onReconnect: fetchTags}); - useFocusEffect(fetchTags); + useEffect(() => { + fetchTags(); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const cleanupSelectedOption = useCallback(() => setSelectedTags({}), []); useCleanupSelectedOptions(cleanupSelectedOption); diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index c750e139403b..a8e2bd0a6c73 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -1,5 +1,4 @@ -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -82,11 +81,11 @@ function WorkspaceTaxesPage({ const {isOffline} = useNetwork({onReconnect: fetchTaxes}); - useFocusEffect( - useCallback(() => { - fetchTaxes(); - }, [fetchTaxes]), - ); + useEffect(() => { + fetchTaxes(); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const cleanupSelectedOption = useCallback(() => setSelectedTaxesIDs([]), []); useCleanupSelectedOptions(cleanupSelectedOption); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index a639b05f99c8..efd05804d91a 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -1,5 +1,4 @@ -import {useFocusEffect} from '@react-navigation/native'; -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import {ActivityIndicator, InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import ApprovalWorkflowSection from '@components/ApprovalWorkflowSection'; @@ -47,10 +46,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import ToggleSettingOptionRow from './ToggleSettingsOptionRow'; import type {ToggleSettingOptionRowProps} from './ToggleSettingsOptionRow'; -import {getAutoReportingFrequencyDisplayNames} from './WorkspaceAutoReportingFrequencyPage'; +import ToggleSettingOptionRow from './ToggleSettingsOptionRow'; import type {AutoReportingFrequencyKey} from './WorkspaceAutoReportingFrequencyPage'; +import {getAutoReportingFrequencyDisplayNames} from './WorkspaceAutoReportingFrequencyPage'; type WorkspaceWorkflowsPageProps = WithPolicyProps & PlatformStackScreenProps; @@ -103,13 +102,13 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const {isOffline} = useNetwork({onReconnect: fetchData}); const isPolicyAdmin = isPolicyAdminUtil(policy); - useFocusEffect( - useCallback(() => { - InteractionManager.runAfterInteractions(() => { - fetchData(); - }); - }, [fetchData]), - ); + useEffect(() => { + InteractionManager.runAfterInteractions(() => { + fetchData(); + }); + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // User should be allowed to add new Approval Workflow only if he's upgraded to Control Plan, otherwise redirected to the Upgrade Page const addApprovalAction = useCallback(() => { diff --git a/src/types/modules/pusher.d.ts b/src/types/modules/pusher.d.ts index ffcf7744773a..e0070ac28783 100644 --- a/src/types/modules/pusher.d.ts +++ b/src/types/modules/pusher.d.ts @@ -1,9 +1,10 @@ +import type {Pusher as MobilePusher} from '@pusher/pusher-websocket-react-native'; import type Pusher from 'pusher-js/types/src/core/pusher'; declare global { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { - getPusherInstance: () => Pusher | null; + getPusherInstance: () => Pusher | MobilePusher | null; } // eslint-disable-next-line @typescript-eslint/consistent-type-definitions diff --git a/src/types/onyx/LastPaymentMethod.ts b/src/types/onyx/LastPaymentMethod.ts index ea0c644fc730..62805e86af4b 100644 --- a/src/types/onyx/LastPaymentMethod.ts +++ b/src/types/onyx/LastPaymentMethod.ts @@ -1,4 +1,18 @@ +/** + * The new lastPaymentMethod object + */ +type LastPaymentMethodType = { + /** The default last payment method */ + lastUsed: string; + /** The lastPaymentMethod of an IOU */ + Iou: string; + /** The lastPaymentMethod of an Expense */ + Expense: string; + /** The lastPaymentMethod of an Invoice */ + Invoice: string; +}; + /** Record of last payment methods, indexed by policy id */ -type LastPaymentMethod = Record; +type LastPaymentMethod = Record; -export default LastPaymentMethod; +export type {LastPaymentMethodType, LastPaymentMethod}; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index d89dc91f76ae..465b744deb0c 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1073,9 +1073,9 @@ type NetSuiteConnection = { }; /** - * NSQS Payable account + * NSQS Payment account */ -type NSQSPayableAccount = { +type NSQSPaymentAccount = { /** ID assigned to the account in NSQS */ id: string; @@ -1096,8 +1096,8 @@ type NSQSPayableAccount = { * Connection data for NSQS */ type NSQSConnectionData = { - /** Collection of the payable accounts */ - payableAccounts: NSQSPayableAccount[]; + /** Collection of the payments accounts */ + paymentAccounts: NSQSPaymentAccount[]; }; /** @@ -1155,8 +1155,8 @@ type NSQSConnectionConfig = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Whether the connection is configured */ isConfigured: boolean; - /** The account used for approvals in NSQS */ - approvalAccount: string; + /** The account used for payments in NSQS */ + paymentAccount: string; /** Collections of form field errors */ errorFields?: OnyxCommon.ErrorFields; @@ -2027,7 +2027,7 @@ export type { NetSuiteTaxAccount, NetSuiteCustomFormIDOptions, NetSuiteCustomFormID, - NSQSPayableAccount, + NSQSPaymentAccount, SageIntacctMappingValue, SageIntacctMappingType, SageIntacctMappingName, diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index d0be7ddd1651..09f28b4efff8 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -173,6 +173,9 @@ type SearchReport = { /** Whether the user is not an admin of policyExpenseChat chat */ isOwnPolicyExpenseChat?: boolean; + + /** The policy name to use for an archived report */ + oldPolicyName?: string; }; /** Model of report action search result */ diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 569504437fb2..f5a9acb5f3ea 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -35,7 +35,7 @@ import type InvitedEmailsToAccountIDs from './InvitedEmailsToAccountIDs'; import type IOU from './IOU'; import type JoinablePolicies from './JoinablePolicies'; import type LastExportMethod from './LastExportMethod'; -import type LastPaymentMethod from './LastPaymentMethod'; +import type {LastPaymentMethod, LastPaymentMethodType} from './LastPaymentMethod'; import type LastSelectedDistanceRates from './LastSelectedDistanceRates'; import type Locale from './Locale'; import type {LoginList} from './Login'; @@ -250,4 +250,5 @@ export type { JoinablePolicies, DismissedProductTraining, TravelProvisioning, + LastPaymentMethodType, }; diff --git a/tests/utils/PusherHelper.ts b/tests/utils/PusherHelper.ts index 8547c25b1235..fe84cc9344de 100644 --- a/tests/utils/PusherHelper.ts +++ b/tests/utils/PusherHelper.ts @@ -1,9 +1,8 @@ +import Pusher from '@libs/Pusher'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import * as Pusher from '@src/libs/Pusher/pusher'; import PusherConnectionManager from '@src/libs/PusherConnectionManager'; import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; -import asMutable from '@src/types/utils/asMutable'; const CHANNEL_NAME = `${CONST.PUSHER.PRIVATE_USER_CHANNEL_PREFIX}1${CONFIG.PUSHER.SUFFIX}`; @@ -12,8 +11,8 @@ function setup() { // channel already in a subscribed state. These methods are normally used to prevent // duplicated subscriptions, but we don't need them for this test so forcing them to // return false will make the testing less complex. - asMutable(Pusher).isSubscribed = jest.fn().mockReturnValue(false); - asMutable(Pusher).isAlreadySubscribing = jest.fn().mockReturnValue(false); + jest.spyOn(Pusher, 'isSubscribed').mockReturnValue(false); + jest.spyOn(Pusher, 'isAlreadySubscribing').mockReturnValue(false); // Connect to Pusher PusherConnectionManager.init(); @@ -23,15 +22,17 @@ function setup() { authEndpoint: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api/AuthenticatePusher?`, }); - window.getPusherInstance()?.connection.emit('connected'); + const pusher = window.getPusherInstance(); + if (pusher && 'connection' in pusher) { + pusher.connection?.emit('connected'); + } } function emitOnyxUpdate(args: OnyxServerUpdate[]) { - const channel = Pusher.getChannel(CHANNEL_NAME); - channel?.emit(Pusher.TYPE.MULTIPLE_EVENTS, { + Pusher.sendEvent(CHANNEL_NAME, Pusher.TYPE.MULTIPLE_EVENTS, { type: 'pusher', - lastUpdateID: null, - previousUpdateID: null, + lastUpdateID: 0, + previousUpdateID: 0, updates: [ { eventType: Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, diff --git a/tests/utils/TestHelper.ts b/tests/utils/TestHelper.ts index f3814d4b91cb..24c15aa274d1 100644 --- a/tests/utils/TestHelper.ts +++ b/tests/utils/TestHelper.ts @@ -4,8 +4,8 @@ import {Linking} from 'react-native'; import Onyx from 'react-native-onyx'; import type {ConnectOptions} from 'react-native-onyx/dist/types'; import type {ApiCommand, ApiRequestCommandParameters} from '@libs/API/types'; -import * as Localize from '@libs/Localize'; -import * as Pusher from '@libs/Pusher/pusher'; +import {translateLocal} from '@libs/Localize'; +import Pusher from '@libs/Pusher'; import PusherConnectionManager from '@libs/PusherConnectionManager'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; @@ -330,7 +330,7 @@ function assertFormDataMatchesObject(obj: Report, formData?: FormData) { } async function navigateToSidebarOption(index: number): Promise { - const hintText = Localize.translateLocal('accessibilityHints.navigatesToChat'); + const hintText = translateLocal('accessibilityHints.navigatesToChat'); const optionRow = screen.queryAllByAccessibilityHint(hintText).at(index); if (!optionRow) { return;