diff --git a/__mocks__/@react-native-async-storage/async-storage.js b/__mocks__/@react-native-async-storage/async-storage.js deleted file mode 100644 index 1051fa919c94..000000000000 --- a/__mocks__/@react-native-async-storage/async-storage.js +++ /dev/null @@ -1 +0,0 @@ -export {default} from '@react-native-async-storage/async-storage/jest/async-storage-mock'; diff --git a/__mocks__/@react-native-clipboard/clipboard.js b/__mocks__/@react-native-clipboard/clipboard.js new file mode 100644 index 000000000000..e56e290c3cc9 --- /dev/null +++ b/__mocks__/@react-native-clipboard/clipboard.js @@ -0,0 +1,3 @@ +import MockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; + +export default MockClipboard; diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js deleted file mode 100644 index 86059f362924..000000000000 --- a/__mocks__/fileMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = 'test-file-stub'; diff --git a/__mocks__/fileMock.ts b/__mocks__/fileMock.ts new file mode 100644 index 000000000000..3592aaaaf26b --- /dev/null +++ b/__mocks__/fileMock.ts @@ -0,0 +1,3 @@ +const fileMock = 'test-file-stub'; + +export default fileMock; diff --git a/__mocks__/react-native-config.js b/__mocks__/react-native-config.js deleted file mode 100644 index 7c3900efa21e..000000000000 --- a/__mocks__/react-native-config.js +++ /dev/null @@ -1,6 +0,0 @@ -const path = require('path'); -const dotenv = require('dotenv'); - -const env = dotenv.config({path: path.resolve('./.env.example')}).parsed; - -export default env; diff --git a/__mocks__/react-native-config.ts b/__mocks__/react-native-config.ts new file mode 100644 index 000000000000..129d7c97ed43 --- /dev/null +++ b/__mocks__/react-native-config.ts @@ -0,0 +1,8 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +type ReactNativeConfigMock = dotenv.DotenvParseOutput | undefined; + +const reactNativeConfigMock: ReactNativeConfigMock = dotenv.config({path: path.resolve('./.env.example')}).parsed; + +export default reactNativeConfigMock; diff --git a/__mocks__/react-native-document-picker.js b/__mocks__/react-native-document-picker.js new file mode 100644 index 000000000000..8cba2bc1eba4 --- /dev/null +++ b/__mocks__/react-native-document-picker.js @@ -0,0 +1,23 @@ +export default { + getConstants: jest.fn(), + pick: jest.fn(), + releaseSecureAccess: jest.fn(), + pickDirectory: jest.fn(), + + types: Object.freeze({ + allFiles: 'public.item', + audio: 'public.audio', + csv: 'public.comma-separated-values-text', + doc: 'com.microsoft.word.doc', + docx: 'org.openxmlformats.wordprocessingml.document', + images: 'public.image', + pdf: 'com.adobe.pdf', + plainText: 'public.plain-text', + ppt: 'com.microsoft.powerpoint.ppt', + pptx: 'org.openxmlformats.presentationml.presentation', + video: 'public.movie', + xls: 'com.microsoft.excel.xls', + xlsx: 'org.openxmlformats.spreadsheetml.sheet', + zip: 'public.zip-archive', + }), +}; diff --git a/__mocks__/react-native-image-picker.js b/__mocks__/react-native-image-picker.js deleted file mode 100644 index 7e4c29fa79ac..000000000000 --- a/__mocks__/react-native-image-picker.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - showImagePicker: jest.fn(), - launchCamera: jest.fn(), - launchImageLibrary: jest.fn(), -}; diff --git a/__mocks__/react-native-key-command.js b/__mocks__/react-native-key-command.js deleted file mode 100644 index 5bd02dfd121d..000000000000 --- a/__mocks__/react-native-key-command.js +++ /dev/null @@ -1,7 +0,0 @@ -const registerKeyCommands = () => {}; -const unregisterKeyCommands = () => {}; -const constants = {}; -const eventEmitter = () => {}; -const addListener = () => {}; - -export {registerKeyCommands, unregisterKeyCommands, constants, eventEmitter, addListener}; diff --git a/__mocks__/react-native-key-command.ts b/__mocks__/react-native-key-command.ts new file mode 100644 index 000000000000..288b7847684d --- /dev/null +++ b/__mocks__/react-native-key-command.ts @@ -0,0 +1,9 @@ +import type {addListener as _addListener, constants as _constants} from 'react-native-key-command'; + +const registerKeyCommands = () => {}; +const unregisterKeyCommands = () => {}; +const constants: Partial = {}; +const eventEmitter = () => {}; +const addListener: typeof _addListener = () => () => {}; + +export {addListener, constants, eventEmitter, registerKeyCommands, unregisterKeyCommands}; diff --git a/android/app/build.gradle b/android/app/build.gradle index ed7b2f568b93..3e5f373efcd7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043907 - versionName "1.4.39-7" + versionCode 1001044002 + versionName "1.4.40-2" } flavorDimensions "default" diff --git a/android/app/src/main/res/raw/attention.mp3 b/android/app/src/main/res/raw/attention.mp3 new file mode 100644 index 000000000000..854be9cbcf07 Binary files /dev/null and b/android/app/src/main/res/raw/attention.mp3 differ diff --git a/android/app/src/main/res/raw/done.mp3 b/android/app/src/main/res/raw/done.mp3 new file mode 100644 index 000000000000..23d6a2b6bdb5 Binary files /dev/null and b/android/app/src/main/res/raw/done.mp3 differ diff --git a/android/app/src/main/res/raw/receive.mp3 b/android/app/src/main/res/raw/receive.mp3 new file mode 100644 index 000000000000..28f03052a14b Binary files /dev/null and b/android/app/src/main/res/raw/receive.mp3 differ diff --git a/android/app/src/main/res/raw/success.mp3 b/android/app/src/main/res/raw/success.mp3 new file mode 100644 index 000000000000..bd1af6526e40 Binary files /dev/null and b/android/app/src/main/res/raw/success.mp3 differ diff --git a/assets/css/fonts.css b/assets/css/fonts.css index 078cec114c31..e93fb6d806da 100644 --- a/assets/css/fonts.css +++ b/assets/css/fonts.css @@ -55,8 +55,8 @@ } @font-face { - font-family: Windows Segoe UI Emoji; - src: url('/fonts/seguiemj.ttf'); + font-family: Noto Color Emoji; + src: url('/fonts/NotoColorEmoji-Regular.woff2') format('woff2'), url('/fonts/NotoColorEmoji-Regular.woff') format('woff'); } * { diff --git a/assets/fonts/web/NotoColorEmoji-Regular.woff b/assets/fonts/web/NotoColorEmoji-Regular.woff new file mode 100644 index 000000000000..ba2eccd5eac9 Binary files /dev/null and b/assets/fonts/web/NotoColorEmoji-Regular.woff differ diff --git a/assets/fonts/web/NotoColorEmoji-Regular.woff2 b/assets/fonts/web/NotoColorEmoji-Regular.woff2 new file mode 100644 index 000000000000..7cb695dc176b Binary files /dev/null and b/assets/fonts/web/NotoColorEmoji-Regular.woff2 differ diff --git a/assets/sounds/attention.mp3 b/assets/sounds/attention.mp3 new file mode 100644 index 000000000000..854be9cbcf07 Binary files /dev/null and b/assets/sounds/attention.mp3 differ diff --git a/assets/sounds/done.mp3 b/assets/sounds/done.mp3 new file mode 100644 index 000000000000..23d6a2b6bdb5 Binary files /dev/null and b/assets/sounds/done.mp3 differ diff --git a/assets/sounds/receive.mp3 b/assets/sounds/receive.mp3 new file mode 100644 index 000000000000..28f03052a14b Binary files /dev/null and b/assets/sounds/receive.mp3 differ diff --git a/assets/sounds/success.mp3 b/assets/sounds/success.mp3 new file mode 100644 index 000000000000..bd1af6526e40 Binary files /dev/null and b/assets/sounds/success.mp3 differ diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 9b1a28382fa2..8ad574d3b2e0 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -58,7 +58,9 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ publicPath: '/', }, stats: { - warningsFilter: [], + // We can ignore the "module not installed" warning from lottie-react-native + // because we are not using the library for JSON format of Lottie animations. + warningsFilter: ['./node_modules/lottie-react-native/lib/module/LottieView/index.web.js'], }, plugins: [ new CleanWebpackPlugin(), @@ -97,6 +99,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ {from: 'web/manifest.json'}, {from: 'assets/css', to: 'css'}, {from: 'assets/fonts/web', to: 'fonts'}, + {from: 'assets/sounds', to: 'sounds'}, {from: 'node_modules/react-pdf/dist/esm/Page/AnnotationLayer.css', to: 'css/AnnotationLayer.css'}, {from: 'node_modules/react-pdf/dist/esm/Page/TextLayer.css', to: 'css/TextLayer.css'}, {from: 'assets/images/shadow.png', to: 'images/shadow.png'}, @@ -200,7 +203,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ alias: { 'react-native-config': 'react-web-config', 'react-native$': 'react-native-web', - + 'react-native-sound': 'react-native-web-sound', // Module alias for web & desktop // https://webpack.js.org/configuration/resolve/#resolvealias '@assets': path.resolve(__dirname, '../../assets'), @@ -239,6 +242,7 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ 'process/browser': require.resolve('process/browser'), }, }, + optimization: { runtimeChunk: 'single', splitChunks: { diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 268c706bedef..bdc1b8e4bb1e 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-serve": "^1.2.0", + "electron-serve": "^1.3.0", "electron-updater": "^6.1.7", "node-machine-id": "^1.1.12" } @@ -145,9 +145,9 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-serve": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.2.0.tgz", - "integrity": "sha512-zJG3wisMrDn2G/gnjrhyB074COvly1FnS0U7Edm8bfXLB8MYX7UtwR9/y2LkFreYjzQHm9nEbAfgCmF+9M9LHQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.3.0.tgz", + "integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg==", "engines": { "node": ">=12" }, @@ -536,9 +536,9 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-serve": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.2.0.tgz", - "integrity": "sha512-zJG3wisMrDn2G/gnjrhyB074COvly1FnS0U7Edm8bfXLB8MYX7UtwR9/y2LkFreYjzQHm9nEbAfgCmF+9M9LHQ==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/electron-serve/-/electron-serve-1.3.0.tgz", + "integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg==" }, "electron-updater": { "version": "6.1.7", diff --git a/desktop/package.json b/desktop/package.json index 563a45851eb2..da1610e49b23 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-serve": "^1.2.0", + "electron-serve": "^1.3.0", "electron-updater": "^6.1.7", "node-machine-id": "^1.1.12" }, diff --git a/ios/NewExpensify.xcodeproj/project.pbxproj b/ios/NewExpensify.xcodeproj/project.pbxproj index 5d3bf1c07985..9b1451b2bf94 100644 --- a/ios/NewExpensify.xcodeproj/project.pbxproj +++ b/ios/NewExpensify.xcodeproj/project.pbxproj @@ -8,6 +8,10 @@ /* Begin PBXBuildFile section */ 059DC4EFD39EF39437E6823D /* libPods-NotificationServiceExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A997AA8204EA3D90907FA80 /* libPods-NotificationServiceExtension.a */; }; + 083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E72B5AB22900C603C0 /* attention.mp3 */; }; + 083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E82B5AB22900C603C0 /* done.mp3 */; }; + 083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353E92B5AB22900C603C0 /* receive.mp3 */; }; + 083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 083353EA2B5AB22900C603C0 /* success.mp3 */; }; 0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */ = {isa = PBXBuildFile; fileRef = E704648954784DDFBAADF568 /* ExpensifyMono-Regular.otf */; }; 0CDA8E34287DD650004ECBEC /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */; }; 0CDA8E35287DD650004ECBEC /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */; }; @@ -79,6 +83,10 @@ 00E356EE1AD99517003FC87E /* NewExpensifyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NewExpensifyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 076FD9E41E08971BBF51D580 /* libPods-NewExpensify-NewExpensifyTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-NewExpensify-NewExpensifyTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 083353E72B5AB22900C603C0 /* attention.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = attention.mp3; path = ../assets/sounds/attention.mp3; sourceTree = ""; }; + 083353E82B5AB22900C603C0 /* done.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = done.mp3; path = ../assets/sounds/done.mp3; sourceTree = ""; }; + 083353E92B5AB22900C603C0 /* receive.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = receive.mp3; path = ../assets/sounds/receive.mp3; sourceTree = ""; }; + 083353EA2B5AB22900C603C0 /* success.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; name = success.mp3; path = ../assets/sounds/success.mp3; sourceTree = ""; }; 0CDA8E33287DD650004ECBEC /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = NewExpensify/AppDelegate.mm; sourceTree = ""; }; 0CDA8E36287DD6A0004ECBEC /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = NewExpensify/Images.xcassets; sourceTree = ""; }; 0F5BE0CD252686320097D869 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -311,6 +319,10 @@ A9EA265D209D4558995C9BD4 /* Resources */ = { isa = PBXGroup; children = ( + 083353E72B5AB22900C603C0 /* attention.mp3 */, + 083353E82B5AB22900C603C0 /* done.mp3 */, + 083353E92B5AB22900C603C0 /* receive.mp3 */, + 083353EA2B5AB22900C603C0 /* success.mp3 */, 44BF435285B94E5B95F90994 /* ExpensifyNewKansas-Medium.otf */, D2AFB39EC1D44BF9B91D3227 /* ExpensifyNewKansas-MediumItalic.otf */, DCF33E34FFEC48128CDD41D4 /* ExpensifyMono-Bold.otf */, @@ -496,13 +508,17 @@ 0F5BE0CE252686330097D869 /* GoogleService-Info.plist in Resources */, E9DF872D2525201700607FDC /* AirshipConfig.plist in Resources */, F0C450EA2705020500FD2970 /* colors.json in Resources */, + 083353EB2B5AB22A00C603C0 /* attention.mp3 in Resources */, 0CDA8E37287DD6A0004ECBEC /* Images.xcassets in Resources */, 70CF6E82262E297300711ADC /* BootSplash.storyboard in Resources */, FF941A8D48F849269AB85C9A /* ExpensifyNewKansas-Medium.otf in Resources */, BDB853621F354EBB84E619C2 /* ExpensifyNewKansas-MediumItalic.otf in Resources */, 26AF3C3540374A9FACB6C19E /* ExpensifyMono-Bold.otf in Resources */, + 083353EE2B5AB22A00C603C0 /* success.mp3 in Resources */, 0C7C65547D7346EB923BE808 /* ExpensifyMono-Regular.otf in Resources */, 2A9F8CDA983746B0B9204209 /* ExpensifyNeue-Bold.otf in Resources */, + 083353EC2B5AB22A00C603C0 /* done.mp3 in Resources */, + 083353ED2B5AB22A00C603C0 /* receive.mp3 in Resources */, ED222ED90E074A5481A854FA /* ExpensifyNeue-BoldItalic.otf in Resources */, 30581EA8AAFD4FCE88C5D191 /* ExpensifyNeue-Italic.otf in Resources */, 1246A3EF20E54E7A9494C8B9 /* ExpensifyNeue-Regular.otf in Resources */, diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 4f571739ecc7..e69495f809b7 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.39.7 + 1.4.40.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index bc29fdff60c2..d1b6888f942f 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleSignature ???? CFBundleVersion - 1.4.39.7 + 1.4.40.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index b54ddc36ddf0..379384a63442 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.39 + 1.4.40 CFBundleVersion - 1.4.39.7 + 1.4.40.2 NSExtension NSExtensionPointIdentifier diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2c05bf4175d8..19a579b846e8 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1166,7 +1166,7 @@ PODS: - react-native-config/App (= 1.4.6) - react-native-config/App (1.4.6): - React-Core - - react-native-document-picker (8.2.1): + - react-native-document-picker (9.1.1): - React-Core - react-native-geolocation (3.0.6): - React-Core @@ -1372,7 +1372,7 @@ PODS: - React-Core - RNCAsyncStorage (1.21.0): - React-Core - - RNCClipboard (1.12.1): + - RNCClipboard (1.13.2): - React-Core - RNCPicker (2.5.1): - React-Core @@ -1438,6 +1438,11 @@ PODS: - glog - RCT-Folly (= 2022.05.16.00) - React-Core + - RNSound (0.11.2): + - React-Core + - RNSound/Core (= 0.11.2) + - RNSound/Core (0.11.2): + - React-Core - RNSVG (14.0.0): - React-Core - SDWebImage (5.17.0): @@ -1580,6 +1585,7 @@ DEPENDENCIES: - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReanimated (from `../node_modules/react-native-reanimated`) - RNScreens (from `../node_modules/react-native-screens`) + - RNSound (from `../node_modules/react-native-sound`) - RNSVG (from `../node_modules/react-native-svg`) - VisionCamera (from `../node_modules/react-native-vision-camera`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -1827,6 +1833,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-reanimated" RNScreens: :path: "../node_modules/react-native-screens" + RNSound: + :path: "../node_modules/react-native-sound" RNSVG: :path: "../node_modules/react-native-svg" VisionCamera: @@ -1916,7 +1924,7 @@ SPEC CHECKSUMS: react-native-blob-util: 30a6c9fd067aadf9177e61a998f2c7efb670598d react-native-cameraroll: 8ffb0af7a5e5de225fd667610e2979fc1f0c2151 react-native-config: 7cd105e71d903104e8919261480858940a6b9c0e - react-native-document-picker: 69ca2094d8780cfc1e7e613894d15290fdc54bba + react-native-document-picker: 3599b238843369026201d2ef466df53f77ae0452 react-native-geolocation: 0f7fe8a4c2de477e278b0365cce27d089a8c5903 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b @@ -1954,7 +1962,7 @@ SPEC CHECKSUMS: ReactCommon: 45b5d4f784e869c44a6f5a8fad5b114ca8f78c53 RNAppleAuthentication: 0571c08da8c327ae2afc0261b48b4a515b0286a6 RNCAsyncStorage: 618d03a5f52fbccb3d7010076bc54712844c18ef - RNCClipboard: d77213bfa269013bf4b857b7a9ca37ee062d8ef1 + RNCClipboard: 60fed4b71560d7bfe40e9d35dea9762b024da86d RNCPicker: 529d564911e93598cc399b56cc0769ce3675f8c8 RNDeviceInfo: 4701f0bf2a06b34654745053db0ce4cb0c53ada7 RNDevMenu: 72807568fe4188bd4c40ce32675d82434b43c45d @@ -1973,6 +1981,7 @@ SPEC CHECKSUMS: RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReanimated: 57f436e7aa3d277fbfed05e003230b43428157c0 RNScreens: b582cb834dc4133307562e930e8fa914b8c04ef2 + RNSound: 6c156f925295bdc83e8e422e7d8b38d33bc71852 RNSVG: 255767813dac22db1ec2062c8b7e7b856d4e5ae6 SDWebImage: 750adf017a315a280c60fde706ab1e552a3ae4e9 SDWebImageAVIFCoder: 8348fef6d0ec69e129c66c9fe4d74fbfbf366112 diff --git a/jest.config.js b/jest.config.js index b347db593d83..95ecc350ed9f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,6 @@ module.exports = { setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], cacheDirectory: '/.jest-cache', moduleNameMapper: { - '\\.(lottie)$': '/__mocks__/fileMock.js', + '\\.(lottie)$': '/__mocks__/fileMock.ts', }, }; diff --git a/jest/setup.ts b/jest/setup.ts index 68d904fac5be..55774ff136f1 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -38,3 +38,11 @@ jest.mock('react-native-fs', () => ({ unlink: jest.fn(() => new Promise((res) => res())), CachesDirectoryPath: jest.fn(), })); + +jest.mock('react-native-sound', () => { + class SoundMock { + play = jest.fn(); + } + + return SoundMock; +}); diff --git a/package-lock.json b/package-lock.json index 1cc630ce5dac..d499a59df097 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -27,7 +27,7 @@ "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "5.4.0", - "@react-native-clipboard/clipboard": "^1.12.1", + "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "^3.0.6", "@react-native-community/netinfo": "11.2.1", "@react-native-firebase/analytics": "^12.3.0", @@ -52,7 +52,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4a61536649cbfe49236a35bc7542b5dfd0767e4a", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", @@ -82,7 +82,7 @@ "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", - "react-native-document-picker": "^8.2.1", + "react-native-document-picker": "^9.1.1", "react-native-draggable-flatlist": "^4.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.14.1", @@ -101,7 +101,7 @@ "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -109,13 +109,15 @@ "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.7.4", "react-native-screens": "3.29.0", - "react-native-svg": "14.0.0", + "react-native-sound": "^0.11.2", + "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "2.16.8", "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", + "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.3", "react-pdf": "7.3.3", "react-plaid-link": "3.3.2", @@ -8736,9 +8738,9 @@ } }, "node_modules/@react-native-clipboard/clipboard": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.12.1.tgz", - "integrity": "sha512-+PNk8kflpGte0W1Nz61/Dp8gHTxyuRjkVyRYBawymSIGTDHCC/zOJSbig6kGIkD8MeaGHC2vGYQJyUyCrgVPBQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@react-native-clipboard/clipboard/-/clipboard-1.13.2.tgz", + "integrity": "sha512-uVM55oEGc6a6ZmSATDeTcMm55A/C1km5X47g0xaoF0Zagv7N/8RGvLceA5L/izPwflIy78t7XQeJUcnGSib0nA==", "peerDependencies": { "react": ">=16.0", "react-native": ">=0.57.0" @@ -31234,8 +31236,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#4a61536649cbfe49236a35bc7542b5dfd0767e4a", - "integrity": "sha512-UOy3btYvKRZ1kS4etLPw6Lgfqx+yiM3GMd340K06YLasn24alKgMOmg2dqSRTApF7RltS2FjOXRddAhzgvJZ3w==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", + "integrity": "sha512-f5zeUthFwmWFiGO1as+ARuYv7TbXVktnKAjoFKhaVVSTZvgbojSnzGClCCh4uwbdl9HDRv7aiXfRN1nzuIFOTg==", "license": "MIT", "dependencies": { "classnames": "2.4.0", @@ -33754,6 +33756,11 @@ "node": ">=10" } }, + "node_modules/howler": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", + "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==" + }, "node_modules/hpack.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", @@ -44903,9 +44910,9 @@ } }, "node_modules/react-native-document-picker": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-8.2.1.tgz", - "integrity": "sha512-luH2hKdq4cUwE651OscyGderLMsCusOsBzw4MBca91CgprlAGVMm1/pDwJDV5t9LIewVK8DIgXGXzgrsusKVhA==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/react-native-document-picker/-/react-native-document-picker-9.1.1.tgz", + "integrity": "sha512-BW+7DbsILuFThlBm7NUFVUmKKf6Awkcf9R0q8wiCU2DlGGtAKQTt2iHpO5+Dn/7WMPB+rqNv3X1HsmJQ0t5R3g==", "dependencies": { "invariant": "^2.2.4" }, @@ -45202,8 +45209,8 @@ }, "node_modules/react-native-picker-select": { "version": "8.1.0", - "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", - "integrity": "sha512-e8TAWVR4AEw2PFGFxlevCBFr1RwvwTqq1M2w9Yi6xNz+d4SbG6tDIcJDNIqt0gyBqvxlL7BuK0G5BjbiZDLKsg==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", + "integrity": "sha512-iASqj8cXSQY+P3ZhfW1eoVcK0UB+TRTddrNSQ3lmIH0a4lYO3m4XJC+cnoCjjPl/sTzUaShpOnpBfqMueR6UMA==", "license": "MIT", "dependencies": { "lodash.isequal": "^4.5.0" @@ -45343,10 +45350,18 @@ "react-native": "*" } }, + "node_modules/react-native-sound": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/react-native-sound/-/react-native-sound-0.11.2.tgz", + "integrity": "sha512-LmGc8lgOK3qecYMVQpyHvww/C+wgT6sWeMpVbOe4NCRGC2yKd4fo4U0KBUo9PO7AqKESO3I/2GZg1/C0+bwiiA==", + "peerDependencies": { + "react-native": ">=0.8.0" + } + }, "node_modules/react-native-svg": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-14.0.0.tgz", - "integrity": "sha512-17W/gWXRUMS7p7PSHu/WyGkZUc1NzRTGxxXc0VqBLjzKSchyb0EmgsiWf9aKmfC6gmY0wcsmKZcGV41bCcNfBA==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-14.1.0.tgz", + "integrity": "sha512-HeseElmEk+AXGwFZl3h56s0LtYD9HyGdrpg8yd9QM26X+d7kjETrRQ9vCjtxuT5dCZEIQ5uggU1dQhzasnsCWA==", "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3" @@ -45428,6 +45443,17 @@ "react-native-web": "*" } }, + "node_modules/react-native-web-sound": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/react-native-web-sound/-/react-native-web-sound-0.1.3.tgz", + "integrity": "sha512-aUWNZuRFM7aREePiDgsTaBaaKX+aHKF0cDXtfOwFn4fQXkN+w5Ny3HykFrbDMxZagXSav5QjPntcA51lpWnSgg==", + "dependencies": { + "howler": "^2.2.1" + }, + "peerDependencies": { + "react-native-web": "*" + } + }, "node_modules/react-native-web/node_modules/memoize-one": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", diff --git a/package.json b/package.json index 5870241897b9..7157b28faf4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.39-7", + "version": "1.4.40-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -75,7 +75,7 @@ "@onfido/react-native-sdk": "8.3.0", "@react-native-async-storage/async-storage": "1.21.0", "@react-native-camera-roll/camera-roll": "5.4.0", - "@react-native-clipboard/clipboard": "^1.12.1", + "@react-native-clipboard/clipboard": "^1.13.2", "@react-native-community/geolocation": "^3.0.6", "@react-native-community/netinfo": "11.2.1", "@react-native-firebase/analytics": "^12.3.0", @@ -100,7 +100,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#4a61536649cbfe49236a35bc7542b5dfd0767e4a", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#7aa1d42600d2f59565c1ff962f691b494ccc2813", "expo": "^50.0.3", "expo-image": "1.10.1", "fbjs": "^3.0.2", @@ -130,7 +130,7 @@ "react-native-config": "^1.4.5", "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", - "react-native-document-picker": "^8.2.1", + "react-native-document-picker": "^9.1.1", "react-native-draggable-flatlist": "^4.0.1", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "2.14.1", @@ -149,7 +149,7 @@ "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#42b334d0c4e71d225601f72828d3dedd0bc22212", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#da50d2c5c54e268499047f9cc98b8df4196c1ddf", "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", @@ -157,13 +157,15 @@ "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.7.4", "react-native-screens": "3.29.0", - "react-native-svg": "14.0.0", + "react-native-sound": "^0.11.2", + "react-native-svg": "14.1.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", "react-native-view-shot": "3.8.0", "react-native-vision-camera": "2.16.8", "react-native-web": "^0.19.9", "react-native-web-linear-gradient": "^1.1.2", + "react-native-web-sound": "^0.1.3", "react-native-webview": "13.6.3", "react-pdf": "7.3.3", "react-plaid-link": "3.3.2", diff --git a/patches/react-native-sound+0.11.2.patch b/patches/react-native-sound+0.11.2.patch new file mode 100644 index 000000000000..661e39263c43 --- /dev/null +++ b/patches/react-native-sound+0.11.2.patch @@ -0,0 +1,38 @@ +diff --git a/node_modules/react-native-sound/RNSound/RNSound.h b/node_modules/react-native-sound/RNSound/RNSound.h +index 7f5b97b..1a3c840 100644 +--- a/node_modules/react-native-sound/RNSound/RNSound.h ++++ b/node_modules/react-native-sound/RNSound/RNSound.h +@@ -1,17 +1,7 @@ +-#if __has_include() + #import +-#else +-#import "RCTBridgeModule.h" +-#endif +- + #import +- +-#if __has_include() + #import +-#else +-#import "RCTEventEmitter.h" +-#endif + + @interface RNSound : RCTEventEmitter +-@property (nonatomic, weak) NSNumber *_key; ++@property(nonatomic, weak) NSNumber *_key; + @end +diff --git a/node_modules/react-native-sound/RNSound/RNSound.m b/node_modules/react-native-sound/RNSound/RNSound.m +index df3784e..d34ac01 100644 +--- a/node_modules/react-native-sound/RNSound/RNSound.m ++++ b/node_modules/react-native-sound/RNSound/RNSound.m +@@ -1,10 +1,6 @@ + #import "RNSound.h" + +-#if __has_include("RCTUtils.h") +-#import "RCTUtils.h" +-#else + #import +-#endif + + @implementation RNSound { + NSMutableDictionary *_playerPool; diff --git a/patches/react-native-web+0.19.9+003+fix-pointer-events.patch b/patches/react-native-web+0.19.9+003+fix-pointer-events.patch deleted file mode 100644 index a457fbcfe36c..000000000000 --- a/patches/react-native-web+0.19.9+003+fix-pointer-events.patch +++ /dev/null @@ -1,22 +0,0 @@ -diff --git a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -index bdcecc2..63f1364 100644 ---- a/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -+++ b/node_modules/react-native-web/dist/exports/StyleSheet/compiler/index.js -@@ -353,7 +353,7 @@ function createAtomicRules(identifier, property, value) { - var _block2 = createDeclarationBlock({ - pointerEvents: 'none' - }); -- rules.push(selector + ">*" + _block2); -+ rules.push(selector + " *" + _block2); - } - } else if (value === 'none' || value === 'box-none') { - finalValue = 'none!important'; -@@ -361,7 +361,7 @@ function createAtomicRules(identifier, property, value) { - var _block3 = createDeclarationBlock({ - pointerEvents: 'auto' - }); -- rules.push(selector + ">*" + _block3); -+ rules.push(selector + " *" + _block3); - } - } - var _block4 = createDeclarationBlock({ diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b46d3db8b60d..4a9c809485e5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -63,6 +63,14 @@ const ONYXKEYS = { /** Contains all the info for Tasks */ TASK: 'task', + /** + * Contains all the info for Workspace Rate and Unit while editing. + * + * Note: This is not under the COLLECTION key as we can edit rate and unit + * for one workspace only at a time. And we don't need to store + * rates and units for different workspaces at the same time. */ + WORKSPACE_RATE_AND_UNIT: 'workspaceRateAndUnit', + /** Contains a list of all currencies available to the user - user can * select a currency based on the list */ CURRENCY_LIST: 'currencyList', @@ -393,6 +401,7 @@ type OnyxValues = { [ONYXKEYS.PERSONAL_DETAILS_LIST]: OnyxTypes.PersonalDetailsList; [ONYXKEYS.PRIVATE_PERSONAL_DETAILS]: OnyxTypes.PrivatePersonalDetails; [ONYXKEYS.TASK]: OnyxTypes.Task; + [ONYXKEYS.WORKSPACE_RATE_AND_UNIT]: OnyxTypes.WorkspaceRateAndUnit; [ONYXKEYS.CURRENCY_LIST]: Record; [ONYXKEYS.UPDATE_AVAILABLE]: boolean; [ONYXKEYS.SCREEN_SHARE_REQUEST]: OnyxTypes.ScreenShareRequest; @@ -450,6 +459,7 @@ type OnyxValues = { [ONYXKEYS.MAX_CANVAS_AREA]: number; [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; + [ONYXKEYS.IS_SEARCHING_FOR_REPORTS]: boolean; [ONYXKEYS.LAST_VISITED_PATH]: string | undefined; [ONYXKEYS.RECENTLY_USED_REPORT_FIELDS]: OnyxTypes.RecentlyUsedReportFields; [ONYXKEYS.UPDATE_REQUIRED]: boolean; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5a2ab8cfc7de..082cea49d771 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -272,10 +272,6 @@ const ROUTES = { route: ':iouType/new/confirmation/:reportID?', getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}` as const, }, - MONEY_REQUEST_DATE: { - route: ':iouType/new/date/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}` as const, - }, MONEY_REQUEST_CURRENCY: { route: ':iouType/new/currency/:reportID?', getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, @@ -337,9 +333,9 @@ const ROUTES = { getUrlWithBackToParam(`create/${iouType}/currency/${transactionID}/${reportID}/${pageIndex}`, backTo), }, MONEY_REQUEST_STEP_DATE: { - route: 'create/:iouType/date/:transactionID/:reportID', - getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => - getUrlWithBackToParam(`create/${iouType}/date/${transactionID}/${reportID}`, backTo), + route: ':action/:iouType/date/:transactionID/:reportID', + getRoute: (action: ValueOf, iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`${action}/${iouType}/date/${transactionID}/${reportID}`, backTo), }, MONEY_REQUEST_STEP_DESCRIPTION: { route: ':action/:iouType/description/:transactionID/:reportID', @@ -468,6 +464,14 @@ const ROUTES = { route: 'workspace/:policyID/rateandunit', getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const, }, + WORKSPACE_RATE_AND_UNIT_RATE: { + route: 'workspace/:policyID/rateandunit/rate', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/rate` as const, + }, + WORKSPACE_RATE_AND_UNIT_UNIT: { + route: 'workspace/:policyID/rateandunit/unit', + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit/unit` as const, + }, WORKSPACE_BILLS: { route: 'workspace/:policyID/bills', getRoute: (policyID: string) => `workspace/${policyID}/bills` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 1d7c77bf129c..1626fdbd1898 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -146,7 +146,6 @@ const SCREENS = { PARTICIPANTS: 'Money_Request_Participants', CONFIRMATION: 'Money_Request_Confirmation', CURRENCY: 'Money_Request_Currency', - DATE: 'Money_Request_Date', CATEGORY: 'Money_Request_Category', MERCHANT: 'Money_Request_Merchant', WAYPOINT: 'Money_Request_Waypoint', @@ -198,6 +197,8 @@ const SCREENS = { CARD: 'Workspace_Card', REIMBURSE: 'Workspace_Reimburse', RATE_AND_UNIT: 'Workspace_RateAndUnit', + RATE_AND_UNIT_RATE: 'Workspace_RateAndUnit_Rate', + RATE_AND_UNIT_UNIT: 'Workspace_RateAndUnit_Unit', BILLS: 'Workspace_Bills', INVOICES: 'Workspace_Invoices', TRAVEL: 'Workspace_Travel', diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx new file mode 100644 index 000000000000..4214d804af06 --- /dev/null +++ b/src/components/AmountForm.tsx @@ -0,0 +1,241 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import type {NativeSyntheticEvent, TextInput, TextInputSelectionChangeEventData} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Browser from '@libs/Browser'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import getOperatingSystem from '@libs/getOperatingSystem'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import CONST from '@src/CONST'; +import BigNumberPad from './BigNumberPad'; +import FormHelpMessage from './FormHelpMessage'; +import TextInputWithCurrencySymbol from './TextInputWithCurrencySymbol'; + +type AmountFormProps = { + /** Amount supplied by the FormProvider */ + value?: string; + + /** Currency supplied by user */ + currency?: string; + + /** Tells how many extra decimal digits are allowed. Default is 0. */ + extraDecimals?: number; + + /** Error to display at the bottom of the component */ + errorText?: string; + + /** Callback to update the amount in the FormProvider */ + onInputChange?: (value: string) => void; + + /** Fired when back button pressed, navigates to currency selection page */ + onCurrencyButtonPress?: () => void; +}; + +/** + * Returns the new selection object based on the updated amount's length + */ +const getNewSelection = (oldSelection: {start: number; end: number}, prevLength: number, newLength: number) => { + const cursorPosition = oldSelection.end + (newLength - prevLength); + return {start: cursorPosition, end: cursorPosition}; +}; + +const AMOUNT_VIEW_ID = 'amountView'; +const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView'; +const NUM_PAD_VIEW_ID = 'numPadView'; + +function AmountForm( + {value: amount, currency = CONST.CURRENCY.USD, extraDecimals = 0, errorText, onInputChange, onCurrencyButtonPress}: AmountFormProps, + forwardedRef: ForwardedRef, +) { + const styles = useThemeStyles(); + const {toLocaleDigit, numberFormat} = useLocalize(); + + const textInput = useRef(null); + + const decimals = CurrencyUtils.getCurrencyDecimals(currency) + extraDecimals; + const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); + + const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); + + const [selection, setSelection] = useState({ + start: currentAmount.length, + end: currentAmount.length, + }); + + const forwardDeletePressedRef = useRef(false); + + /** + * Event occurs when a user presses a mouse button over an DOM element. + */ + const focusTextInput = (event: React.MouseEvent, ids: string[]) => { + const relatedTargetId = (event.nativeEvent?.target as HTMLElement | null)?.id ?? ''; + if (!ids.includes(relatedTargetId)) { + return; + } + event.preventDefault(); + if (!textInput.current) { + return; + } + if (!textInput.current.isFocused()) { + textInput.current.focus(); + } + }; + + /** + * Sets the selection and the amount accordingly to the value passed to the input + * @param newAmount - Changed amount from user input + */ + const setNewAmount = useCallback( + (newAmount: string) => { + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value + // More info: https://github.com/Expensify/App/issues/16974 + const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); + // Use a shallow copy of selection to trigger setSelection + // More info: https://github.com/Expensify/App/issues/16385 + if (!MoneyRequestUtils.validateAmount(newAmountWithoutSpaces, decimals)) { + setSelection((prevSelection) => ({...prevSelection})); + return; + } + + const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces); + const isForwardDelete = currentAmount.length > strippedAmount.length && forwardDeletePressedRef.current; + setSelection((prevSelection) => getNewSelection(prevSelection, isForwardDelete ? strippedAmount.length : currentAmount.length, strippedAmount.length)); + onInputChange?.(strippedAmount); + }, + [currentAmount, decimals, onInputChange], + ); + + // Modifies the amount to match the decimals for changed currency. + useEffect(() => { + // If the changed currency supports decimals, we can return + if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) { + return; + } + + // If the changed currency doesn't support decimals, we can strip the decimals + setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); + + // we want to update only when decimals change (setNewAmount also changes when decimals change). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [decimals]); + + /** + * Update amount with number or Backspace pressed for BigNumberPad. + * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button + */ + const updateAmountNumberPad = useCallback( + (key: string) => { + if (shouldUpdateSelection && !textInput.current?.isFocused()) { + textInput.current?.focus(); + } + // Backspace button is pressed + if (key === '<' || key === 'Backspace') { + if (currentAmount.length > 0) { + const selectionStart = selection.start === selection.end ? selection.start - 1 : selection.start; + const newAmount = `${currentAmount.substring(0, selectionStart)}${currentAmount.substring(selection.end)}`; + setNewAmount(MoneyRequestUtils.addLeadingZero(newAmount)); + } + return; + } + const newAmount = MoneyRequestUtils.addLeadingZero(`${currentAmount.substring(0, selection.start)}${key}${currentAmount.substring(selection.end)}`); + setNewAmount(newAmount); + }, + [currentAmount, selection, shouldUpdateSelection, setNewAmount], + ); + + /** + * Update long press value, to remove items pressing on < + * + * @param value - Changed text from user input + */ + const updateLongPressHandlerState = useCallback((value: boolean) => { + setShouldUpdateSelection(!value); + if (!value && !textInput.current?.isFocused()) { + textInput.current?.focus(); + } + }, []); + + /** + * Input handler to check for a forward-delete key (or keyboard shortcut) press. + */ + const textInputKeyPress = (event: NativeSyntheticEvent) => { + const key = event.nativeEvent.key.toLowerCase(); + if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { + // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being + // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. + forwardDeletePressedRef.current = true; + return; + } + // Control-D on Mac is a keyboard shortcut for forward-delete. See https://support.apple.com/en-us/HT201236 for Mac keyboard shortcuts. + // Also check for the keyboard shortcut on iOS in cases where a hardware keyboard may be connected to the device. + const operatingSystem = getOperatingSystem() as string | null; + const allowedOS: string[] = [CONST.OS.MAC_OS, CONST.OS.IOS]; + forwardDeletePressedRef.current = key === 'delete' || (allowedOS.includes(operatingSystem ?? '') && event.nativeEvent.ctrlKey && key === 'd'); + }; + + const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); + + return ( + <> + focusTextInput(event, [AMOUNT_VIEW_ID])} + style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} + > + { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef && 'current' in forwardedRef) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + selectedCurrencyCode={currency} + selection={selection} + onSelectionChange={(e: NativeSyntheticEvent) => { + if (!shouldUpdateSelection) { + return; + } + setSelection(e.nativeEvent.selection); + }} + onKeyPress={textInputKeyPress} + /> + {!!errorText && ( + + )} + + {canUseTouchScreen ? ( + focusTextInput(event, [NUM_PAD_CONTAINER_VIEW_ID, NUM_PAD_VIEW_ID])} + style={[styles.w100, styles.justifyContentEnd, styles.pageWrapper, styles.pt0]} + id={NUM_PAD_CONTAINER_VIEW_ID} + > + + + ) : null} + + ); +} + +AmountForm.displayName = 'AmountForm'; + +export default forwardRef(AmountForm); diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx index 80f7794d0ad3..de43bf548a98 100644 --- a/src/components/BigNumberPad.tsx +++ b/src/components/BigNumberPad.tsx @@ -17,7 +17,7 @@ type BigNumberPadProps = { id?: string; /** Whether long press is disabled */ - isLongPressDisabled: boolean; + isLongPressDisabled?: boolean; }; const padNumbers = [ diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index f336fe659074..b08d2106ff93 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -109,6 +109,9 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected, activeEmoji}) { initialFocusedIndex: -1, disableCyclicTraversal: true, onFocusedIndexChange, + disableHorizontalKeys: isFocused, + // We pass true without checking visibility of the component because if the popover is not visible this picker won't be mounted + isActive: true, }); const filterEmojis = _.throttle((searchTerm) => { diff --git a/src/components/FeatureList.js b/src/components/FeatureList.tsx similarity index 66% rename from src/components/FeatureList.js rename to src/components/FeatureList.tsx index ba82e26e5a0c..53f42956d834 100644 --- a/src/components/FeatureList.js +++ b/src/components/FeatureList.tsx @@ -1,60 +1,61 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import stylePropTypes from '@styles/stylePropTypes'; import variables from '@styles/variables'; +import type {TranslationPaths} from '@src/languages/types'; +import type IconAsset from '@src/types/utils/IconAsset'; import Button from './Button'; +import type DotLottieAnimation from './LottieAnimations/types'; import MenuItem from './MenuItem'; -import menuItemPropTypes from './menuItemPropTypes'; import Section from './Section'; -const propTypes = { +type FeatureListItem = { + icon: IconAsset; + translationKey: TranslationPaths; +}; + +type FeatureListProps = { /** The text to display in the title of the section */ - title: PropTypes.string.isRequired, + title: string; /** The text to display in the subtitle of the section */ - subtitle: PropTypes.string, + subtitle?: string; /** Text of the call to action button */ - ctaText: PropTypes.string, + ctaText?: string; /** Accessibility label for the call to action button */ - ctaAccessibilityLabel: PropTypes.string, + ctaAccessibilityLabel?: string; /** Action to call on cta button press */ - onCtaPress: PropTypes.func, + onCtaPress?: () => void; /** A list of menuItems representing the feature list. */ - menuItems: PropTypes.arrayOf(PropTypes.shape({...menuItemPropTypes, translationKey: PropTypes.string})).isRequired, + menuItems: FeatureListItem[]; /** The illustration to display in the header. Can be a JSON object representing a Lottie animation. */ - illustration: PropTypes.shape({ - file: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - w: PropTypes.number.isRequired, - h: PropTypes.number.isRequired, - }), + illustration: DotLottieAnimation; /** The style passed to the illustration */ - illustrationStyle: stylePropTypes, + illustrationStyle?: StyleProp; /** The background color to apply in the upper half of the screen. */ - illustrationBackgroundColor: PropTypes.string, -}; - -const defaultProps = { - ctaText: '', - ctaAccessibilityLabel: '', - subtitle: '', - onCtaPress: () => {}, - illustration: null, - illustrationBackgroundColor: null, - illustrationStyle: [], + illustrationBackgroundColor?: string; }; -function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPress, menuItems, illustration, illustrationStyle, illustrationBackgroundColor}) { +function FeatureList({ + title, + subtitle = '', + ctaText = '', + ctaAccessibilityLabel = '', + onCtaPress, + menuItems, + illustration, + illustrationStyle, + illustrationBackgroundColor = '', +}: FeatureListProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -70,7 +71,7 @@ function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPres > - {_.map(menuItems, ({translationKey, icon}) => ( + {menuItems.map(({translationKey, icon}) => ( @@ -101,8 +102,7 @@ function FeatureList({title, subtitle, ctaText, ctaAccessibilityLabel, onCtaPres ); } -FeatureList.propTypes = propTypes; -FeatureList.defaultProps = defaultProps; FeatureList.displayName = 'FeatureList'; export default FeatureList; +export type {FeatureListItem}; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index ba0f823fdbad..82a8d0870946 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -1,7 +1,7 @@ import lodashIsEqual from 'lodash/isEqual'; import type {ForwardedRef, MutableRefObject, ReactNode} from 'react'; import React, {createRef, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react'; -import type {NativeSyntheticEvent, TextInputSubmitEditingEventData} from 'react-native'; +import type {NativeSyntheticEvent, StyleProp, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import * as ValidationUtils from '@libs/ValidationUtils'; @@ -62,6 +62,12 @@ type FormProviderProps = FormProvider /** Should validate function be called when the value of the input is changed */ shouldValidateOnChange?: boolean; + + /** Styles that will be applied to the submit button only */ + submitButtonStyles?: StyleProp; + + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; }; type FormRef = { diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 074069ec3ea7..e8778cf25d58 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -28,6 +28,9 @@ type FormWrapperProps = ChildrenProps & /** Submit button styles */ submitButtonStyles?: StyleProp; + /** Whether to apply flex to the submit button */ + submitFlexEnabled?: boolean; + /** Server side errors keyed by microtime */ errors: Errors; @@ -49,6 +52,7 @@ function FormWrapper({ isSubmitButtonVisible = true, style, submitButtonStyles, + submitFlexEnabled = true, enabledWhenOffline, isSubmitActionDangerous = false, formID, @@ -109,7 +113,7 @@ function FormWrapper({ onSubmit={onSubmit} footerContent={footerContent} onFixTheErrorsLinkPressed={onFixTheErrorsLinkPressed} - containerStyles={[styles.mh0, styles.mt5, styles.flex1, submitButtonStyles]} + containerStyles={[styles.mh0, styles.mt5, submitFlexEnabled ? styles.flex1 : {}, submitButtonStyles]} enabledWhenOffline={enabledWhenOffline} isSubmitActionDangerous={isSubmitActionDangerous} disablePressOnEnter={disablePressOnEnter} @@ -134,6 +138,7 @@ function FormWrapper({ styles.mh0, styles.mt5, submitButtonStyles, + submitFlexEnabled, submitButtonText, shouldHideFixErrorsAlert, onFixTheErrorsLinkPressed, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 353a6927caf7..5ade522e6d7f 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -1,6 +1,7 @@ import type {ComponentProps, FocusEvent, Key, MutableRefObject, ReactNode, Ref} from 'react'; import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, TextInputFocusEventData, TextInputSubmitEditingEventData, ViewStyle} from 'react-native'; import type AddressSearch from '@components/AddressSearch'; +import type AmountForm from '@components/AmountForm'; import type AmountTextInput from '@components/AmountTextInput'; import type CheckboxWithLabel from '@components/CheckboxWithLabel'; import type Picker from '@components/Picker'; @@ -17,7 +18,7 @@ import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; * TODO: Add remaining inputs here once these components are migrated to Typescript: * CountrySelector | StatePicker | DatePicker | EmojiPickerButtonDropdown | RoomNameInput | ValuePicker */ -type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch; +type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch | typeof AmountForm; type ValueTypeKey = 'string' | 'boolean' | 'date'; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx index d1c11dc12ed5..788db4ea4de2 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import type {TextStyle} from 'react-native'; import {splitBoxModelStyle} from 'react-native-render-html'; import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import * as HTMLEngineUtils from '@components/HTMLEngineProvider/htmlEngineUtils'; @@ -41,7 +40,7 @@ function CodeRenderer({TDefaultRenderer, key, style, ...defaultRendererProps}: C return ( void | Promise; @@ -233,6 +233,8 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { isPaneMenu?: boolean; }; +type MenuItemProps = (IconProps | AvatarProps | NoIcon) & MenuItemBaseProps; + function MenuItem( { interactive = true, @@ -625,5 +627,5 @@ function MenuItem( MenuItem.displayName = 'MenuItem'; -export type {MenuItemProps}; +export type {IconProps, AvatarProps, NoIcon, MenuItemBaseProps, MenuItemProps}; export default forwardRef(MenuItem); diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index da9838701fa5..7608447a213e 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -703,11 +703,15 @@ function MoneyRequestConfirmationList(props) { style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - if (props.isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_DATE.getRoute(props.iouType, props.reportID)); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( + CONST.IOU.ACTION.EDIT, + props.iouType, + transaction.transactionID, + props.reportID, + Navigation.getActiveRouteWithoutParams(), + ), + ); }} disabled={didConfirm} interactive={!props.isReadOnly} diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index a2f79d2696b8..418ba97a70b9 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -21,6 +21,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -549,6 +550,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ return; } + playSound(SOUNDS.DONE); setDidConfirm(true); onConfirm(selectedParticipants); } @@ -790,11 +792,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => { - if (isEditingSplitBill) { - Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(reportID, reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); - return; - } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams())); + Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.CREATE, iouType, transaction.transactionID, reportID, Navigation.getActiveRouteWithoutParams()), + ); }} disabled={didConfirm} interactive={!isReadOnly} diff --git a/src/components/PDFView/index.native.js b/src/components/PDFView/index.native.js index bfdb80131aa6..3802fe7a2ea6 100644 --- a/src/components/PDFView/index.native.js +++ b/src/components/PDFView/index.native.js @@ -1,25 +1,21 @@ -import React, {Component} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import PDF from 'react-native-pdf'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import KeyboardAvoidingView from '@components/KeyboardAvoidingView'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Text from '@components/Text'; -import withKeyboardState, {keyboardStatePropTypes} from '@components/withKeyboardState'; -import withLocalize from '@components/withLocalize'; -import withStyleUtils, {withStyleUtilsPropTypes} from '@components/withStyleUtils'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import withWindowDimensions from '@components/withWindowDimensions'; -import compose from '@libs/compose'; +import useKeyboardState from '@hooks/useKeyboardState'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import CONST from '@src/CONST'; import PDFPasswordForm from './PDFPasswordForm'; import {defaultProps, propTypes as pdfViewPropTypes} from './pdfViewPropTypes'; const propTypes = { ...pdfViewPropTypes, - ...keyboardStatePropTypes, - ...withThemeStylesPropTypes, - ...withStyleUtilsPropTypes, }; /** @@ -36,41 +32,24 @@ const propTypes = { * so that PDFPasswordForm doesn't bounce when react-native-pdf/PDF * is (temporarily) rendered. */ -class PDFView extends Component { - constructor(props) { - super(props); - this.state = { - shouldRequestPassword: false, - shouldAttemptPDFLoad: true, - shouldShowLoadingIndicator: true, - isPasswordInvalid: false, - failedToLoadPDF: false, - successToLoadPDF: false, - password: '', - }; - this.initiatePasswordChallenge = this.initiatePasswordChallenge.bind(this); - this.attemptPDFLoadWithPassword = this.attemptPDFLoadWithPassword.bind(this); - this.finishPDFLoad = this.finishPDFLoad.bind(this); - this.handleFailureToLoadPDF = this.handleFailureToLoadPDF.bind(this); - } - - componentDidUpdate() { - this.props.onToggleKeyboard(this.props.isKeyboardShown); - } - - handleFailureToLoadPDF(error) { - if (error.message.match(/password/i)) { - this.initiatePasswordChallenge(); - return; - } - this.setState({ - failedToLoadPDF: true, - shouldAttemptPDFLoad: false, - shouldRequestPassword: false, - shouldShowLoadingIndicator: false, - }); - } +function PDFView({onToggleKeyboard, onLoadComplete, fileName, onPress, isFocused, onScaleChanged, sourceURL, errorLabelStyles}) { + const [shouldRequestPassword, setShouldRequestPassword] = useState(false); + const [shouldAttemptPDFLoad, setShouldAttemptPDFLoad] = useState(true); + const [shouldShowLoadingIndicator, setShouldShowLoadingIndicator] = useState(true); + const [isPasswordInvalid, setIsPasswordInvalid] = useState(false); + const [failedToLoadPDF, setFailedToLoadPDF] = useState(false); + const [successToLoadPDF, setSuccessToLoadPDF] = useState(false); + const [password, setPassword] = useState(''); + const {windowWidth, windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const themeStyles = useThemeStyles(); + const {isKeyboardShown} = useKeyboardState(); + const StyleUtils = useStyleUtils(); + + useEffect(() => { + onToggleKeyboard(isKeyboardShown); + }); /** * Initiate password challenge if message received from react-native-pdf/PDF @@ -80,100 +59,101 @@ class PDFView extends Component { * Note that the message doesn't specify whether the password is simply empty or * invalid. */ - initiatePasswordChallenge() { - this.setState({shouldShowLoadingIndicator: false}); + + const initiatePasswordChallenge = useCallback(() => { + setShouldShowLoadingIndicator(false); // Render password form, and don't render PDF and loading indicator. - this.setState({ - shouldRequestPassword: true, - shouldAttemptPDFLoad: false, - }); + setShouldRequestPassword(true); + setShouldAttemptPDFLoad(false); // The message provided by react-native-pdf doesn't indicate whether this // is an initial password request or if the password is invalid. So we just assume // that if a password was already entered then it's an invalid password error. - if (this.state.password) { - this.setState({isPasswordInvalid: true}); + if (password) { + setIsPasswordInvalid(true); } - } + }, [password]); + + const handleFailureToLoadPDF = (error) => { + if (error.message.match(/password/i)) { + initiatePasswordChallenge(); + return; + } + setFailedToLoadPDF(true); + setShouldShowLoadingIndicator(false); + setShouldRequestPassword(false); + setShouldAttemptPDFLoad(false); + }; /** * When the password is submitted via PDFPasswordForm, save the password * in state and attempt to load the PDF. Also show the loading indicator * since react-native-pdf/PDF will need to reload the PDF. * - * @param {String} password Password submitted via PDFPasswordForm + * @param {String} pdfPassword Password submitted via PDFPasswordForm */ - attemptPDFLoadWithPassword(password) { + const attemptPDFLoadWithPassword = (pdfPassword) => { // Render react-native-pdf/PDF so that it can validate the password. // Note that at this point in the password challenge, shouldRequestPassword is true. // Thus react-native-pdf/PDF will be rendered - but not visible. - this.setState({ - password, - shouldAttemptPDFLoad: true, - shouldShowLoadingIndicator: true, - }); - } - + setPassword(pdfPassword); + setShouldAttemptPDFLoad(true); + setShouldShowLoadingIndicator(true); + }; /** * After the PDF is successfully loaded hide PDFPasswordForm and the loading * indicator. */ - finishPDFLoad() { - this.setState({ - shouldRequestPassword: false, - shouldShowLoadingIndicator: false, - successToLoadPDF: true, - }); - this.props.onLoadComplete(); - } + const finishPDFLoad = () => { + setShouldRequestPassword(false); + setShouldShowLoadingIndicator(false); + setSuccessToLoadPDF(true); + onLoadComplete(); + }; - renderPDFView() { - const pdfStyles = [this.props.themeStyles.imageModalPDF, this.props.StyleUtils.getWidthAndHeightStyle(this.props.windowWidth, this.props.windowHeight)]; + function renderPDFView() { + const pdfStyles = [themeStyles.imageModalPDF, StyleUtils.getWidthAndHeightStyle(windowWidth, windowHeight)]; // If we haven't yet successfully validated the password and loaded the PDF, // then we need to hide the react-native-pdf/PDF component so that PDFPasswordForm // is positioned nicely. We're specifically hiding it because we still need to render // the PDF component so that it can validate the password. - if (this.state.shouldRequestPassword) { - pdfStyles.push(this.props.themeStyles.invisible); + if (shouldRequestPassword) { + pdfStyles.push(themeStyles.invisible); } - const containerStyles = - this.state.shouldRequestPassword && this.props.isSmallScreenWidth - ? [this.props.themeStyles.w100, this.props.themeStyles.flex1] - : [this.props.themeStyles.alignItemsCenter, this.props.themeStyles.flex1]; + const containerStyles = shouldRequestPassword && isSmallScreenWidth ? [themeStyles.w100, themeStyles.flex1] : [themeStyles.alignItemsCenter, themeStyles.flex1]; return ( - {this.state.failedToLoadPDF && ( - - {this.props.translate('attachmentView.failedToLoadPDF')} + {failedToLoadPDF && ( + + {translate('attachmentView.failedToLoadPDF')} )} - {this.state.shouldAttemptPDFLoad && ( + {shouldAttemptPDFLoad && ( } - source={{uri: this.props.sourceURL}} + source={{uri: sourceURL}} style={pdfStyles} - onError={this.handleFailureToLoadPDF} - password={this.state.password} - onLoadComplete={this.finishPDFLoad} - onPageSingleTap={this.props.onPress} - onScaleChanged={this.props.onScaleChanged} + onError={handleFailureToLoadPDF} + password={password} + onLoadComplete={finishPDFLoad} + onPageSingleTap={onPress} + onScaleChanged={onScaleChanged} /> )} - - {this.state.shouldRequestPassword && ( - + {shouldRequestPassword && ( + this.setState({isPasswordInvalid: false})} - isPasswordInvalid={this.state.isPasswordInvalid} - shouldShowLoadingIndicator={this.state.shouldShowLoadingIndicator} + isFocused={isFocused} + onSubmit={attemptPDFLoadWithPassword} + onPasswordUpdated={() => setIsPasswordInvalid(false)} + isPasswordInvalid={isPasswordInvalid} + shouldShowLoadingIndicator={shouldShowLoadingIndicator} /> )} @@ -181,23 +161,22 @@ class PDFView extends Component { ); } - render() { - return this.props.onPress && !this.state.successToLoadPDF ? ( - - {this.renderPDFView()} - - ) : ( - this.renderPDFView() - ); - } + return onPress && !successToLoadPDF ? ( + + {renderPDFView()} + + ) : ( + renderPDFView() + ); } +PDFView.displayName = 'PDFView'; PDFView.propTypes = propTypes; PDFView.defaultProps = defaultProps; -export default compose(withWindowDimensions, withKeyboardState, withLocalize, withThemeStyles, withStyleUtils)(PDFView); +export default PDFView; diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index f0956da948c9..6b16f272e4c8 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -20,17 +20,19 @@ import type {ViolationField} from '@hooks/useViolations'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; -import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; +import ViolationsUtils from '@libs/Violations/ViolationsUtils'; +import Navigation from '@navigation/Navigation'; import AnimatedEmptyStateBackground from '@pages/home/report/AnimatedEmptyStateBackground'; import * as IOU from '@userActions/IOU'; import * as Transaction from '@userActions/Transaction'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -196,6 +198,43 @@ function MoneyRequestView({ const pendingAction = transaction?.pendingAction; const getPendingFieldAction = (fieldPath: TransactionPendingFieldsKey) => transaction?.pendingFields?.[fieldPath] ?? pendingAction; + const getErrorForField = useCallback( + (field: ViolationField) => { + // Checks applied when creating a new money request + // NOTE: receipt field can return multiple violations, so we need to handle it separately + const fieldChecks: Partial> = { + amount: { + isError: transactionAmount === 0, + translationPath: 'common.error.enterAmount', + }, + merchant: { + isError: !isSettled && !isCancelled && isPolicyExpenseChat && isEmptyMerchant, + translationPath: 'common.error.enterMerchant', + }, + date: { + isError: transactionDate === '', + translationPath: 'common.error.enterDate', + }, + }; + + const {isError, translationPath} = fieldChecks[field] ?? {}; + + // Return form errors if there are any + if (hasErrors && isError && translationPath) { + return translate(translationPath); + } + + // Return violations if there are any + if (canUseViolations && hasViolations(field)) { + const violations = getViolationsForField(field); + return ViolationsUtils.getViolationTranslation(violations[0], translate); + } + + return ''; + }, + [transactionAmount, isSettled, isCancelled, isPolicyExpenseChat, isEmptyMerchant, transactionDate, hasErrors, canUseViolations, hasViolations, translate, getViolationsForField], + ); + return ( @@ -253,10 +292,9 @@ function MoneyRequestView({ interactive={canEditAmount} shouldShowRightIcon={canEditAmount} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} - brickRoadIndicator={hasViolations('amount') || (hasErrors && transactionAmount === 0) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} + brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('amount')} /> - {canUseViolations && } - {canUseViolations && } {isDistanceRequest ? ( @@ -297,14 +335,9 @@ function MoneyRequestView({ shouldShowRightIcon={canEditMerchant} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))} - brickRoadIndicator={ - hasViolations('merchant') || (!isSettled && !isCancelled && hasErrors && isEmptyMerchant && isPolicyExpenseChat) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined - } - error={!isSettled && !isCancelled && hasErrors && isPolicyExpenseChat && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} + brickRoadIndicator={hasViolations('merchant') || (hasErrors && isEmptyMerchant && isPolicyExpenseChat) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={hasErrors && isPolicyExpenseChat && isEmptyMerchant ? translate('common.error.enterMerchant') : ''} /> - {canUseViolations && } )} @@ -314,11 +347,12 @@ function MoneyRequestView({ interactive={canEditDate} shouldShowRightIcon={canEditDate} titleStyle={styles.flex1} - onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} - brickRoadIndicator={hasViolations('date') || (hasErrors && transactionDate === '') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} - error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) + } + brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('date')} /> - {canUseViolations && } {shouldShowCategory && ( @@ -329,9 +363,9 @@ function MoneyRequestView({ shouldShowRightIcon={canEdit} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.CATEGORY))} - brickRoadIndicator={hasViolations('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('category')} /> - {canUseViolations && } )} {shouldShowTag && ( @@ -345,9 +379,9 @@ function MoneyRequestView({ onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_TAG.getRoute(CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, transaction?.transactionID ?? '', report.reportID)) } - brickRoadIndicator={hasViolations('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={getErrorForField('tag') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + error={getErrorForField('tag')} /> - {canUseViolations && } )} {isCardTransaction && ( @@ -369,13 +403,13 @@ function MoneyRequestView({ isOn={!!transactionBillable} onToggle={saveBillable} /> + {getErrorForField('billable') && ( + + )} - {hasViolations('billable') && ( - - )} )} diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index c77e244b4c92..3ac34c758c98 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -180,9 +180,8 @@ function BaseSelectionList( * Logic to run when a row is selected, either with click/press or keyboard hotkeys. * * @param item - the list item - * @param shouldUnfocusRow - flag to decide if we should unfocus all rows. True when selecting a row with click or press (not keyboard) */ - const selectRow = (item: TItem, shouldUnfocusRow = false) => { + const selectRow = (item: TItem) => { // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item if (canSelectMultiple) { if (sections.length > 1) { @@ -191,20 +190,11 @@ function BaseSelectionList( // we focus the first one after all the selected (selected items are always at the top). const selectedOptionsCount = item.isSelected ? flattenedSections.selectedOptions.length - 1 : flattenedSections.selectedOptions.length + 1; - if (!shouldUnfocusRow) { - setFocusedIndex(selectedOptionsCount); - } - if (!item.isSelected) { // If we're selecting an item, scroll to it's position at the top, so we can see it scrollToIndex(Math.max(selectedOptionsCount - 1, 0), true); } } - - if (shouldUnfocusRow) { - // Unfocus all rows when selecting row with click/press - setFocusedIndex(-1); - } } onSelectRow(item); @@ -295,7 +285,7 @@ function BaseSelectionList( isDisabled={isDisabled} showTooltip={showTooltip} canSelectMultiple={canSelectMultiple} - onSelectRow={() => selectRow(item, true)} + onSelectRow={() => selectRow(item)} onDismissError={onDismissError} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} rightHandSideComponent={rightHandSideComponent} diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 80bedc84f069..50bfcd4cc8be 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -5,6 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as BankAccounts from '@userActions/BankAccounts'; import * as IOU from '@userActions/IOU'; import * as PaymentMethods from '@userActions/PaymentMethods'; @@ -201,6 +202,7 @@ function SettlementButton({ return; } + playSound(SOUNDS.DONE); onPress(iouPaymentType); }; diff --git a/src/components/TextInput/BaseTextInput/index.native.tsx b/src/components/TextInput/BaseTextInput/index.native.tsx index c6429747bf97..9e169ab2464a 100644 --- a/src/components/TextInput/BaseTextInput/index.native.tsx +++ b/src/components/TextInput/BaseTextInput/index.native.tsx @@ -265,11 +265,12 @@ function BaseTextInput( return ( <> void]; @@ -28,6 +29,7 @@ type UseArrowKeyFocusManager = [number, (index: number) => void]; * @param [config.isActive] – Whether the component is ready and should subscribe to KeyboardShortcut * @param [config.itemsPerRow] – The number of items per row. If provided, the arrow keys will move focus horizontally as well as vertically * @param [config.disableCyclicTraversal] – Whether to disable cyclic traversal of the list. If true, the arrow keys will have no effect when the first or last item is focused + * @param [config.disableHorizontalKeys] – Whether to disable the right/left keys */ export default function useArrowKeyFocusManager({ maxIndex, @@ -41,6 +43,7 @@ export default function useArrowKeyFocusManager({ isActive, itemsPerRow, disableCyclicTraversal = false, + disableHorizontalKeys = false, }: Config): UseArrowKeyFocusManager { const allowHorizontalArrowKeys = !!itemsPerRow; const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex); @@ -52,6 +55,14 @@ export default function useArrowKeyFocusManager({ [isActive, shouldExcludeTextAreaNodes], ); + const horizontalArrowConfig = useMemo( + () => ({ + excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], + isActive: isActive && !disableHorizontalKeys, + }), + [isActive, shouldExcludeTextAreaNodes, disableHorizontalKeys], + ); + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex]); @@ -155,7 +166,7 @@ export default function useArrowKeyFocusManager({ }); }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, maxIndex]); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, horizontalArrowConfig); const arrowRightCallback = useCallback(() => { if (maxIndex < 0 || !allowHorizontalArrowKeys) { @@ -182,7 +193,7 @@ export default function useArrowKeyFocusManager({ }); }, [allowHorizontalArrowKeys, disableCyclicTraversal, disabledIndexes, maxIndex]); - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, horizontalArrowConfig); // Note: you don't need to manually manage focusedIndex in the parent. setFocusedIndex is only exposed in case you want to reset focusedIndex or focus a specific item return [focusedIndex, setFocusedIndex]; diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index 382cce2c8b09..e753218e8406 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -16,7 +16,7 @@ function useMarkdownStyle(): MarkdownStyle { color: theme.link, }, h1: { - fontSize: variables.fontSizeh1, + fontSize: variables.fontSizeLarge, }, blockquote: { borderColor: theme.border, diff --git a/src/hooks/useViolations.ts b/src/hooks/useViolations.ts index 76d48158237b..70e66ab65a08 100644 --- a/src/hooks/useViolations.ts +++ b/src/hooks/useViolations.ts @@ -49,14 +49,13 @@ type ViolationsMap = Map; function useViolations(violations: TransactionViolation[]) { const violationsByField = useMemo((): ViolationsMap => { + const filteredViolations = violations.filter((violation) => violation.type === 'violation'); const violationGroups = new Map(); - - for (const violation of violations) { + for (const violation of filteredViolations) { const field = violationFields[violation.name]; const existingViolations = violationGroups.get(field) ?? []; violationGroups.set(field, [...existingViolations, violation]); } - return violationGroups ?? new Map(); }, [violations]); diff --git a/src/languages/en.ts b/src/languages/en.ts index 9928bc61518f..e4042ddd6ddd 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -84,7 +84,6 @@ import type { ViolationsInvoiceMarkupParams, ViolationsMaxAgeParams, ViolationsMissingTagParams, - ViolationsOverAutoApprovalLimitParams, ViolationsOverCategoryLimitParams, ViolationsOverLimitParams, ViolationsPerDayLimitParams, @@ -1065,6 +1064,7 @@ export default { subtitle: 'Settings to help debug and test the app on staging.', }, receiveRelevantFeatureUpdatesAndExpensifyNews: 'Receive relevant feature updates and Expensify news', + muteAllSounds: 'Mute all sounds from Expensify', }, priorityModePage: { priorityMode: 'Priority mode', @@ -1634,6 +1634,9 @@ export default { trackDistanceCopy: 'Set the per mile/km rate and choose a default unit to track.', trackDistanceRate: 'Rate', trackDistanceUnit: 'Unit', + trackDistanceChooseUnit: 'Choose a default unit to track.', + kilometers: 'Kilometers', + miles: 'Miles', unlockNextDayReimbursements: 'Unlock next-day reimbursements', captureNoVBACopyBeforeEmail: 'Ask your workspace members to forward receipts to ', captureNoVBACopyAfterEmail: ' and download the Expensify App to track cash expenses on the go.', @@ -2153,7 +2156,7 @@ export default { allTagLevelsRequired: 'All tags required', autoReportedRejectedExpense: ({rejectReason, rejectedBy}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rejected this expense with the comment "${rejectReason}"`, billableExpense: 'Billable no longer valid', - cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required over ${amount}`, + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Receipt required${formattedLimit ? ` over ${formattedLimit}` : ''}`, categoryOutOfPolicy: 'Category no longer valid', conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `Applied ${surcharge}% conversion surcharge`, customUnitOutOfPolicy: 'Unit no longer valid', @@ -2164,17 +2167,18 @@ export default { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Date older than ${maxAge} days`, missingCategory: 'Missing category', missingComment: 'Description required for selected category', - missingTag: ({tagName}: ViolationsMissingTagParams) => `Missing ${tagName ?? 'tag'}`, + missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Missing ${tagName ?? 'tag'}`, modifiedAmount: 'Amount greater than scanned receipt', modifiedDate: 'Date differs from scanned receipt', nonExpensiworksExpense: 'Non-Expensiworks expense', - overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Expense exceeds auto approval limit of ${formattedLimitAmount}`, - overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${categoryLimit}/person category limit`, - overLimit: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, - overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Amount over ${amount}/person limit`, - perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Amount over daily ${limit}/person category limit`, + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Expense exceeds auto approval limit of ${formattedLimit}`, + overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Amount over ${formattedLimit}/person category limit`, + overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, + overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Amount over ${formattedLimit}/person limit`, + perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Amount over daily ${formattedLimit}/person category limit`, receiptNotSmartScanned: 'Receipt not verified. Please confirm accuracy.', - receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Receipt required over ${amount} ${category ? ' category limit' : ''}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams = {}) => + `Receipt required${formattedLimit ? ` over ${formattedLimit}${category ? ' category limit' : ''}` : ''}`, rter: ({brokenBankConnection, email, isAdmin, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { return isAdmin diff --git a/src/languages/es.ts b/src/languages/es.ts index 3aad5a7ac9b4..643e9d63fe5f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1053,7 +1053,6 @@ export default { defaultPaymentMethod: 'Predeterminado', }, preferencesPage: { - receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', appSection: { title: 'Preferencias de la aplicación', subtitle: 'Personaliza tu cuenta de Expensify.', @@ -1062,6 +1061,8 @@ export default { title: 'Preferencias para tests', subtitle: 'Ajustes para ayudar a depurar y probar la aplicación en “staging”.', }, + receiveRelevantFeatureUpdatesAndExpensifyNews: 'Recibir noticias sobre Expensify y actualizaciones del producto', + muteAllSounds: 'Silenciar todos los sonidos de Expensify', }, priorityModePage: { priorityMode: 'Modo prioridad', @@ -1657,6 +1658,9 @@ export default { trackDistanceCopy: 'Configura la tarifa y unidad usadas para medir distancias.', trackDistanceRate: 'Tarifa', trackDistanceUnit: 'Unidad', + trackDistanceChooseUnit: 'Elija una unidad predeterminada para rastrear.', + kilometers: 'Kilómetros', + miles: 'Millas', unlockNextDayReimbursements: 'Desbloquea reembolsos diarios', captureNoVBACopyBeforeEmail: 'Pide a los miembros de tu espacio de trabajo que envíen recibos a ', captureNoVBACopyAfterEmail: ' y descarga la App de Expensify para controlar tus gastos en efectivo sobre la marcha.', @@ -2640,9 +2644,9 @@ export default { allTagLevelsRequired: 'Todas las etiquetas son obligatorias', autoReportedRejectedExpense: ({rejectedBy, rejectReason}: ViolationsAutoReportedRejectedExpenseParams) => `${rejectedBy} rechazó la solicitud y comentó "${rejectReason}"`, billableExpense: 'La opción facturable ya no es válida', - cashExpenseWithNoReceipt: ({amount}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${amount}`, + cashExpenseWithNoReceipt: ({formattedLimit}: ViolationsCashExpenseWithNoReceiptParams) => `Recibo obligatorio para cantidades mayores de ${formattedLimit}`, categoryOutOfPolicy: 'La categoría ya no es válida', - conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams) => `${surcharge}% de recargo aplicado`, + conversionSurcharge: ({surcharge}: ViolationsConversionSurchargeParams = {}) => `${surcharge}% de recargo aplicado`, customUnitOutOfPolicy: 'La unidad ya no es válida', duplicatedTransaction: 'Posible duplicado', fieldRequired: 'Los campos del informe son obligatorios', @@ -2651,17 +2655,19 @@ export default { maxAge: ({maxAge}: ViolationsMaxAgeParams) => `Fecha de más de ${maxAge} días`, missingCategory: 'Falta categoría', missingComment: 'Descripción obligatoria para la categoría seleccionada', - missingTag: ({tagName}: ViolationsMissingTagParams) => `Falta ${tagName}`, + missingTag: ({tagName}: ViolationsMissingTagParams = {}) => `Falta ${tagName ?? 'etiqueta'}`, modifiedAmount: 'Importe superior al del recibo escaneado', modifiedDate: 'Fecha difiere del recibo escaneado', nonExpensiworksExpense: 'Gasto no proviene de Expensiworks', - overAutoApprovalLimit: ({formattedLimitAmount}: ViolationsOverAutoApprovalLimitParams) => `Importe supera el límite de aprobación automática de ${formattedLimitAmount}`, - overCategoryLimit: ({categoryLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría de ${categoryLimit}/persona`, - overLimit: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, - overLimitAttendee: ({amount}: ViolationsOverLimitParams) => `Importe supera el límite de ${amount}/persona`, - perDayLimit: ({limit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría de ${limit}/persona`, + overAutoApprovalLimit: ({formattedLimit}: ViolationsOverAutoApprovalLimitParams) => + `Importe supera el límite de aprobación automática${formattedLimit ? ` de ${formattedLimit}` : ''}`, + overCategoryLimit: ({formattedLimit}: ViolationsOverCategoryLimitParams) => `Importe supera el límite para la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + overLimit: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + overLimitAttendee: ({formattedLimit}: ViolationsOverLimitParams) => `Importe supera el límite${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, + perDayLimit: ({formattedLimit}: ViolationsPerDayLimitParams) => `Importe supera el límite diario de la categoría${formattedLimit ? ` de ${formattedLimit}/persona` : ''}`, receiptNotSmartScanned: 'Recibo no verificado. Por favor, confirma su exactitud', - receiptRequired: ({amount, category}: ViolationsReceiptRequiredParams) => `Recibo obligatorio para importes sobre ${category ? 'el limite de la categoría de ' : ''}${amount}`, + receiptRequired: ({formattedLimit, category}: ViolationsReceiptRequiredParams = {}) => + `Recibo obligatorio${formattedLimit ? ` para importes sobre ${category ? 'el limite de la categoría de ' : ''}${formattedLimit}` : ''}`, rter: ({brokenBankConnection, isAdmin, email, isTransactionOlderThan7Days, member}: ViolationsRterParams) => { if (brokenBankConnection) { return isAdmin diff --git a/src/languages/types.ts b/src/languages/types.ts index c9442c6560a3..ca98e23a4c07 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -213,7 +213,7 @@ type WalletProgramParams = {walletProgram: string}; type ViolationsAutoReportedRejectedExpenseParams = {rejectedBy: string; rejectReason: string}; -type ViolationsCashExpenseWithNoReceiptParams = {amount: string}; +type ViolationsCashExpenseWithNoReceiptParams = {formattedLimit?: string}; type ViolationsConversionSurchargeParams = {surcharge?: number}; @@ -223,15 +223,15 @@ type ViolationsMaxAgeParams = {maxAge: number}; type ViolationsMissingTagParams = {tagName?: string}; -type ViolationsOverAutoApprovalLimitParams = {formattedLimitAmount: string}; +type ViolationsOverAutoApprovalLimitParams = {formattedLimit?: string}; -type ViolationsOverCategoryLimitParams = {categoryLimit: string}; +type ViolationsOverCategoryLimitParams = {formattedLimit?: string}; -type ViolationsOverLimitParams = {amount: string}; +type ViolationsOverLimitParams = {formattedLimit?: string}; -type ViolationsPerDayLimitParams = {limit: string}; +type ViolationsPerDayLimitParams = {formattedLimit?: string}; -type ViolationsReceiptRequiredParams = {amount: string; category?: string}; +type ViolationsReceiptRequiredParams = {formattedLimit?: string; category?: string}; type ViolationsRterParams = { brokenBankConnection: boolean; diff --git a/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts b/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts index 22bbd20c7308..010bcaa1e60a 100644 --- a/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts +++ b/src/libs/API/parameters/UpdateWorkspaceCustomUnitAndRateParams.ts @@ -1,6 +1,6 @@ type UpdateWorkspaceCustomUnitAndRateParams = { policyID: string; - lastModified: number; + lastModified?: string; customUnit: string; customUnitRate: string; }; diff --git a/src/libs/CurrencyUtils.ts b/src/libs/CurrencyUtils.ts index cec9d1e09088..4098cbcd31fc 100644 --- a/src/libs/CurrencyUtils.ts +++ b/src/libs/CurrencyUtils.ts @@ -110,6 +110,21 @@ function convertToDisplayString(amountInCents = 0, currency: string = CONST.CURR }); } +/** + * Given an amount, convert it to a string for display in the UI. + * + * @param amount – should be a float. + * @param currency - IOU currency + */ +function convertAmountToDisplayString(amount = 0, currency: string = CONST.CURRENCY.USD): string { + const convertedAmount = amount / 100.0; + return NumberFormatUtils.format(BaseLocaleListener.getPreferredLocale(), convertedAmount, { + style: 'currency', + currency, + minimumFractionDigits: getCurrencyDecimals(currency) + 1, + }); +} + /** * Checks if passed currency code is a valid currency based on currency list */ @@ -127,5 +142,6 @@ export { convertToBackendAmount, convertToFrontendAmount, convertToDisplayString, + convertAmountToDisplayString, isValidCurrencyCode, }; diff --git a/src/libs/DoInteractionTask/index.desktop.ts b/src/libs/DoInteractionTask/index.desktop.ts index 73b3cb19ec32..3b0340d68425 100644 --- a/src/libs/DoInteractionTask/index.desktop.ts +++ b/src/libs/DoInteractionTask/index.desktop.ts @@ -1,10 +1,10 @@ import {InteractionManager} from 'react-native'; +import type DoInteractionTask from './types'; // For desktop, we should call the callback after all interactions to prevent freezing. See more detail in https://github.com/Expensify/App/issues/28916 -function doInteractionTask(callback: () => void) { - return InteractionManager.runAfterInteractions(() => { +const doInteractionTask: DoInteractionTask = (callback) => + InteractionManager.runAfterInteractions(() => { callback(); }); -} export default doInteractionTask; diff --git a/src/libs/DoInteractionTask/index.ts b/src/libs/DoInteractionTask/index.ts index dffbb0562b98..0eadb8f7dbee 100644 --- a/src/libs/DoInteractionTask/index.ts +++ b/src/libs/DoInteractionTask/index.ts @@ -1,6 +1,8 @@ -function doInteractionTask(callback: () => void) { +import type DoInteractionTask from './types'; + +const doInteractionTask: DoInteractionTask = (callback) => { callback(); return null; -} +}; export default doInteractionTask; diff --git a/src/libs/DoInteractionTask/types.ts b/src/libs/DoInteractionTask/types.ts new file mode 100644 index 000000000000..3c1da4b5364d --- /dev/null +++ b/src/libs/DoInteractionTask/types.ts @@ -0,0 +1,5 @@ +import type {InteractionManager} from 'react-native'; + +type DoInteractionTask = (callback: () => void) => ReturnType | null; + +export default DoInteractionTask; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 8cfaa684917e..58946983bb35 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -132,7 +132,7 @@ function getErrorsWithTranslationData(errors: Localize.MaybePhraseKey | Errors): * @param errors - An object containing current errors in the form * @param message - Message to assign to the inputID errors */ -function addErrorMessage(errors: Errors, inputID?: string, message?: TKey | Localize.MaybePhraseKey) { +function addErrorMessage(errors: Errors, inputID?: string | null, message?: TKey | Localize.MaybePhraseKey) { if (!message || !inputID) { return; } diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index ff86070395b3..da8a5b843ec1 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -34,10 +34,10 @@ function addLeadingZero(amount: string): string { /** * Calculate the length of the amount with leading zeroes */ -function calculateAmountLength(amount: string): number { +function calculateAmountLength(amount: string, decimals: number): number { const leadingZeroes = amount.match(/^0+/); const leadingZeroesLength = leadingZeroes?.[0]?.length ?? 0; - const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 100).toFixed(2)).toString(); + const absAmount = parseFloat((Number(stripCommaFromAmount(amount)) * 10 ** decimals).toFixed(2)).toString(); if (/\D/.test(absAmount)) { return CONST.IOU.AMOUNT_MAX_LENGTH + 1; @@ -55,7 +55,7 @@ function validateAmount(amount: string, decimals: number): boolean { ? `^\\d+(,\\d*)*$` // Don't allow decimal point if decimals === 0 : `^\\d+(,\\d*)*(\\.\\d{0,${decimals}})?$`; // Allow the decimal point and the desired number of digits after the point const decimalNumberRegex = new RegExp(regexString, 'i'); - return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount) <= CONST.IOU.AMOUNT_MAX_LENGTH); + return amount === '' || (decimalNumberRegex.test(amount) && calculateAmountLength(amount, decimals) <= CONST.IOU.AMOUNT_MAX_LENGTH); } /** diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index b0ec6d1f3a94..4723bbfd9b4e 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -99,7 +99,6 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CONFIRMATION]: () => require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType, - [SCREENS.MONEY_REQUEST.DATE]: () => require('../../../pages/iou/MoneyRequestDatePage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType, [SCREENS.MONEY_REQUEST.MERCHANT]: () => require('../../../pages/iou/MoneyRequestMerchantPage').default as React.ComponentType, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType, @@ -238,7 +237,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType, - [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType, + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE]: () => require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType, [SCREENS.WORKSPACE.INVITE_MESSAGE]: () => require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType, [SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType, diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index d96ad416832d..1936de564c37 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -3,7 +3,7 @@ import SCREENS from '@src/SCREENS'; const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = { [SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY], - [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT], + [SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT], [SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE], }; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index ab218adc3879..52e512d32c59 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -229,6 +229,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.RATE_AND_UNIT]: { path: ROUTES.WORKSPACE_RATE_AND_UNIT.route, }, + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT_RATE.route, + }, + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: { + path: ROUTES.WORKSPACE_RATE_AND_UNIT_UNIT.route, + }, [SCREENS.WORKSPACE.INVITE]: { path: ROUTES.WORKSPACE_INVITE.route, }, @@ -405,7 +411,6 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_TAX_RATE]: ROUTES.MONEY_REQUEST_STEP_TAX_RATE.route, [SCREENS.MONEY_REQUEST.PARTICIPANTS]: ROUTES.MONEY_REQUEST_PARTICIPANTS.route, [SCREENS.MONEY_REQUEST.CONFIRMATION]: ROUTES.MONEY_REQUEST_CONFIRMATION.route, - [SCREENS.MONEY_REQUEST.DATE]: ROUTES.MONEY_REQUEST_DATE.route, [SCREENS.MONEY_REQUEST.CURRENCY]: ROUTES.MONEY_REQUEST_CURRENCY.route, [SCREENS.MONEY_REQUEST.CATEGORY]: ROUTES.MONEY_REQUEST_CATEGORY.route, [SCREENS.MONEY_REQUEST.MERCHANT]: ROUTES.MONEY_REQUEST_MERCHANT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index bbdb03ab3df8..d354131206ca 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -140,7 +140,15 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.WORKSPACE.CURRENCY]: undefined; [SCREENS.WORKSPACE.NAME]: undefined; - [SCREENS.WORKSPACE.RATE_AND_UNIT]: undefined; + [SCREENS.WORKSPACE.RATE_AND_UNIT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: { + policyID: string; + }; [SCREENS.WORKSPACE.INVITE]: { policyID: string; }; @@ -224,11 +232,12 @@ type MoneyRequestNavigatorParamList = { currency: string; backTo: string; }; - [SCREENS.MONEY_REQUEST.DATE]: { - iouType: string; + [SCREENS.MONEY_REQUEST.STEP_DATE]: { + action: ValueOf; + iouType: ValueOf; + transactionID: string; reportID: string; - field: string; - threadReportID: string; + backTo: string; }; [SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: { action: ValueOf; diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.ts b/src/libs/Notification/LocalNotification/BrowserNotifications.ts index 911e665f3ff4..f44b6802b540 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.ts +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.ts @@ -42,7 +42,7 @@ function canUseBrowserNotifications(): Promise { * @param icon Path to icon * @param data extra data to attach to the notification */ -function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}) { +function push(title: string, body = '', icon: string | ImageSourcePropType = '', data: LocalNotificationData = {}, onClick: LocalNotificationClickHandler = () => {}, silent = false) { canUseBrowserNotifications().then((canUseNotifications) => { if (!canUseNotifications) { return; @@ -54,6 +54,7 @@ function push(title: string, body = '', icon: string | ImageSourcePropType = '', body, icon: String(icon), data, + silent, }); notificationCache[notificationID].onclick = () => { onClick(); @@ -104,7 +105,7 @@ export default { reportID: report.reportID, }; - push(title, body, icon, data, onClick); + push(title, body, icon, data, onClick, true); }, pushModifiedExpenseNotification(report: Report, reportAction: ReportAction, onClick: LocalNotificationClickHandler, usesIcon = false) { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 046a66a8d6e0..0515260378e0 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1756,7 +1756,7 @@ function getIOUConfirmationOptionsFromParticipants(participants: Participant[], * Build the options for the New Group view */ function getFilteredOptions( - reports: Record, + reports: OnyxCollection, personalDetails: OnyxEntry, betas: Beta[] = [], searchValue = '', @@ -1942,7 +1942,7 @@ function formatSectionsFromSearchTerm( searchTerm: string, selectedOptions: ReportUtils.OptionData[], filteredRecentReports: ReportUtils.OptionData[], - filteredPersonalDetails: PersonalDetails[], + filteredPersonalDetails: ReportUtils.OptionData[], maxOptionsSelected: boolean, indexOffset = 0, personalDetails: OnyxEntry = {}, @@ -2025,4 +2025,4 @@ export { transformedTaxRates, }; -export type {MemberForList}; +export type {MemberForList, CategorySection}; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 0575e297da0c..a3729ef81fe4 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -15,7 +15,9 @@ type UnitRate = {rate: number}; * These are policies that we can use to create reports with in NewDot. */ function getActivePolicies(policies: OnyxCollection): Policy[] | undefined { - return Object.values(policies ?? {}).filter((policy): policy is Policy => policy !== null && policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + return Object.values(policies ?? {}).filter( + (policy): policy is Policy => policy !== null && policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!policy.name && !!policy.id, + ); } /** diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 1aeb6e6e7343..3658fb53a722 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -73,6 +73,19 @@ Onyx.connect({ callback: (val) => (isNetworkOffline = val?.isOffline ?? false), }); +let currentUserAccountID: number | undefined; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + // When signed out, value is undefined + if (!value) { + return; + } + + currentUserAccountID = value.accountID; + }, +}); + let environmentURL: string; Environment.getEnvironmentURL().then((url: string) => (environmentURL = url)); @@ -117,6 +130,16 @@ function isWhisperAction(reportAction: OnyxEntry): boolean { return (reportAction?.whisperedToAccountIDs ?? []).length > 0; } +/** + * Checks whether the report action is a whisper targeting someone other than the current user. + */ +function isWhisperActionTargetedToOthers(reportAction: OnyxEntry): boolean { + if (!isWhisperAction(reportAction)) { + return false; + } + return !reportAction?.whisperedToAccountIDs?.includes(currentUserAccountID ?? 0); +} + function isReimbursementQueuedAction(reportAction: OnyxEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED; } @@ -370,6 +393,10 @@ function shouldReportActionBeVisible(reportAction: OnyxEntry, key: return false; } + if (isWhisperActionTargetedToOthers(reportAction)) { + return false; + } + if (isPendingRemove(reportAction) && !reportAction.childVisibleActionCount) { return false; } @@ -864,6 +891,7 @@ export { isThreadParentMessage, isTransactionThread, isWhisperAction, + isWhisperActionTargetedToOthers, isReimbursementQueuedAction, shouldReportActionBeVisible, shouldHideNewMarker, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 26280f95447d..a2401ad926d3 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3634,7 +3634,10 @@ function isUnread(report: OnyxEntry): boolean { // lastVisibleActionCreated and lastReadTime are both datetime strings and can be compared directly const lastVisibleActionCreated = report.lastVisibleActionCreated ?? ''; const lastReadTime = report.lastReadTime ?? ''; - return lastReadTime < lastVisibleActionCreated; + const lastMentionedTime = report.lastMentionedTime ?? ''; + + // If the user was mentioned and the comment got deleted the lastMentionedTime will be more recent than the lastVisibleActionCreated + return lastReadTime < lastVisibleActionCreated || lastReadTime < lastMentionedTime; } function isIOUOwnedByCurrentUser(report: OnyxEntry, allReportsDict: OnyxCollection = null): boolean { diff --git a/src/libs/Sound/config/index.native.ts b/src/libs/Sound/config/index.native.ts new file mode 100644 index 000000000000..e948c1e189e8 --- /dev/null +++ b/src/libs/Sound/config/index.native.ts @@ -0,0 +1,3 @@ +const config = {prefix: ''}; + +export default config; diff --git a/src/libs/Sound/config/index.ts b/src/libs/Sound/config/index.ts new file mode 100644 index 000000000000..f58755750d9d --- /dev/null +++ b/src/libs/Sound/config/index.ts @@ -0,0 +1,3 @@ +const config = {prefix: '/sounds/'}; + +export default config; diff --git a/src/libs/Sound/index.ts b/src/libs/Sound/index.ts new file mode 100644 index 000000000000..4639887e831c --- /dev/null +++ b/src/libs/Sound/index.ts @@ -0,0 +1,71 @@ +import Onyx from 'react-native-onyx'; +import Sound from 'react-native-sound'; +import type {ValueOf} from 'type-fest'; +import ONYXKEYS from '@src/ONYXKEYS'; +import config from './config'; + +let isMuted = false; + +Onyx.connect({ + key: ONYXKEYS.USER, + callback: (val) => (isMuted = !!val?.isMutedAllSounds), +}); + +const SOUNDS = { + DONE: 'done', + SUCCESS: 'success', + ATTENTION: 'attention', + RECEIVE: 'receive', +} as const; + +/** + * Creates a version of the given function that, when called, queues the execution and ensures that + * calls are spaced out by at least the specified `minExecutionTime`, even if called more frequently. This allows + * for throttling frequent calls to a function, ensuring each is executed with a minimum `minExecutionTime` between calls. + * Each call returns a promise that resolves when the function call is executed, allowing for asynchronous handling. + */ +function withMinimalExecutionTime) => ReturnType>(func: F, minExecutionTime: number) { + const queue: Array<[() => ReturnType, (value?: unknown) => void]> = []; + let timerId: NodeJS.Timeout | null = null; + + function processQueue() { + if (queue.length > 0) { + const next = queue.shift(); + + if (!next) { + return; + } + + const [nextFunc, resolve] = next; + nextFunc(); + resolve(); + timerId = setTimeout(processQueue, minExecutionTime); + } else { + timerId = null; + } + } + + return function (...args: Parameters) { + return new Promise((resolve) => { + queue.push([() => func(...args), resolve]); + + if (!timerId) { + // If the timer isn't running, start processing the queue + processQueue(); + } + }); + }; +} + +const playSound = (soundFile: ValueOf) => { + const sound = new Sound(`${config.prefix}${soundFile}.mp3`, Sound.MAIN_BUNDLE, (error) => { + if (error || isMuted) { + return; + } + + sound.play(); + }); +}; + +export {SOUNDS}; +export default withMinimalExecutionTime(playSound, 300); diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 992449267934..0e14c1530259 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -93,22 +93,41 @@ const ViolationsUtils = { violation: TransactionViolation, translate: (phraseKey: TKey, ...phraseParameters: PhraseParameters>) => string, ): string { + const { + brokenBankConnection = false, + isAdmin = false, + email, + isTransactionOlderThan7Days = false, + member, + category, + rejectedBy, + rejectReason, + formattedLimit, + surcharge, + invoiceMarkup, + maxAge = 0, + tagName, + taxName, + } = violation.data ?? {}; + switch (violation.name) { case 'allTagLevelsRequired': return translate('violations.allTagLevelsRequired'); case 'autoReportedRejectedExpense': - return translate('violations.autoReportedRejectedExpense', { - rejectedBy: violation.data?.rejectedBy ?? '', - rejectReason: violation.data?.rejectReason ?? '', - }); + return rejectReason && rejectedBy + ? translate('violations.autoReportedRejectedExpense', { + rejectedBy, + rejectReason, + }) + : ''; case 'billableExpense': return translate('violations.billableExpense'); case 'cashExpenseWithNoReceipt': - return translate('violations.cashExpenseWithNoReceipt', {amount: violation.data?.amount ?? ''}); + return translate('violations.cashExpenseWithNoReceipt', {formattedLimit}); case 'categoryOutOfPolicy': return translate('violations.categoryOutOfPolicy'); case 'conversionSurcharge': - return translate('violations.conversionSurcharge', {surcharge: violation.data?.surcharge}); + return translate('violations.conversionSurcharge', {surcharge}); case 'customUnitOutOfPolicy': return translate('violations.customUnitOutOfPolicy'); case 'duplicatedTransaction': @@ -118,15 +137,15 @@ const ViolationsUtils = { case 'futureDate': return translate('violations.futureDate'); case 'invoiceMarkup': - return translate('violations.invoiceMarkup', {invoiceMarkup: violation.data?.invoiceMarkup}); + return translate('violations.invoiceMarkup', {invoiceMarkup}); case 'maxAge': - return translate('violations.maxAge', {maxAge: violation.data?.maxAge ?? 0}); + return translate('violations.maxAge', {maxAge}); case 'missingCategory': return translate('violations.missingCategory'); case 'missingComment': return translate('violations.missingComment'); case 'missingTag': - return translate('violations.missingTag', {tagName: violation.data?.tagName}); + return translate('violations.missingTag', {tagName}); case 'modifiedAmount': return translate('violations.modifiedAmount'); case 'modifiedDate': @@ -134,40 +153,37 @@ const ViolationsUtils = { case 'nonExpensiworksExpense': return translate('violations.nonExpensiworksExpense'); case 'overAutoApprovalLimit': - return translate('violations.overAutoApprovalLimit', {formattedLimitAmount: violation.data?.formattedLimitAmount ?? ''}); + return translate('violations.overAutoApprovalLimit', {formattedLimit}); case 'overCategoryLimit': - return translate('violations.overCategoryLimit', {categoryLimit: violation.data?.categoryLimit ?? ''}); + return translate('violations.overCategoryLimit', {formattedLimit}); case 'overLimit': - return translate('violations.overLimit', {amount: violation.data?.amount ?? ''}); + return translate('violations.overLimit', {formattedLimit}); case 'overLimitAttendee': - return translate('violations.overLimitAttendee', {amount: violation.data?.amount ?? ''}); + return translate('violations.overLimitAttendee', {formattedLimit}); case 'perDayLimit': - return translate('violations.perDayLimit', {limit: violation.data?.limit ?? ''}); + return translate('violations.perDayLimit', {formattedLimit}); case 'receiptNotSmartScanned': return translate('violations.receiptNotSmartScanned'); case 'receiptRequired': - return translate('violations.receiptRequired', { - amount: violation.data?.amount ?? '0', - category: violation.data?.category ?? '', - }); + return translate('violations.receiptRequired', {formattedLimit, category}); case 'rter': return translate('violations.rter', { - brokenBankConnection: violation.data?.brokenBankConnection ?? false, - isAdmin: violation.data?.isAdmin ?? false, - email: violation.data?.email, - isTransactionOlderThan7Days: !!violation.data?.isTransactionOlderThan7Days, - member: violation.data?.member, + brokenBankConnection, + isAdmin, + email, + isTransactionOlderThan7Days, + member, }); case 'smartscanFailed': return translate('violations.smartscanFailed'); case 'someTagLevelsRequired': return translate('violations.someTagLevelsRequired'); case 'tagOutOfPolicy': - return translate('violations.tagOutOfPolicy', {tagName: violation.data?.tagName}); + return translate('violations.tagOutOfPolicy', {tagName}); case 'taxAmountChanged': return translate('violations.taxAmountChanged'); case 'taxOutOfPolicy': - return translate('violations.taxOutOfPolicy', {taxName: violation.data?.taxName}); + return translate('violations.taxOutOfPolicy', {taxName}); case 'taxRateChanged': return translate('violations.taxRateChanged'); case 'taxRequired': diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index d7ac6f3c9361..7fca6614f1a1 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -269,8 +269,8 @@ function setMoneyRequestAmount_temporaryForRefactor(transactionID: string, amoun } // eslint-disable-next-line @typescript-eslint/naming-convention -function setMoneyRequestCreated_temporaryForRefactor(transactionID: string, created: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {created}); +function setMoneyRequestCreated(transactionID: string, created: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {created}); } // eslint-disable-next-line @typescript-eslint/naming-convention @@ -1128,7 +1128,7 @@ function getUpdateMoneyRequestParams( }); } - // Optimistically modify the transaction + // Optimistically modify the transaction and the transaction thread optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, @@ -1140,6 +1140,14 @@ function getUpdateMoneyRequestParams( }, }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastActorAccountID: updatedReportAction.actorAccountID, + }, + }); + if (isScanning && ('amount' in transactionChanges || 'currency' in transactionChanges)) { optimisticData.push( { @@ -1237,6 +1245,13 @@ function getUpdateMoneyRequestParams( }); } + // Reset the transaction thread to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: transactionThread, + }); + return { params, onyxData: {optimisticData, successData, failureData}, @@ -3712,10 +3727,6 @@ function setMoneyRequestAmount(amount: number) { Onyx.merge(ONYXKEYS.IOU, {amount}); } -function setMoneyRequestCreated(created: string) { - Onyx.merge(ONYXKEYS.IOU, {created}); -} - function setMoneyRequestCurrency(currency: string) { Onyx.merge(ONYXKEYS.IOU, {currency}); } @@ -3858,7 +3869,7 @@ export { setMoneyRequestAmount_temporaryForRefactor, setMoneyRequestBillable_temporaryForRefactor, setMoneyRequestCategory_temporaryForRefactor, - setMoneyRequestCreated_temporaryForRefactor, + setMoneyRequestCreated, setMoneyRequestCurrency_temporaryForRefactor, setMoneyRequestDescription, setMoneyRequestOriginalCurrency_temporaryForRefactor, @@ -3869,7 +3880,6 @@ export { setMoneyRequestAmount, setMoneyRequestBillable, setMoneyRequestCategory, - setMoneyRequestCreated, setMoneyRequestCurrency, setMoneyRequestId, setMoneyRequestMerchant, diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 498ce6918509..f197a75871ef 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -49,7 +49,7 @@ import type { Transaction, } from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -import type {CustomUnit} from '@src/types/onyx/Policy'; +import type {Attributes, CustomUnit, Rate, Unit} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -82,6 +82,13 @@ type OptimisticCustomUnits = { type PoliciesRecord = Record>; +type NewCustomUnit = { + customUnitID: string; + name: string; + attributes: Attributes; + rates: Rate; +}; + const allPolicies: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY, @@ -116,6 +123,13 @@ Onyx.connect({ }, }); +let allReports: OnyxCollection = null; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + let allPolicyMembers: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_MEMBERS, @@ -218,7 +232,7 @@ function hasActiveFreePolicy(policies: Array> | PoliciesRecord /** * Delete the workspace */ -function deleteWorkspace(policyID: string, reports: Report[], policyName: string) { +function deleteWorkspace(policyID: string, policyName: string) { if (!allPolicies) { return; } @@ -247,7 +261,9 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string : []), ]; - reports.forEach(({reportID, ownerAccountID}) => { + const reportsToArchive = Object.values(allReports ?? {}).filter((report) => report?.policyID === policyID && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report))); + reportsToArchive.forEach((report) => { + const {reportID, ownerAccountID} = report ?? {}; optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -292,7 +308,8 @@ function deleteWorkspace(policyID: string, reports: Report[], policyName: string }, ]; - reports.forEach(({reportID, stateNum, statusNum, hasDraft, oldPolicyName}) => { + reportsToArchive.forEach((report) => { + const {reportID, stateNum, statusNum, hasDraft, oldPolicyName} = report ?? {}; failureData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, @@ -945,7 +962,7 @@ function hideWorkspaceAlertMessage(policyID: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {alertMessage: ''}); } -function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: CustomUnit, lastModified: number) { +function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: CustomUnit, newCustomUnit: NewCustomUnit, lastModified?: string) { if (!currentCustomUnit.customUnitID || !newCustomUnit?.customUnitID || !newCustomUnit.rates?.customUnitRateID) { return; } @@ -959,7 +976,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [newCustomUnit.customUnitID]: { ...newCustomUnit, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { ...newCustomUnit.rates, errors: null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, @@ -982,7 +999,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C pendingAction: null, errors: null, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { pendingAction: null, }, }, @@ -1001,7 +1018,7 @@ function updateWorkspaceCustomUnitAndRate(policyID: string, currentCustomUnit: C [currentCustomUnit.customUnitID]: { customUnitID: currentCustomUnit.customUnitID, rates: { - [newCustomUnit.rates.customUnitRateID as string]: { + [newCustomUnit.rates.customUnitRateID]: { ...currentCustomUnit.rates, errors: ErrorUtils.getMicroSecondOnyxError('workspace.reimburse.updateCustomUnitError'), }, @@ -1460,6 +1477,22 @@ function openWorkspaceReimburseView(policyID: string) { API.read(READ_COMMANDS.OPEN_WORKSPACE_REIMBURSE_VIEW, params, {successData, failureData}); } +function setPolicyIDForReimburseView(policyID: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {policyID, rate: null, unit: null}); +} + +function clearOnyxDataForReimburseView() { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, null); +} + +function setRateForReimburseView(rate: string) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {rate}); +} + +function setUnitForReimburseView(unit: Unit) { + Onyx.merge(ONYXKEYS.WORKSPACE_RATE_AND_UNIT, {unit}); +} + /** * Returns the accountIDs of the members of the policy whose data is passed in the parameters */ @@ -2009,6 +2042,10 @@ export { clearAddMemberError, clearDeleteWorkspaceError, openWorkspaceReimburseView, + setPolicyIDForReimburseView, + clearOnyxDataForReimburseView, + setRateForReimburseView, + setUnitForReimburseView, generateDefaultWorkspaceName, updateGeneralSettings, clearWorkspaceGeneralSettingsErrors, diff --git a/src/libs/actions/Task.ts b/src/libs/actions/Task.ts index 901e3698376b..28cecf460a5f 100644 --- a/src/libs/actions/Task.ts +++ b/src/libs/actions/Task.ts @@ -13,6 +13,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -309,6 +310,7 @@ function completeTask(taskReport: OnyxEntry) { completedTaskReportActionID: completedTaskReportAction.reportActionID, }; + playSound(SOUNDS.SUCCESS); API.write(WRITE_COMMANDS.COMPLETE_TASK, parameters, {optimisticData, successData, failureData}); } diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 09cc6e49e6cc..75c61d7fab5f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -28,6 +28,7 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as Pusher from '@libs/Pusher/pusher'; import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -37,6 +38,7 @@ import type {OnyxServerUpdate} from '@src/types/onyx/OnyxUpdatesFromServer'; import type OnyxPersonalDetails from '@src/types/onyx/PersonalDetails'; import type {Status} from '@src/types/onyx/PersonalDetails'; import type ReportAction from '@src/types/onyx/ReportAction'; +import type {OriginalMessage} from '@src/types/onyx/ReportAction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import * as Link from './Link'; @@ -458,7 +460,7 @@ function isBlockedFromConcierge(blockedFromConciergeNVP: OnyxEntry { - if (!update.shouldNotify) { + if (!update.shouldNotify && !update.shouldShowPushNotification) { return; } @@ -469,6 +471,101 @@ function triggerNotifications(onyxUpdates: OnyxServerUpdate[]) { }); } +const isChannelMuted = (reportId: string) => + new Promise((resolve) => { + const connectionId = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportId}`, + callback: (report) => { + Onyx.disconnect(connectionId); + + resolve( + !report?.notificationPreference || + report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.MUTE || + report?.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + ); + }, + }); + }); + +function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { + const reportActionsOnly = pushJSON.filter((update) => update.key.includes('reportActions_')); + // "reportActions_5134363522480668" -> "5134363522480668" + const reportIDs = reportActionsOnly.map((value) => value.key.split('_')[1]); + + Promise.all(reportIDs.map((reportID) => isChannelMuted(reportID))) + .then((muted) => muted.every((isMuted) => isMuted)) + .then((isSoundMuted) => { + if (isSoundMuted) { + return; + } + + try { + const flatten = reportActionsOnly.flatMap((update) => { + const value = update.value as OnyxCollection; + + if (!value) { + return []; + } + + return Object.values(value); + }) as ReportAction[]; + + for (const data of flatten) { + // Someone completes a task + if (data.actionName === 'TASKCOMPLETED') { + return playSound(SOUNDS.SUCCESS); + } + } + + const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[]; + + for (const message of types) { + // someone sent money + if ('IOUDetails' in message) { + return playSound(SOUNDS.SUCCESS); + } + + // mention user + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSound(SOUNDS.ATTENTION); + } + + // mention @here + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSound(SOUNDS.ATTENTION); + } + + // assign a task + if ('taskReportID' in message) { + return playSound(SOUNDS.ATTENTION); + } + + // request money + if ('IOUTransactionID' in message) { + return playSound(SOUNDS.ATTENTION); + } + + // Someone completes a money request + if ('IOUReportID' in message) { + return playSound(SOUNDS.SUCCESS); + } + + // plain message + if ('html' in message) { + return playSound(SOUNDS.RECEIVE); + } + } + } catch (e) { + let errorMessage = String(e); + if (e instanceof Error) { + errorMessage = e.message; + } + + Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`); + } + }); +} + /** * Handles the newest events from Pusher where a single mega multipleEvents contains * an array of singular events all in one event @@ -514,8 +611,10 @@ function subscribeToUserEvents() { }); // Handles Onyx updates coming from Pusher through the mega multipleEvents. - PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON: OnyxServerUpdate[]) => - SequentialQueue.getCurrentRequest().then(() => { + PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON: OnyxServerUpdate[]) => { + playSoundForMessageType(pushJSON); + + return SequentialQueue.getCurrentRequest().then(() => { // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher if (currentUserAccountID === -1) { return; @@ -528,8 +627,8 @@ function subscribeToUserEvents() { // Return a promise when Onyx is done updating so that the OnyxUpdatesManager can properly apply all // the onyx updates in order return onyxUpdatePromise; - }), - ); + }); + }); } /** @@ -613,6 +712,10 @@ function clearUserErrorMessage() { Onyx.merge(ONYXKEYS.USER, {error: ''}); } +function setMuteAllSounds(isMutedAllSounds: boolean) { + Onyx.merge(ONYXKEYS.USER, {isMutedAllSounds}); +} + /** * Clear the data about a screen share request from Onyx. */ @@ -880,6 +983,7 @@ export { subscribeToUserEvents, updatePreferredSkinTone, setShouldUseStagingServer, + setMuteAllSounds, clearUserErrorMessage, updateFrequentlyUsedEmojis, joinScreenShare, diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js deleted file mode 100644 index 2b5a8abaa349..000000000000 --- a/src/pages/EditRequestCreatedPage.js +++ /dev/null @@ -1,55 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - /** Transaction defailt created value */ - defaultCreated: PropTypes.string.isRequired, - - /** Callback to fire when the Save button is pressed */ - onSubmit: PropTypes.func.isRequired, -}; - -function EditRequestCreatedPage({defaultCreated, onSubmit}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - return ( - - - - - - - ); -} - -EditRequestCreatedPage.propTypes = propTypes; -EditRequestCreatedPage.displayName = 'EditRequestCreatedPage'; - -export default EditRequestCreatedPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 777321fc2068..aa22439dee70 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -21,7 +21,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import EditRequestAmountPage from './EditRequestAmountPage'; import EditRequestCategoryPage from './EditRequestCategoryPage'; -import EditRequestCreatedPage from './EditRequestCreatedPage'; import EditRequestDistancePage from './EditRequestDistancePage'; import EditRequestMerchantPage from './EditRequestMerchantPage'; import EditRequestReceiptPage from './EditRequestReceiptPage'; @@ -129,19 +128,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p [transaction, report, policy, policyTags, policyCategories], ); - const saveCreated = useCallback( - ({created: newCreated}) => { - // If the value hasn't changed, don't request to save changes on the server and just close the modal - if (newCreated === TransactionUtils.getCreated(transaction)) { - Navigation.dismissModal(); - return; - } - IOU.updateMoneyRequestDate(transaction.transactionID, report.reportID, newCreated, policy, policyTags, policyCategories); - Navigation.dismissModal(); - }, - [transaction, report, policy, policyTags, policyCategories], - ); - const saveMerchant = useCallback( ({merchant: newMerchant}) => { const newTrimmedMerchant = newMerchant.trim(); @@ -190,15 +176,6 @@ function EditRequestPage({report, route, policy, policyCategories, policyTags, p [transactionCategory, transaction.transactionID, report.reportID, policy, policyTags, policyCategories], ); - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { - return ( - - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( - ); - } - if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { return ( ; /** All of the personal details for everyone */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), + personalDetails: OnyxEntry; - /** All reports shared with the user */ - reports: PropTypes.objectOf(reportPropTypes), + betas: OnyxEntry; /** An object that holds data about which referral banners have been dismissed */ - dismissedReferralBanners: PropTypes.objectOf(PropTypes.bool), - - ...windowDimensionsPropTypes, - - ...withLocalizePropTypes, + dismissedReferralBanners: DismissedReferralBanners; /** Whether we are searching for reports in the server */ - isSearchingForReports: PropTypes.bool, + isSearchingForReports: OnyxEntry; }; -const defaultProps = { - betas: [], - dismissedReferralBanners: {}, - personalDetails: {}, - reports: {}, - isSearchingForReports: false, +type NewChatPageProps = NewChatPageWithOnyxProps & { + isGroupChat: boolean; }; -const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE); +const excludedGroupEmails = CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE); -function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, isSearchingForReports, dismissedReferralBanners}) { +function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingForReports, dismissedReferralBanners}: NewChatPageProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, setSearchTerm] = useState(''); - const [filteredRecentReports, setFilteredRecentReports] = useState([]); - const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); - const [filteredUserToInvite, setFilteredUserToInvite] = useState(); - const [selectedOptions, setSelectedOptions] = useState([]); + const [filteredRecentReports, setFilteredRecentReports] = useState([]); + const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); + const [filteredUserToInvite, setFilteredUserToInvite] = useState(); + const [selectedOptions, setSelectedOptions] = useState([]); const {isOffline} = useNetwork(); const {isSmallScreenWidth} = useWindowDimensions(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); @@ -76,16 +66,18 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i Boolean(filteredUserToInvite), searchTerm.trim(), maxParticipantsReached, - _.some(selectedOptions, (participant) => participant.searchText.toLowerCase().includes(searchTerm.trim().toLowerCase())), + selectedOptions.some((participant) => participant?.searchText?.toLowerCase().includes(searchTerm.trim().toLowerCase())), ); + const isOptionsDataReady = ReportUtils.isReportDataReady() && OptionsListUtils.isPersonalDetailsReady(personalDetails); - const sections = useMemo(() => { - const sectionsList = []; + const sections = useMemo((): OptionsListUtils.CategorySection[] => { + const sectionsList: OptionsListUtils.CategorySection[] = []; let indexOffset = 0; const formatResults = OptionsListUtils.formatSectionsFromSearchTerm(searchTerm, selectedOptions, filteredRecentReports, filteredPersonalDetails, maxParticipantsReached, indexOffset); sectionsList.push(formatResults.section); + indexOffset = formatResults.newIndexOffset; if (maxParticipantsReached) { @@ -95,7 +87,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i sectionsList.push({ title: translate('common.recents'), data: filteredRecentReports, - shouldShow: !_.isEmpty(filteredRecentReports), + shouldShow: filteredRecentReports.length > 0, indexOffset, }); indexOffset += filteredRecentReports.length; @@ -103,7 +95,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i sectionsList.push({ title: translate('common.contacts'), data: filteredPersonalDetails, - shouldShow: !_.isEmpty(filteredPersonalDetails), + shouldShow: filteredPersonalDetails.length > 0, indexOffset, }); indexOffset += filteredPersonalDetails.length; @@ -122,15 +114,14 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i /** * Removes a selected option from list if already selected. If not already selected add this option to the list. - * @param {Object} option */ - const toggleOption = (option) => { - const isOptionInList = _.some(selectedOptions, (selectedOption) => selectedOption.login === option.login); + const toggleOption = (option: OptionData) => { + const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); let newSelectedOptions; if (isOptionInList) { - newSelectedOptions = _.reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); + newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); } else { newSelectedOptions = [...selectedOptions, option]; } @@ -142,7 +133,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i } = OptionsListUtils.getFilteredOptions( reports, personalDetails, - betas, + betas ?? [], searchTerm, newSelectedOptions, isGroupChat ? excludedGroupEmails : [], @@ -166,10 +157,11 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i /** * Creates a new 1:1 chat with the option and the current user, * or navigates to the existing chat if one with those participants already exists. - * - * @param {Object} option */ - const createChat = (option) => { + const createChat = (option: OptionData) => { + if (!option.login) { + return; + } Report.navigateToAndOpenReport([option.login]); }; @@ -178,10 +170,12 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i * or navigates to the existing chat if one with those participants already exists. */ const createGroup = () => { - const logins = _.pluck(selectedOptions, 'login'); + const logins = selectedOptions.map((option) => option.login).filter((login): login is string => typeof login === 'string'); + if (logins.length < 1) { return; } + Report.navigateToAndOpenReport(logins); }; @@ -193,7 +187,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i } = OptionsListUtils.getFilteredOptions( reports, personalDetails, - betas, + betas ?? [], searchTerm, selectedOptions, isGroupChat ? excludedGroupEmails : [], @@ -223,6 +217,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i if (!interactionTask) { return; } + interactionTask.cancel(); }; }, []); @@ -251,11 +246,12 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i behavior="padding" // Offset is needed as KeyboardAvoidingView in nested inside of TabNavigator instead of wrapping whole screen. // This is because when wrapping whole screen the screen was freezing when changing Tabs. - keyboardVerticalOffset={variables.contentHeaderHeight + insets.top + variables.tabSelectorButtonHeight + variables.tabSelectorButtonPadding} + keyboardVerticalOffset={variables.contentHeaderHeight + (insets?.top ?? 0) + variables.tabSelectorButtonHeight + variables.tabSelectorButtonPadding} > 0 ? safeAreaPaddingBottomStyle : {}]}> data.dismissedReferralBanners || {}, - }, - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - betas: { - key: ONYXKEYS.BETAS, - }, - isSearchingForReports: { - key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, - initWithStoredValues: false, - }, - }), -)(NewChatPage); +export default withOnyx({ + dismissedReferralBanners: { + key: ONYXKEYS.ACCOUNT, + selector: (data) => data?.dismissedReferralBanners ?? {}, + }, + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + betas: { + key: ONYXKEYS.BETAS, + }, + isSearchingForReports: { + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + initWithStoredValues: false, + }, +})(NewChatPage); diff --git a/src/pages/RoomMembersPage.js b/src/pages/RoomMembersPage.js index 30ffd60aa4ac..89b9ea9b7d99 100644 --- a/src/pages/RoomMembersPage.js +++ b/src/pages/RoomMembersPage.js @@ -218,6 +218,7 @@ function RoomMembersPage(props) { source: UserUtils.getAvatar(details.avatar, accountID), name: details.login, type: CONST.ICON_TYPE_AVATAR, + id: Number(accountID), }, ], }); diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 602fef1fca58..8741006efab1 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -206,6 +206,7 @@ function ReportScreen({ oldPolicyName: reportProp.oldPolicyName, policyName: reportProp.policyName, isOptimisticReport: reportProp.isOptimisticReport, + lastMentionedTime: reportProp.lastMentionedTime, }), [ reportProp.lastReadTime, @@ -242,6 +243,7 @@ function ReportScreen({ reportProp.oldPolicyName, reportProp.policyName, reportProp.isOptimisticReport, + reportProp.lastMentionedTime, ], ); @@ -487,6 +489,7 @@ function ReportScreen({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const reportIDFromParams = lodashGet(route.params, 'reportID'); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = useMemo( () => @@ -497,8 +500,9 @@ function ReportScreen({ !reportMetadata.isLoadingInitialReportActions && !isLoading && !userLeavingStatus) || - shouldHideReport, - [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus], + shouldHideReport || + (reportIDFromParams && !ReportUtils.isValidReportIDFromPath(reportIDFromParams)), + [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus, reportIDFromParams], ); const actionListValue = useMemo(() => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]); diff --git a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js index cc07716209a2..8ec0bce9d1a7 100644 --- a/src/pages/home/report/ReportActionCompose/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose/ReportActionCompose.js @@ -24,6 +24,7 @@ import getDraftComment from '@libs/ComposerUtils/getDraftComment'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getModalState from '@libs/getModalState'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import willBlurTextInputOnTapOutsideFunc from '@libs/willBlurTextInputOnTapOutside'; import ParticipantLocalTime from '@pages/home/report/ParticipantLocalTime'; import ReportDropUI from '@pages/home/report/ReportDropUI'; @@ -255,6 +256,7 @@ function ReportActionCompose({ */ const addAttachment = useCallback( (file) => { + playSound(SOUNDS.DONE); const newComment = composerRef.current.prepareCommentAndResetComposer(); Report.addAttachment(reportID, file, newComment); setTextInputShouldClear(false); @@ -287,6 +289,7 @@ function ReportActionCompose({ return; } + playSound(SOUNDS.DONE); onSubmit(newComment); }, [onSubmit], diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index f960b987427b..859f9c183d80 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -698,6 +698,13 @@ function ReportActionItem(props) { return null; } + // We currently send whispers to all report participants and hide them in the UI for users that shouldn't see them. + // This is a temporary solution needed for comment-linking. + // The long term solution will leverage end-to-end encryption and only targeted users will be able to decrypt. + if (ReportActionsUtils.isWhisperActionTargetedToOthers(props.action)) { + return null; + } + const hasErrors = !_.isEmpty(props.action.errors); const whisperedToAccountIDs = props.action.whisperedToAccountIDs || []; const isWhisper = whisperedToAccountIDs.length > 0; diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index afe97b2b95c1..1caa951d16a7 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -117,6 +117,7 @@ export default withOnyx; + /** Metadata of the report currently being looked at */ + reportMetadata: OnyxEntry; + /** The policies which the user has access to */ policies: OnyxCollection; @@ -42,28 +45,29 @@ export default function ( return function (WrappedComponent: ComponentType>) { function WithReportOrNotFound(props: TProps, ref: ForwardedRef) { const contentShown = React.useRef(false); + const isReportIdInRoute = !!props.route.params.reportID?.length; + const isReportLoaded = !isEmptyObject(props.report) && !!props.report?.reportID; - const isReportIdInRoute = props.route.params.reportID?.length; + // The `isLoadingInitialReportActions` value will become `false` only after the first OpenReport API call is finished (either succeeded or failed) + const shouldFetchReport = isReportIdInRoute && props.reportMetadata?.isLoadingInitialReportActions !== false; // When accessing certain report-dependant pages (e.g. Task Title) by deeplink, the OpenReport API is not called, // So we need to call OpenReport API here to make sure the report data is loaded if it exists on the Server useEffect(() => { - if (!isReportIdInRoute || !isEmptyObject(props.report)) { + if (isReportLoaded || !shouldFetchReport) { // If the report is not required or is already loaded, we don't need to call the API return; } Report.openReport(props.route.params.reportID); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isReportIdInRoute, props.route.params.reportID]); + }, [shouldFetchReport, isReportLoaded, props.route.params.reportID]); if (shouldRequireReportID || isReportIdInRoute) { - const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.report ?? {}).length || !props.report?.reportID); - - const shouldShowNotFoundPage = - !Object.entries(props.report ?? {}).length || !props.report?.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas); + const shouldShowFullScreenLoadingIndicator = !isReportLoaded && (props.isLoadingReportData !== false || shouldFetchReport); + const shouldShowNotFoundPage = !isReportLoaded || !ReportUtils.canAccessReport(props.report, props.policies, props.betas); - // If the content was shown but it's not anymore that means the report was deleted and we are probably navigating out of this screen. + // If the content was shown, but it's not anymore, that means the report was deleted, and we are probably navigating out of this screen. // Return null for this case to avoid rendering FullScreenLoadingIndicator or NotFoundPage when animating transition. if (shouldShowNotFoundPage && contentShown.current) { return null; @@ -97,6 +101,9 @@ export default function ( report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, }, + reportMetadata: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${route.params.reportID}`, + }, isLoadingReportData: { key: ONYXKEYS.IS_LOADING_REPORT_DATA, }, diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index 773d6b6d4acc..2fe44639e184 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -1,7 +1,9 @@ /* eslint-disable rulesdir/onyx-props-must-have-default */ +import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useRef} from 'react'; import {InteractionManager, StyleSheet, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import Breadcrumbs from '@components/Breadcrumbs'; import LHNOptionsList from '@components/LHNOptionsList/LHNOptionsList'; @@ -42,7 +44,7 @@ const propTypes = { isActiveReport: PropTypes.func.isRequired, }; -function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport, isCreateMenuOpen}) { +function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priorityMode = CONST.PRIORITY_MODE.DEFAULT, isActiveReport, isCreateMenuOpen, activePolicy}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const modal = useRef({}); @@ -133,9 +135,14 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority `${ONYXKEYS.COLLECTION.POLICY}${activeWorkspaceID}`, + }, +})(SidebarLinks); export {basePropTypes}; diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js index abe6d66b3759..3bd538e8beab 100644 --- a/src/pages/home/sidebar/SidebarLinksData.js +++ b/src/pages/home/sidebar/SidebarLinksData.js @@ -208,6 +208,7 @@ function SidebarLinksData({ isActiveReport={isActiveReport} isLoading={isLoading} optionListItems={optionListItemsWithCurrentReport} + activeWorkspaceID={activeWorkspaceID} /> ); diff --git a/src/pages/iou/MoneyRequestDatePage.js b/src/pages/iou/MoneyRequestDatePage.js deleted file mode 100644 index f6159abd73f6..000000000000 --- a/src/pages/iou/MoneyRequestDatePage.js +++ /dev/null @@ -1,126 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useEffect} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import DatePicker from '@components/DatePicker'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; -import Navigation from '@libs/Navigation/Navigation'; -import * as IOU from '@userActions/IOU'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import {iouDefaultProps, iouPropTypes} from './propTypes'; - -const propTypes = { - /** Onyx Props */ - /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ - iou: iouPropTypes, - - /** Route from navigation */ - route: PropTypes.shape({ - /** Params from the route */ - params: PropTypes.shape({ - /** The type of IOU report, i.e. bill, request, send */ - iouType: PropTypes.string, - - /** The report ID of the IOU */ - reportID: PropTypes.string, - - /** Which field we are editing */ - field: PropTypes.string, - - /** reportID for the "transaction thread" */ - threadReportID: PropTypes.string, - }), - }).isRequired, - - /** The current tab we have navigated to in the request modal. String that corresponds to the request type. */ - selectedTab: PropTypes.oneOf(_.values(CONST.TAB_REQUEST)).isRequired, -}; - -const defaultProps = { - iou: iouDefaultProps, -}; - -function MoneyRequestDatePage({iou, route, selectedTab}) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const iouType = lodashGet(route, 'params.iouType', ''); - const reportID = lodashGet(route, 'params.reportID', ''); - const isDistanceRequest = MoneyRequestUtils.isDistanceRequest(iouType, selectedTab); - - useEffect(() => { - const moneyRequestId = `${iouType}${reportID}`; - const shouldReset = iou.id !== moneyRequestId; - if (shouldReset) { - IOU.resetMoneyRequestInfo(moneyRequestId); - } - - if (!isDistanceRequest && (_.isEmpty(iou.participants) || (iou.amount === 0 && !iou.receiptPath) || shouldReset)) { - Navigation.goBack(ROUTES.MONEY_REQUEST.getRoute(iouType, reportID), true); - } - }, [iou.id, iou.participants, iou.amount, iou.receiptPath, iouType, reportID, isDistanceRequest]); - - function navigateBack() { - Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID)); - } - - /** - * Sets the money request comment by saving it to Onyx. - * - * @param {Object} value - * @param {String} value.moneyRequestCreated - */ - function updateDate(value) { - IOU.setMoneyRequestCreated(value.moneyRequestCreated); - navigateBack(); - } - - return ( - - navigateBack()} - /> - updateDate(value)} - submitButtonText={translate('common.save')} - enabledWhenOffline - > - - - - ); -} - -MoneyRequestDatePage.propTypes = propTypes; -MoneyRequestDatePage.defaultProps = defaultProps; -MoneyRequestDatePage.displayName = 'MoneyRequestDatePage'; - -export default withOnyx({ - iou: { - key: ONYXKEYS.IOU, - }, - selectedTab: { - key: `${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.RECEIPT_TAB_ID}`, - }, -})(MoneyRequestDatePage); diff --git a/src/pages/iou/request/step/IOURequestStepDate.js b/src/pages/iou/request/step/IOURequestStepDate.js index 5677ef46fcfa..d25ca4d26706 100644 --- a/src/pages/iou/request/step/IOURequestStepDate.js +++ b/src/pages/iou/request/step/IOURequestStepDate.js @@ -1,16 +1,24 @@ +import lodashGet from 'lodash/get'; +import lodashIsEmpty from 'lodash/isEmpty'; +import PropTypes from 'prop-types'; import React from 'react'; +import {withOnyx} from 'react-native-onyx'; +import categoryPropTypes from '@components/categoryPropTypes'; import DatePicker from '@components/DatePicker'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; +import tagPropTypes from '@components/tagPropTypes'; import transactionPropTypes from '@components/transactionPropTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import * as IOUUtils from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {policyPropTypes} from '@src/pages/workspace/withPolicy'; import IOURequestStepRoutePropTypes from './IOURequestStepRoutePropTypes'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -23,20 +31,44 @@ const propTypes = { /** Onyx Props */ /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ transaction: transactionPropTypes, + + /** The draft transaction that holds data to be persisted on the current transaction */ + splitDraftTransaction: transactionPropTypes, + + /** The policy of the report */ + policy: policyPropTypes.policy, + + /** Collection of categories attached to a policy */ + policyCategories: PropTypes.objectOf(categoryPropTypes), + + /** Collection of tags attached to a policy */ + policyTags: tagPropTypes, }; const defaultProps = { transaction: {}, + splitDraftTransaction: {}, + policy: null, + policyTags: null, + policyCategories: null, }; function IOURequestStepDate({ route: { - params: {iouType, backTo, transactionID}, + params: {action, iouType, reportID, backTo}, }, transaction, + splitDraftTransaction, + policy, + policyTags, + policyCategories, }) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const isEditing = action === CONST.IOU.ACTION.EDIT; + // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value + const isEditingSplitBill = iouType === CONST.IOU.TYPE.SPLIT && isEditing; + const currentCreated = isEditingSplitBill && !lodashIsEmpty(splitDraftTransaction) ? TransactionUtils.getCreated(splitDraftTransaction) : TransactionUtils.getCreated(transaction); const navigateBack = () => { Navigation.goBack(backTo); @@ -47,7 +79,27 @@ function IOURequestStepDate({ * @param {String} value.moneyRequestCreated */ const updateDate = (value) => { - IOU.setMoneyRequestCreated_temporaryForRefactor(transactionID, value.moneyRequestCreated); + const newCreated = value.moneyRequestCreated; + + // Only update created if it has changed + if (newCreated === currentCreated) { + navigateBack(); + return; + } + + // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value + if (isEditingSplitBill) { + IOU.setDraftSplitTransaction(transaction.transactionID, {created: newCreated}); + navigateBack(); + return; + } + + IOU.setMoneyRequestCreated(transaction.transactionID, newCreated, action === CONST.IOU.ACTION.CREATE); + + if (isEditing) { + IOU.updateMoneyRequestDate(transaction.transactionID, reportID, newCreated, policy, policyTags, policyCategories); + } + navigateBack(); }; @@ -70,7 +122,7 @@ function IOURequestStepDate({ InputComponent={DatePicker} inputID="moneyRequestCreated" label={translate('common.date')} - defaultValue={transaction.created} + defaultValue={currentCreated} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} minDate={CONST.CALENDAR_PICKER.MIN_DATE} /> @@ -83,4 +135,24 @@ IOURequestStepDate.propTypes = propTypes; IOURequestStepDate.defaultProps = defaultProps; IOURequestStepDate.displayName = 'IOURequestStepDate'; -export default compose(withWritableReportOrNotFound, withFullTransactionOrNotFound)(IOURequestStepDate); +export default compose( + withWritableReportOrNotFound, + withFullTransactionOrNotFound, + withOnyx({ + splitDraftTransaction: { + key: ({route}) => { + const transactionID = lodashGet(route, 'params.transactionID', 0); + return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`; + }, + }, + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + }, + policyCategories: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, + }, + policyTags: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, + }, + }), +)(IOURequestStepDate); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js index a1a3ed946967..b23420b5ef69 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js @@ -65,9 +65,26 @@ function IOURequestStepScan({ const camera = useRef(null); const [flash, setFlash] = useState(false); const [cameraPermissionStatus, setCameraPermissionStatus] = useState(undefined); + const askedForPermission = useRef(false); const {translate} = useLocalize(); + const askForPermissions = (showPermissionsAlert = true) => { + // There's no way we can check for the BLOCKED status without requesting the permission first + // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 + CameraPermission.requestCameraPermission() + .then((status) => { + setCameraPermissionStatus(status); + + if (status === RESULTS.BLOCKED && showPermissionsAlert) { + FileUtils.showCameraPermissionsAlert(); + } + }) + .catch(() => { + setCameraPermissionStatus(RESULTS.UNAVAILABLE); + }); + }; + const focusIndicatorOpacity = useSharedValue(0); const focusIndicatorScale = useSharedValue(2); const focusIndicatorPosition = useSharedValue({x: 0, y: 0}); @@ -104,14 +121,22 @@ function IOURequestStepScan({ }); useEffect(() => { - const refreshCameraPermissionStatus = () => { + const refreshCameraPermissionStatus = (shouldAskForPermission = false) => { CameraPermission.getCameraPermissionStatus() - .then(setCameraPermissionStatus) + .then((res) => { + // In android device app data, the status is not set to blocked until denied twice, + // due to that the app will ask for permission twice whenever users opens uses the scan tab + setCameraPermissionStatus(res); + if (shouldAskForPermission && !askedForPermission.current) { + askedForPermission.current = true; + askForPermissions(false); + } + }) .catch(() => setCameraPermissionStatus(RESULTS.UNAVAILABLE)); }; // Check initial camera permission status - refreshCameraPermissionStatus(); + refreshCameraPermissionStatus(true); // Refresh permission status when app gain focus const subscription = AppState.addEventListener('change', (appState) => { @@ -146,22 +171,6 @@ function IOURequestStepScan({ return true; }; - const askForPermissions = () => { - // There's no way we can check for the BLOCKED status without requesting the permission first - // https://github.com/zoontek/react-native-permissions/blob/a836e114ce3a180b2b23916292c79841a267d828/README.md?plain=1#L670 - CameraPermission.requestCameraPermission() - .then((status) => { - setCameraPermissionStatus(status); - - if (status === RESULTS.BLOCKED) { - FileUtils.showCameraPermissionsAlert(); - } - }) - .catch(() => { - setCameraPermissionStatus(RESULTS.UNAVAILABLE); - }); - }; - const navigateBack = () => { Navigation.goBack(); }; @@ -213,6 +222,11 @@ function IOURequestStepScan({ }; const capturePhoto = useCallback(() => { + if (!camera.current && (cameraPermissionStatus === RESULTS.DENIED || cameraPermissionStatus === RESULTS.BLOCKED)) { + askForPermissions(cameraPermissionStatus !== RESULTS.DENIED); + return; + } + const showCameraAlert = () => { Alert.alert(translate('receipt.cameraErrorTitle'), translate('receipt.cameraErrorMessage')); }; @@ -245,7 +259,7 @@ function IOURequestStepScan({ showCameraAlert(); Log.warn('Error taking photo', error); }); - }, [flash, action, translate, transactionID, updateScanAndNavigate, navigateToConfirmationStep]); + }, [flash, action, translate, transactionID, updateScanAndNavigate, navigateToConfirmationStep, cameraPermissionStatus]); // Wait for camera permission status to render if (cameraPermissionStatus == null) { @@ -278,7 +292,7 @@ function IOURequestStepScan({ text={translate('common.continue')} accessibilityLabel={translate('common.continue')} style={[styles.p9, styles.pt5]} - onPress={askForPermissions} + onPress={capturePhoto} /> )} diff --git a/src/pages/settings/Preferences/PreferencesPage.js b/src/pages/settings/Preferences/PreferencesPage.js index 6a9af62e75c5..5ac78f6d20c6 100755 --- a/src/pages/settings/Preferences/PreferencesPage.js +++ b/src/pages/settings/Preferences/PreferencesPage.js @@ -90,6 +90,18 @@ function PreferencesPage(props) { /> + + + {translate('preferencesPage.muteAllSounds')} + + + + + ; -} - -export default AppleSignInDesktopPage; diff --git a/src/pages/signin/AppleSignInDesktopPage/index.website.tsx b/src/pages/signin/AppleSignInDesktopPage/index.website.tsx new file mode 100644 index 000000000000..e23280a83132 --- /dev/null +++ b/src/pages/signin/AppleSignInDesktopPage/index.website.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ThirdPartySignInPage from '@pages/signin/ThirdPartySignInPage'; +import CONST from '@src/CONST'; + +function AppleSignInDesktopPage() { + return ( + + ); +} + +export default AppleSignInDesktopPage; diff --git a/src/pages/signin/DesktopRedirectPage.js b/src/pages/signin/DesktopRedirectPage.tsx similarity index 95% rename from src/pages/signin/DesktopRedirectPage.js rename to src/pages/signin/DesktopRedirectPage.tsx index 1c2521be09e2..1c57df514f31 100644 --- a/src/pages/signin/DesktopRedirectPage.js +++ b/src/pages/signin/DesktopRedirectPage.tsx @@ -6,8 +6,6 @@ import * as App from '@userActions/App'; * Landing page for when a user enters third party login flow on desktop. * Allows user to open the link in browser if they accidentally canceled the auto-prompt. * Also allows them to continue to the web app if they want to use it there. - * - * @returns {React.Component} */ function DesktopRedirectPage() { return ; diff --git a/src/pages/signin/DesktopSignInRedirectPage/index.js b/src/pages/signin/DesktopSignInRedirectPage/index.ts similarity index 100% rename from src/pages/signin/DesktopSignInRedirectPage/index.js rename to src/pages/signin/DesktopSignInRedirectPage/index.ts diff --git a/src/pages/signin/DesktopSignInRedirectPage/index.website.js b/src/pages/signin/DesktopSignInRedirectPage/index.website.tsx similarity index 100% rename from src/pages/signin/DesktopSignInRedirectPage/index.website.js rename to src/pages/signin/DesktopSignInRedirectPage/index.website.tsx diff --git a/src/pages/signin/GoogleSignInDesktopPage/index.js b/src/pages/signin/GoogleSignInDesktopPage/index.ts similarity index 100% rename from src/pages/signin/GoogleSignInDesktopPage/index.js rename to src/pages/signin/GoogleSignInDesktopPage/index.ts diff --git a/src/pages/signin/GoogleSignInDesktopPage/index.website.js b/src/pages/signin/GoogleSignInDesktopPage/index.website.js deleted file mode 100644 index b9451460dfb0..000000000000 --- a/src/pages/signin/GoogleSignInDesktopPage/index.website.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import ThirdPartySignInPage from '@pages/signin/ThirdPartySignInPage'; -import CONST from '@src/CONST'; - -function GoogleSignInDesktopPage() { - return ; -} - -export default GoogleSignInDesktopPage; diff --git a/src/pages/signin/GoogleSignInDesktopPage/index.website.tsx b/src/pages/signin/GoogleSignInDesktopPage/index.website.tsx new file mode 100644 index 000000000000..dda1a512e37d --- /dev/null +++ b/src/pages/signin/GoogleSignInDesktopPage/index.website.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ThirdPartySignInPage from '@pages/signin/ThirdPartySignInPage'; +import CONST from '@src/CONST'; + +function GoogleSignInDesktopPage() { + return ( + + ); +} + +export default GoogleSignInDesktopPage; diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js index bf54d02f778f..f77285190e62 100644 --- a/src/pages/tasks/NewTaskPage.js +++ b/src/pages/tasks/NewTaskPage.js @@ -17,6 +17,7 @@ import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import playSound, {SOUNDS} from '@libs/Sound'; import reportPropTypes from '@pages/reportPropTypes'; import * as Task from '@userActions/Task'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -124,6 +125,7 @@ function NewTaskPage(props) { return; } + playSound(SOUNDS.DONE); Task.createTaskAndNavigate( parentReport.reportID, props.task.title, diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index 5c77f3a03191..36a9647fb486 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -114,7 +114,7 @@ function WorkspaceNewRoomPage(props) { key: policy.id, value: policy.id, }), - ), + ).sort((a, b) => a.label.toLowerCase().localeCompare(b.label.toLowerCase())), [props.policies], ); const [policyID, setPolicyID] = useState(() => { diff --git a/src/pages/workspace/WorkspacePageWithSections.tsx b/src/pages/workspace/WorkspacePageWithSections.tsx index 70198f38f18c..09fe372bd1da 100644 --- a/src/pages/workspace/WorkspacePageWithSections.tsx +++ b/src/pages/workspace/WorkspacePageWithSections.tsx @@ -21,7 +21,6 @@ import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {Policy, ReimbursementAccount, User} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {PolicyRoute} from './withPolicy'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; @@ -41,9 +40,6 @@ type WorkspacePageWithSectionsProps = WithPolicyAndFullscreenLoadingProps & /** The text to display in the header */ headerText: string; - /** The route object passed to this page from the navigator */ - route: PolicyRoute; - /** Main content of the page */ children: (hasVBA: boolean, policyID: string, isUsingECard: boolean) => ReactNode; @@ -84,7 +80,7 @@ function fetchData(skipVBBACal?: boolean) { } function WorkspacePageWithSections({ - backButtonRoute = '', + backButtonRoute, children = () => null, footer = null, guidesCallTaskID = '', diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 74798c40a5fb..86b3d2aae51f 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -6,6 +6,7 @@ import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import FeatureList from '@components/FeatureList'; +import type {FeatureListItem} from '@components/FeatureList'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; @@ -77,7 +78,7 @@ type WorkspaceListPageOnyxProps = { type WorkspaceListPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceListPageOnyxProps; -const workspaceFeatures = [ +const workspaceFeatures: FeatureListItem[] = [ { icon: Illustrations.MoneyReceipts, translationKey: 'workspace.emptyWorkspace.features.trackAndCollect', @@ -124,7 +125,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r return; } - Policy.deleteWorkspace(policyIDToDelete, [], policyNameToDelete); + Policy.deleteWorkspace(policyIDToDelete, policyNameToDelete); setIsDeleteModalOpen(false); }; @@ -207,7 +208,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, r } return ( - + - - {props.translate('workspace.card.noVBACopy')} - - - - - - ); -} - -WorkspaceCardNoVBAView.propTypes = propTypes; -WorkspaceCardNoVBAView.displayName = 'WorkspaceCardNoVBAView'; - -export default withLocalize(WorkspaceCardNoVBAView); diff --git a/src/pages/workspace/card/WorkspaceCardNoVBAView.tsx b/src/pages/workspace/card/WorkspaceCardNoVBAView.tsx new file mode 100644 index 000000000000..cd6293298830 --- /dev/null +++ b/src/pages/workspace/card/WorkspaceCardNoVBAView.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import {View} from 'react-native'; +import ConnectBankAccountButton from '@components/ConnectBankAccountButton'; +import * as Illustrations from '@components/Icon/Illustrations'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import UnorderedList from '@components/UnorderedList'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +type WorkspaceCardNoVBAViewProps = { + /** The policy ID currently being configured */ + policyID: string; +}; + +function WorkspaceCardNoVBAView({policyID}: WorkspaceCardNoVBAViewProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const unorderedListItems = [translate('workspace.card.benefit1'), translate('workspace.card.benefit2'), translate('workspace.card.benefit3'), translate('workspace.card.benefit4')]; + + return ( +
+ + {translate('workspace.card.noVBACopy')} + + + + +
+ ); +} + +WorkspaceCardNoVBAView.displayName = 'WorkspaceCardNoVBAView'; + +export default WorkspaceCardNoVBAView; diff --git a/src/pages/workspace/card/WorkspaceCardPage.js b/src/pages/workspace/card/WorkspaceCardPage.tsx similarity index 62% rename from src/pages/workspace/card/WorkspaceCardPage.js rename to src/pages/workspace/card/WorkspaceCardPage.tsx index 90dc1662cd76..710ef3735026 100644 --- a/src/pages/workspace/card/WorkspaceCardPage.js +++ b/src/pages/workspace/card/WorkspaceCardPage.tsx @@ -1,43 +1,35 @@ -import PropTypes from 'prop-types'; +import type {StackScreenProps} from '@react-navigation/stack'; import React from 'react'; import {View} from 'react-native'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import type {CentralPaneNavigatorParamList} from '@libs/Navigation/types'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; import WorkspaceCardNoVBAView from './WorkspaceCardNoVBAView'; import WorkspaceCardVBANoECardView from './WorkspaceCardVBANoECardView'; import WorkspaceCardVBAWithECardView from './WorkspaceCardVBAWithECardView'; -const propTypes = { - /** The route object passed to this page from the navigator */ - route: PropTypes.shape({ - /** Each parameter passed via the URL */ - params: PropTypes.shape({ - /** The policyID that is being configured */ - policyID: PropTypes.string.isRequired, - }).isRequired, - }).isRequired, +type WorkspaceCardPageProps = StackScreenProps; - ...withLocalizePropTypes, -}; - -function WorkspaceCardPage(props) { +function WorkspaceCardPage({route}: WorkspaceCardPageProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const {isSmallScreenWidth} = useWindowDimensions(); return ( - {(hasVBA, policyID, isUsingECard) => ( + {(hasVBA?: boolean, policyID?: string, isUsingECard?: boolean) => ( - {!hasVBA && } + {!hasVBA && } {hasVBA && !isUsingECard && } @@ -48,7 +40,6 @@ function WorkspaceCardPage(props) { ); } -WorkspaceCardPage.propTypes = propTypes; WorkspaceCardPage.displayName = 'WorkspaceCardPage'; -export default withLocalize(WorkspaceCardPage); +export default WorkspaceCardPage; diff --git a/src/pages/workspace/card/WorkspaceCardVBANoECardView.js b/src/pages/workspace/card/WorkspaceCardVBANoECardView.tsx similarity index 52% rename from src/pages/workspace/card/WorkspaceCardVBANoECardView.js rename to src/pages/workspace/card/WorkspaceCardVBANoECardView.tsx index 7e2e5b835b74..02874a96ba76 100644 --- a/src/pages/workspace/card/WorkspaceCardVBANoECardView.js +++ b/src/pages/workspace/card/WorkspaceCardVBANoECardView.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -7,46 +8,37 @@ import * as Illustrations from '@components/Icon/Illustrations'; import Section from '@components/Section'; import Text from '@components/Text'; import UnorderedList from '@components/UnorderedList'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import userPropTypes from '@pages/settings/userPropTypes'; import * as Link from '@userActions/Link'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {User} from '@src/types/onyx'; -const propTypes = { +type WorkspaceCardVBANoECardViewOnyxProps = { /** Information about the logged in user's account */ - user: userPropTypes, - - ...withLocalizePropTypes, + user: OnyxEntry; }; -const defaultProps = { - user: {}, -}; +type WorkspaceCardVBANoECardViewProps = WorkspaceCardVBANoECardViewOnyxProps; -function WorkspaceCardVBANoECardView(props) { +function WorkspaceCardVBANoECardView({user}: WorkspaceCardVBANoECardViewProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + const unorderedListItems = [translate('workspace.card.benefit1'), translate('workspace.card.benefit2'), translate('workspace.card.benefit3'), translate('workspace.card.benefit4')]; + return ( <>
- +
- {Boolean(props.user.isCheckingDomain) && {props.translate('workspace.card.checkingDomain')}} + {!!user?.isCheckingDomain && {translate('workspace.card.checkingDomain')}} ); } -WorkspaceCardVBANoECardView.propTypes = propTypes; -WorkspaceCardVBANoECardView.defaultProps = defaultProps; WorkspaceCardVBANoECardView.displayName = 'WorkspaceCardVBANoECardView'; -export default compose( - withLocalize, - withOnyx({ - user: { - key: ONYXKEYS.USER, - }, - }), -)(WorkspaceCardVBANoECardView); +export default withOnyx({ + user: { + key: ONYXKEYS.USER, + }, +})(WorkspaceCardVBANoECardView); diff --git a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.tsx similarity index 66% rename from src/pages/workspace/card/WorkspaceCardVBAWithECardView.js rename to src/pages/workspace/card/WorkspaceCardVBAWithECardView.tsx index b5a4872770a9..61c2e43f23bf 100644 --- a/src/pages/workspace/card/WorkspaceCardVBAWithECardView.js +++ b/src/pages/workspace/card/WorkspaceCardVBAWithECardView.tsx @@ -2,28 +2,29 @@ import React from 'react'; import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; +import type {MenuItemWithLink} from '@components/MenuItemList'; import Section from '@components/Section'; import Text from '@components/Text'; import UnorderedList from '@components/UnorderedList'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Link from '@userActions/Link'; -const propTypes = { - ...withLocalizePropTypes, -}; - const MENU_LINKS = { ISSUE_AND_MANAGE_CARDS: 'domain_companycards', RECONCILE_CARDS: encodeURI('domain_companycards?param={"section":"cardReconciliation"}'), SETTLEMENT_FREQUENCY: encodeURI('domain_companycards?param={"section":"configureSettings"}'), -}; +} as const; -function WorkspaceCardVBAWithECardView(props) { +function WorkspaceCardVBAWithECardView() { const styles = useThemeStyles(); - const menuItems = [ + const {translate} = useLocalize(); + + const unorderedListItems = [translate('workspace.card.benefit1'), translate('workspace.card.benefit2'), translate('workspace.card.benefit3'), translate('workspace.card.benefit4')]; + + const menuItems: MenuItemWithLink[] = [ { - title: props.translate('workspace.common.issueAndManageCards'), + title: translate('workspace.common.issueAndManageCards'), onPress: () => Link.openOldDotLink(MENU_LINKS.ISSUE_AND_MANAGE_CARDS), icon: Expensicons.ExpensifyCard, shouldShowRightIcon: true, @@ -32,7 +33,7 @@ function WorkspaceCardVBAWithECardView(props) { link: () => Link.buildOldDotURL(MENU_LINKS.ISSUE_AND_MANAGE_CARDS), }, { - title: props.translate('workspace.common.reconcileCards'), + title: translate('workspace.common.reconcileCards'), onPress: () => Link.openOldDotLink(MENU_LINKS.RECONCILE_CARDS), icon: Expensicons.ReceiptSearch, shouldShowRightIcon: true, @@ -41,7 +42,7 @@ function WorkspaceCardVBAWithECardView(props) { link: () => Link.buildOldDotURL(MENU_LINKS.RECONCILE_CARDS), }, { - title: props.translate('workspace.common.settlementFrequency'), + title: translate('workspace.common.settlementFrequency'), onPress: () => Link.openOldDotLink(MENU_LINKS.SETTLEMENT_FREQUENCY), icon: Expensicons.Gear, shouldShowRightIcon: true, @@ -53,30 +54,22 @@ function WorkspaceCardVBAWithECardView(props) { return (
- {props.translate('workspace.card.VBAWithECardCopy')} + {translate('workspace.card.VBAWithECardCopy')} - +
); } -WorkspaceCardVBAWithECardView.propTypes = propTypes; WorkspaceCardVBAWithECardView.displayName = 'WorkspaceCardVBAWithECardView'; -export default withLocalize(WorkspaceCardVBAWithECardView); +export default WorkspaceCardVBAWithECardView; diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js deleted file mode 100644 index 2ed09212233c..000000000000 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.js +++ /dev/null @@ -1,172 +0,0 @@ -import lodashGet from 'lodash/get'; -import React, {useEffect} from 'react'; -import {Keyboard, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {withNetwork} from '@components/OnyxProvider'; -import Picker from '@components/Picker'; -import TextInput from '@components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; -import compose from '@libs/compose'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; -import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator'; -import Navigation from '@libs/Navigation/Navigation'; -import * as NumberUtils from '@libs/NumberUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; -import withPolicy, {policyDefaultProps, policyPropTypes} from '@pages/workspace/withPolicy'; -import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as Policy from '@userActions/Policy'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -const propTypes = { - ...policyPropTypes, - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, -}; - -const defaultProps = { - reimbursementAccount: {}, - ...policyDefaultProps, -}; - -function WorkspaceRateAndUnitPage(props) { - useEffect(() => { - if (lodashGet(props, 'policy.customUnits', []).length !== 0) { - return; - } - - BankAccounts.setReimbursementAccountLoading(true); - Policy.openWorkspaceReimburseView(props.policy.id); - }, [props]); - - const unitItems = [ - {label: props.translate('common.kilometers'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, - {label: props.translate('common.miles'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, - ]; - - const saveUnitAndRate = (unit, rate) => { - const distanceCustomUnit = _.find(lodashGet(props, 'policy.customUnits', {}), (customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - if (!distanceCustomUnit) { - return; - } - const currentCustomUnitRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (r) => r.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - const unitID = lodashGet(distanceCustomUnit, 'customUnitID', ''); - const unitName = lodashGet(distanceCustomUnit, 'name', ''); - const rateNumValue = PolicyUtils.getNumericValue(rate, props.toLocaleDigit); - - const newCustomUnit = { - customUnitID: unitID, - name: unitName, - attributes: {unit}, - rates: { - ...currentCustomUnitRate, - rate: rateNumValue * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET, - }, - }; - Policy.updateWorkspaceCustomUnitAndRate(props.policy.id, distanceCustomUnit, newCustomUnit, props.policy.lastModified); - }; - - const submit = (values) => { - saveUnitAndRate(values.unit, values.rate); - Keyboard.dismiss(); - Navigation.goBack(); - }; - - const validate = (values) => { - const errors = {}; - const decimalSeparator = props.toLocaleDigit('.'); - const outputCurrency = lodashGet(props, 'policy.outputCurrency', CONST.CURRENCY.USD); - // Allow one more decimal place for accuracy - const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i'); - if (!rateValueRegex.test(values.rate) || values.rate === '') { - errors.rate = 'workspace.reimburse.invalidRateError'; - } else if (NumberUtils.parseFloatAnyLocale(values.rate) <= 0) { - errors.rate = 'workspace.reimburse.lowRateError'; - } - return errors; - }; - - const distanceCustomUnit = _.find(lodashGet(props, 'policy.customUnits', {}), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const distanceCustomRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - - return ( - - {() => ( - - - Policy.clearCustomUnitErrors(props.policy.id, lodashGet(distanceCustomUnit, 'customUnitID', ''), lodashGet(distanceCustomRate, 'customUnitRateID', '')) - } - > - - - - - - - - )} - - ); -} - -WorkspaceRateAndUnitPage.propTypes = propTypes; -WorkspaceRateAndUnitPage.defaultProps = defaultProps; -WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage'; - -export default compose( - withPolicy, - withLocalize, - withNetwork(), - withOnyx({ - reimbursementAccount: { - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - }, - }), - withThemeStyles, -)(WorkspaceRateAndUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx new file mode 100644 index 000000000000..1cafc20e5c3f --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/InitialPage.tsx @@ -0,0 +1,175 @@ +import React, {useEffect, useMemo} from 'react'; +import {ScrollView, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {withNetwork} from '@components/OnyxProvider'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as BankAccounts from '@userActions/BankAccounts'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Network, ReimbursementAccount, WorkspaceRateAndUnit} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type WorkspaceRateAndUnitPageBaseProps = WithPolicyProps & { + // eslint-disable-next-line react/no-unused-prop-types + network: OnyxEntry; +}; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; + // eslint-disable-next-line react/no-unused-prop-types + reimbursementAccount: OnyxEntry; +}; + +type WorkspaceRateAndUnitPageProps = WorkspaceRateAndUnitPageBaseProps & WorkspaceRateAndUnitOnyxProps; + +function WorkspaceRateAndUnitPage(props: WorkspaceRateAndUnitPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const customUnits = props.policy?.customUnits ?? {}; + if (!isEmptyObject(customUnits)) { + return; + } + + BankAccounts.setReimbursementAccountLoading(true); + Policy.openWorkspaceReimburseView(props.policy?.id ?? ''); + }, [props]); + + const unitItems = useMemo( + () => ({ + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: translate('workspace.reimburse.kilometers'), + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: translate('workspace.reimburse.miles'), + }), + [translate], + ); + + const saveUnitAndRate = (newUnit: Unit, newRate: string) => { + const distanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + if (!distanceCustomUnit) { + return; + } + const currentCustomUnitRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + const unitID = distanceCustomUnit.customUnitID ?? ''; + const unitName = distanceCustomUnit.name ?? ''; + + const newCustomUnit = { + customUnitID: unitID, + name: unitName, + attributes: {unit: newUnit}, + rates: { + ...currentCustomUnitRate, + rate: parseFloat(newRate), + }, + }; + Policy.updateWorkspaceCustomUnitAndRate(props.policy?.id ?? '', distanceCustomUnit, newCustomUnit, props.policy?.lastModified); + }; + + const distanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceCustomRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + + const unitValue = props.workspaceRateAndUnit?.unit ?? distanceCustomUnit?.attributes.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + const rateValue = props.workspaceRateAndUnit?.rate ?? distanceCustomRate?.rate?.toString() ?? ''; + + const submit = () => { + saveUnitAndRate(unitValue, rateValue); + Policy.clearOnyxDataForReimburseView(); + Navigation.goBack(); + }; + + return ( + + {() => ( + + + + Policy.clearCustomUnitErrors(props.policy?.id ?? '', distanceCustomUnit?.customUnitID ?? '', distanceCustomRate?.customUnitRateID ?? '')} + > + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT_RATE.getRoute(props.policy?.id ?? ''))} + shouldShowRightIcon + /> + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT_UNIT.getRoute(props.policy?.id ?? ''))} + shouldShowRightIcon + /> + + + + + submit()} + enabledWhenOffline + buttonText={translate('common.save')} + containerStyles={[styles.mh0, styles.mt5, styles.flex1, styles.ph5]} + isAlertVisible={false} + /> + + + )} + + ); +} + +WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage'; + +export default compose( + withOnyx({ + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, + withNetwork(), +)(WorkspaceRateAndUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx new file mode 100644 index 000000000000..37723fe654c0 --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx @@ -0,0 +1,119 @@ +import React, {useEffect, useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapperWithRef from '@components/Form/InputWrapper'; +import type {OnyxFormValuesFields} from '@components/Form/types'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator'; +import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import * as NumberUtils from '@libs/NumberUtils'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {WorkspaceRateAndUnit} from '@src/types/onyx'; + +type WorkspaceRatePageBaseProps = WithPolicyProps; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; +}; + +type WorkspaceRatePageProps = WorkspaceRatePageBaseProps & WorkspaceRateAndUnitOnyxProps; + +function WorkspaceRatePage(props: WorkspaceRatePageProps) { + const styles = useThemeStyles(); + const {translate, toLocaleDigit} = useLocalize(); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const submit = (values: OnyxFormValuesFields) => { + const rate = values.rate as string; + Policy.setRateForReimburseView((parseFloat(rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET).toFixed(1)); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); + }; + + const validate = (values: OnyxFormValuesFields) => { + const errors: {rate?: string} = {}; + const rate = values.rate as string; + const parsedRate = MoneyRequestUtils.replaceAllDigits(rate, toLocaleDigit); + const decimalSeparator = toLocaleDigit('.'); + const outputCurrency = props.policy?.outputCurrency ?? CONST.CURRENCY.USD; + // Allow one more decimal place for accuracy + const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i'); + if (!rateValueRegex.test(parsedRate) || parsedRate === '') { + errors.rate = 'workspace.reimburse.invalidRateError'; + } else if (NumberUtils.parseFloatAnyLocale(parsedRate) <= 0) { + errors.rate = 'workspace.reimburse.lowRateError'; + } + return errors; + }; + + const defaultValue = useMemo(() => { + const defaultDistanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceCustomRate = Object.values(defaultDistanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + return distanceCustomRate?.rate ?? 0; + }, [props.policy?.customUnits]); + + return ( + + {() => ( + + + + )} + + ); +} + +WorkspaceRatePage.displayName = 'WorkspaceRatePage'; + +export default compose( + withOnyx({ + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, +)(WorkspaceRatePage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx new file mode 100644 index 000000000000..07f31d53193e --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx @@ -0,0 +1,111 @@ +import React, {useEffect, useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import SelectionList from '@components/SelectionList'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import compose from '@libs/compose'; +import Navigation from '@libs/Navigation/Navigation'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {WorkspaceRateAndUnit} from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; + +type OptionRow = { + value: Unit; + text: string; + keyForList: string; + isSelected: boolean; +}; + +type WorkspaceUnitPageBaseProps = WithPolicyProps; + +type WorkspaceRateAndUnitOnyxProps = { + workspaceRateAndUnit: OnyxEntry; +}; + +type WorkspaceUnitPageProps = WorkspaceUnitPageBaseProps & WorkspaceRateAndUnitOnyxProps; +function WorkspaceUnitPage(props: WorkspaceUnitPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const unitItems = useMemo( + () => ({ + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS]: translate('workspace.reimburse.kilometers'), + [CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES]: translate('workspace.reimburse.miles'), + }), + [translate], + ); + + useEffect(() => { + if (props.workspaceRateAndUnit?.policyID === props.policy?.id) { + return; + } + Policy.setPolicyIDForReimburseView(props.policy?.id ?? ''); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateUnit = (unit: Unit) => { + Policy.setUnitForReimburseView(unit); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); + }; + + const defaultValue = useMemo(() => { + const defaultDistanceCustomUnit = Object.values(props.policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + return defaultDistanceCustomUnit?.attributes.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES; + }, [props.policy?.customUnits]); + + const unitOptions = useMemo(() => { + const arr: OptionRow[] = []; + Object.entries(unitItems).forEach(([unit, label]) => { + arr.push({ + value: unit as Unit, + text: label, + keyForList: unit, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + isSelected: (props.workspaceRateAndUnit?.unit || defaultValue) === unit, + }); + }); + return arr; + }, [defaultValue, props.workspaceRateAndUnit?.unit, unitItems]); + + return ( + + {() => ( + <> + {translate('workspace.reimburse.trackDistanceChooseUnit')} + + updateUnit(unit.value)} + initiallyFocusedOptionKey={unitOptions.find((unit) => unit.isSelected)?.keyForList} + /> + + )} + + ); +} + +WorkspaceUnitPage.displayName = 'WorkspaceUnitPage'; + +export default compose( + withOnyx({ + workspaceRateAndUnit: { + key: ONYXKEYS.WORKSPACE_RATE_AND_UNIT, + }, + }), + withPolicy, +)(WorkspaceUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseView.js b/src/pages/workspace/reimburse/WorkspaceReimburseView.js index 8749ea53bfc5..636675098d23 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseView.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseView.js @@ -150,7 +150,10 @@ function WorkspaceReimburseView(props) { title={currentRatePerUnit} description={translate('workspace.reimburse.trackDistanceRate')} shouldShowRightIcon - onPress={() => Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy.id))} + onPress={() => { + Policy.setPolicyIDForReimburseView(props.policy.id); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy.id)); + }} wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3]} brickRoadIndicator={(lodashGet(distanceCustomUnit, 'errors') || lodashGet(distanceCustomRate, 'errors')) && CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR} /> diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 147377521e88..1230c4ae03c2 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -21,7 +21,7 @@ const darkTheme = { textSupporting: colors.productDark800, text: colors.productDark900, textColorfulBackground: colors.ivory, - syntax: colors.productDark600, + syntax: colors.productDark800, link: colors.blue300, linkHover: colors.blue100, buttonDefaultBG: colors.productDark400, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 4768ca7f774c..ad0bdfb296ce 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -21,7 +21,7 @@ const lightTheme = { textSupporting: colors.productLight800, text: colors.productLight900, textColorfulBackground: colors.ivory, - syntax: colors.productLight600, + syntax: colors.productLight800, link: colors.blue600, linkHover: colors.blue500, buttonDefaultBG: colors.productLight400, diff --git a/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts b/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts index 902b94021b32..4cdeac2d9570 100644 --- a/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts +++ b/src/styles/utils/FontUtils/fontFamily/multiFontFamily.ts @@ -8,7 +8,7 @@ import type FontFamilyStyles from './types'; const fontFamily: FontFamilyStyles = { EXP_NEUE_ITALIC: 'ExpensifyNeue-Italic, Segoe UI Emoji, Noto Color Emoji', EXP_NEUE_BOLD: multiBold, - EXP_NEUE: 'ExpensifyNeue-Regular, Segoe UI Emoji, Noto Color Emoji', + EXP_NEUE: 'ExpensifyNeue-Regular, Segoe UI Emoji', EXP_NEW_KANSAS_MEDIUM: 'ExpensifyNewKansas-Medium, Segoe UI Emoji, Noto Color Emoji', EXP_NEW_KANSAS_MEDIUM_ITALIC: 'ExpensifyNewKansas-MediumItalic, Segoe UI Emoji, Noto Color Emoji', SYSTEM: 'System', @@ -20,7 +20,7 @@ const fontFamily: FontFamilyStyles = { if (getOperatingSystem() === CONST.OS.WINDOWS) { Object.keys(fontFamily).forEach((key) => { - fontFamily[key as keyof FontFamilyStyles] = fontFamily[key as keyof FontFamilyStyles].replace('Segoe UI Emoji', 'Windows Segoe UI Emoji'); + fontFamily[key as keyof FontFamilyStyles] = fontFamily[key as keyof FontFamilyStyles].replace('Segoe UI Emoji', 'Noto Color Emoji'); }); } diff --git a/src/styles/utils/pointerEventsNone/index.native.ts b/src/styles/utils/pointerEventsNone/index.native.ts deleted file mode 100644 index 1503fd9f68f0..000000000000 --- a/src/styles/utils/pointerEventsNone/index.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type PointerEventsNone from './types'; - -const pointerEventsNone: PointerEventsNone = {}; - -export default pointerEventsNone; diff --git a/src/types/modules/react-native-key-command.d.ts b/src/types/modules/react-native-key-command.d.ts index 4c7f07bd6d7e..d7f9641c5033 100644 --- a/src/types/modules/react-native-key-command.d.ts +++ b/src/types/modules/react-native-key-command.d.ts @@ -26,7 +26,10 @@ declare module 'react-native-key-command' { type KeyCommand = {input: string; modifierFlags?: string}; declare function addListener(keyCommand: KeyCommand, callback: (keycommandEvent: KeyCommand, event: KeyboardEvent) => void): () => void; + declare function registerKeyCommands(): void; + declare function unregisterKeyCommands(): void; + declare function eventEmitter(): void; // eslint-disable-next-line import/prefer-default-export - export {constants, addListener}; + export {constants, addListener, registerKeyCommands, unregisterKeyCommands, eventEmitter}; } diff --git a/src/types/onyx/OnyxUpdatesFromServer.ts b/src/types/onyx/OnyxUpdatesFromServer.ts index b3932bbb7841..f6104ed470a0 100644 --- a/src/types/onyx/OnyxUpdatesFromServer.ts +++ b/src/types/onyx/OnyxUpdatesFromServer.ts @@ -2,7 +2,10 @@ import type {OnyxUpdate} from 'react-native-onyx'; import type Request from './Request'; import type Response from './Response'; -type OnyxServerUpdate = OnyxUpdate & {shouldNotify?: boolean}; +type OnyxServerUpdate = OnyxUpdate & { + shouldNotify?: boolean; + shouldShowPushNotification?: boolean; +}; type OnyxUpdateEvent = { eventType: string; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index d5ed5dd36aba..afdc236ca043 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -10,7 +10,7 @@ type Rate = { currency?: string; customUnitRateID?: string; errors?: OnyxCommon.Errors; - pendingAction?: string; + pendingAction?: OnyxCommon.PendingAction; }; type Attributes = { @@ -22,7 +22,7 @@ type CustomUnit = { customUnitID: string; attributes: Attributes; rates: Record; - pendingAction?: string; + pendingAction?: OnyxCommon.PendingAction; errors?: OnyxCommon.Errors; }; @@ -177,4 +177,4 @@ type Policy = { export default Policy; -export type {Unit, CustomUnit}; +export type {Unit, CustomUnit, Attributes, Rate}; diff --git a/src/types/onyx/TransactionViolation.ts b/src/types/onyx/TransactionViolation.ts index e7be24d5cb18..b1764b4aeb80 100644 --- a/src/types/onyx/TransactionViolation.ts +++ b/src/types/onyx/TransactionViolation.ts @@ -13,12 +13,11 @@ type TransactionViolation = { data?: { rejectedBy?: string; rejectReason?: string; - amount?: string; + formattedLimit?: string; surcharge?: number; invoiceMarkup?: number; maxAge?: number; tagName?: string; - formattedLimitAmount?: string; categoryLimit?: string; limit?: string; category?: string; diff --git a/src/types/onyx/User.ts b/src/types/onyx/User.ts index f770b22b2272..973f09e16b82 100644 --- a/src/types/onyx/User.ts +++ b/src/types/onyx/User.ts @@ -5,6 +5,9 @@ type User = { /** Whether we should use the staging version of the secure API server */ shouldUseStagingServer?: boolean; + /** Whether user muted all sounds in application */ + isMutedAllSounds?: boolean; + /** Is the user account validated? */ validated: boolean; diff --git a/src/types/onyx/WorkspaceRateAndUnit.ts b/src/types/onyx/WorkspaceRateAndUnit.ts new file mode 100644 index 000000000000..a374239c93f8 --- /dev/null +++ b/src/types/onyx/WorkspaceRateAndUnit.ts @@ -0,0 +1,14 @@ +type Unit = 'mi' | 'km'; + +type WorkspaceRateAndUnit = { + /** policyID of the Workspace */ + policyID: string; + + /** Unit of the Workspace */ + unit?: Unit; + + /** Unit of the Workspace */ + rate?: string; +}; + +export default WorkspaceRateAndUnit; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index aae3b6f2532a..e7f4422c9fed 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -84,6 +84,7 @@ import type WalletOnfido from './WalletOnfido'; import type WalletStatement from './WalletStatement'; import type WalletTerms from './WalletTerms'; import type WalletTransfer from './WalletTransfer'; +import type WorkspaceRateAndUnit from './WorkspaceRateAndUnit'; export type { Account, @@ -163,6 +164,7 @@ export type { WalletStatement, WalletTerms, WalletTransfer, + WorkspaceRateAndUnit, WorkspaceSettingsForm, ReportUserIsTyping, PolicyReportField, diff --git a/tests/unit/ErrorUtilsTest.js b/tests/unit/ErrorUtilsTest.ts similarity index 81% rename from tests/unit/ErrorUtilsTest.js rename to tests/unit/ErrorUtilsTest.ts index 7a46c71a1aa1..9168c1ca12a5 100644 --- a/tests/unit/ErrorUtilsTest.js +++ b/tests/unit/ErrorUtilsTest.ts @@ -1,50 +1,51 @@ -import * as ErrorUtils from '../../src/libs/ErrorUtils'; +import * as ErrorUtils from '@src/libs/ErrorUtils'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; describe('ErrorUtils', () => { test('should add a new error message for a given inputID', () => { - const errors = {}; + const errors: Errors = {}; ErrorUtils.addErrorMessage(errors, 'username', 'Username cannot be empty'); expect(errors).toEqual({username: ['Username cannot be empty', {isTranslated: true}]}); }); test('should append an error message to an existing error message for a given inputID', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, 'username', 'Username must be at least 6 characters long'); expect(errors).toEqual({username: ['Username cannot be empty\nUsername must be at least 6 characters long', {isTranslated: true}]}); }); test('should add an error to input which does not contain any errors yet', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, 'password', 'Password cannot be empty'); expect(errors).toEqual({username: 'Username cannot be empty', password: ['Password cannot be empty', {isTranslated: true}]}); }); test('should not mutate the errors object when message is empty', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, 'username', ''); expect(errors).toEqual({username: 'Username cannot be empty'}); }); test('should not mutate the errors object when inputID is null', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, null, 'InputID cannot be null'); expect(errors).toEqual({username: 'Username cannot be empty'}); }); test('should not mutate the errors object when message is null', () => { - const errors = {username: 'Username cannot be empty'}; + const errors: Errors = {username: 'Username cannot be empty'}; ErrorUtils.addErrorMessage(errors, 'username', null); expect(errors).toEqual({username: 'Username cannot be empty'}); }); test('should add multiple error messages for the same inputID', () => { - const errors = {}; + const errors: Errors = {}; ErrorUtils.addErrorMessage(errors, 'username', 'Username cannot be empty'); ErrorUtils.addErrorMessage(errors, 'username', 'Username must be at least 6 characters long'); ErrorUtils.addErrorMessage(errors, 'username', 'Username must contain at least one letter'); @@ -53,7 +54,7 @@ describe('ErrorUtils', () => { }); test('should append multiple error messages to an existing error message for the same inputID', () => { - const errors = {username: 'Username cannot be empty\nUsername must be at least 6 characters long'}; + const errors: Errors = {username: 'Username cannot be empty\nUsername must be at least 6 characters long'}; ErrorUtils.addErrorMessage(errors, 'username', 'Username must contain at least one letter'); ErrorUtils.addErrorMessage(errors, 'username', 'Username must not contain special characters'); diff --git a/tests/unit/generateMonthMatrixTest.js b/tests/unit/generateMonthMatrixTest.ts similarity index 85% rename from tests/unit/generateMonthMatrixTest.js rename to tests/unit/generateMonthMatrixTest.ts index 67dd65e6b1fd..6f81ebc5c585 100644 --- a/tests/unit/generateMonthMatrixTest.js +++ b/tests/unit/generateMonthMatrixTest.ts @@ -1,8 +1,10 @@ -import generateMonthMatrix from '../../src/components/DatePicker/CalendarPicker/generateMonthMatrix'; +import generateMonthMatrix from '@src/components/DatePicker/CalendarPicker/generateMonthMatrix'; + +type MonthMatrix = Array>; describe('generateMonthMatrix', () => { it('returns the correct matrix for January 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, null, 1, 2], [3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16], @@ -14,7 +16,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for February 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, 1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12, 13], [14, 15, 16, 17, 18, 19, 20], @@ -25,7 +27,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for leap year February 2020', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, null, 1, 2], [3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16], @@ -36,7 +38,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for March 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, 1, 2, 3, 4, 5, 6], [7, 8, 9, 10, 11, 12, 13], [14, 15, 16, 17, 18, 19, 20], @@ -47,7 +49,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for April 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, 1, 2, 3], [4, 5, 6, 7, 8, 9, 10], [11, 12, 13, 14, 15, 16, 17], @@ -58,7 +60,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for December 2022', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, 1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11], [12, 13, 14, 15, 16, 17, 18], @@ -69,7 +71,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for January 2025', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, 1, 2, 3, 4, 5], [6, 7, 8, 9, 10, 11, 12], [13, 14, 15, 16, 17, 18, 19], @@ -80,7 +82,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for February 2025', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, null, 1, 2], [3, 4, 5, 6, 7, 8, 9], [10, 11, 12, 13, 14, 15, 16], @@ -91,7 +93,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for June 2025', () => { - const expected = [ + const expected: MonthMatrix = [ [null, null, null, null, null, null, 1], [2, 3, 4, 5, 6, 7, 8], [9, 10, 11, 12, 13, 14, 15], @@ -103,7 +105,7 @@ describe('generateMonthMatrix', () => { }); it('returns the correct matrix for December 2025', () => { - const expected = [ + const expected: MonthMatrix = [ [1, 2, 3, 4, 5, 6, 7], [8, 9, 10, 11, 12, 13, 14], [15, 16, 17, 18, 19, 20, 21], @@ -125,13 +127,6 @@ describe('generateMonthMatrix', () => { expect(() => generateMonthMatrix(-1, 0)).toThrow(); }); - it('throws an error if year or month is not a number', () => { - expect(() => generateMonthMatrix()).toThrow(); - expect(() => generateMonthMatrix(2022, 'invalid')).toThrow(); - expect(() => generateMonthMatrix('2022', '0')).toThrow(); - expect(() => generateMonthMatrix(null, undefined)).toThrow(); - }); - it('returns a matrix with 6 rows and 7 columns for January 2022', () => { const matrix = generateMonthMatrix(2022, 0); expect(matrix.length).toBe(6);