diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js index 47f4b7968566..d3f5ecfc0a5c 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -24,6 +24,7 @@ jest.doMock('react-native', () => { BootSplash: { getVisibilityStatus: jest.fn(), hide: jest.fn(), + navigationBarHeight: 0, }, StartupTimer: {stop: jest.fn()}, }, diff --git a/android/app/build.gradle b/android/app/build.gradle index 089d89f9afe1..a61340620405 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -106,8 +106,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001031505 - versionName "1.3.15-5" + versionCode 1001031605 + versionName "1.3.16-5" } splits { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4202dd4162fb..05302f43ef8d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,10 +8,10 @@ + mPromiseQueue = new BootSplashQueue<>(); private static boolean mShouldKeepOnScreen = true; @Nullable @@ -39,6 +47,24 @@ public String getName() { return NAME; } + @Override + public Map getConstants() { + final HashMap constants = new HashMap<>(); + final Context context = getReactApplicationContext(); + final Resources resources = context.getResources(); + + @SuppressLint({"DiscouragedApi", "InternalInsetResource"}) final int heightResId = + resources.getIdentifier("navigation_bar_height", "dimen", "android"); + + final float height = + heightResId > 0 && !ViewConfiguration.get(context).hasPermanentMenuKey() + ? Math.round(PixelUtil.toDIPFromPixel(resources.getDimensionPixelSize(heightResId))) + : 0; + + constants.put("navigationBarHeight", height); + return constants; + } + protected static void init(@Nullable final Activity activity) { if (activity == null) { FLog.w(ReactConstants.TAG, NAME + ": Ignored initialization, current activity is null."); @@ -68,13 +94,14 @@ public boolean onPreDraw() { }); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // This is not called on Android 12 when activity is started using Android studio / notifications + // This is not called on Android 12 when activity is started using intent + // (Android studio / CLI / notification / widget…) activity .getSplashScreen() .setOnExitAnimationListener(new SplashScreen.OnExitAnimationListener() { @Override public void onSplashScreenExit(@NonNull SplashScreenView view) { - view.remove(); // Remove it without animation + view.remove(); // Remove it immediately, without animation } }); } @@ -96,35 +123,39 @@ public void run() { }); } - private void waitAndHide() { - final Timer timer = new Timer(); + private void clearPromiseQueue() { + while (!mPromiseQueue.isEmpty()) { + Promise promise = mPromiseQueue.shift(); - timer.schedule(new TimerTask() { - @Override - public void run() { - hide(); - timer.cancel(); - } - }, 250); + if (promise != null) + promise.resolve(true); + } } - @ReactMethod - public void hide() { + private void hideAndClearPromiseQueue() { UiThreadUtil.runOnUiThread(new Runnable() { @Override public void run() { final Activity activity = getReactApplicationContext().getCurrentActivity(); - if (activity == null || activity.isFinishing()) { - waitAndHide(); - return; - } + if (mShouldKeepOnScreen || activity == null || activity.isFinishing()) { + final Timer timer = new Timer(); - if (mDialog != null) { + timer.schedule(new TimerTask() { + @Override + public void run() { + timer.cancel(); + hideAndClearPromiseQueue(); + } + }, 100); + } else if (mDialog == null) { + clearPromiseQueue(); + } else { mDialog.setOnDismissListener(new DialogInterface.OnDismissListener() { @Override public void onDismiss(DialogInterface dialog) { mDialog = null; + clearPromiseQueue(); } }); @@ -134,8 +165,14 @@ public void onDismiss(DialogInterface dialog) { }); } + @ReactMethod + public void hide(final Promise promise) { + mPromiseQueue.push(promise); + hideAndClearPromiseQueue(); + } + @ReactMethod public void getVisibilityStatus(final Promise promise) { - promise.resolve(mDialog != null ? "visible" : "hidden"); + promise.resolve(mShouldKeepOnScreen || mDialog != null ? "visible" : "hidden"); } } diff --git a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashQueue.java b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashQueue.java new file mode 100644 index 000000000000..4e35a066708c --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashQueue.java @@ -0,0 +1,28 @@ +package com.expensify.chat.bootsplash; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Vector; + +/** + * Represents a first-in-first-out (FIFO) thread safe queue of objects. + * Its source code is based on Java internal Stack. + */ +public class BootSplashQueue extends Vector { + + @Nullable + public synchronized T shift() { + if (size() == 0) { + return null; + } + + T item = elementAt(0); + removeElementAt(0); + + return item; + } + + public void push(@NonNull T item) { + addElement(item); + } +} diff --git a/android/app/src/main/res/anim/fade_out.xml b/android/app/src/main/res/anim/fade_out.xml deleted file mode 100644 index 049a8e36ddad..000000000000 --- a/android/app/src/main/res/anim/fade_out.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/android/app/src/main/res/mipmap-hdpi/bootsplash_logo.png b/android/app/src/main/res/mipmap-hdpi/bootsplash_logo.png index 5fc519ee898a..95124d59275e 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/bootsplash_logo.png and b/android/app/src/main/res/mipmap-hdpi/bootsplash_logo.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/bootsplash_logo.png b/android/app/src/main/res/mipmap-mdpi/bootsplash_logo.png index 2cacc654e77c..c6b62d8cac9b 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/bootsplash_logo.png and b/android/app/src/main/res/mipmap-mdpi/bootsplash_logo.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/bootsplash_logo.png b/android/app/src/main/res/mipmap-xhdpi/bootsplash_logo.png index 600f8bd7f0fb..a3f54d63e0ee 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/bootsplash_logo.png and b/android/app/src/main/res/mipmap-xhdpi/bootsplash_logo.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/bootsplash_logo.png b/android/app/src/main/res/mipmap-xxhdpi/bootsplash_logo.png index b341ad440d37..06b2bfc8447b 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/bootsplash_logo.png and b/android/app/src/main/res/mipmap-xxhdpi/bootsplash_logo.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/bootsplash_logo.png b/android/app/src/main/res/mipmap-xxxhdpi/bootsplash_logo.png index 7959763c13c2..c49a0f3bb854 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/bootsplash_logo.png and b/android/app/src/main/res/mipmap-xxxhdpi/bootsplash_logo.png differ diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index b4d8c2181b0b..07a41cec581f 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -1,5 +1,6 @@ #03D47C + #061B09 #FFFFFF #03D47C #0b1b34 diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 19d4257a8a77..c789cdfef09f 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -6,7 +6,7 @@ - - diff --git a/assets/images/new-expensify-dark.svg b/assets/images/new-expensify-dark.svg new file mode 100644 index 000000000000..567cc667e972 --- /dev/null +++ b/assets/images/new-expensify-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/webpack/webpack.common.js b/config/webpack/webpack.common.js index 7c6d1b9de3a9..9bae001c2b53 100644 --- a/config/webpack/webpack.common.js +++ b/config/webpack/webpack.common.js @@ -1,11 +1,11 @@ const path = require('path'); +const fs = require('fs'); const {IgnorePlugin, DefinePlugin, ProvidePlugin, EnvironmentPlugin} = require('webpack'); const {CleanWebpackPlugin} = require('clean-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const dotenv = require('dotenv'); const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer'); -const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin'); const FontPreloadPlugin = require('webpack-font-preload-plugin'); const CustomVersionFilePlugin = require('./CustomVersionFilePlugin'); @@ -52,7 +52,6 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ devtool: 'source-map', entry: { main: ['babel-polyfill', './index.js'], - splash: ['./web/splash/splash.js'], }, output: { filename: '[name]-[contenthash].bundle.js', @@ -73,10 +72,9 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ new HtmlWebpackPlugin({ template: 'web/index.html', filename: 'index.html', + splashLogo: fs.readFileSync(path.resolve(__dirname, `../../assets/images/new-expensify${mapEnvToLogoSuffix(envFile)}.svg`), 'utf-8'), usePolyfillIO: platform === 'web', - }), - new HtmlInlineScriptPlugin({ - scriptMatchPattern: [/splash.+[.]js$/], + isStaging: envFile === '.env.staging', }), new FontPreloadPlugin({ extensions: ['woff2'], @@ -173,18 +171,6 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ }, ], }, - { - test: /splash.css$/i, - use: [ - { - loader: 'style-loader', - options: { - insert: 'head', - injectType: 'singletonStyleTag', - }, - }, - ], - }, { test: /\.css$/i, use: ['style-loader', 'css-loader'], @@ -201,7 +187,6 @@ const webpackConfig = ({envFile = '.env', platform = 'web'}) => ({ }, resolve: { alias: { - logo$: path.resolve(__dirname, `../../assets/images/new-expensify${mapEnvToLogoSuffix(envFile)}.svg`), 'react-native-config': 'react-web-config', 'react-native$': '@expensify/react-native-web', 'react-native-web': '@expensify/react-native-web', diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo.png b/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo.png index 8fbef1c5ab06..6b031c1bd43d 100644 Binary files a/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo.png and b/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo.png differ diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo@2x.png b/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo@2x.png index 186a2f85e1dd..d1a1700c1c03 100644 Binary files a/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo@2x.png and b/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo@2x.png differ diff --git a/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo@3x.png b/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo@3x.png index e208d1e0f8ab..32c8c76a2a37 100644 Binary files a/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo@3x.png and b/ios/NewExpensify/Images.xcassets/BootSplashLogo.imageset/bootsplash_logo@3x.png differ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index beb91c4abb0d..d66f9cfc3664 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.15 + 1.3.16 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.3.15.5 + 1.3.16.5 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensify/RCTBootSplash.m b/ios/NewExpensify/RCTBootSplash.m index 5e32ffc659ff..bceac70efdcf 100644 --- a/ios/NewExpensify/RCTBootSplash.m +++ b/ios/NewExpensify/RCTBootSplash.m @@ -10,8 +10,9 @@ #import "RCTBootSplash.h" +static NSMutableArray *_resolverQueue = nil; static RCTRootView *_rootView = nil; -static bool _hideHasBeenCalled = false; +static bool _nativeHidden = false; @implementation RCTBootSplash @@ -39,11 +40,22 @@ + (void)initWithStoryboard:(NSString * _Nonnull)storyboardName UIStoryboard *storyboard = [UIStoryboard storyboardWithName:storyboardName bundle:nil]; UIView *loadingView = [[storyboard instantiateInitialViewController] view]; - if (_hideHasBeenCalled) + if ([self resolverQueueExists]) return; [_rootView setLoadingView:loadingView]; + [NSTimer scheduledTimerWithTimeInterval:0.35 + repeats:NO + block:^(NSTimer * _Nonnull timer) { + // wait for native iOS launch screen to fade out + _nativeHidden = true; + + // hide has been called before native launch screen fade out + if ([self resolverQueueExists]) + [self hideAndClearResolverQueue]; + }]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onContentDidAppear) name:RCTContentDidAppearNotification @@ -59,28 +71,29 @@ + (bool)isHidden { return _rootView == nil || _rootView.loadingView == nil || [_rootView.loadingView isHidden]; } -+ (void)hideWithFade:(bool)fade { - if ([self isHidden]) ++ (bool)resolverQueueExists { + return _resolverQueue != nil; +} + ++ (void)clearResolverQueue { + if (![self resolverQueueExists]) return; - if (fade) { - dispatch_async(dispatch_get_main_queue(), ^{ - [UIView transitionWithView:_rootView - duration:0.250 - options:UIViewAnimationOptionTransitionCrossDissolve - animations:^{ - _rootView.loadingView.hidden = YES; - } - completion:^(__unused BOOL finished) { - [_rootView.loadingView removeFromSuperview]; - _rootView.loadingView = nil; - }]; - }); - } else { + while ([_resolverQueue count] > 0) { + RCTPromiseResolveBlock resolve = [_resolverQueue objectAtIndex:0]; + [_resolverQueue removeObjectAtIndex:0]; + resolve(@(true)); + } +} + ++ (void)hideAndClearResolverQueue { + if (![self isHidden]) { _rootView.loadingView.hidden = YES; [_rootView.loadingView removeFromSuperview]; _rootView.loadingView = nil; } + + [RCTBootSplash clearResolverQueue]; } + (void)onContentDidAppear { @@ -89,28 +102,36 @@ + (void)onContentDidAppear { block:^(NSTimer * _Nonnull timer) { [timer invalidate]; - _hideHasBeenCalled = true; - [self hideWithFade:true]; + if (_resolverQueue == nil) + _resolverQueue = [[NSMutableArray alloc] init]; + + [self hideAndClearResolverQueue]; }]; [[NSNotificationCenter defaultCenter] removeObserver:self]; } + (void)onJavaScriptDidFailToLoad { - [self hideWithFade:false]; + [self hideAndClearResolverQueue]; [[NSNotificationCenter defaultCenter] removeObserver:self]; } -RCT_EXPORT_METHOD(hide) { - if (!_hideHasBeenCalled && !RCTRunningInAppExtension()) { - _hideHasBeenCalled = true; - [RCTBootSplash hideWithFade:true]; - } +RCT_EXPORT_METHOD(hide:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + if (_resolverQueue == nil) + _resolverQueue = [[NSMutableArray alloc] init]; + + [_resolverQueue addObject:resolve]; + + if ([RCTBootSplash isHidden] || RCTRunningInAppExtension()) + return [RCTBootSplash clearResolverQueue]; + + if (_nativeHidden) + return [RCTBootSplash hideAndClearResolverQueue]; } -RCT_REMAP_METHOD(getVisibilityStatus, - getVisibilityStatusWithResolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { +RCT_EXPORT_METHOD(getVisibilityStatus:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { resolve([RCTBootSplash isHidden] ? @"hidden" : @"visible"); } diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 98bb06c2fb27..0ccf71908353 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.15 + 1.3.16 CFBundleSignature ???? CFBundleVersion - 1.3.15.5 + 1.3.16.5 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 75cca4de91b3..43c64fa15997 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -670,7 +670,7 @@ PODS: - React-Core - RNReactNativeHapticFeedback (1.14.0): - React-Core - - RNReanimated (3.0.0-rc.10): + - RNReanimated (3.1.0): - DoubleConversion - FBLazyVector - FBReactNativeSpec @@ -1124,7 +1124,7 @@ SPEC CHECKSUMS: RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c - RNReanimated: fbc356493970e3acddc15586b1bccb5eab3ff1ec + RNReanimated: b1220a0e5168745283ff5d53bfc7d2144b2cee1b RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9 RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d diff --git a/jest/setup.js b/jest/setup.js index 407059251579..228f3a22f33b 100644 --- a/jest/setup.js +++ b/jest/setup.js @@ -1,3 +1,4 @@ +import 'setimmediate'; import 'react-native-gesture-handler/jestSetup'; import * as reanimatedJestUtils from 'react-native-reanimated/src/reanimated2/jestUtils'; import setupMockImages from './setupMockImages'; diff --git a/package-lock.json b/package-lock.json index 0b8f86ea8e97..17d8159aef7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.15-5", + "version": "1.3.16-5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.15-5", + "version": "1.3.16-5", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -34,13 +34,14 @@ "@react-navigation/drawer": "github:Expensify/react-navigation#react-navigation-drawer-v6.5.0-alpha1-gitpkg", "@react-navigation/native": "6.0.13", "@react-navigation/stack": "6.3.1", + "@react-ng/bounds-observer": "^0.2.1", "@ua/react-native-airship": "^15.2.3", "awesome-phonenumber": "^5.4.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", @@ -67,7 +68,7 @@ "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", "react-native-document-picker": "^8.0.0", - "react-native-fast-image": "git+https://github.com/Expensify/react-native-fast-image.git#afedf204bfc253d18f08fdcc5356a2bb82f6a87c", + "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "2.9.0", "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5", "react-native-haptic-feedback": "^1.13.0", @@ -85,7 +86,7 @@ "react-native-plaid-link-sdk": "^10.0.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", - "react-native-reanimated": "3.0.0-rc.10", + "react-native-reanimated": "3.1.0", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.17.0", @@ -154,7 +155,6 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^0.5.13", "flipper-plugin-bridgespy-client": "^0.1.9", - "html-inline-script-webpack-plugin": "^3.1.0", "html-webpack-plugin": "^5.5.0", "jest": "29.4.1", "jest-circus": "29.4.1", @@ -171,6 +171,7 @@ "react-native-performance-flipper-reporter": "^2.0.0", "react-native-svg-transformer": "^1.0.0", "react-test-renderer": "18.1.0", + "setimmediate": "^1.0.5", "shellcheck": "^1.1.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", @@ -2594,6 +2595,11 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@html-ng/bounding-client-rect-observer": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@html-ng/bounding-client-rect-observer/-/bounding-client-rect-observer-0.1.3.tgz", + "integrity": "sha512-RV1Lz23ckbpOgU1bNGxxTS4XTCEFGxiXoEmi8EOHtzTVzS+AEMkoqxllugn6IHEMqNkbcHipURRupEJe8Dsp1g==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.5.0", "dev": true, @@ -7266,6 +7272,38 @@ "react-native-screens": ">= 3.0.0" } }, + "node_modules/@react-ng/bounds-observer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-ng/bounds-observer/-/bounds-observer-0.2.1.tgz", + "integrity": "sha512-i0h7x0qOLJz+JKxhOpngHFob6PH2Qmra85aQ0e/viS1yYgidoBvPJHn8WPGn5LXff98fE+fPhngsaD7FSbxcwQ==", + "dependencies": { + "@html-ng/bounding-client-rect-observer": "^0.1.3", + "@types/react": "^18.0.31", + "@types/react-dom": "^18.0.11", + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@react-ng/bounds-observer/node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/@react-ng/bounds-observer/node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@sentry/browser": { "version": "7.11.1", "license": "BSD-3-Clause", @@ -15136,8 +15174,7 @@ }, "node_modules/@types/prop-types": { "version": "15.7.5", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/qs": { "version": "6.9.7", @@ -15157,15 +15194,23 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.0.24", - "license": "MIT", - "peer": true, + "version": "18.2.6", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.6.tgz", + "integrity": "sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "18.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.4.tgz", + "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-native": { "version": "0.70.6", "license": "MIT", @@ -15189,8 +15234,7 @@ }, "node_modules/@types/scheduler": { "version": "0.16.2", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/seedrandom": { "version": "2.4.30", @@ -23476,8 +23520,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", - "integrity": "sha512-0XZtJOzpH5cwaWMeW/25ZEazELbFd65Q/SPqoOyEhSWx/rarhZZNGYyQlcfHfj+c1wk1eSUEVL1nEIT9VOAUNg==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", + "integrity": "sha512-xFCTXFz295JjSSYOjcDvR1FeN6rx+t2vCz64ahqm+TohI0eCWWJSzdUq/2jvrDm72e509rjdYwYcLWy4PumeYA==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -25293,19 +25337,6 @@ "version": "2.0.2", "license": "MIT" }, - "node_modules/html-inline-script-webpack-plugin": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0", - "npm": ">=6.0.0" - }, - "peerDependencies": { - "html-webpack-plugin": "^5.0.0", - "webpack": "^5.0.0" - } - }, "node_modules/html-minifier-terser": { "version": "6.1.0", "dev": true, @@ -34898,8 +34929,6 @@ }, "node_modules/react-native-fast-image": { "version": "8.6.3", - "resolved": "git+ssh://git@github.com/Expensify/react-native-fast-image.git#afedf204bfc253d18f08fdcc5356a2bb82f6a87c", - "integrity": "sha512-jfARLcakkTtqh9jVkGWkJ+3zlpMmcoGH/6LHdnLuestM90Tn3cTPjWfWQloCYJmFwK/O2f4m1SHp1BOs0VR+cw==", "license": "(MIT AND Apache-2.0)", "peerDependencies": { "react": "^17 || ^18", @@ -35157,23 +35186,31 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.0.0-rc.10", - "license": "MIT", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.1.0.tgz", + "integrity": "sha512-8YJR7yHnrqK6yKWzkGLVEawi1WZqJ9bGIehKEnE8zG58yLrSwUZe1T220XTbftpkA3r37Sy0kJJ/HOOiaIU+HQ==", "dependencies": { "@babel/plugin-transform-object-assign": "^7.16.7", "@babel/preset-typescript": "^7.16.7", - "convert-source-map": "^1.7.0", - "invariant": "^2.2.4", - "lodash.isequal": "^4.5.0", - "setimmediate": "^1.0.5", - "string-hash-64": "^1.0.3" + "convert-source-map": "^2.0.0", + "invariant": "^2.2.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0-0", + "@babel/plugin-proposal-optional-chaining": "^7.0.0-0", + "@babel/plugin-transform-arrow-functions": "^7.0.0-0", + "@babel/plugin-transform-shorthand-properties": "^7.0.0-0", + "@babel/plugin-transform-template-literals": "^7.0.0-0", "react": "*", "react-native": "*" } }, + "node_modules/react-native-reanimated/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, "node_modules/react-native-render-html": { "version": "6.3.1", "license": "BSD-2-Clause", @@ -37485,7 +37522,8 @@ }, "node_modules/setimmediate": { "version": "1.0.5", - "license": "MIT" + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" }, "node_modules/setprototypeof": { "version": "1.2.0", @@ -38361,10 +38399,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string-hash-64": { - "version": "1.0.3", - "license": "MIT" - }, "node_modules/string-length": { "version": "4.0.2", "license": "MIT", @@ -43233,6 +43267,11 @@ "@hapi/hoek": "^9.0.0" } }, + "@html-ng/bounding-client-rect-observer": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@html-ng/bounding-client-rect-observer/-/bounding-client-rect-observer-0.1.3.tgz", + "integrity": "sha512-RV1Lz23ckbpOgU1bNGxxTS4XTCEFGxiXoEmi8EOHtzTVzS+AEMkoqxllugn6IHEMqNkbcHipURRupEJe8Dsp1g==" + }, "@humanwhocodes/config-array": { "version": "0.5.0", "dev": true, @@ -46542,6 +46581,37 @@ "warn-once": "^0.1.0" } }, + "@react-ng/bounds-observer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-ng/bounds-observer/-/bounds-observer-0.2.1.tgz", + "integrity": "sha512-i0h7x0qOLJz+JKxhOpngHFob6PH2Qmra85aQ0e/viS1yYgidoBvPJHn8WPGn5LXff98fE+fPhngsaD7FSbxcwQ==", + "requires": { + "@html-ng/bounding-client-rect-observer": "^0.1.3", + "@types/react": "^18.0.31", + "@types/react-dom": "^18.0.11", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "dependencies": { + "react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + } + }, + "scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "requires": { + "loose-envify": "^1.1.0" + } + } + } + }, "@sentry/browser": { "version": "7.11.1", "requires": { @@ -51733,8 +51803,7 @@ "dev": true }, "@types/prop-types": { - "version": "15.7.5", - "peer": true + "version": "15.7.5" }, "@types/qs": { "version": "6.9.7", @@ -51751,14 +51820,23 @@ "dev": true }, "@types/react": { - "version": "18.0.24", - "peer": true, + "version": "18.2.6", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.6.tgz", + "integrity": "sha512-wRZClXn//zxCFW+ye/D2qY65UsYP1Fpex2YXorHc8awoNamkMZSvBxwxdYVInsHOZZd2Ppq8isnSzJL5Mpf8OA==", "requires": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, + "@types/react-dom": { + "version": "18.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.4.tgz", + "integrity": "sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==", + "requires": { + "@types/react": "*" + } + }, "@types/react-native": { "version": "0.70.6", "peer": true, @@ -51778,8 +51856,7 @@ "dev": true }, "@types/scheduler": { - "version": "0.16.2", - "peer": true + "version": "0.16.2" }, "@types/seedrandom": { "version": "2.4.30", @@ -57324,9 +57401,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", - "integrity": "sha512-0XZtJOzpH5cwaWMeW/25ZEazELbFd65Q/SPqoOyEhSWx/rarhZZNGYyQlcfHfj+c1wk1eSUEVL1nEIT9VOAUNg==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", + "integrity": "sha512-xFCTXFz295JjSSYOjcDvR1FeN6rx+t2vCz64ahqm+TohI0eCWWJSzdUq/2jvrDm72e509rjdYwYcLWy4PumeYA==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -58522,11 +58599,6 @@ "html-escaper": { "version": "2.0.2" }, - "html-inline-script-webpack-plugin": { - "version": "3.1.0", - "dev": true, - "requires": {} - }, "html-minifier-terser": { "version": "6.1.0", "dev": true, @@ -64945,9 +65017,7 @@ } }, "react-native-fast-image": { - "version": "git+ssh://git@github.com/Expensify/react-native-fast-image.git#afedf204bfc253d18f08fdcc5356a2bb82f6a87c", - "integrity": "sha512-jfARLcakkTtqh9jVkGWkJ+3zlpMmcoGH/6LHdnLuestM90Tn3cTPjWfWQloCYJmFwK/O2f4m1SHp1BOs0VR+cw==", - "from": "react-native-fast-image@git+https://github.com/Expensify/react-native-fast-image.git#afedf204bfc253d18f08fdcc5356a2bb82f6a87c", + "version": "8.6.3", "requires": {} }, "react-native-flipper": { @@ -65088,15 +65158,21 @@ "requires": {} }, "react-native-reanimated": { - "version": "3.0.0-rc.10", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.1.0.tgz", + "integrity": "sha512-8YJR7yHnrqK6yKWzkGLVEawi1WZqJ9bGIehKEnE8zG58yLrSwUZe1T220XTbftpkA3r37Sy0kJJ/HOOiaIU+HQ==", "requires": { "@babel/plugin-transform-object-assign": "^7.16.7", "@babel/preset-typescript": "^7.16.7", - "convert-source-map": "^1.7.0", - "invariant": "^2.2.4", - "lodash.isequal": "^4.5.0", - "setimmediate": "^1.0.5", - "string-hash-64": "^1.0.3" + "convert-source-map": "^2.0.0", + "invariant": "^2.2.4" + }, + "dependencies": { + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + } } }, "react-native-render-html": { @@ -66562,7 +66638,9 @@ } }, "setimmediate": { - "version": "1.0.5" + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" }, "setprototypeof": { "version": "1.2.0" @@ -67175,9 +67253,6 @@ "safe-buffer": "~5.1.0" } }, - "string-hash-64": { - "version": "1.0.3" - }, "string-length": { "version": "4.0.2", "requires": { diff --git a/package.json b/package.json index b8512f51b2fe..5506d4bc9f51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.15-5", + "version": "1.3.16-5", "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.", @@ -69,13 +69,14 @@ "@react-navigation/drawer": "github:Expensify/react-navigation#react-navigation-drawer-v6.5.0-alpha1-gitpkg", "@react-navigation/native": "6.0.13", "@react-navigation/stack": "6.3.1", + "@react-ng/bounds-observer": "^0.2.1", "@ua/react-native-airship": "^15.2.3", "awesome-phonenumber": "^5.4.0", "babel-plugin-transform-remove-console": "^6.9.4", "babel-polyfill": "^6.26.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#879df466155918ec0e0a6b36176af4211302b184", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#92088df062e60cba227fd1d6fa59ebfcde27d460", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.0", @@ -102,7 +103,7 @@ "react-native-dev-menu": "^4.1.1", "react-native-device-info": "^10.3.0", "react-native-document-picker": "^8.0.0", - "react-native-fast-image": "git+https://github.com/Expensify/react-native-fast-image.git#afedf204bfc253d18f08fdcc5356a2bb82f6a87c", + "react-native-fast-image": "^8.6.3", "react-native-gesture-handler": "2.9.0", "react-native-google-places-autocomplete": "git+https://github.com/Expensify/react-native-google-places-autocomplete.git#6f436a06a3018cb49750bb110b89df75f6a865d5", "react-native-haptic-feedback": "^1.13.0", @@ -120,7 +121,7 @@ "react-native-plaid-link-sdk": "^10.0.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", - "react-native-reanimated": "3.0.0-rc.10", + "react-native-reanimated": "3.1.0", "react-native-render-html": "6.3.1", "react-native-safe-area-context": "4.4.1", "react-native-screens": "3.17.0", @@ -189,7 +190,6 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "^0.5.13", "flipper-plugin-bridgespy-client": "^0.1.9", - "html-inline-script-webpack-plugin": "^3.1.0", "html-webpack-plugin": "^5.5.0", "jest": "29.4.1", "jest-circus": "29.4.1", @@ -206,6 +206,7 @@ "react-native-performance-flipper-reporter": "^2.0.0", "react-native-svg-transformer": "^1.0.0", "react-test-renderer": "18.1.0", + "setimmediate": "^1.0.5", "shellcheck": "^1.1.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", @@ -220,6 +221,17 @@ "overrides": { "react-native": "$react-native" }, + "electronmon": { + "patterns": [ + "!node_modules", + "!node_modules/**/*", + "!**/*.map", + "!ios/**", + "!android/**", + "*.test.*", + "*.spec.*" + ] + }, "engines": { "node": "16.15.1", "npm": "8.11.0" diff --git a/patches/react-native-fast-image+8.6.3.patch b/patches/react-native-fast-image+8.6.3.patch new file mode 100644 index 000000000000..f01b87b7fd91 --- /dev/null +++ b/patches/react-native-fast-image+8.6.3.patch @@ -0,0 +1,300 @@ +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java +new file mode 100644 +index 0000000..2bd58b8 +--- /dev/null ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeDecoder.java +@@ -0,0 +1,44 @@ ++package com.dylanvann.fastimage; ++ ++import android.graphics.BitmapFactory; ++ ++import androidx.annotation.NonNull; ++import androidx.annotation.Nullable; ++import androidx.exifinterface.media.ExifInterface; ++ ++import com.bumptech.glide.load.Options; ++import com.bumptech.glide.load.ResourceDecoder; ++import com.bumptech.glide.load.engine.Resource; ++import com.bumptech.glide.load.resource.SimpleResource; ++ ++import java.io.IOException; ++import java.io.InputStream; ++ ++public class BitmapSizeDecoder implements ResourceDecoder { ++ ++ @Override ++ public boolean handles(@NonNull InputStream source, @NonNull Options options) throws IOException { ++ return true; ++ } ++ ++ @Nullable ++ @Override ++ public Resource decode(@NonNull InputStream source, int width, int height, @NonNull Options options) throws IOException { ++ BitmapFactory.Options bitmapOptions = new BitmapFactory.Options(); ++ bitmapOptions.inJustDecodeBounds = true; ++ BitmapFactory.decodeStream(source, null, bitmapOptions); ++ ++ // BitmapFactory#decodeStream leaves stream's position where ever it was after reading the encoded data ++ // https://developer.android.com/reference/android/graphics/BitmapFactory#decodeStream(java.io.InputStream) ++ // so we need to rewind the stream to be able to read image header with exif values ++ source.reset(); ++ ++ int orientation = new ExifInterface(source).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_UNDEFINED); ++ if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) { ++ int tmpWidth = bitmapOptions.outWidth; ++ bitmapOptions.outWidth = bitmapOptions.outHeight; ++ bitmapOptions.outHeight = tmpWidth; ++ } ++ return new SimpleResource(bitmapOptions); ++ } ++} +\ No newline at end of file +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeTranscoder.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeTranscoder.java +new file mode 100644 +index 0000000..7d208d1 +--- /dev/null ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/BitmapSizeTranscoder.java +@@ -0,0 +1,23 @@ ++package com.dylanvann.fastimage; ++ ++import android.graphics.BitmapFactory; ++ ++import androidx.annotation.NonNull; ++import androidx.annotation.Nullable; ++ ++import com.bumptech.glide.load.Options; ++import com.bumptech.glide.load.engine.Resource; ++import com.bumptech.glide.load.resource.SimpleResource; ++import com.bumptech.glide.load.resource.transcode.ResourceTranscoder; ++ ++public class BitmapSizeTranscoder implements ResourceTranscoder { ++ @Nullable ++ @Override ++ public Resource transcode(@NonNull Resource toTranscode, @NonNull Options options) { ++ BitmapFactory.Options bitmap = toTranscode.get(); ++ Size size = new Size(); ++ size.width = bitmap.outWidth; ++ size.height = bitmap.outHeight; ++ return new SimpleResource(size); ++ } ++} +\ No newline at end of file +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java +index 811292a..f60b87c 100644 +--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageOkHttpProgressGlideModule.java +@@ -2,6 +2,7 @@ package com.dylanvann.fastimage; + + import android.content.Context; + import androidx.annotation.NonNull; ++import android.graphics.BitmapFactory; + + import com.bumptech.glide.Glide; + import com.bumptech.glide.Registry; +@@ -47,6 +48,9 @@ public class FastImageOkHttpProgressGlideModule extends LibraryGlideModule { + .build(); + OkHttpUrlLoader.Factory factory = new OkHttpUrlLoader.Factory(client); + registry.replace(GlideUrl.class, InputStream.class, factory); ++ // Decoder + Transcoder pair for InputStream -> Size ++ registry.prepend(InputStream.class, BitmapFactory.Options.class, new BitmapSizeDecoder()); ++ registry.register(BitmapFactory.Options.class, Size.class, new BitmapSizeTranscoder()); + } + + private static Interceptor createInterceptor(final ResponseProgressListener listener) { +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageRequestListener.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageRequestListener.java +index dbeb813..bf8f21c 100644 +--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageRequestListener.java ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageRequestListener.java +@@ -22,13 +22,6 @@ public class FastImageRequestListener implements RequestListener { + this.key = key; + } + +- private static WritableMap mapFromResource(Drawable resource) { +- WritableMap resourceData = new WritableNativeMap(); +- resourceData.putInt("width", resource.getIntrinsicWidth()); +- resourceData.putInt("height", resource.getIntrinsicHeight()); +- return resourceData; +- } +- + @Override + public boolean onLoadFailed(@androidx.annotation.Nullable GlideException e, Object model, Target target, boolean isFirstResource) { + FastImageOkHttpProgressGlideModule.forget(key); +@@ -53,7 +46,6 @@ public class FastImageRequestListener implements RequestListener { + ThemedReactContext context = (ThemedReactContext) view.getContext(); + RCTEventEmitter eventEmitter = context.getJSModule(RCTEventEmitter.class); + int viewId = view.getId(); +- eventEmitter.receiveEvent(viewId, REACT_ON_LOAD_EVENT, mapFromResource(resource)); + eventEmitter.receiveEvent(viewId, REACT_ON_LOAD_END_EVENT, new WritableNativeMap()); + return false; + } +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java +index 34fcf89..1339f5c 100644 +--- a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/FastImageViewWithUrl.java +@@ -2,6 +2,7 @@ package com.dylanvann.fastimage; + + import static com.dylanvann.fastimage.FastImageRequestListener.REACT_ON_ERROR_EVENT; + ++import androidx.annotation.NonNull; + import android.annotation.SuppressLint; + import android.content.Context; + import android.graphics.drawable.Drawable; +@@ -9,16 +10,24 @@ import android.graphics.drawable.Drawable; + import androidx.annotation.Nullable; + import androidx.appcompat.widget.AppCompatImageView; + ++import com.bumptech.glide.Glide; + import com.bumptech.glide.RequestBuilder; + import com.bumptech.glide.RequestManager; ++import com.bumptech.glide.load.DataSource; ++import com.bumptech.glide.load.engine.GlideException; + import com.bumptech.glide.load.model.GlideUrl; + import com.bumptech.glide.request.Request; ++import com.bumptech.glide.request.RequestListener; ++import com.bumptech.glide.request.target.SimpleTarget; ++import com.bumptech.glide.request.target.Target; ++import com.bumptech.glide.request.transition.Transition; + import com.facebook.react.bridge.ReadableMap; + import com.facebook.react.bridge.WritableMap; + import com.facebook.react.bridge.WritableNativeMap; + import com.facebook.react.uimanager.ThemedReactContext; + import com.facebook.react.uimanager.events.RCTEventEmitter; + ++import java.io.File; + import java.util.ArrayList; + import java.util.Collections; + import java.util.List; +@@ -124,9 +133,34 @@ class FastImageViewWithUrl extends AppCompatImageView { + RCTEventEmitter eventEmitter = context.getJSModule(RCTEventEmitter.class); + int viewId = this.getId(); + +- eventEmitter.receiveEvent(viewId, +- FastImageViewManager.REACT_ON_LOAD_START_EVENT, +- new WritableNativeMap()); ++ // Request the URL from cache to see if it exists there and if so pass the cache ++ // path as an argument in the onLoadStart event ++ requestManager ++ .asFile() ++ .load(glideUrl) ++ .onlyRetrieveFromCache(true) ++ .listener(new RequestListener() { ++ @Override ++ public boolean onLoadFailed(@Nullable GlideException e, Object model, Target target, boolean isFirstResource) { ++ WritableNativeMap result = new WritableNativeMap(); ++ result.putNull("cachePath"); ++ eventEmitter.receiveEvent(viewId, ++ FastImageViewManager.REACT_ON_LOAD_START_EVENT, ++ result); ++ return false; ++ } ++ ++ @Override ++ public boolean onResourceReady(File resource, Object model, Target target, DataSource dataSource, boolean isFirstResource) { ++ WritableNativeMap result = new WritableNativeMap(); ++ result.putString("cachePath", resource.getAbsolutePath()); ++ eventEmitter.receiveEvent(viewId, ++ FastImageViewManager.REACT_ON_LOAD_START_EVENT, ++ result); ++ return false; ++ } ++ }) ++ .submit(); + } + + if (requestManager != null) { +@@ -148,6 +182,25 @@ class FastImageViewWithUrl extends AppCompatImageView { + builder.listener(new FastImageRequestListener(key)); + + builder.into(this); ++ ++ // Used specifically to handle the `onLoad` event for the image ++ RCTEventEmitter eventEmitter = context.getJSModule(RCTEventEmitter.class); ++ int viewId = this.getId(); ++ requestManager ++ .as(Size.class) ++ .load(imageSource == null ? null : imageSource.getSourceForLoad()) ++ .into(new SimpleTarget() { ++ @Override ++ public void onResourceReady(@NonNull Size resource, @Nullable Transition super Size> transition) { ++ WritableMap resourceData = new WritableNativeMap(); ++ resourceData.putInt("width", resource.width); ++ resourceData.putInt("height", resource.height); ++ eventEmitter.receiveEvent(viewId, ++ "onFastImageLoad", ++ resourceData ++ ); ++ } ++ }); + } + } + +diff --git a/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/Size.java b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/Size.java +new file mode 100644 +index 0000000..2fe8a47 +--- /dev/null ++++ b/node_modules/react-native-fast-image/android/src/main/java/com/dylanvann/fastimage/Size.java +@@ -0,0 +1,6 @@ ++package com.dylanvann.fastimage; ++ ++public class Size { ++ int width; ++ int height; ++} +\ No newline at end of file +diff --git a/node_modules/react-native-fast-image/dist/index.d.ts b/node_modules/react-native-fast-image/dist/index.d.ts +index 5abb7c9..a2672c6 100644 +--- a/node_modules/react-native-fast-image/dist/index.d.ts ++++ b/node_modules/react-native-fast-image/dist/index.d.ts +@@ -27,6 +27,11 @@ export declare type Source = { + priority?: Priority; + cache?: Cache; + }; ++export interface OnLoadStartEvent { ++ nativeEvent: { ++ cachePath: string | null; ++ }; ++} + export interface OnLoadEvent { + nativeEvent: { + width: number; +@@ -57,7 +62,7 @@ export interface FastImageProps extends AccessibilityProps, ViewProps { + defaultSource?: ImageRequireSource; + resizeMode?: ResizeMode; + fallback?: boolean; +- onLoadStart?(): void; ++ onLoadStart?(event: OnLoadStartEvent): void; + onProgress?(event: OnProgressEvent): void; + onLoad?(event: OnLoadEvent): void; + onError?(): void; +diff --git a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m +index f710081..391ef92 100644 +--- a/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m ++++ b/node_modules/react-native-fast-image/ios/FastImage/FFFastImageView.m +@@ -54,7 +54,6 @@ - (void) setOnFastImageError: (RCTDirectEventBlock)onFastImageError { + - (void) setOnFastImageLoadStart: (RCTDirectEventBlock)onFastImageLoadStart { + if (_source && !self.hasSentOnLoadStart) { + _onFastImageLoadStart = onFastImageLoadStart; +- onFastImageLoadStart(@{}); + self.hasSentOnLoadStart = YES; + } else { + _onFastImageLoadStart = onFastImageLoadStart; +@@ -188,7 +187,18 @@ - (void) reloadImage { + } + + if (self.onFastImageLoadStart) { +- self.onFastImageLoadStart(@{}); ++ NSString* cachePath = [[SDImageCache sharedImageCache] cachePathForKey:url]; ++ BOOL isCached = [[SDImageCache sharedImageCache] diskImageDataExistsWithKey:url]; ++ if (isCached) { ++ self.onFastImageLoadStart(@{ ++ @"cachePath": cachePath ++ }); ++ } ++ else { ++ self.onFastImageLoadStart(@{ ++ @"cachePath": [NSNull null] ++ }); ++ } + self.hasSentOnLoadStart = YES; + } else { + self.hasSentOnLoadStart = NO; diff --git a/patches/react-native-reanimated+3.1.0.patch b/patches/react-native-reanimated+3.1.0.patch new file mode 100644 index 000000000000..6dc6e0d3bc9b --- /dev/null +++ b/patches/react-native-reanimated+3.1.0.patch @@ -0,0 +1,177 @@ +diff --git a/node_modules/react-native-reanimated/ios/REANodesManager.mm b/node_modules/react-native-reanimated/ios/REANodesManager.mm +index 26bb253..4108293 100644 +--- a/node_modules/react-native-reanimated/ios/REANodesManager.mm ++++ b/node_modules/react-native-reanimated/ios/REANodesManager.mm +@@ -85,19 +85,77 @@ - (void)runSyncUIUpdatesWithObserver:(id)observer + + @end + +-@interface REANodesManager () ++#ifndef RCT_NEW_ARCH_ENABLED + ++@interface REASyncUpdateObserver : NSObject + @end + ++@implementation REASyncUpdateObserver { ++ volatile void (^_mounting)(void); ++ volatile BOOL _waitTimedOut; ++ dispatch_semaphore_t _semaphore; ++} ++ ++- (instancetype)init ++{ ++ self = [super init]; ++ if (self) { ++ _mounting = nil; ++ _waitTimedOut = NO; ++ _semaphore = dispatch_semaphore_create(0); ++ } ++ return self; ++} ++ ++- (void)dealloc ++{ ++ RCTAssert(_mounting == nil, @"Mouting block was set but never executed. This may lead to UI inconsistencies"); ++} ++ ++- (void)unblockUIThread ++{ ++ RCTAssertUIManagerQueue(); ++ dispatch_semaphore_signal(_semaphore); ++} ++ ++- (void)waitAndMountWithTimeout:(NSTimeInterval)timeout ++{ ++ RCTAssertMainQueue(); ++ long result = dispatch_semaphore_wait(_semaphore, dispatch_time(DISPATCH_TIME_NOW, timeout * NSEC_PER_SEC)); ++ if (result != 0) { ++ @synchronized(self) { ++ _waitTimedOut = YES; ++ } ++ } ++ if (_mounting) { ++ _mounting(); ++ _mounting = nil; ++ } ++} ++ ++- (BOOL)uiManager:(RCTUIManager *)manager performMountingWithBlock:(RCTUIManagerMountingBlock)block ++{ ++ RCTAssertUIManagerQueue(); ++ @synchronized(self) { ++ if (_waitTimedOut) { ++ return NO; ++ } else { ++ _mounting = block; ++ return YES; ++ } ++ } ++} ++ ++@end ++ ++#endif ++ + @implementation REANodesManager { + CADisplayLink *_displayLink; + BOOL _wantRunUpdates; + NSMutableArray *_onAnimationCallbacks; + BOOL _tryRunBatchUpdatesSynchronously; + REAEventHandler _eventHandler; +- volatile void (^_mounting)(void); +- NSObject *_syncLayoutUpdatesWaitLock; +- volatile BOOL _syncLayoutUpdatesWaitTimedOut; + NSMutableDictionary *_componentUpdateBuffer; + NSMutableDictionary *_viewRegistry; + #ifdef RCT_NEW_ARCH_ENABLED +@@ -125,7 +183,6 @@ - (nonnull instancetype)initWithModule:(REAModule *)reanimatedModule + _operationsInBatch = [NSMutableDictionary new]; + _componentUpdateBuffer = [NSMutableDictionary new]; + _viewRegistry = [_uiManager valueForKey:@"_viewRegistry"]; +- _syncLayoutUpdatesWaitLock = [NSObject new]; + } + + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onAnimationFrame:)]; +@@ -241,19 +298,6 @@ - (void)onAnimationFrame:(CADisplayLink *)displayLink + } + } + +-- (BOOL)uiManager:(RCTUIManager *)manager performMountingWithBlock:(RCTUIManagerMountingBlock)block +-{ +- RCTAssert(_mounting == nil, @"Mouting block is expected to not be set"); +- @synchronized(_syncLayoutUpdatesWaitLock) { +- if (_syncLayoutUpdatesWaitTimedOut) { +- return NO; +- } else { +- _mounting = block; +- return YES; +- } +- } +-} +- + - (void)performOperations + { + #ifdef RCT_NEW_ARCH_ENABLED +@@ -268,8 +312,7 @@ - (void)performOperations + _tryRunBatchUpdatesSynchronously = NO; + + __weak __typeof__(self) weakSelf = self; +- dispatch_semaphore_t semaphore = dispatch_semaphore_create(0); +- _syncLayoutUpdatesWaitTimedOut = NO; ++ REASyncUpdateObserver *syncUpdateObserver = [REASyncUpdateObserver new]; + RCTExecuteOnUIManagerQueue(^{ + __typeof__(self) strongSelf = weakSelf; + if (strongSelf == nil) { +@@ -278,7 +321,7 @@ - (void)performOperations + BOOL canUpdateSynchronously = trySynchronously && ![strongSelf.uiManager hasEnqueuedUICommands]; + + if (!canUpdateSynchronously) { +- dispatch_semaphore_signal(semaphore); ++ [syncUpdateObserver unblockUIThread]; + } + + for (int i = 0; i < copiedOperationsQueue.count; i++) { +@@ -286,8 +329,8 @@ - (void)performOperations + } + + if (canUpdateSynchronously) { +- [strongSelf.uiManager runSyncUIUpdatesWithObserver:strongSelf]; +- dispatch_semaphore_signal(semaphore); ++ [strongSelf.uiManager runSyncUIUpdatesWithObserver:syncUpdateObserver]; ++ [syncUpdateObserver unblockUIThread]; + } + // In case canUpdateSynchronously=true we still have to send uiManagerWillPerformMounting event + // to observers because some components (e.g. TextInput) update their UIViews only on that event. +@@ -298,17 +341,7 @@ - (void)performOperations + // from CADisplayLink but it is easier to hardcode it for the time being. + // The reason why we use frame duration here is that if takes longer than one frame to complete layout tasks + // there is no point of synchronizing layout with the UI interaction as we get that one frame delay anyways. +- long result = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 16 * NSEC_PER_MSEC)); +- if (result != 0) { +- @synchronized(_syncLayoutUpdatesWaitLock) { +- _syncLayoutUpdatesWaitTimedOut = YES; +- } +- } +- } +- +- if (_mounting) { +- _mounting(); +- _mounting = nil; ++ [syncUpdateObserver waitAndMountWithTimeout:0.016]; + } + } + _wantRunUpdates = NO; +diff --git a/node_modules/react-native-reanimated/mock.js b/node_modules/react-native-reanimated/mock.js +index 68b20d2..b088001 100644 +--- a/node_modules/react-native-reanimated/mock.js ++++ b/node_modules/react-native-reanimated/mock.js +@@ -41,6 +41,9 @@ const Reanimated = { + createAnimatedComponent: (Component) => Component, + addWhitelistedUIProps: NOOP, + addWhitelistedNativeProps: NOOP, ++ ++ // used by react-navigation fork ++ isConfigured: () => true, + }; + + module.exports = { diff --git a/src/CONST.js b/src/CONST.js index 47fb10ec576a..a04c20192037 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -282,6 +282,7 @@ const CONST = { MX: 'MX', AU: 'AU', CA: 'CA', + GB: 'GB', }, DESKTOP_DEEPLINK_APP_STATE: { CHECKING: 'checking', @@ -559,6 +560,7 @@ const CONST = { }, STATE: { SUBMITTED: 'SUBMITTED', + PROCESSING: 'PROCESSING', }, STATE_NUM: { OPEN: 0, @@ -960,6 +962,7 @@ const CONST = { ELSEWHERE: 'Elsewhere', EXPENSIFY: 'Expensify', PAYPAL_ME: 'PayPal.me', + VBBA: 'ACH', }, MONEY_REQUEST_TYPE: { SEND: 'send', diff --git a/src/Expensify.js b/src/Expensify.js index f6831443d907..4fc000302ce7 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -27,6 +27,7 @@ import Navigation from './libs/Navigation/Navigation'; import DeeplinkWrapper from './components/DeeplinkWrapper'; import PopoverReportActionContextMenu from './pages/home/report/ContextMenu/PopoverReportActionContextMenu'; import * as ReportActionContextMenu from './pages/home/report/ContextMenu/ReportActionContextMenu'; +import SplashScreenHider from './components/SplashScreenHider'; import KeyboardShortcutsModal from './components/KeyboardShortcutsModal'; // This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection @@ -86,9 +87,11 @@ function Expensify(props) { const appStateChangeListener = useRef(null); const [isNavigationReady, setIsNavigationReady] = useState(false); const [isOnyxMigrated, setIsOnyxMigrated] = useState(false); - const [isSplashShown, setIsSplashShown] = useState(true); + const [isSplashHidden, setIsSplashHidden] = useState(false); const isAuthenticated = useMemo(() => Boolean(lodashGet(props.session, 'authToken', null)), [props.session]); + const shouldInit = isNavigationReady && (!isAuthenticated || props.isSidebarLoaded); + const shouldHideSplash = shouldInit && !isSplashHidden; const initializeClient = () => { if (!Visibility.isVisible()) { @@ -105,6 +108,10 @@ function Expensify(props) { Navigation.setIsNavigationReady(); }, []); + const onSplashHide = useCallback(() => { + setIsSplashHidden(true); + }, []); + useLayoutEffect(() => { // Initialize this client as being an active client ActiveClientManager.init(); @@ -158,20 +165,6 @@ function Expensify(props) { // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again }, []); - useEffect(() => { - if (!isNavigationReady || !isSplashShown) { - return; - } - - const shouldHideSplash = !isAuthenticated || props.isSidebarLoaded; - - if (shouldHideSplash) { - BootSplash.hide(); - - setIsSplashShown(false); - } - }, [props.isSidebarLoaded, isNavigationReady, isSplashShown, isAuthenticated]); - // Display a blank page until the onyx migration completes if (!isOnyxMigrated) { return null; @@ -179,7 +172,7 @@ function Expensify(props) { return ( - {!isSplashShown && ( + {shouldInit && ( <> @@ -204,6 +197,8 @@ function Expensify(props) { onReady={setNavigationReady} authenticated={isAuthenticated} /> + + {shouldHideSplash && } ); } diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index a5e3c97740da..1805db98f1d5 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -91,6 +91,9 @@ export default { // A unique identifier that each user has that's used to send notifications NVP_PRIVATE_PUSH_NOTIFICATION_ID: 'private_pushNotificationID', + // The NVP with the last payment method used per policy + NVP_LAST_PAYMENT_METHOD: 'nvp_lastPaymentMethod', + // Does this user have push notifications enabled for this device? PUSH_NOTIFICATIONS_ENABLED: 'pushNotificationsEnabled', @@ -194,6 +197,7 @@ export default { CLOSE_ACCOUNT_FORM: 'closeAccount', PROFILE_SETTINGS_FORM: 'profileSettingsForm', DISPLAY_NAME_FORM: 'displayNameForm', + ROOM_NAME_FORM: 'roomNameForm', LEGAL_NAME_FORM: 'legalNameForm', WORKSPACE_INVITE_MESSAGE_FORM: 'workspaceInviteMessageForm', DATE_OF_BIRTH_FORM: 'dateOfBirthForm', diff --git a/src/ROUTES.js b/src/ROUTES.js index 272b0f9b6c70..2dc2338f2e05 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -19,6 +19,7 @@ const SETTINGS_CONTACT_METHODS = 'settings/profile/contact-methods'; export default { BANK_ACCOUNT: 'bank-account', + BANK_ACCOUNT_NEW: 'bank-account/new', BANK_ACCOUNT_WITH_STEP_TO_OPEN: 'bank-account/:stepToOpen?', BANK_ACCOUNT_PERSONAL: 'bank-account/personal', getBankAccountRoute: (stepToOpen = '', policyID = '') => `bank-account/${stepToOpen}?policyID=${policyID}`, @@ -90,9 +91,9 @@ export default { IOU_SEND_ADD_BANK_ACCOUNT: `${IOU_SEND}/add-bank-account`, IOU_SEND_ADD_DEBIT_CARD: `${IOU_SEND}/add-debit-card`, IOU_SEND_ENABLE_PAYMENTS: `${IOU_SEND}/enable-payments`, - getIouRequestCurrencyRoute: (reportID) => `${IOU_REQUEST_CURRENCY}/${reportID}`, - getIouBillCurrencyRoute: (reportID) => `${IOU_BILL_CURRENCY}/${reportID}`, - getIouSendCurrencyRoute: (reportID) => `${IOU_SEND_CURRENCY}/${reportID}`, + getIouRequestCurrencyRoute: (reportID, currency, backTo) => `${IOU_REQUEST_CURRENCY}/${reportID}?currency=${currency}&backTo=${backTo}`, + getIouBillCurrencyRoute: (reportID, currency, backTo) => `${IOU_BILL_CURRENCY}/${reportID}?currency=${currency}&backTo=${backTo}`, + getIouSendCurrencyRoute: (reportID, currency, backTo) => `${IOU_SEND_CURRENCY}/${reportID}?currency=${currency}&backTo=${backTo}`, IOU_DETAILS, IOU_DETAILS_ADD_BANK_ACCOUNT: `${IOU_DETAILS}/add-bank-account`, IOU_DETAILS_ADD_DEBIT_CARD: `${IOU_DETAILS}/add-debit-card`, @@ -123,7 +124,11 @@ export default { REPORT_WITH_ID_DETAILS: 'r/:reportID/details', getReportDetailsRoute: (reportID) => `r/${reportID}/details`, REPORT_SETTINGS: 'r/:reportID/settings', + REPORT_SETTINGS_ROOM_NAME: 'r/:reportID/settings/room-name', + REPORT_SETTINGS_NOTIFICATION_PREFERENCES: 'r/:reportID/settings/notification-preferences', getReportSettingsRoute: (reportID) => `r/${reportID}/settings`, + getReportSettingsRoomNameRoute: (reportID) => `r/${reportID}/settings/room-name`, + getReportSettingsNotificationPreferencesRoute: (reportID) => `r/${reportID}/settings/notification-preferences`, TRANSITION_FROM_OLD_DOT: 'transition', VALIDATE_LOGIN: 'v/:accountID/:validateCode', GET_ASSISTANCE: 'get-assistance/:taskID', diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js index 61927aefb868..2581bde85b4e 100644 --- a/src/components/AddPaymentMethodMenu.js +++ b/src/components/AddPaymentMethodMenu.js @@ -12,11 +12,16 @@ import PopoverMenu from './PopoverMenu'; import paypalMeDataPropTypes from './paypalMeDataPropTypes'; const propTypes = { + /** Should the component be visible? */ isVisible: PropTypes.bool.isRequired, + + /** Callback to execute when the component closes. */ onClose: PropTypes.func.isRequired, + + /** Anchor position for the AddPaymentMenu. */ anchorPosition: PropTypes.shape({ - top: PropTypes.number, - left: PropTypes.number, + horizontal: PropTypes.number, + vertical: PropTypes.number, }), /** Account details for PayPal.Me */ @@ -43,7 +48,7 @@ const AddPaymentMethodMenu = (props) => ( isVisible={props.isVisible} onClose={props.onClose} anchorPosition={props.anchorPosition} - onItemSelected={() => props.onClose()} + onItemSelected={props.onClose} menuItems={[ { text: props.translate('common.bankAccount'), diff --git a/src/components/AddressSearch/index.js b/src/components/AddressSearch/index.js index 52ca14dbf701..5219c5b4855f 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -119,6 +119,7 @@ const AddressSearch = (props) => { postal_town: postalTown, postal_code: zipCode, administrative_area_level_1: state, + administrative_area_level_2: stateFallback, country, } = GooglePlacesUtils.getAddressComponents(addressComponents, { street_number: 'long_name', @@ -129,6 +130,7 @@ const AddressSearch = (props) => { postal_town: 'long_name', postal_code: 'long_name', administrative_area_level_1: 'short_name', + administrative_area_level_2: 'long_name', country: 'short_name', }); @@ -164,6 +166,12 @@ const AddressSearch = (props) => { values.state = longStateName; } + // UK addresses return countries (e.g. England) in the state field (administrative_area_level_1) + // So we use a secondary field (administrative_area_level_2) as a fallback + if (country === CONST.COUNTRY.GB) { + values.state = stateFallback; + } + // Not all pages define the Address Line 2 field, so in that case we append any additional address details // (e.g. Apt #) to Address Line 1 if (subpremise && typeof props.renamedInputKeys.street2 === 'undefined') { diff --git a/src/components/AvatarCropModal/ImageCropView.js b/src/components/AvatarCropModal/ImageCropView.js index 7605ad83da5b..3ad72bd3d674 100644 --- a/src/components/AvatarCropModal/ImageCropView.js +++ b/src/components/AvatarCropModal/ImageCropView.js @@ -52,16 +52,23 @@ const defaultProps = { const ImageCropView = (props) => { const containerStyle = StyleUtils.getWidthAndHeightStyle(props.containerSize, props.containerSize); + const originalImageHeight = props.originalImageHeight; + const originalImageWidth = props.originalImageWidth; + const rotation = props.rotation; + const translateX = props.translateX; + const translateY = props.translateY; + const scale = props.scale; + // A reanimated memoized style, which updates when the image's size or scale changes. const imageStyle = useAnimatedStyle(() => { - const height = props.originalImageHeight.value; - const width = props.originalImageWidth.value; + const height = originalImageHeight.value; + const width = originalImageWidth.value; const aspectRatio = height > width ? height / width : width / height; - const rotate = interpolate(props.rotation.value, [0, 360], [0, 360]); + const rotate = interpolate(rotation.value, [0, 360], [0, 360]); return { - transform: [{translateX: props.translateX.value}, {translateY: props.translateY.value}, {scale: props.scale.value * aspectRatio}, {rotate: `${rotate}deg`}], + transform: [{translateX: translateX.value}, {translateY: translateY.value}, {scale: scale.value * aspectRatio}, {rotate: `${rotate}deg`}], }; - }, [props.originalImageHeight, props.originalImageWidth]); + }, [originalImageHeight, originalImageWidth, rotation, translateX, translateY, scale]); // We're preventing text selection with ControlSelection.blockElement to prevent safari // default behaviour of cursor - I-beam cursor on drag. See https://github.com/Expensify/App/issues/13688 diff --git a/src/components/AvatarCropModal/Slider.js b/src/components/AvatarCropModal/Slider.js index 29bbd378f3ba..2877b3a9c917 100644 --- a/src/components/AvatarCropModal/Slider.js +++ b/src/components/AvatarCropModal/Slider.js @@ -26,12 +26,13 @@ const defaultProps = { // This component can't be written using class since reanimated API uses hooks. const Slider = (props) => { + const sliderValue = props.sliderValue; const [tooltipIsVisible, setTooltipIsVisible] = useState(true); // A reanimated memoized style, which tracks // a translateX shared value and updates the slider position. const rSliderStyle = useAnimatedStyle(() => ({ - transform: [{translateX: props.sliderValue.value}], + transform: [{translateX: sliderValue.value}], })); // We're preventing text selection with ControlSelection.blockElement to prevent safari diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index f65696ec3541..29424233891d 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -294,6 +294,7 @@ class AvatarWithImagePicker extends React.Component { onItemSelected={() => this.setState({isMenuVisible: false})} menuItems={this.createMenuItems(openPicker)} anchorPosition={this.props.anchorPosition} + anchorAlignment={this.props.anchorAlignment} /> > )} diff --git a/src/components/Button/index.js b/src/components/Button/index.js index 65107cfe9392..f9200d085b28 100644 --- a/src/components/Button/index.js +++ b/src/components/Button/index.js @@ -109,6 +109,9 @@ const propTypes = { /** Accessibility label for the component */ accessibilityLabel: PropTypes.string, + + /** A ref to forward the button */ + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.oneOfType([PropTypes.instanceOf(React.Component), PropTypes.func])})]), }; const defaultProps = { @@ -141,6 +144,7 @@ const defaultProps = { shouldEnableHapticFeedback: false, nativeID: '', accessibilityLabel: '', + forwardedRef: undefined, }; class Button extends Component { @@ -240,6 +244,7 @@ class Button extends Component { render() { return ( { if (e && e.type === 'click') { e.currentTarget.blur(); @@ -303,4 +308,15 @@ class Button extends Component { Button.propTypes = propTypes; Button.defaultProps = defaultProps; -export default compose(withNavigationFallback, withNavigationFocus)(Button); +export default compose( + withNavigationFallback, + withNavigationFocus, +)( + React.forwardRef((props, ref) => ( + + )), +); diff --git a/src/components/ButtonWithDropdown.js b/src/components/ButtonWithDropdown.js deleted file mode 100644 index 61b578488e36..000000000000 --- a/src/components/ButtonWithDropdown.js +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {View} from 'react-native'; -import styles from '../styles/styles'; -import Button from './Button'; -import Icon from './Icon'; -import * as Expensicons from './Icon/Expensicons'; -import themeColors from '../styles/themes/default'; - -const propTypes = { - /** Text to display for the main button */ - buttonText: PropTypes.string.isRequired, - - /** Callback to execute when the main button is pressed */ - onButtonPress: PropTypes.func, - - /** Callback to execute when the dropdown element is pressed */ - onDropdownPress: PropTypes.func, - - /** Whether we should show a loading state for the main button */ - isLoading: PropTypes.bool, - - /** Should the button be disabled */ - isDisabled: PropTypes.bool, -}; - -const defaultProps = { - onButtonPress: () => {}, - onDropdownPress: () => {}, - isDisabled: false, - isLoading: false, -}; - -const ButtonWithDropdown = (props) => ( - - - - - - - -); - -ButtonWithDropdown.propTypes = propTypes; -ButtonWithDropdown.defaultProps = defaultProps; -ButtonWithDropdown.displayName = 'ButtonWithDropdown'; -export default ButtonWithDropdown; diff --git a/src/components/ButtonWithDropdownMenu.js b/src/components/ButtonWithDropdownMenu.js new file mode 100644 index 000000000000..a518ce647c75 --- /dev/null +++ b/src/components/ButtonWithDropdownMenu.js @@ -0,0 +1,139 @@ +import React, {useState, useRef, useEffect} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import _ from 'underscore'; +import useWindowDimensions from '../hooks/useWindowDimensions'; +import styles from '../styles/styles'; +import Button from './Button'; +import PopoverMenu from './PopoverMenu'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import themeColors from '../styles/themes/default'; +import CONST from '../CONST'; + +const propTypes = { + /** Text to display for the menu header */ + menuHeaderText: PropTypes.string, + + /** Callback to execute when the main button is pressed */ + onPress: PropTypes.func.isRequired, + + /** Whether we should show a loading state for the main button */ + isLoading: PropTypes.bool, + + /** Should the confirmation button be disabled? */ + isDisabled: PropTypes.bool, + + /** Additional styles to add to the component */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + + /** Menu options to display */ + /** e.g. [{text: 'Pay with Expensify', icon: Wallet}, {text: 'PayPal', icon: PayPal}] */ + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + icon: PropTypes.elementType, + iconWidth: PropTypes.number, + iconHeight: PropTypes.number, + iconDescription: PropTypes.string, + }), + ).isRequired, +}; + +const defaultProps = { + isLoading: false, + isDisabled: false, + menuHeaderText: '', + style: [], +}; + +const ButtonWithDropdownMenu = (props) => { + const [selectedItemIndex, setSelectedItemIndex] = useState(0); + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [popoverAnchorPosition, setPopoverAnchorPosition] = useState(null); + const {width: windowWidth, height: windowHeight} = useWindowDimensions(); + const caretButton = useRef(null); + useEffect(() => { + if (!caretButton.current) { + return; + } + caretButton.current.measureInWindow((x, y, w, h) => { + setPopoverAnchorPosition({ + horizontal: x + w, + vertical: y + h, + }); + }); + }, [windowWidth, windowHeight]); + + const selectedItem = props.options[selectedItemIndex]; + return ( + + {props.options.length > 1 ? ( + + props.onPress(event, selectedItem.value)} + text={selectedItem.text} + isDisabled={props.isDisabled} + isLoading={props.isLoading} + shouldRemoveRightBorderRadius + style={[styles.flex1, styles.pr0]} + pressOnEnter + /> + + { + setIsMenuVisible(true); + }} + shouldRemoveLeftBorderRadius + > + + + + ) : ( + props.onPress(event, props.options[0].value)} + pressOnEnter + /> + )} + {props.options.length > 1 && !_.isEmpty(popoverAnchorPosition) && ( + setIsMenuVisible(false)} + onItemSelected={() => setIsMenuVisible(false)} + anchorPosition={popoverAnchorPosition} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }} + headerText={props.menuHeaderText} + menuItems={_.map(props.options, (item, index) => ({ + ...item, + onSelected: () => { + setSelectedItemIndex(index); + }, + }))} + /> + )} + + ); +}; + +ButtonWithDropdownMenu.propTypes = propTypes; +ButtonWithDropdownMenu.defaultProps = defaultProps; +ButtonWithDropdownMenu.displayName = 'ButtonWithDropdownMenu'; + +export default React.memo(ButtonWithDropdownMenu); diff --git a/src/components/ButtonWithMenu.js b/src/components/ButtonWithMenu.js deleted file mode 100644 index a1576ad5f551..000000000000 --- a/src/components/ButtonWithMenu.js +++ /dev/null @@ -1,105 +0,0 @@ -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {View} from 'react-native'; -import _ from 'underscore'; -import styles from '../styles/styles'; -import Button from './Button'; -import ButtonWithDropdown from './ButtonWithDropdown'; -import PopoverMenu from './PopoverMenu'; - -const propTypes = { - /** Text to display for the menu header */ - menuHeaderText: PropTypes.string, - - /** Callback to execute when the main button is pressed */ - onPress: PropTypes.func.isRequired, - - /** Whether we should show a loading state for the main button */ - isLoading: PropTypes.bool, - - /** Should the confirmation button be disabled? */ - isDisabled: PropTypes.bool, - - /** Menu options to display */ - /** e.g. [{text: 'Pay with Expensify', icon: Wallet}, {text: 'PayPal', icon: PayPal}] */ - options: PropTypes.arrayOf( - PropTypes.shape({ - value: PropTypes.string.isRequired, - text: PropTypes.string.isRequired, - icon: PropTypes.elementType, - iconWidth: PropTypes.number, - iconHeight: PropTypes.number, - iconDescription: PropTypes.string, - }), - ).isRequired, -}; - -const defaultProps = { - isLoading: false, - isDisabled: false, - menuHeaderText: '', -}; - -class ButtonWithMenu extends PureComponent { - constructor(props) { - super(props); - - this.state = { - selectedItemIndex: 0, - isMenuVisible: false, - }; - } - - setMenuVisibility(isMenuVisible) { - this.setState({isMenuVisible}); - } - - render() { - const selectedItem = this.props.options[this.state.selectedItemIndex]; - return ( - - {this.props.options.length > 1 ? ( - this.props.onPress(event, selectedItem.value)} - onDropdownPress={() => { - this.setMenuVisibility(true); - }} - /> - ) : ( - this.props.onPress(event, this.props.options[0].value)} - pressOnEnter - /> - )} - {this.props.options.length > 1 && ( - this.setMenuVisibility(false)} - onItemSelected={() => this.setMenuVisibility(false)} - anchorPosition={styles.createMenuPositionRightSidepane} - headerText={this.props.menuHeaderText} - menuItems={_.map(this.props.options, (item, index) => ({ - ...item, - onSelected: () => { - this.setState({selectedItemIndex: index}); - }, - }))} - /> - )} - - ); - } -} - -ButtonWithMenu.propTypes = propTypes; -ButtonWithMenu.defaultProps = defaultProps; - -export default ButtonWithMenu; diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js index 92b5f08b1581..417734eaa25a 100644 --- a/src/components/CheckboxWithLabel.js +++ b/src/components/CheckboxWithLabel.js @@ -1,12 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {View, TouchableOpacity} from 'react-native'; +import {View} from 'react-native'; import _ from 'underscore'; import styles from '../styles/styles'; import Checkbox from './Checkbox'; import Text from './Text'; import FormHelpMessage from './FormHelpMessage'; import variables from '../styles/variables'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; const requiredPropsCheck = (props) => { if (!props.label && !props.LabelComponent) { @@ -101,15 +102,16 @@ class CheckboxWithLabel extends React.Component { hasError={Boolean(this.props.errorText)} forwardedRef={this.props.forwardedRef} /> - {this.props.label && {this.props.label}} {this.LabelComponent && } - + diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index ae9578f68a4b..1e15e9e20bf3 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -20,7 +20,6 @@ class EmojiPicker extends React.Component { this.measureEmojiPopoverAnchorPosition = this.measureEmojiPopoverAnchorPosition.bind(this); this.measureEmojiPopoverAnchorPositionAndUpdateState = this.measureEmojiPopoverAnchorPositionAndUpdateState.bind(this); this.focusEmojiSearchInput = this.focusEmojiSearchInput.bind(this); - this.measureContent = this.measureContent.bind(this); this.onModalHide = () => {}; this.onEmojiSelected = () => {}; @@ -131,20 +130,6 @@ class EmojiPicker extends React.Component { }); } - /** - * Used to calculate the EmojiPicker Dimensions - * - * @returns {JSX} - */ - measureContent() { - return ( - (this.emojiSearchInput = el)} - /> - ); - } - /** * Focus the search input in the emoji picker. */ @@ -176,8 +161,7 @@ class EmojiPicker extends React.Component { width: CONST.EMOJI_PICKER_SIZE.WIDTH, height: CONST.EMOJI_PICKER_SIZE.HEIGHT, }} - anchorOrigin={this.state.emojiPopoverAnchorOrigin} - measureContent={this.measureContent} + anchorAlignment={this.state.emojiPopoverAnchorOrigin} > { {...defaultRendererProps} fontSize={variables.fontSizeSmall} color={themeColors.textSupporting} + style={[styles.alignItemsBaseline, editedLabelStyles]} > - {` ${props.translate('reportActionCompose.edited')}`} + {/* Native devices do not support margin between nested text */} + + {' '} + + {props.translate('reportActionCompose.edited')} ); }; diff --git a/src/components/HeaderWithCloseButton.js b/src/components/HeaderWithCloseButton.js index 78f9d9a04d85..f6e979cbde7c 100755 --- a/src/components/HeaderWithCloseButton.js +++ b/src/components/HeaderWithCloseButton.js @@ -122,8 +122,8 @@ const defaultProps = { stepCounter: null, threeDotsMenuItems: [], threeDotsAnchorPosition: { - top: 0, - left: 0, + vertical: 0, + horizontal: 0, }, }; diff --git a/src/components/ImageView/index.native.js b/src/components/ImageView/index.native.js index c8bd9b075e2d..056631515f34 100644 --- a/src/components/ImageView/index.native.js +++ b/src/components/ImageView/index.native.js @@ -210,7 +210,6 @@ class ImageView extends PureComponent { // due to ImageZoom shouldShowLoadingIndicator ? styles.opacity0 : styles.opacity1, ]} - disableTransformation source={{uri: this.props.url}} isAuthTokenRequired={this.props.isAuthTokenRequired} resizeMode={Image.resizeMode.contain} diff --git a/src/components/KYCWall/BaseKYCWall.js b/src/components/KYCWall/BaseKYCWall.js index e1579fd38e73..956e1fd425c9 100644 --- a/src/components/KYCWall/BaseKYCWall.js +++ b/src/components/KYCWall/BaseKYCWall.js @@ -1,6 +1,7 @@ import React from 'react'; import {withOnyx} from 'react-native-onyx'; import {Dimensions} from 'react-native'; +import lodashGet from 'lodash/get'; import CONST from '../../CONST'; import Navigation from '../../libs/Navigation/Navigation'; import AddPaymentMethodMenu from '../AddPaymentMethodMenu'; @@ -11,6 +12,7 @@ import ONYXKEYS from '../../ONYXKEYS'; import Log from '../../libs/Log'; import {propTypes, defaultProps} from './kycWallPropTypes'; import * as Wallet from '../../libs/actions/Wallet'; +import * as ReportUtils from '../../libs/ReportUtils'; // This component allows us to block various actions by forcing the user to first add a default payment method and successfully make it through our Know Your Customer flow // before continuing to take whatever action they originally intended to take. It requires a button as a child and a native event so we can get the coordinates and use it @@ -24,8 +26,8 @@ class KYCWall extends React.Component { this.state = { shouldShowAddPaymentMenu: false, - anchorPositionTop: 0, - anchorPositionLeft: 0, + anchorPositionVertical: 0, + anchorPositionHorizontal: 0, transferBalanceButton: null, }; } @@ -61,14 +63,14 @@ class KYCWall extends React.Component { getAnchorPosition(domRect) { if (this.props.popoverPlacement === 'bottom') { return { - anchorPositionTop: domRect.top + (domRect.height - 2), - anchorPositionLeft: domRect.left + 20, + anchorPositionVertical: domRect.top + (domRect.height - 2), + anchorPositionHorizontal: domRect.left + 20, }; } return { - anchorPositionTop: domRect.top - 150, - anchorPositionLeft: domRect.left, + anchorPositionVertical: domRect.top - 150, + anchorPositionHorizontal: domRect.left, }; } @@ -79,8 +81,8 @@ class KYCWall extends React.Component { */ setPositionAddPaymentMenu(position) { this.setState({ - anchorPositionTop: position.anchorPositionTop, - anchorPositionLeft: position.anchorPositionLeft, + anchorPositionVertical: position.anchorPositionVertical, + anchorPositionHorizontal: position.anchorPositionHorizontal, }); } @@ -90,14 +92,17 @@ class KYCWall extends React.Component { * If they are already KYC'd we will continue whatever action is gated behind the KYC wall. * * @param {Event} event + * @param {String} iouPaymentType */ - continue(event) { - this.setState({ - transferBalanceButton: event.nativeEvent.target, - }); + continue(event, iouPaymentType) { + this.setState({transferBalanceButton: event.nativeEvent.target}); + const isExpenseReport = ReportUtils.isExpenseReport(this.props.iouReport); // Check to see if user has a valid payment method on file and display the add payment popover if they don't - if (!PaymentUtils.hasExpensifyPaymentMethod(this.props.cardList, this.props.bankAccountList)) { + if ( + (isExpenseReport && lodashGet(this.props.reimbursementAccount, 'achData.state', '') !== CONST.BANK_ACCOUNT.STATE.OPEN) || + (!isExpenseReport && !PaymentUtils.hasExpensifyPaymentMethod(this.props.cardList, this.props.bankAccountList)) + ) { Log.info('[KYC Wallet] User does not have valid payment method'); const clickedElementLocation = getClickedTargetLocation(event.nativeEvent.target); const position = this.getAnchorPosition(clickedElementLocation); @@ -108,16 +113,18 @@ class KYCWall extends React.Component { return; } - // Ask the user to upgrade to a gold wallet as this means they have not yet went through our Know Your Customer (KYC) checks - const hasGoldWallet = this.props.userWallet.tierName && this.props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD; - if (!hasGoldWallet) { - Log.info('[KYC Wallet] User does not have gold wallet'); - Navigation.navigate(this.props.enablePaymentsRoute); - return; + if (!isExpenseReport) { + // Ask the user to upgrade to a gold wallet as this means they have not yet gone through our Know Your Customer (KYC) checks + const hasGoldWallet = this.props.userWallet.tierName && this.props.userWallet.tierName === CONST.WALLET.TIER_NAME.GOLD; + if (!hasGoldWallet) { + Log.info('[KYC Wallet] User does not have gold wallet'); + Navigation.navigate(this.props.enablePaymentsRoute); + return; + } } - Log.info('[KYC Wallet] User has valid payment method and passed KYC checks'); - this.props.onSuccessfulKYC(); + Log.info('[KYC Wallet] User has valid payment method and passed KYC checks or did not need them'); + this.props.onSuccessfulKYC(iouPaymentType); } render() { @@ -127,8 +134,8 @@ class KYCWall extends React.Component { isVisible={this.state.shouldShowAddPaymentMenu} onClose={() => this.setState({shouldShowAddPaymentMenu: false})} anchorPosition={{ - top: this.state.anchorPositionTop, - left: this.state.anchorPositionLeft, + vertical: this.state.anchorPositionVertical, + horizontal: this.state.anchorPositionHorizontal, }} shouldShowPaypal={false} onItemSelected={(item) => { @@ -159,4 +166,10 @@ export default withOnyx({ bankAccountList: { key: ONYXKEYS.BANK_ACCOUNT_LIST, }, + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, + chatReport: { + key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, + }, })(KYCWall); diff --git a/src/components/KYCWall/kycWallPropTypes.js b/src/components/KYCWall/kycWallPropTypes.js index 4558d56ceb25..4f23d10174a2 100644 --- a/src/components/KYCWall/kycWallPropTypes.js +++ b/src/components/KYCWall/kycWallPropTypes.js @@ -2,13 +2,15 @@ import PropTypes from 'prop-types'; import userWalletPropTypes from '../../pages/EnablePayments/userWalletPropTypes'; import bankAccountPropTypes from '../bankAccountPropTypes'; import cardPropTypes from '../cardPropTypes'; +import iouReportPropTypes from '../../pages/iouReportPropTypes'; +import reimbursementAccountPropTypes from '../../pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes'; const propTypes = { /** Route for the Add Bank Account screen for a given navigation stack */ addBankAccountRoute: PropTypes.string.isRequired, /** Route for the Add Debit Card screen for a given navigation stack */ - addDebitCardRoute: PropTypes.string.isRequired, + addDebitCardRoute: PropTypes.string, /** Route for the KYC enable payments screen for a given navigation stack */ enablePaymentsRoute: PropTypes.string.isRequired, @@ -33,6 +35,15 @@ const propTypes = { /** List of bank accounts */ bankAccountList: PropTypes.objectOf(bankAccountPropTypes), + + /** The chat report this report is linked to */ + chatReport: iouReportPropTypes, + + /** The IOU/Expense report we are paying */ + iouReport: iouReportPropTypes, + + /** The reimbursement account linked to the Workspace */ + reimbursementAccount: reimbursementAccountPropTypes, }; const defaultProps = { @@ -43,6 +54,10 @@ const defaultProps = { chatReportID: '', bankAccountList: {}, cardList: {}, + chatReport: null, + reimbursementAccount: {}, + addDebitCardRoute: '', + iouReport: {}, }; export {propTypes, defaultProps}; diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 2d4a31fde889..249cd938889b 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -8,9 +8,15 @@ import CONST from '../CONST'; import Text from './Text'; import TextInput from './TextInput'; import FormHelpMessage from './FormHelpMessage'; +import {withNetwork} from './OnyxProvider'; +import networkPropTypes from './networkPropTypes'; +import useOnNetworkReconnect from './hooks/useOnNetworkReconnect'; import * as Browser from '../libs/Browser'; const propTypes = { + /** Information about the network */ + network: networkPropTypes.isRequired, + /** Name attribute for the input */ name: PropTypes.string, @@ -42,6 +48,9 @@ const propTypes = { /** Specifies if the input has a validation error */ hasError: PropTypes.bool, + + /** Specifies the max length of the input */ + maxLength: PropTypes.number, }; const defaultProps = { @@ -55,6 +64,7 @@ const defaultProps = { onChangeText: () => {}, onFulfill: () => {}, hasError: false, + maxLength: CONST.MAGIC_CODE_LENGTH, }; /** @@ -62,12 +72,13 @@ const defaultProps = { * number of elements as the number of inputs. * * @param {String} value + * @param {Number} length * @returns {Array} */ -const decomposeString = (value) => { - let arr = _.map(value.split('').slice(0, CONST.MAGIC_CODE_LENGTH), (v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)); - if (arr.length < CONST.MAGIC_CODE_LENGTH) { - arr = arr.concat(Array(CONST.MAGIC_CODE_LENGTH - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR)); +const decomposeString = (value, length) => { + let arr = _.map(value.split('').slice(0, length), (v) => (ValidationUtils.isNumeric(v) ? v : CONST.MAGIC_CODE_EMPTY_CHAR)); + if (arr.length < length) { + arr = arr.concat(Array(length - arr.length).fill(CONST.MAGIC_CODE_EMPTY_CHAR)); } return arr; }; @@ -81,7 +92,7 @@ const decomposeString = (value) => { */ const composeToString = (value) => _.map(value, (v) => (v === undefined || v === '' ? CONST.MAGIC_CODE_EMPTY_CHAR : v)).join(''); -const inputPlaceholderSlots = Array.from(Array(CONST.MAGIC_CODE_LENGTH).keys()); +const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys()); function MagicCodeInput(props) { const inputRefs = useRef([]); @@ -103,16 +114,22 @@ function MagicCodeInput(props) { }, })); - useEffect(() => { - // Blurs the input and removes focus from the last input and, if it should submit - // on complete, it will call the onFulfill callback. - const numbers = decomposeString(props.value); - if (!props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== CONST.MAGIC_CODE_LENGTH) { + const validateAndSubmit = () => { + const numbers = decomposeString(props.value, props.maxLength); + if (!props.shouldSubmitOnComplete || _.filter(numbers, (n) => ValidationUtils.isNumeric(n)).length !== props.maxLength || props.network.isOffline) { return; } + // Blurs the input and removes focus from the last input and, if it should submit + // on complete, it will call the onFulfill callback. inputRefs.current[editIndex].blur(); setFocusedIndex(undefined); props.onFulfill(props.value); + }; + + useOnNetworkReconnect(validateAndSubmit); + + useEffect(() => { + validateAndSubmit(); // We have not added the editIndex as the dependency because we don't want to run this logic after focusing on an input to edit it after the user has completed the code. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -182,11 +199,11 @@ function MagicCodeInput(props) { const numbersArr = value .trim() .split('') - .slice(0, CONST.MAGIC_CODE_LENGTH - editIndex); - const updatedFocusedIndex = Math.min(editIndex + (numbersArr.length - 1) + 1, CONST.MAGIC_CODE_LENGTH - 1); + .slice(0, props.maxLength - editIndex); + const updatedFocusedIndex = Math.min(editIndex + (numbersArr.length - 1) + 1, props.maxLength - 1); - let numbers = decomposeString(props.value); - numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, CONST.MAGIC_CODE_LENGTH)]; + let numbers = decomposeString(props.value, props.maxLength); + numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)]; setFocusedIndex(updatedFocusedIndex); setInput(value); @@ -205,13 +222,13 @@ function MagicCodeInput(props) { */ const onKeyPress = ({nativeEvent: {key: keyValue}}) => { if (keyValue === 'Backspace') { - let numbers = decomposeString(props.value); + let numbers = decomposeString(props.value, props.maxLength); // If the currently focused index already has a value, it will delete // that value but maintain the focus on the same input. if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { setInput(''); - numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH)]; + numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)]; setEditIndex(focusedIndex); props.onChangeText(composeToString(numbers)); return; @@ -221,11 +238,11 @@ function MagicCodeInput(props) { // Fill the array with empty characters if there are no inputs. if (focusedIndex === 0 && !hasInputs) { - numbers = Array(CONST.MAGIC_CODE_LENGTH).fill(CONST.MAGIC_CODE_EMPTY_CHAR); + numbers = Array(props.maxLength).fill(CONST.MAGIC_CODE_EMPTY_CHAR); // Deletes the value of the previous input and focuses on it. } else if (focusedIndex !== 0) { - numbers = [...numbers.slice(0, Math.max(0, focusedIndex - 1)), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex, CONST.MAGIC_CODE_LENGTH)]; + numbers = [...numbers.slice(0, Math.max(0, focusedIndex - 1)), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex, props.maxLength)]; } const newFocusedIndex = Math.max(0, focusedIndex - 1); @@ -249,12 +266,16 @@ function MagicCodeInput(props) { setEditIndex(newFocusedIndex); inputRefs.current[newFocusedIndex].focus(); } else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) { - const newFocusedIndex = Math.min(focusedIndex + 1, CONST.MAGIC_CODE_LENGTH - 1); + const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1); setInput(''); setFocusedIndex(newFocusedIndex); setEditIndex(newFocusedIndex); inputRefs.current[newFocusedIndex].focus(); } else if (keyValue === 'Enter') { + // We should prevent users from submitting when it's offline. + if (props.network.isOffline) { + return; + } setInput(''); props.onFulfill(props.value); } @@ -268,13 +289,13 @@ function MagicCodeInput(props) { return ( <> - {_.map(inputPlaceholderSlots, (index) => ( + {_.map(getInputPlaceholderSlots(props.maxLength), (index) => ( - {decomposeString(props.value)[index] || ''} + {decomposeString(props.value, props.maxLength)[index] || ''} ( - -)); +export default withNetwork()( + forwardRef((props, ref) => ( + + )), +); diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js index 51325f619f1a..710743bb0edb 100644 --- a/src/components/Modal/BaseModal.js +++ b/src/components/Modal/BaseModal.js @@ -138,6 +138,7 @@ class BaseModal extends PureComponent { animationInTiming={this.props.animationInTiming} animationOutTiming={this.props.animationOutTiming} statusBarTranslucent={this.props.statusBarTranslucent} + onLayout={this.props.onLayout} > {(insets) => { diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 916ed12219b3..8b6a43f47215 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -10,7 +10,7 @@ import withLocalize, {withLocalizePropTypes} from './withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; import compose from '../libs/compose'; import CONST from '../CONST'; -import ButtonWithMenu from './ButtonWithMenu'; +import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import Log from '../libs/Log'; import SettlementButton from './SettlementButton'; import ROUTES from '../ROUTES'; @@ -67,6 +67,9 @@ const propTypes = { /** Callback function to navigate to a provided step in the MoneyRequestModal flow */ navigateToStep: PropTypes.func.isRequired, + + /** The policyID of the request */ + policyID: PropTypes.string.isRequired, }; const defaultProps = { @@ -288,9 +291,10 @@ class MoneyRequestConfirmationList extends Component { addBankAccountRoute={ROUTES.IOU_SEND_ADD_BANK_ACCOUNT} addDebitCardRoute={ROUTES.IOU_SEND_ADD_DEBIT_CARD} currency={this.props.iou.selectedCurrencyCode} + policyID={this.props.policyID} /> ) : ( - this.confirm(value)} options={this.getSplitOrRequestOptions()} diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index 7e1685a4e8c8..3f8a074e0bbb 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -1,8 +1,8 @@ import React from 'react'; +import {withOnyx} from 'react-native-onyx'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; -import {withOnyx} from 'react-native-onyx'; import HeaderWithCloseButton from './HeaderWithCloseButton'; import iouReportPropTypes from '../pages/iouReportPropTypes'; import withLocalize, {withLocalizePropTypes} from './withLocalize'; @@ -19,10 +19,14 @@ import compose from '../libs/compose'; import Navigation from '../libs/Navigation/Navigation'; import ROUTES from '../ROUTES'; import Icon from './Icon'; +import SettlementButton from './SettlementButton'; +import * as Policy from '../libs/actions/Policy'; +import ONYXKEYS from '../ONYXKEYS'; +import * as IOU from '../libs/actions/IOU'; import * as CurrencyUtils from '../libs/CurrencyUtils'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import DateUtils from '../libs/DateUtils'; -import ONYXKEYS from '../ONYXKEYS'; +import reportPropTypes from '../pages/reportPropTypes'; const propTypes = { /** The report currently being looked at */ @@ -37,31 +41,41 @@ const propTypes = { name: PropTypes.string, }).isRequired, + /** The chat report this report is linked to */ + chatReport: reportPropTypes, + /** Personal details so we can get the ones for the report participants */ personalDetails: PropTypes.objectOf(participantPropTypes).isRequired, /** Whether we're viewing a report with a single transaction in it */ isSingleTransactionView: PropTypes.bool, + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user email */ + email: PropTypes.string, + }), + ...withLocalizePropTypes, }; const defaultProps = { isSingleTransactionView: false, + chatReport: {}, + session: { + email: null, + }, parentReport: {}, }; const MoneyRequestHeader = (props) => { - // These are only used for the single transaction view and not "money requests" - const transactionAmount = lodashGet(props.parentReportAction, ['originalMessage', 'amount']); - const transactionCurrency = lodashGet(props.parentReportAction, ['originalMessage', 'currency']); - const transactionDescription = lodashGet(props.parentReportAction, ['originalMessage', 'comment']); + // These are only used for the single transaction view and not for expense and iou reports + const {amount: transactionAmount, currency: transactionCurrency, comment: transactionDescription} = ReportUtils.getMoneyRequestAction(props.parentReportAction); const formattedTransactionAmount = transactionAmount && transactionCurrency && CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); const transactionDate = lodashGet(props.parentReportAction, ['created']); const formattedTransactionDate = DateUtils.getDateStringFromISOTimestamp(transactionDate); const formattedAmount = CurrencyUtils.convertToDisplayString(ReportUtils.getMoneyRequestTotal(props.report), props.report.currency); - const moneyRequestReport = props.isSingleTransactionView ? props.parentReport : props.report; const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); const isExpenseReport = ReportUtils.isExpenseReport(moneyRequestReport); @@ -69,11 +83,14 @@ const MoneyRequestHeader = (props) => { const payeeAvatar = isExpenseReport ? ReportUtils.getWorkspaceAvatar(moneyRequestReport) : ReportUtils.getAvatar(lodashGet(props.personalDetails, [moneyRequestReport.managerEmail, 'avatar']), moneyRequestReport.managerEmail); + const policy = props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`]; + const isPayer = Policy.isAdminOfFreePolicy([policy]) || (ReportUtils.isMoneyRequestReport(props.report) && lodashGet(props.session, 'email', null) === props.report.managerEmail); + const shouldShowSettlementButton = !isSettled && !props.isSingleTransactionView && isPayer; return ( { onSelected: () => {}, }, ]} - threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton} + threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(props.windowWidth)} report={moneyRequestReport} policies={props.policies} personalDetails={props.personalDetails} @@ -116,7 +133,7 @@ const MoneyRequestHeader = (props) => { )} - + {!props.isSingleTransactionView && {formattedAmount}} {isSettled && ( @@ -126,8 +143,36 @@ const MoneyRequestHeader = (props) => { /> )} + {shouldShowSettlementButton && !props.isSmallScreenWidth && ( + + IOU.payMoneyRequest(paymentType, props.chatReport, props.report)} + enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} + addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + shouldShowPaymentOptions + /> + + )} + {shouldShowSettlementButton && props.isSmallScreenWidth && ( + IOU.payMoneyRequest(paymentType, props.chatReport, props.report)} + enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} + addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + shouldShowPaymentOptions + /> + )} {props.isSingleTransactionView && ( <> @@ -158,6 +203,12 @@ export default compose( withWindowDimensions, withLocalize, withOnyx({ + chatReport: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`, + }, + session: { + key: ONYXKEYS.SESSION, + }, parentReport: { key: (props) => `${ONYXKEYS.COLLECTION.REPORT}${props.report.parentReportID}`, }, diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.js index 31d4f9962b1f..0e85dba3337b 100644 --- a/src/components/MultipleAvatars.js +++ b/src/components/MultipleAvatars.js @@ -43,6 +43,9 @@ const propTypes = { /** Whether avatars are displayed within a reportAction */ isInReportAction: PropTypes.bool, + + /** Whether avatars are displayed within an IOUAction */ + shouldUseCardBackground: PropTypes.bool, }; const defaultProps = { @@ -56,6 +59,7 @@ const defaultProps = { isPressed: false, isFocusMode: false, isInReportAction: false, + shouldUseCardBackground: false, }; const MultipleAvatars = (props) => { @@ -107,42 +111,64 @@ const MultipleAvatars = (props) => { {props.shouldStackHorizontally ? ( <> {_.map([...props.icons].splice(0, 4), (icon, index) => ( - - - + + + + ))} {props.icons.length > 4 && ( - - - {`+${props.icons.length - 4}`} + + + {`+${props.icons.length - 4}`} + - + )} > ) : ( diff --git a/src/components/Popover/index.js b/src/components/Popover/index.js index 031d9703d2c4..2c3adf263cc5 100644 --- a/src/components/Popover/index.js +++ b/src/components/Popover/index.js @@ -20,6 +20,7 @@ const Popover = (props) => { animationInTiming={props.disableAnimation ? 1 : props.animationInTiming} animationOutTiming={props.disableAnimation ? 1 : props.animationOutTiming} shouldCloseOnOutsideClick + onLayout={props.onLayout} />, document.body, ); @@ -33,6 +34,7 @@ const Popover = (props) => { fullscreen={props.isSmallScreenWidth ? true : props.fullscreen} animationInTiming={props.disableAnimation && !props.isSmallScreenWidth ? 1 : props.animationInTiming} animationOutTiming={props.disableAnimation && !props.isSmallScreenWidth ? 1 : props.animationOutTiming} + onLayout={props.onLayout} /> ); }; diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js index 76dfc7e30e80..5b5eadffcdce 100644 --- a/src/components/PopoverMenu/index.js +++ b/src/components/PopoverMenu/index.js @@ -1,135 +1,99 @@ import _ from 'underscore'; -import React, {PureComponent} from 'react'; +import React from 'react'; +import PropTypes from 'prop-types'; import {View} from 'react-native'; -import Popover from '../Popover'; +import PopoverWithMeasuredContent from '../PopoverWithMeasuredContent'; import styles from '../../styles/styles'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import MenuItem from '../MenuItem'; -import {propTypes as createMenuPropTypes, defaultProps} from './popoverMenuPropTypes'; -import ArrowKeyFocusManager from '../ArrowKeyFocusManager'; +import {propTypes as createMenuPropTypes, defaultProps as createMenuDefaultProps} from './popoverMenuPropTypes'; import Text from '../Text'; -import KeyboardShortcut from '../../libs/KeyboardShortcut'; import CONST from '../../CONST'; +import useArrowKeyFocusManager from '../../hooks/useArrowKeyFocusManager'; +import useKeyboardShortcut from '../../hooks/useKeyboardShortcut'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; const propTypes = { ...createMenuPropTypes, ...windowDimensionsPropTypes, -}; - -class PopoverMenu extends PureComponent { - constructor(props) { - super(props); - this.state = { - focusedIndex: -1, - }; - this.resetFocusAndHideModal = this.resetFocusAndHideModal.bind(this); - this.removeKeyboardListener = this.removeKeyboardListener.bind(this); - this.attachKeyboardListener = this.attachKeyboardListener.bind(this); - this.selectedItem = null; - } - - componentDidUpdate(prevProps) { - if (this.props.isVisible === prevProps.isVisible) { - return; - } - if (this.props.isVisible) { - this.attachKeyboardListener(); - } else { - this.removeKeyboardListener(); - } - } + /** The horizontal and vertical anchors points for the popover */ + anchorPosition: PropTypes.shape({ + horizontal: PropTypes.number.isRequired, + vertical: PropTypes.number.isRequired, + }).isRequired, - componentWillUnmount() { - this.removeKeyboardListener(); - } + /** Where the popover should be positioned relative to the anchor points. */ + anchorAlignment: PropTypes.shape({ + horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), + vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), + }), +}; - /** - * Set item to local variable to fire `onSelected` action after the menu popup closes - * @param {Object} item - */ - selectItem(item) { - this.selectedItem = item; - this.props.onItemSelected(item); - } +const defaultProps = { + ...createMenuDefaultProps, + anchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, + }, +}; - attachKeyboardListener() { - const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; - this.unsubscribeEnterKey = KeyboardShortcut.subscribe( - shortcutConfig.shortcutKey, - () => { - if (this.state.focusedIndex === -1) { - return; - } - this.selectItem(this.props.menuItems[this.state.focusedIndex]); - this.setState({focusedIndex: -1}); // Reset the focusedIndex on selecting any menu - }, - shortcutConfig.descriptionKey, - shortcutConfig.modifiers, - true, - ); - } +const PopoverMenu = (props) => { + const {isSmallScreenWidth} = useWindowDimensions(); - removeKeyboardListener() { - if (!this.unsubscribeEnterKey) { - return; - } - this.unsubscribeEnterKey(); - } + const selectItem = (index) => { + const selectedItem = props.menuItems[index]; + props.onItemSelected(selectedItem); + selectedItem.onSelected(); + }; - resetFocusAndHideModal() { - this.setState({focusedIndex: -1}); // Reset the focusedIndex on modal hide - this.removeKeyboardListener(); - if (this.selectedItem) { - this.selectedItem.onSelected(); - this.selectedItem = null; - } - } + const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: props.menuItems.length - 1}); + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ENTER, + () => { + if (focusedIndex === -1) { + return; + } + selectItem(focusedIndex); + setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu + }, + {isActive: props.isVisible}, + ); - render() { - return ( - - - {!_.isEmpty(this.props.headerText) && ( - - {this.props.headerText} - - )} - this.setState({focusedIndex: index})} - > - {_.map(this.props.menuItems, (item, menuIndex) => ( - this.selectItem(item)} - focused={this.state.focusedIndex === menuIndex} - /> - ))} - - - - ); - } -} + return ( + setFocusedIndex(-1)} + animationIn={props.animationIn} + animationOut={props.animationOut} + animationInTiming={props.animationInTiming} + disableAnimation={props.disableAnimation} + fromSidebarMediumScreen={props.fromSidebarMediumScreen} + > + + {!_.isEmpty(props.headerText) && {props.headerText}} + {_.map(props.menuItems, (item, menuIndex) => ( + selectItem(menuIndex)} + focused={focusedIndex === menuIndex} + /> + ))} + + + ); +}; PopoverMenu.propTypes = propTypes; PopoverMenu.defaultProps = defaultProps; +PopoverMenu.displayName = 'PopoverMenu'; -export default withWindowDimensions(PopoverMenu); +export default React.memo(withWindowDimensions(PopoverMenu)); diff --git a/src/components/PopoverWithMeasuredContent.js b/src/components/PopoverWithMeasuredContent.js index 7452738c355c..438e6cce010e 100644 --- a/src/components/PopoverWithMeasuredContent.js +++ b/src/components/PopoverWithMeasuredContent.js @@ -21,16 +21,13 @@ const propTypes = { vertical: PropTypes.number.isRequired, }).isRequired, - /** Where the popover should be positioned relative to the anchor points. */ - anchorOrigin: PropTypes.shape({ + /** How the popover should be aligned. The value you passed will is the part of the component that will be aligned to the + * anchorPosition. ie: vertical:top means the top of the menu will be positioned in the anchorPosition */ + anchorAlignment: PropTypes.shape({ horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), - /** A function with content to measure. This component will use this.props.children by default, - but in the case the children are not displayed, the measurement will not work. */ - measureContent: PropTypes.func.isRequired, - /** Static dimensions for the popover. * Note: When passed, it will skip dimensions measuring of the popover, and provided dimensions will be used to calculate the anchor position. */ @@ -46,7 +43,7 @@ const defaultProps = { ...defaultPopoverProps, // Default positioning of the popover - anchorOrigin: { + anchorAlignment: { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }, @@ -125,7 +122,7 @@ class PopoverWithMeasuredContent extends Component { */ calculateAdjustedAnchorPosition() { let horizontalConstraint; - switch (this.props.anchorOrigin.horizontal) { + switch (this.props.anchorAlignment.horizontal) { case CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT: horizontalConstraint = {left: this.props.anchorPosition.horizontal - this.popoverWidth}; break; @@ -140,7 +137,7 @@ class PopoverWithMeasuredContent extends Component { } let verticalConstraint; - switch (this.props.anchorOrigin.vertical) { + switch (this.props.anchorAlignment.vertical) { case CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM: verticalConstraint = {top: this.props.anchorPosition.vertical - this.popoverHeight}; break; @@ -164,7 +161,7 @@ class PopoverWithMeasuredContent extends Component { const adjustedAnchorPosition = this.calculateAdjustedAnchorPosition(); const horizontalShift = computeHorizontalShift(adjustedAnchorPosition.left, this.popoverWidth, this.props.windowWidth); const verticalShift = computeVerticalShift(adjustedAnchorPosition.top, this.popoverHeight, this.props.windowHeight); - const shifedAnchorPosition = { + const shiftedAnchorPosition = { left: adjustedAnchorPosition.left + horizontalShift, top: adjustedAnchorPosition.top + verticalShift, }; @@ -172,17 +169,17 @@ class PopoverWithMeasuredContent extends Component { - {this.props.measureContent()} + {this.props.children} ) : ( /* - This is an invisible view used to measure the size of the popover, - before it ever needs to be displayed. - We do this because we need to know its dimensions in order to correctly animate the popover, - but we can't measure its dimensions without first rendering it. - */ + This is an invisible view used to measure the size of the popover, + before it ever needs to be displayed. + We do this because we need to know its dimensions in order to correctly animate the popover, + but we can't measure its dimensions without first rendering it. + */ { onLayout={onLayout} ref={ref} onPress={!isDisabled ? onPressHandler : undefined} - onLongPress={!isDisabled ? onLongPressHandler : undefined} + onLongPress={!isDisabled && onLongPress ? onLongPressHandler : undefined} onKeyPress={!isDisabled ? onKeyPressHandler : undefined} onPressIn={!isDisabled ? onPressIn : undefined} onPressOut={!isDisabled ? onPressOut : undefined} diff --git a/src/components/Pressable/GenericPressable/PropTypes.js b/src/components/Pressable/GenericPressable/PropTypes.js index 5d92885fb95d..950c443f8e96 100644 --- a/src/components/Pressable/GenericPressable/PropTypes.js +++ b/src/components/Pressable/GenericPressable/PropTypes.js @@ -4,6 +4,19 @@ import CONST from '../../../CONST'; const stylePropTypeWithFunction = PropTypes.oneOfType([stylePropType, PropTypes.func]); +/** + * Custom test for required props + * + accessibilityLabel is required when accessible is true + * @param {Object} props + * @returns {Error} Error if prop is required + */ +function requiredPropsCheck(props) { + if (props.accessible !== true || (props.accessibilityLabel !== undefined && typeof props.accessibilityLabel === 'string')) { + return; + } + return new Error(`Provide a valid string for accessibilityLabel prop when accessible is true`); +} + const pressablePropTypes = { /** * onPress callback @@ -92,7 +105,7 @@ const pressablePropTypes = { * @example 'Search' * @example 'Close' */ - accessibilityLabel: PropTypes.string.isRequired, + accessibilityLabel: requiredPropsCheck, /** * Specifies the accessibility hint for the component @@ -119,6 +132,7 @@ const defaultProps = { enableInScreenReaderStates: CONST.SCREEN_READER_STATES.ALL, nextFocusRef: undefined, shouldUseAutoHitSlop: true, + accessible: true, }; export default { diff --git a/src/components/RadioButtonWithLabel.js b/src/components/RadioButtonWithLabel.js index 45d36cf4fcca..776235f90cfe 100644 --- a/src/components/RadioButtonWithLabel.js +++ b/src/components/RadioButtonWithLabel.js @@ -1,11 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {View, TouchableOpacity} from 'react-native'; +import {View} from 'react-native'; import _ from 'underscore'; import styles from '../styles/styles'; import RadioButton from './RadioButton'; import Text from './Text'; import FormHelpMessage from './FormHelpMessage'; +import * as Pressables from './Pressable'; const propTypes = { /** Whether the radioButton is checked */ @@ -38,6 +39,8 @@ const defaultProps = { errorText: '', }; +const PressableWithFeedback = Pressables.PressableWithFeedback; + const RadioButtonWithLabel = (props) => { const LabelComponent = props.LabelComponent; const defaultStyles = [styles.flexRow, styles.alignItemsCenter]; @@ -55,13 +58,19 @@ const RadioButtonWithLabel = (props) => { label={props.label} hasError={props.hasError} /> - props.onPress()} - style={[styles.ml3, styles.pr2, styles.w100, styles.flexRow, styles.flexWrap, styles.flexShrink1, styles.alignItemsCenter]} + style={[styles.flexRow, styles.flexWrap, styles.flexShrink1, styles.alignItemsCenter]} + wrapperStyle={[styles.ml3, styles.pr2, styles.w100]} + // disable hover style when disabled + hoverDimmingValue={1} + pressDimmingValue={0.2} > {Boolean(props.label) && {props.label}} {Boolean(LabelComponent) && } - + > diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/IOUPreview.js index b9fb8f7432e8..9e8863fe9953 100644 --- a/src/components/ReportActionItem/IOUPreview.js +++ b/src/components/ReportActionItem/IOUPreview.js @@ -218,7 +218,10 @@ const IOUPreview = (props) => { diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 030f3f4fbc3e..9758c6ab61d4 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -6,24 +6,24 @@ import lodashGet from 'lodash/get'; import _ from 'underscore'; import Text from '../Text'; import Icon from '../Icon'; -import CONST from '../../CONST'; import * as Expensicons from '../Icon/Expensicons'; import styles from '../../styles/styles'; import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import compose from '../../libs/compose'; +import ONYXKEYS from '../../ONYXKEYS'; import ControlSelection from '../../libs/ControlSelection'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import {showContextMenuForReport} from '../ShowContextMenuContext'; import * as StyleUtils from '../../styles/StyleUtils'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; import * as ReportUtils from '../../libs/ReportUtils'; -import Button from '../Button'; -import themeColors from '../../styles/themes/default'; -import getButtonState from '../../libs/getButtonState'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; -import ONYXKEYS from '../../ONYXKEYS'; +import SettlementButton from '../SettlementButton'; +import themeColors from '../../styles/themes/default'; +import getButtonState from '../../libs/getButtonState'; +import * as IOU from '../../libs/actions/IOU'; const propTypes = { /** All the data of the action */ @@ -99,8 +99,8 @@ const ReportPreview = (props) => { const managerName = ReportUtils.isPolicyExpenseChat(props.chatReport) ? ReportUtils.getPolicyName(props.chatReport) : ReportUtils.getDisplayNameForParticipant(managerEmail, true); const isCurrentUserManager = managerEmail === lodashGet(props.session, 'email', null); return ( - - {_.map(props.action.message, (index) => ( + + {_.map(props.action.message, (message, index) => ( { @@ -114,10 +114,14 @@ const ReportPreview = (props) => { > {props.iouReport.hasOutstandingIOU ? ( - {props.translate('iou.payerOwesAmount', {payer: managerName, amount: reportAmount})} + + {lodashGet(message, 'html', props.translate('iou.payerOwesAmount', {payer: managerName, amount: reportAmount}))} + ) : ( - {props.translate('iou.payerSettled', {amount: reportAmount})} + + {lodashGet(message, 'html', props.translate('iou.payerSettled', {amount: reportAmount}))} + {!props.iouReport.hasOutstandingIOU && ( { /> ))} - {isCurrentUserManager && props.iouReport.stateNum === CONST.REPORT.STATE_NUM.PROCESSING && ( - IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)} + enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} + addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} style={[styles.requestPreviewBox]} - onPress={() => { - Navigation.navigate(ROUTES.getIouDetailsRoute(props.chatReportID, props.iouReportID)); - }} - onPressIn={() => DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={() => ControlSelection.unblock()} - text={props.translate('iou.pay')} - success - medium /> )} diff --git a/src/components/ReportWelcomeText.js b/src/components/ReportWelcomeText.js index 58f533f5c6d2..07b7b16bedfd 100644 --- a/src/components/ReportWelcomeText.js +++ b/src/components/ReportWelcomeText.js @@ -114,10 +114,7 @@ const ReportWelcomeText = (props) => { )} {(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SEND) || moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)) && ( - - {/* Need to confirm copy for the below with marketing, and then add to translations. */} - {props.translate('reportActionsView.usePlusButton')} - + {props.translate('reportActionsView.usePlusButton')} )} > diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 2308fe99751c..1c9c5ed3bcd7 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import React from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; -import ButtonWithMenu from './ButtonWithMenu'; +import ButtonWithDropdownMenu from './ButtonWithDropdownMenu'; import * as Expensicons from './Icon/Expensicons'; import Permissions from '../libs/Permissions'; import ONYXKEYS from '../ONYXKEYS'; @@ -14,6 +14,8 @@ import KYCWall from './KYCWall'; import withNavigation from './withNavigation'; import {withNetwork} from './OnyxProvider'; import networkPropTypes from './networkPropTypes'; +import iouReportPropTypes from '../pages/iouReportPropTypes'; +import * as ReportUtils from '../libs/ReportUtils'; const propTypes = { /** Callback to execute when this button is pressed. Receives a single payment type argument. */ @@ -31,9 +33,27 @@ const propTypes = { /** When the button is opened via an IOU, ID for the chatReport that the IOU is linked to */ chatReportID: PropTypes.string, + /** The IOU/Expense report we are paying */ + iouReport: iouReportPropTypes, + /** List of betas available to current user */ betas: PropTypes.arrayOf(PropTypes.string), + /** The route to redirect if user does not have a payment method setup */ + enablePaymentsRoute: PropTypes.string.isRequired, + + /** Should we show the payment options? */ + shouldShowPaymentOptions: PropTypes.bool, + + /** The last payment method used per policy */ + nvp_lastPaymentMethod: PropTypes.objectOf(PropTypes.string), + + /** The policyID of the report we are paying */ + policyID: PropTypes.string.isRequired, + + /** Additional styles to add to the component */ + style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + ...withLocalizePropTypes, }; @@ -42,6 +62,10 @@ const defaultProps = { shouldShowPaypal: false, chatReportID: '', betas: [], + shouldShowPaymentOptions: false, + nvp_lastPaymentMethod: {}, + style: [], + iouReport: {}, }; class SettlementButton extends React.Component { @@ -51,55 +75,61 @@ class SettlementButton extends React.Component { getButtonOptionsFromProps() { const buttonOptions = []; - - if (this.props.currency === CONST.CURRENCY.USD && Permissions.canUsePayWithExpensify(this.props.betas) && Permissions.canUseWallet(this.props.betas)) { - buttonOptions.push({ + const paymentMethods = { + [CONST.IOU.PAYMENT_TYPE.EXPENSIFY]: { text: this.props.translate('iou.settleExpensify'), icon: Expensicons.Wallet, - value: CONST.IOU.PAYMENT_TYPE.EXPENSIFY, - }); - } - - if (this.props.shouldShowPaypal && _.includes(CONST.PAYPAL_SUPPORTED_CURRENCIES, this.props.currency)) { - buttonOptions.push({ + value: ReportUtils.isExpenseReport(this.props.iouReport) ? CONST.IOU.PAYMENT_TYPE.VBBA : CONST.IOU.PAYMENT_TYPE.EXPENSIFY, + }, + [CONST.IOU.PAYMENT_TYPE.PAYPAL_ME]: { text: this.props.translate('iou.settlePaypalMe'), icon: Expensicons.PayPal, value: CONST.IOU.PAYMENT_TYPE.PAYPAL_ME, - }); + }, + [CONST.IOU.PAYMENT_TYPE.ELSEWHERE]: { + text: this.props.translate('iou.settleElsewhere'), + icon: Expensicons.Cash, + value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, + }, + }; + if (!this.props.shouldShowPaymentOptions && this.props.nvp_lastPaymentMethod[this.props.policyID]) { + return [paymentMethods[this.props.nvp_lastPaymentMethod[this.props.policyID]]]; } - - buttonOptions.push({ - text: this.props.translate('iou.settleElsewhere'), - icon: Expensicons.Cash, - value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, - }); - + if (this.props.currency === CONST.CURRENCY.USD && Permissions.canUsePayWithExpensify(this.props.betas) && Permissions.canUseWallet(this.props.betas)) { + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]); + } + if (this.props.shouldShowPaypal && _.includes(CONST.PAYPAL_SUPPORTED_CURRENCIES, this.props.currency)) { + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.PAYPAL_ME]); + } + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]); return buttonOptions; } render() { return ( this.props.onPress(CONST.IOU.PAYMENT_TYPE.EXPENSIFY)} + onSuccessfulKYC={(iouPaymentType) => this.props.onPress(iouPaymentType)} enablePaymentsRoute={this.props.enablePaymentsRoute} addBankAccountRoute={this.props.addBankAccountRoute} addDebitCardRoute={this.props.addDebitCardRoute} isDisabled={this.props.network.isOffline} chatReportID={this.props.chatReportID} + iouReport={this.props.iouReport} > {(triggerKYCFlow) => ( - { - if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - triggerKYCFlow(event); + if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { + triggerKYCFlow(event, iouPaymentType); return; } this.props.onPress(iouPaymentType); }} options={this.getButtonOptionsFromProps()} + style={this.props.style} /> )} @@ -118,5 +148,8 @@ export default compose( betas: { key: ONYXKEYS.BETAS, }, + nvp_lastPaymentMethod: { + key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD, + }, }), )(SettlementButton); diff --git a/src/components/SplashScreenHider/index.js b/src/components/SplashScreenHider/index.js new file mode 100644 index 000000000000..cf8745715572 --- /dev/null +++ b/src/components/SplashScreenHider/index.js @@ -0,0 +1,28 @@ +import {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import BootSplash from '../../libs/BootSplash'; + +const propTypes = { + /** Splash screen has been hidden */ + onHide: PropTypes.func, +}; + +const defaultProps = { + onHide: () => {}, +}; + +const SplashScreenHider = (props) => { + const {onHide} = props; + + useEffect(() => { + BootSplash.hide().then(() => onHide()); + }, [onHide]); + + return null; +}; + +SplashScreenHider.displayName = 'SplashScreenHider'; +SplashScreenHider.propTypes = propTypes; +SplashScreenHider.defaultProps = defaultProps; + +export default SplashScreenHider; diff --git a/src/components/SplashScreenHider/index.native.js b/src/components/SplashScreenHider/index.native.js new file mode 100644 index 000000000000..8e544b7da2a1 --- /dev/null +++ b/src/components/SplashScreenHider/index.native.js @@ -0,0 +1,87 @@ +import {useCallback, useRef} from 'react'; +import PropTypes from 'prop-types'; +import {StatusBar, StyleSheet} from 'react-native'; +import Reanimated, {useSharedValue, withTiming, Easing, useAnimatedStyle, runOnJS} from 'react-native-reanimated'; +import BootSplash from '../../libs/BootSplash'; +import Logo from '../../../assets/images/new-expensify-dark.svg'; +import styles from '../../styles/styles'; + +const propTypes = { + /** Splash screen has been hidden */ + onHide: PropTypes.func, +}; + +const defaultProps = { + onHide: () => {}, +}; + +const SplashScreenHider = (props) => { + const {onHide} = props; + + const opacity = useSharedValue(1); + const scale = useSharedValue(1); + + const opacityStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); + const scaleStyle = useAnimatedStyle(() => ({ + transform: [{scale: scale.value}], + })); + + const hideHasBeenCalled = useRef(false); + + const hide = useCallback(() => { + // hide can only be called once + if (hideHasBeenCalled.current) { + return; + } + + hideHasBeenCalled.current = true; + + BootSplash.hide().then(() => { + scale.value = withTiming(0, { + duration: 200, + easing: Easing.back(2), + }); + + opacity.value = withTiming( + 0, + { + duration: 250, + easing: Easing.out(Easing.ease), + }, + () => runOnJS(onHide)(), + ); + }); + }, [opacity, scale, onHide]); + + return ( + + + + + + ); +}; + +SplashScreenHider.displayName = 'SplashScreenHider'; +SplashScreenHider.propTypes = propTypes; +SplashScreenHider.defaultProps = defaultProps; + +export default SplashScreenHider; diff --git a/src/components/TaskHeader.js b/src/components/TaskHeader.js index 474a009693ee..4b21d5a2ebe5 100644 --- a/src/components/TaskHeader.js +++ b/src/components/TaskHeader.js @@ -101,13 +101,13 @@ function TaskHeader(props) { Navigation.navigate(ROUTES.getTaskReportTitleRoute(props.report.reportID))} disabled={!isOpen} /> Navigation.navigate(ROUTES.getTaskReportDescriptionRoute(props.report.reportID))} disabled={!isOpen} /> diff --git a/src/components/Tooltip/TooltipRenderedOnPageBody.js b/src/components/Tooltip/TooltipRenderedOnPageBody.js index 360bcca2d790..3d18235e8cf3 100644 --- a/src/components/Tooltip/TooltipRenderedOnPageBody.js +++ b/src/components/Tooltip/TooltipRenderedOnPageBody.js @@ -20,11 +20,11 @@ const propTypes = { /** The distance between the top of the wrapper view and the top of the window */ yOffset: PropTypes.number.isRequired, - /** The width of the tooltip wrapper */ - wrapperWidth: PropTypes.number.isRequired, + /** The width of the tooltip's target */ + targetWidth: PropTypes.number.isRequired, - /** The Height of the tooltip wrapper */ - wrapperHeight: PropTypes.number.isRequired, + /** The height of the tooltip's target */ + targetHeight: PropTypes.number.isRequired, /** Any additional amount to manually adjust the horizontal position of the tooltip. A positive value shifts the tooltip to the right, and a negative value shifts it to the left. */ @@ -60,14 +60,13 @@ const defaultProps = { // There will be n number of tooltip components in the page. // It's good to memoize this one. const TooltipRenderedOnPageBody = (props) => { - // The width of tooltip's inner content. Has to be undefined in the beginning - // as a width of 0 will cause the content to be rendered of a width of 0, + // The width and height of tooltip's inner content. Has to be undefined in the beginning + // as a width/height of 0 will cause the content to be rendered of a width/height of 0, // which prevents us from measuring it correctly. - const [tooltipContentWidth, setTooltipContentWidth] = useState(undefined); - const [tooltipWidth, setTooltipWidth] = useState(0); - const [tooltipHeight, setTooltipHeight] = useState(0); + const [contentMeasuredWidth, setContentMeasuredWidth] = useState(undefined); + const [contentMeasuredHeight, setContentMeasuredHeight] = useState(undefined); const contentRef = useRef(); - const wrapper = useRef(); + const rootWrapper = useRef(); useEffect(() => { if (!props.renderTooltipContent || !props.text) { @@ -79,40 +78,37 @@ const TooltipRenderedOnPageBody = (props) => { useLayoutEffect(() => { // Calculate the tooltip width and height before the browser repaints the screen to prevent flicker // because of the late update of the width and the height from onLayout. - const rect = wrapper.current.getBoundingClientRect(); - - setTooltipWidth(rect.width); - setTooltipHeight(rect.height); - setTooltipContentWidth(contentRef.current.offsetWidth); + const rect = contentRef.current.getBoundingClientRect(); + setContentMeasuredWidth(rect.width); + setContentMeasuredHeight(rect.height); }, []); - const {animationStyle, tooltipWrapperStyle, tooltipTextStyle, pointerWrapperStyle, pointerStyle} = useMemo( + const {animationStyle, rootWrapperStyle, textStyle, pointerWrapperStyle, pointerStyle} = useMemo( () => getTooltipStyles( props.animation, props.windowWidth, props.xOffset, props.yOffset, - props.wrapperWidth, - props.wrapperHeight, + props.targetWidth, + props.targetHeight, props.maxWidth, - tooltipWidth, - tooltipHeight, - tooltipContentWidth, + contentMeasuredWidth, + contentMeasuredHeight, props.shiftHorizontal, props.shiftVertical, + rootWrapper.current, ), [ props.animation, props.windowWidth, props.xOffset, props.yOffset, - props.wrapperWidth, - props.wrapperHeight, + props.targetWidth, + props.targetHeight, props.maxWidth, - tooltipWidth, - tooltipHeight, - tooltipContentWidth, + contentMeasuredWidth, + contentMeasuredHeight, props.shiftHorizontal, props.shiftVertical, ], @@ -125,10 +121,10 @@ const TooltipRenderedOnPageBody = (props) => { content = ( {props.text} @@ -139,8 +135,8 @@ const TooltipRenderedOnPageBody = (props) => { return ReactDOM.createPortal( {content} diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js index 441573430699..f18bc803aa45 100644 --- a/src/components/Tooltip/index.js +++ b/src/components/Tooltip/index.js @@ -1,22 +1,27 @@ import _ from 'underscore'; import React, {PureComponent} from 'react'; import {Animated, View} from 'react-native'; +import {BoundsObserver} from '@react-ng/bounds-observer'; import TooltipRenderedOnPageBody from './TooltipRenderedOnPageBody'; import Hoverable from '../Hoverable'; import withWindowDimensions from '../withWindowDimensions'; -import {propTypes, defaultProps} from './tooltipPropTypes'; +import * as tooltipPropTypes from './tooltipPropTypes'; import TooltipSense from './TooltipSense'; -import makeCancellablePromise from '../../libs/MakeCancellablePromise'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; +// A "target" for the tooltip, i.e. an element that, when hovered over, triggers the tooltip to appear. The tooltip will +// point towards this target. class Tooltip extends PureComponent { constructor(props) { super(props); this.state = { - // Is tooltip rendered? + // Is tooltip already rendered on the page's body? This happens once. isRendered: false, + // Is the tooltip currently visible? + isVisible: false, + // The distance between the left side of the wrapper view and the left side of the window xOffset: 0, @@ -30,57 +35,25 @@ class Tooltip extends PureComponent { // Whether the tooltip is first tooltip to activate the TooltipSense this.isTooltipSenseInitiator = false; - this.shouldStartShowAnimation = false; this.animation = new Animated.Value(0); this.hasHoverSupport = DeviceCapabilities.hasHoverSupport(); - this.getWrapperPosition = this.getWrapperPosition.bind(this); this.showTooltip = this.showTooltip.bind(this); this.hideTooltip = this.hideTooltip.bind(this); - } - - componentDidUpdate(prevProps) { - if (this.props.windowWidth === prevProps.windowWidth && this.props.windowHeight === prevProps.windowHeight) { - return; - } - - this.getWrapperPositionPromise = makeCancellablePromise(this.getWrapperPosition()); - this.getWrapperPositionPromise.promise.then(({x, y}) => this.setState({xOffset: x, yOffset: y})); - } - - componentWillUnmount() { - if (!this.getWrapperPositionPromise) { - return; - } - - this.getWrapperPositionPromise.cancel(); + this.updateBounds = this.updateBounds.bind(this); } /** - * Measure the position of the wrapper view relative to the window. + * Update the tooltip bounding rectangle * - * @returns {Promise} + * @param {Object} bounds - updated bounds */ - getWrapperPosition() { - return new Promise((resolve) => { - // Make sure the wrapper is mounted before attempting to measure it. - if (this.wrapperView && _.isFunction(this.wrapperView.measureInWindow)) { - this.wrapperView.measureInWindow((x, y, width, height) => - resolve({ - x, - y, - width, - height, - }), - ); - } else { - resolve({ - x: 0, - y: 0, - width: 0, - height: 0, - }); - } + updateBounds(bounds) { + this.setState({ + wrapperWidth: bounds.width, + wrapperHeight: bounds.height, + xOffset: bounds.x, + yOffset: bounds.y, }); } @@ -91,38 +64,24 @@ class Tooltip extends PureComponent { if (!this.state.isRendered) { this.setState({isRendered: true}); } + + this.setState({isVisible: true}); + this.animation.stopAnimation(); - this.shouldStartShowAnimation = true; - - // We have to dynamically calculate the position here as tooltip could have been rendered on some elments - // that has changed its position - this.getWrapperPositionPromise = makeCancellablePromise(this.getWrapperPosition()); - this.getWrapperPositionPromise.promise.then(({x, y, width, height}) => { - this.setState({ - wrapperWidth: width, - wrapperHeight: height, - xOffset: x, - yOffset: y, - }); - // We may need this check due to the reason that the animation start will fire async - // and hideTooltip could fire before it thus keeping the Tooltip visible - if (this.shouldStartShowAnimation) { - // When TooltipSense is active, immediately show the tooltip - if (TooltipSense.isActive()) { - this.animation.setValue(1); - } else { - this.isTooltipSenseInitiator = true; - Animated.timing(this.animation, { - toValue: 1, - duration: 140, - delay: 500, - useNativeDriver: false, - }).start(); - } - TooltipSense.activate(); - } - }); + // When TooltipSense is active, immediately show the tooltip + if (TooltipSense.isActive()) { + this.animation.setValue(1); + } else { + this.isTooltipSenseInitiator = true; + Animated.timing(this.animation, { + toValue: 1, + duration: 140, + delay: 500, + useNativeDriver: false, + }).start(); + } + TooltipSense.activate(); } /** @@ -130,7 +89,7 @@ class Tooltip extends PureComponent { */ hideTooltip() { this.animation.stopAnimation(); - this.shouldStartShowAnimation = false; + if (TooltipSense.isActive() && !this.isTooltipSenseInitiator) { this.animation.setValue(0); } else { @@ -142,7 +101,10 @@ class Tooltip extends PureComponent { useNativeDriver: false, }).start(); } + TooltipSense.deactivate(); + + this.setState({isVisible: false}); } render() { @@ -192,8 +154,8 @@ class Tooltip extends PureComponent { windowWidth={this.props.windowWidth} xOffset={this.state.xOffset} yOffset={this.state.yOffset} - wrapperWidth={this.state.wrapperWidth} - wrapperHeight={this.state.wrapperHeight} + targetWidth={this.state.wrapperWidth} + targetHeight={this.state.wrapperHeight} shiftHorizontal={_.result(this.props, 'shiftHorizontal')} shiftVertical={_.result(this.props, 'shiftVertical')} text={this.props.text} @@ -205,19 +167,24 @@ class Tooltip extends PureComponent { key={[this.props.text, ...this.props.renderTooltipContentKey]} /> )} - - {child} - + + {child} + + > ); } } -Tooltip.propTypes = propTypes; -Tooltip.defaultProps = defaultProps; +Tooltip.propTypes = tooltipPropTypes.propTypes; +Tooltip.defaultProps = tooltipPropTypes.defaultProps; export default withWindowDimensions(Tooltip); diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js index 56e4018fb24c..4bd5cfdeb5d8 100644 --- a/src/components/withNavigationFallback.js +++ b/src/components/withNavigationFallback.js @@ -36,7 +36,7 @@ export default function (WrappedComponent) { WithNavigationFallback.contextType = NavigationContext; WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`; WithNavigationFallback.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.oneOfType([PropTypes.instanceOf(React.Component), PropTypes.func])})]), }; WithNavigationFallback.defaultProps = { forwardedRef: undefined, diff --git a/src/components/withNavigationFocus.js b/src/components/withNavigationFocus.js index 45b97472d2ac..0ece1b658c65 100644 --- a/src/components/withNavigationFocus.js +++ b/src/components/withNavigationFocus.js @@ -22,7 +22,7 @@ export default function withNavigationFocus(WrappedComponent) { WithNavigationFocus.displayName = `withNavigationFocus(${getComponentDisplayName(WrappedComponent)})`; WithNavigationFocus.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.instanceOf(React.Component)})]), + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.oneOfType([PropTypes.instanceOf(React.Component), PropTypes.func])})]), }; WithNavigationFocus.defaultProps = { forwardedRef: undefined, diff --git a/src/hooks/useArrowKeyFocusManager.js b/src/hooks/useArrowKeyFocusManager.js new file mode 100644 index 000000000000..09c3f03d488b --- /dev/null +++ b/src/hooks/useArrowKeyFocusManager.js @@ -0,0 +1,72 @@ +import {useState, useEffect} from 'react'; +import useKeyboardShortcut from './useKeyboardShortcut'; +import CONST from '../CONST'; + +/** + * A hook that makes it easy to use the arrow keys to manage focus of items in a list + * + * @param {Object} config + * @param {Number} config.maxIndex – typically the number of items in your list + * @param {Function} [config.onFocusedIndexChange] – optional callback to execute when focusedIndex changes + * @param {Number} [config.initialFocusedIndex] – where to start in the list + * @param {Array} [config.disabledIndexes] – An array of indexes to disable + skip over + * @param {Boolean} [config.shouldExcludeTextAreaNodes] – Whether arrow keys should have any effect when a TextArea node is focused + * @returns {Array} + */ +export default function useArrowKeyFocusManager({maxIndex, onFocusedIndexChange = () => {}, initialFocusedIndex = 0, disabledIndexes = [], shouldExcludeTextAreaNodes = true}) { + const [focusedIndex, setFocusedIndex] = useState(initialFocusedIndex); + useEffect(() => onFocusedIndexChange(focusedIndex), [focusedIndex, onFocusedIndexChange]); + + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ARROW_UP, + () => { + if (maxIndex < 0) { + return; + } + + const currentFocusedIndex = focusedIndex > 0 ? focusedIndex - 1 : maxIndex; + let newFocusedIndex = currentFocusedIndex; + + while (disabledIndexes.includes(newFocusedIndex)) { + newFocusedIndex = newFocusedIndex > 0 ? newFocusedIndex - 1 : maxIndex; + if (newFocusedIndex === currentFocusedIndex) { + // all indexes are disabled + return; // no-op + } + } + + setFocusedIndex(newFocusedIndex); + }, + { + excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], + }, + ); + + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.ARROW_DOWN, + () => { + if (maxIndex < 0) { + return; + } + + const currentFocusedIndex = focusedIndex < maxIndex ? focusedIndex + 1 : 0; + let newFocusedIndex = currentFocusedIndex; + + while (disabledIndexes.includes(newFocusedIndex)) { + newFocusedIndex = newFocusedIndex < maxIndex ? newFocusedIndex + 1 : 0; + if (newFocusedIndex === currentFocusedIndex) { + // all indexes are disabled + return; // no-op + } + } + + setFocusedIndex(newFocusedIndex); + }, + { + excludedNodes: shouldExcludeTextAreaNodes ? ['TEXTAREA'] : [], + }, + ); + + // 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/useKeyboardShortcut.js b/src/hooks/useKeyboardShortcut.js new file mode 100644 index 000000000000..434daeda1921 --- /dev/null +++ b/src/hooks/useKeyboardShortcut.js @@ -0,0 +1,37 @@ +import {useEffect, useRef, useCallback} from 'react'; +import KeyboardShortcut from '../libs/KeyboardShortcut'; + +/** + * Register a keyboard shortcut handler. + * + * @param {Object} shortcut + * @param {Function} callback + * @param {Object} [config] + */ +export default function useKeyboardShortcut(shortcut, callback, config = {}) { + const {captureOnInputs = true, shouldBubble = false, priority = 0, shouldPreventDefault = true, excludedNodes = [], isActive = true} = config; + + const subscription = useRef(null); + const subscribe = useCallback( + () => + KeyboardShortcut.subscribe( + shortcut.shortcutKey, + callback, + shortcut.descriptionKey, + shortcut.modifiers, + captureOnInputs, + shouldBubble, + priority, + shouldPreventDefault, + excludedNodes, + ), + [callback, captureOnInputs, excludedNodes, priority, shortcut.descriptionKey, shortcut.modifiers, shortcut.shortcutKey, shouldBubble, shouldPreventDefault], + ); + + useEffect(() => { + const unsubscribe = subscription.current || (() => {}); + unsubscribe(); + subscription.current = isActive ? subscribe() : null; + return isActive ? subscription.current : () => {}; + }, [isActive, subscribe]); +} diff --git a/src/hooks/useWindowDimensions.js b/src/hooks/useWindowDimensions.js new file mode 100644 index 000000000000..ea46be38246a --- /dev/null +++ b/src/hooks/useWindowDimensions.js @@ -0,0 +1,20 @@ +import {useWindowDimensions} from 'react-native'; +import variables from '../styles/variables'; + +/** + * A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints. + * @returns {Object} + */ +export default function () { + const {width: windowWidth, height: windowHeight} = useWindowDimensions(); + const isSmallScreenWidth = windowWidth <= variables.mobileResponsiveWidthBreakpoint; + const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint; + const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint; + return { + windowWidth, + windowHeight, + isSmallScreenWidth, + isMediumScreenWidth, + isLargeScreenWidth, + }; +} diff --git a/src/languages/en.js b/src/languages/en.js index c92c9faf5d5a..9f4426a2a441 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -123,6 +123,7 @@ export default { enterManually: 'Enter it manually', message: 'Message ', leaveRoom: 'Leave room', + leaveThread: 'Leave thread', you: 'You', youAfterPreposition: 'you', your: 'your', @@ -344,11 +345,14 @@ export default { genericDeleteFailureMessage: 'Unexpected error deleting the money request, please try again later', }, }, - notificationPreferences: { + notificationPreferencesPage: { + header: 'Notification preferences', label: 'Notify me about new messages', - immediately: 'Immediately', - daily: 'Daily', - mute: 'Mute', + notificationPreferences: { + always: 'Immediately', + daily: 'Daily', + mute: 'Mute', + }, }, loginField: { numberHasNotBeenValidated: 'The number has not yet been validated. Click the button to resend the validation link via text.', @@ -1137,6 +1141,7 @@ export default { nameInputLabel: 'Name', nameInputHelpText: 'This is the name you will see on your workspace.', nameIsRequiredError: 'You need to define a name for your workspace.', + nameIsTooLongError: `Your workspace name can be at most ${CONST.WORKSPACE_NAME_CHARACTER_LIMIT} characters long.`, currencyInputLabel: 'Default currency', currencyInputHelpText: 'All expenses on this workspace will be converted to this currency.', currencyInputDisabledText: "The default currency can't be changed because this workspace is linked to a USD bank account.", diff --git a/src/languages/es.js b/src/languages/es.js index 66fb5c22b342..8a024d2f3e8a 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -122,6 +122,7 @@ export default { enterManually: 'Introducir manualmente', message: 'Chatear con ', leaveRoom: 'Salir de la sala de chat', + leaveThread: 'Salir del hilo', you: 'Tú', youAfterPreposition: 'ti', your: 'tu', @@ -343,11 +344,14 @@ export default { genericDeleteFailureMessage: 'Error inesperado eliminando la solicitud de dinero. Por favor, inténtalo más tarde', }, }, - notificationPreferences: { + notificationPreferencesPage: { + header: 'Preferencias de avisos', label: 'Avisar sobre nuevos mensajes', - immediately: 'Inmediatamente', - daily: 'Cada dÃa', - mute: 'Nunca', + notificationPreferences: { + always: 'Inmediatamente', + daily: 'Cada dÃa', + mute: 'Nunca', + }, }, loginField: { numberHasNotBeenValidated: 'El número no está validado todavÃa. Haz click en el botón para reenviar el enlace de confirmación via SMS.', @@ -1143,6 +1147,7 @@ export default { nameInputLabel: 'Nombre', nameInputHelpText: 'Este es el nombre que verás en tu espacio de trabajo.', nameIsRequiredError: 'Debes definir un nombre para tu espacio de trabajo.', + nameIsTooLongError: `El nombre de su espacio de trabajo no puede tener más de ${CONST.WORKSPACE_NAME_CHARACTER_LIMIT} caracteres.`, currencyInputLabel: 'Moneda por defecto', currencyInputHelpText: 'Todas los gastos en este espacio de trabajo serán convertidos a esta moneda.', currencyInputDisabledText: 'La moneda predeterminada no se puede cambiar porque este espacio de trabajo está vinculado a una cuenta bancaria en USD.', diff --git a/src/libs/BootSplash/index.js b/src/libs/BootSplash/index.js index f4cdb7a7427f..b9b8692f687c 100644 --- a/src/libs/BootSplash/index.js +++ b/src/libs/BootSplash/index.js @@ -1,4 +1,28 @@ +import Log from '../Log'; + +function resolveAfter(delay) { + return new Promise((resolve) => setTimeout(resolve, delay)); +} + +function hide() { + Log.info('[BootSplash] hiding splash screen', false); + + return document.fonts.ready.then(() => { + const splash = document.getElementById('splash'); + splash.style.opacity = 0; + + return resolveAfter(250).then(() => { + splash.parentNode.removeChild(splash); + }); + }); +} + +function getVisibilityStatus() { + return Promise.resolve(document.getElementById('splash') ? 'visible' : 'hidden'); +} + export default { - hide: () => {}, - getVisibilityStatus: () => Promise.resolve('hidden'), + hide, + getVisibilityStatus, + navigationBarHeight: 0, }; diff --git a/src/libs/BootSplash/index.native.js b/src/libs/BootSplash/index.native.js index a228422733be..942b3cadb74a 100644 --- a/src/libs/BootSplash/index.native.js +++ b/src/libs/BootSplash/index.native.js @@ -5,10 +5,11 @@ const BootSplash = NativeModules.BootSplash; function hide() { Log.info('[BootSplash] hiding splash screen', false); - BootSplash.hide(); + return BootSplash.hide(); } export default { hide, getVisibilityStatus: BootSplash.getVisibilityStatus, + navigationBarHeight: BootSplash.navigationBarHeight || 0, }; diff --git a/src/libs/E2E/apiMocks/openApp.js b/src/libs/E2E/apiMocks/openApp.js index 36bd85e42d08..360aaf3792df 100644 --- a/src/libs/E2E/apiMocks/openApp.js +++ b/src/libs/E2E/apiMocks/openApp.js @@ -1582,7 +1582,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['concierge@expensify.com'], isPinned: true, - lastReadTimestamp: 1671126234191, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-08-03 06:45:00', lastMessageTimestamp: 1659509100000, @@ -1609,7 +1608,6 @@ export default () => ({ policyID: 'C28C2634DD7226B8', participants: ['applausetester@applause.expensifail.com', 'applausetester+perf2@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1669129206943, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-11-03 20:30:55.599', lastMessageTimestamp: 1667507455599, @@ -1632,7 +1630,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['applausetester+ihchat4@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1671205050152, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-08-02 20:03:42', lastMessageTimestamp: 1659470622000, @@ -1655,7 +1652,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['fake3@gmail.com'], isPinned: false, - lastReadTimestamp: 1671210740419, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-11-04 21:18:00.038', lastMessageTimestamp: 1667596680038, @@ -1678,7 +1674,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['fake3@gmail.com', 'fake6@gmail.com', 'fake7@gmail.com', 'fake8@gmail.com'], isPinned: false, - lastReadTimestamp: 1671209362667, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-08-01 20:48:16', lastMessageTimestamp: 1659386896000, @@ -1701,7 +1696,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['fake1@gmail.com', 'fake2@gmail.com', 'fake3@gmail.com', 'fake4@gmail.com', 'fake5@gmail.com', 'fake6@gmail.com', 'fake7@gmail.com', 'fake8@gmail.com'], isPinned: false, - lastReadTimestamp: 1671470568415, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-08-01 20:49:11', lastMessageTimestamp: 1659386951000, @@ -1724,7 +1718,6 @@ export default () => ({ policyID: '1CE001C4B9F3CA54', participants: ['fake3@gmail.com', 'applausetester+perf2@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1664363369565, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-08-16 12:30:57', lastMessageTimestamp: 1660653057000, @@ -1747,7 +1740,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['applausetester+ihchat4@applause.expensifail.com', 'fake6@gmail.com'], isPinned: false, - lastReadTimestamp: 1669197163626, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-08-02 20:03:41', lastMessageTimestamp: 1659470621000, @@ -1770,7 +1762,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['fake6@gmail.com'], isPinned: false, - lastReadTimestamp: 1671214557025, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-12-09 10:17:18.362', lastMessageTimestamp: 1670581038362, @@ -1794,7 +1785,6 @@ export default () => ({ policyID: 'C28C2634DD7226B8', participants: ['applausetester+pd1005@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1669298874528, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-10-12 17:47:45.228', lastMessageTimestamp: 1665596865228, @@ -1817,7 +1807,6 @@ export default () => ({ policyID: 'A6511FF8D2EE7661', participants: ['applausetester+perf2@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 0, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '', lastMessageTimestamp: 0, @@ -1840,7 +1829,6 @@ export default () => ({ policyID: 'A6511FF8D2EE7661', participants: ['applausetester+perf2@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1669122367932, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '', lastMessageTimestamp: 0, @@ -1869,7 +1857,6 @@ export default () => ({ 'fake3@gmail.com', ], isPinned: false, - lastReadTimestamp: 1671211239096, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-11-03 20:48:58.815', lastMessageTimestamp: 1667508538815, @@ -1892,7 +1879,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['applausetester+42222abb@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1671213666675, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-12-01 08:05:11.009', lastMessageTimestamp: 1669881911009, @@ -1915,7 +1901,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['fake1@gmail.com'], isPinned: false, - lastReadTimestamp: 1669300587843, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '', lastMessageTimestamp: 0, @@ -1938,7 +1923,6 @@ export default () => ({ policyID: 'C28C2634DD7226B8', participants: ['applausetester+pd1005@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1669881965738, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-10-12 12:20:00.668', lastMessageTimestamp: 1665577200668, @@ -1961,7 +1945,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['christoph+hightraffic@margelo.io'], isPinned: false, - lastReadTimestamp: 1671214566347, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-12-16 18:14:00.208', lastMessageTimestamp: 1671214440208, @@ -1985,7 +1968,6 @@ export default () => ({ policyID: 'C28C2634DD7226B8', participants: ['applausetester+pd1005@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1670955487510, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-11-29 12:38:15.985', lastMessageTimestamp: 1669725495985, @@ -2010,7 +1992,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['applausetester+bernardo@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1669634659097, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-11-29 21:08:00.793', lastMessageTimestamp: 1669756080793, @@ -2042,7 +2023,6 @@ export default () => ({ 'applausetester+1904lsn@applause.expensifail.com', ], isPinned: false, - lastReadTimestamp: 1669129467258, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '', lastMessageTimestamp: 0, @@ -2065,7 +2045,6 @@ export default () => ({ policyID: 'A6511FF8D2EE7661', participants: ['applausetester+perf2@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 0, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '', lastMessageTimestamp: 0, @@ -2097,7 +2076,6 @@ export default () => ({ 'applausetester+0604lsn@applause.expensifail.com', ], isPinned: false, - lastReadTimestamp: 1671211247254, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-09-15 12:57:59.526', lastMessageTimestamp: 1663246679526, @@ -2128,7 +2106,6 @@ export default () => ({ 'andreylazutkinutest+160956@gmail.com', ], isPinned: false, - lastReadTimestamp: 1669634649909, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-09-16 11:12:46.739', lastMessageTimestamp: 1663326766739, @@ -2151,7 +2128,6 @@ export default () => ({ policyID: 'C28C2634DD7226B8', participants: ['applausetester+pd1005@applause.expensifail.com'], isPinned: false, - lastReadTimestamp: 1669197883208, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-10-12 12:46:43.577', lastMessageTimestamp: 1665578803577, @@ -2174,7 +2150,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['fake5@gmail.com'], isPinned: false, - lastReadTimestamp: 1671205430161, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '', lastMessageTimestamp: 0, diff --git a/src/libs/E2E/apiMocks/openReport.js b/src/libs/E2E/apiMocks/openReport.js index 70e546d2f8cf..3eb9c7890299 100644 --- a/src/libs/E2E/apiMocks/openReport.js +++ b/src/libs/E2E/apiMocks/openReport.js @@ -11,7 +11,6 @@ export default () => ({ policyID: '_FAKE_', participants: ['fake1@gmail.com', 'fake2@gmail.com', 'fake3@gmail.com', 'fake4@gmail.com', 'fake5@gmail.com', 'fake6@gmail.com', 'fake7@gmail.com', 'fake8@gmail.com'], isPinned: false, - lastReadTimestamp: 1671470568415, lastReadCreated: '1980-01-01 00:00:00.000', lastVisibleActionCreated: '2022-08-01 20:49:11', lastMessageTimestamp: 1659386951000, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 20332e12916d..de821060b832 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -169,6 +169,30 @@ const ReportDetailsModalStackNavigator = createModalStackNavigator([ }, ]); +const ReportSettingsModalStackNavigator = createModalStackNavigator([ + { + getComponent: () => { + const ReportSettingsPage = require('../../../pages/settings/Report/ReportSettingsPage').default; + return ReportSettingsPage; + }, + name: 'Report_Settings_Root', + }, + { + getComponent: () => { + const RoomNamePage = require('../../../pages/settings/Report/RoomNamePage').default; + return RoomNamePage; + }, + name: 'Report_Settings_Room_Name', + }, + { + getComponent: () => { + const NotificationPreferencesPage = require('../../../pages/settings/Report/NotificationPreferencePage').default; + return NotificationPreferencesPage; + }, + name: 'Report_Settings_Notification_Preferences', + }, +]); + const TaskModalStackNavigator = createModalStackNavigator([ { getComponent: () => { @@ -193,16 +217,6 @@ const TaskModalStackNavigator = createModalStackNavigator([ }, ]); -const ReportSettingsModalStackNavigator = createModalStackNavigator([ - { - getComponent: () => { - const ReportSettingsPage = require('../../../pages/ReportSettingsPage').default; - return ReportSettingsPage; - }, - name: 'Report_Settings_Root', - }, -]); - const ReportParticipantsModalStackNavigator = createModalStackNavigator([ { getComponent: () => { diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js index 4bc5a839da55..32bf58e011fe 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.js @@ -5,9 +5,7 @@ import {useFlipper} from '@react-navigation/devtools'; import Navigation, {navigationRef} from './Navigation'; import linkingConfig from './linkingConfig'; import AppNavigator from './AppNavigator'; -import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; import themeColors from '../../styles/themes/default'; -import styles from '../../styles/styles'; import Log from '../Log'; // https://reactnavigation.org/docs/themes @@ -52,7 +50,6 @@ const NavigationRoot = (props) => { useFlipper(navigationRef); return ( } onStateChange={parseAndLogRoute} onReady={props.onReady} theme={navigationTheme} diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 6c1da26c6058..1535880e8997 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -215,7 +215,15 @@ export default { }, Report_Settings: { screens: { - Report_Settings_Root: ROUTES.REPORT_SETTINGS, + Report_Settings_Root: { + path: ROUTES.REPORT_SETTINGS, + }, + Report_Settings_Room_Name: { + path: ROUTES.REPORT_SETTINGS_ROOM_NAME, + }, + Report_Settings_Notification_Preferences: { + path: ROUTES.REPORT_SETTINGS_NOTIFICATION_PREFERENCES, + }, }, }, NewGroup: { diff --git a/src/libs/Notification/LocalNotification/BrowserNotifications.js b/src/libs/Notification/LocalNotification/BrowserNotifications.js index 6db3fdb7f50c..e55c0430fe17 100644 --- a/src/libs/Notification/LocalNotification/BrowserNotifications.js +++ b/src/libs/Notification/LocalNotification/BrowserNotifications.js @@ -3,6 +3,7 @@ import _ from 'underscore'; import focusApp from './focusApp'; import * as AppUpdate from '../../actions/AppUpdate'; import EXPENSIFY_ICON_URL from '../../../../assets/images/expensify-logo-round-clearspace.png'; +import * as ReportUtils from '../../ReportUtils'; const DEFAULT_DELAY = 4000; @@ -92,20 +93,35 @@ export default { * Create a report comment notification * * @param {Object} params + * @param {Object} params.report * @param {Object} params.reportAction * @param {Function} params.onClick * @param {Boolean} usesIcon true if notification uses right circular icon */ - pushReportCommentNotification({reportAction, onClick}, usesIcon = false) { + pushReportCommentNotification({report, reportAction, onClick}, usesIcon = false) { + let title; + let body; + + const isChatRoom = ReportUtils.isChatRoom(report); + const {person, message} = reportAction; const plainTextPerson = _.map(person, (f) => f.text).join(); // Specifically target the comment part of the message const plainTextMessage = (_.find(message, (f) => f.type === 'COMMENT') || {}).text; + if (isChatRoom) { + const roomName = _.get(report, 'displayName', ''); + title = roomName; + body = `${plainTextPerson}: ${plainTextMessage}`; + } else { + title = plainTextPerson; + body = plainTextMessage; + } + push({ - title: plainTextPerson, - body: plainTextMessage, + title, + body, delay: 0, onClick, icon: usesIcon ? EXPENSIFY_ICON_URL : '', diff --git a/src/libs/Notification/LocalNotification/index.desktop.js b/src/libs/Notification/LocalNotification/index.desktop.js index 9564b0ef7f26..2bef51cea0a6 100644 --- a/src/libs/Notification/LocalNotification/index.desktop.js +++ b/src/libs/Notification/LocalNotification/index.desktop.js @@ -1,7 +1,7 @@ import BrowserNotifications from './BrowserNotifications'; -function showCommentNotification({reportAction, onClick}) { - BrowserNotifications.pushReportCommentNotification({reportAction, onClick}); +function showCommentNotification({report, reportAction, onClick}) { + BrowserNotifications.pushReportCommentNotification({report, reportAction, onClick}); } function showUpdateAvailableNotification() { diff --git a/src/libs/Notification/LocalNotification/index.website.js b/src/libs/Notification/LocalNotification/index.website.js index 5c94f3360f4c..3410b3144caf 100644 --- a/src/libs/Notification/LocalNotification/index.website.js +++ b/src/libs/Notification/LocalNotification/index.website.js @@ -1,7 +1,7 @@ import BrowserNotifications from './BrowserNotifications'; -function showCommentNotification({reportAction, onClick}) { - BrowserNotifications.pushReportCommentNotification({reportAction, onClick}, true); +function showCommentNotification({report, reportAction, onClick}) { + BrowserNotifications.pushReportCommentNotification({report, reportAction, onClick}, true); } function showUpdateAvailableNotification() { diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index cb19a449dd11..0b3c4bd35b51 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -891,7 +891,7 @@ function getMemberInviteOptions(personalDetails, betas = [], searchValue = '', e searchInputValue: searchValue.trim(), includePersonalDetails: true, excludeLogins, - sortPersonalDetailsByAlphaAsc: false, + sortPersonalDetailsByAlphaAsc: true, }); } diff --git a/src/libs/PolicyUtils.js b/src/libs/PolicyUtils.js index f19420a2b38a..91396be628a3 100644 --- a/src/libs/PolicyUtils.js +++ b/src/libs/PolicyUtils.js @@ -92,4 +92,12 @@ function isExpensifyTeam(email) { return emailDomain === CONST.EXPENSIFY_PARTNER_NAME || emailDomain === CONST.EMAIL.GUIDES_DOMAIN; } -export {hasPolicyMemberError, hasPolicyError, hasPolicyErrorFields, hasCustomUnitsError, getPolicyBrickRoadIndicatorStatus, shouldShowPolicy, isExpensifyTeam}; +/** + * Checks if the current user is an admin of the policy. + * + * @param {Object} policy + * @returns {Boolean} + */ +const isPolicyAdmin = (policy) => lodashGet(policy, 'role') === CONST.POLICY.ROLE.ADMIN; + +export {hasPolicyMemberError, hasPolicyError, hasPolicyErrorFields, hasCustomUnitsError, getPolicyBrickRoadIndicatorStatus, shouldShowPolicy, isExpensifyTeam, isPolicyAdmin}; diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index 45c83c492ae2..d5551e06ba4a 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -2,7 +2,6 @@ import lodashGet from 'lodash/get'; import _ from 'underscore'; import lodashMerge from 'lodash/merge'; import lodashFindLast from 'lodash/findLast'; -import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Onyx from 'react-native-onyx'; import moment from 'moment'; import * as CollectionUtils from './CollectionUtils'; @@ -102,7 +101,7 @@ function isTransactionThread(parentReportAction) { return ( parentReportAction && parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && - (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage.IOUDetails)) + (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails'))) ); } @@ -184,7 +183,7 @@ function isConsecutiveActionMadeByPreviousActor(reportActions, actionIndex) { } // Comments are only grouped if they happen within 5 minutes of each other - if (moment(currentAction.created).unix() - moment(previousAction.created).unix() > 300) { + if (new Date(currentAction.created).getTime() - new Date(previousAction.created).getTime() > 300000) { return false; } @@ -230,9 +229,7 @@ function getLastVisibleMessageText(reportID, actionsToMerge = {}) { return CONST.ATTACHMENT_MESSAGE_TEXT; } - const htmlText = lodashGet(lastVisibleAction, 'message[0].html', ''); - const parser = new ExpensiMark(); - const messageText = parser.htmlToText(htmlText); + const messageText = lodashGet(message, 'text', ''); return String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); } diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 2bc18cab771f..6122701476f7 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -204,18 +204,28 @@ function isSettled(reportID) { } /** - * Can only delete if the author is this user and the action is an ADDCOMMENT action or an IOU action in an unsettled report + * Can only delete if the author is this user and the action is an ADDCOMMENT action or an IOU action in an unsettled report, or if the user is a + * policy admin * * @param {Object} reportAction + * @param {String} reportID * @returns {Boolean} */ -function canDeleteReportAction(reportAction) { - return ( - reportAction.actorEmail === sessionEmail && - ((reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && !ReportActionsUtils.isCreatedTaskReportAction(reportAction)) || - (ReportActionsUtils.isMoneyRequestAction(reportAction) && !isSettled(reportAction.originalMessage.IOUReportID))) && - reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE - ); +function canDeleteReportAction(reportAction, reportID) { + if ( + reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT || + reportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || + ReportActionsUtils.isCreatedTaskReportAction(reportAction) || + (ReportActionsUtils.isMoneyRequestAction(reportAction) && isSettled(reportAction.originalMessage.IOUReportID)) + ) { + return false; + } + if (reportAction.actorEmail === sessionEmail) { + return true; + } + const report = lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}); + const policy = lodashGet(allPolicies, `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, {}); + return policy.role === CONST.POLICY.ROLE.ADMIN; } /** @@ -473,15 +483,16 @@ function isThreadFirstChat(reportAction, reportID) { /** * Get either the policyName or domainName the chat is tied to * @param {Object} report - * @param {Object} parentReport * @returns {String} */ -function getChatRoomSubtitle(report, parentReport = null) { +function getChatRoomSubtitle(report) { if (isThread(report)) { if (!getChatType(report)) { return ''; } + const parentReport = lodashGet(allReports, [`${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`]); + // If thread is not from a DM or group chat, the subtitle will follow the pattern 'Workspace Name • #roomName' const workspaceName = getPolicyName(report); let roomName = ''; @@ -1113,7 +1124,7 @@ function getReportName(report) { function navigateToDetailsPage(report) { const participants = lodashGet(report, 'participants', []); - if (isChatRoom(report) || isPolicyExpenseChat(report)) { + if (isChatRoom(report) || isPolicyExpenseChat(report) || isThread(report)) { Navigation.navigate(ROUTES.getReportDetailsRoute(report.reportID)); return; } @@ -1372,9 +1383,10 @@ function getIOUReportActionMessage(type, total, comment, currency, paymentType = * @param {String} [paymentType] - Only required if the IOUReportAction type is 'pay'. Can be oneOf(elsewhere, payPal, Expensify). * @param {String} [iouReportID] - Only required if the IOUReportActions type is oneOf(decline, cancel, pay). Generates a randomID as default. * @param {Boolean} [isSettlingUp] - Whether we are settling up an IOU. + * @param {Boolean} [isSendMoneyFlow] - Whether this is send money flow * @returns {Object} */ -function buildOptimisticIOUReportAction(type, amount, currency, comment, participants, transactionID, paymentType = '', iouReportID = '', isSettlingUp = false) { +function buildOptimisticIOUReportAction(type, amount, currency, comment, participants, transactionID, paymentType = '', iouReportID = '', isSettlingUp = false, isSendMoneyFlow = false) { const IOUReportID = iouReportID || generateReportID(); const parser = new ExpensiMark(); const commentText = getParsedComment(comment); @@ -1390,7 +1402,7 @@ function buildOptimisticIOUReportAction(type, amount, currency, comment, partici }; // We store amount, comment, currency in IOUDetails when type = pay - if (type === CONST.IOU.REPORT_ACTION_TYPE.PAY) { + if (type === CONST.IOU.REPORT_ACTION_TYPE.PAY && isSendMoneyFlow) { _.each(['amount', 'comment', 'currency'], (key) => { delete originalMessage[key]; }); @@ -2080,6 +2092,11 @@ function canRequestMoney(report) { * @returns {Array} */ function getMoneyRequestOptions(report, reportParticipants, betas) { + // In the transaction thread, we do not allow any new money requests + if (ReportActionsUtils.isTransactionThread(ReportActionsUtils.getParentReportAction(report))) { + return []; + } + const participants = _.filter(reportParticipants, (email) => currentUserPersonalDetails.login !== email); const hasExcludedIOUEmails = lodashIntersection(reportParticipants, CONST.EXPENSIFY_EMAILS).length > 0; const hasMultipleParticipants = participants.length > 1; @@ -2088,6 +2105,11 @@ function getMoneyRequestOptions(report, reportParticipants, betas) { return []; } + // Additional requests should be blocked for money request reports + if (isMoneyRequestReport(report)) { + return []; + } + // User created policy rooms and default rooms like #admins or #announce will always have the Split Bill option // unless there are no participants at all (e.g. #admins room for a policy with only 1 admin) // DM chats will have the Split Bill option only when there are at least 3 people in the chat. @@ -2100,7 +2122,9 @@ function getMoneyRequestOptions(report, reportParticipants, betas) { // Workspace chats should only see the Request money option, as "easy overages" is not available. return [ ...(canRequestMoney(report) ? [CONST.IOU.MONEY_REQUEST_TYPE.REQUEST] : []), - ...(Permissions.canUseIOUSend(betas) && !isPolicyExpenseChat(report) ? [CONST.IOU.MONEY_REQUEST_TYPE.SEND] : []), + + // Send money option should be visible only in DMs + ...(Permissions.canUseIOUSend(betas) && isChatReport(report) && !isPolicyExpenseChat(report) && participants.length === 1 ? [CONST.IOU.MONEY_REQUEST_TYPE.SEND] : []), ]; } diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js index f4e4fcc56494..aab5ca78e4ed 100644 --- a/src/libs/SelectionScraper/index.js +++ b/src/libs/SelectionScraper/index.js @@ -95,9 +95,10 @@ const getHTMLOfSelection = () => { /** * Clears all attributes from dom elements * @param {Object} dom htmlparser2 dom representation + * @param {Boolean} isChildOfEditorElement * @returns {Object} htmlparser2 dom representation */ -const replaceNodes = (dom) => { +const replaceNodes = (dom, isChildOfEditorElement) => { let domName = dom.name; let domChildren; const domAttribs = {}; @@ -114,10 +115,10 @@ const replaceNodes = (dom) => { if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { domName = dom.attribs[tagAttribute]; } - } else if (dom.name === 'div' && dom.children.length === 1 && dom.children[0].type !== 'text') { - // We are excluding divs that have only one child and no text nodes and don't have a tagAttribute to prevent + } else if (dom.name === 'div' && dom.children.length === 1 && isChildOfEditorElement) { + // We are excluding divs that are children of our editor element and have only one child to prevent // additional newlines from being added in the HTML to Markdown conversion process. - return replaceNodes(dom.children[0]); + return replaceNodes(dom.children[0], isChildOfEditorElement); } // We need to preserve href attribute in order to copy links. @@ -126,7 +127,7 @@ const replaceNodes = (dom) => { } if (dom.children) { - domChildren = _.map(dom.children, (c) => replaceNodes(c)); + domChildren = _.map(dom.children, (c) => replaceNodes(c, isChildOfEditorElement || !_.isEmpty(dom.attribs && dom.attribs[tagAttribute]))); } return { diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index c8e15e929fa1..93220ecf313b 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -15,25 +15,15 @@ import * as LocalePhoneNumber from './LocalePhoneNumber'; // Note: It is very important that the keys subscribed to here are the same // keys that are connected to SidebarLinks withOnyx(). If there was a key missing from SidebarLinks and it's data was updated // for that key, then there would be no re-render and the options wouldn't reflect the new data because SidebarUtils.getOrderedReportIDs() wouldn't be triggered. -// There are a couple of keys here which are OK to have stale data. iouReports for example, doesn't need to exist in withOnyx() because -// when IOUs change, it also triggers a change on the reports collection. Having redundant subscriptions causes more re-renders which should be avoided. +// There are a couple of keys here which are OK to have stale data. Having redundant subscriptions causes more re-renders which should be avoided. // Session also can remain stale because the only way for the current user to change is to sign out and sign in, which would clear out all the Onyx // data anyway and cause SidebarLinks to rerender. -const chatReports = {}; -const moneyRequestReports = {}; +let allReports; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, - callback: (report, key) => { - if (!report) { - delete moneyRequestReports[key]; - delete chatReports[key]; - } else if (ReportUtils.isMoneyRequestReport(report)) { - moneyRequestReports[key] = report; - } else { - chatReports[key] = report; - } - }, + waitForCollectionCallback: true, + callback: (val) => (allReports = val), }); let personalDetails; @@ -108,9 +98,7 @@ function getOrderedReportIDs(reportIDFromRoute) { const isInDefaultMode = !isInGSDMode; // Filter out all the reports that shouldn't be displayed - const reportsToDisplay = _.filter({...chatReports, ...moneyRequestReports}, (report) => - ReportUtils.shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, currentUserLogin, moneyRequestReports, betas, policies), - ); + const reportsToDisplay = _.filter(allReports, (report) => ReportUtils.shouldReportBeInOptionList(report, reportIDFromRoute, isInGSDMode, currentUserLogin, allReports, betas, policies)); // There are a few properties that need to be calculated for the report which are used when sorting reports. _.each(reportsToDisplay, (report) => { @@ -121,7 +109,7 @@ function getOrderedReportIDs(reportIDFromRoute) { report.displayName = ReportUtils.getReportName(report); // eslint-disable-next-line no-param-reassign - report.iouReportAmount = ReportUtils.getMoneyRequestTotal(report, moneyRequestReports); + report.iouReportAmount = ReportUtils.getMoneyRequestTotal(report, allReports); }); // The LHN is split into five distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: @@ -146,7 +134,7 @@ function getOrderedReportIDs(reportIDFromRoute) { return; } - if (report.hasOutstandingIOU && !ReportUtils.isIOUOwnedByCurrentUser(report, moneyRequestReports)) { + if (report.hasOutstandingIOU && !ReportUtils.isIOUOwnedByCurrentUser(report, allReports)) { outstandingIOUReports.push(report); return; } @@ -196,7 +184,7 @@ function getOrderedReportIDs(reportIDFromRoute) { */ function getOptionData(reportID) { const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; - const report = chatReports[reportKey] || moneyRequestReports[reportKey]; + const report = allReports[reportKey]; // When a user signs out, Onyx is cleared. Due to the lazy rendering with a virtual list, it's possible for // this method to be called after the Onyx data has been cleared out. In that case, it's fine to do @@ -260,10 +248,9 @@ function getOptionData(reportID) { result.tooltipText = ReportUtils.getReportParticipantsTitle(report.participants || []); result.hasOutstandingIOU = report.hasOutstandingIOU; result.parentReportID = report.parentReportID || null; - - const parentReport = result.parentReportID ? chatReports[`${ONYXKEYS.COLLECTION.REPORT}${result.parentReportID}`] : null; + const parentReport = result.parentReportID ? allReports[`${ONYXKEYS.COLLECTION.REPORT}${result.parentReportID}`] : null; const hasMultipleParticipants = participantPersonalDetailList.length > 1 || result.isChatRoom || result.isPolicyExpenseChat; - const subtitle = ReportUtils.getChatRoomSubtitle(report, parentReport); + const subtitle = ReportUtils.getChatRoomSubtitle(report); const login = Str.removeSMSDomain(lodashGet(personalDetail, 'login', '')); const formattedLogin = Str.isSMSLogin(login) ? LocalePhoneNumber.formatPhoneNumber(login) : login; @@ -330,8 +317,8 @@ function getOptionData(reportID) { result.alternateText = lastMessageText || formattedLogin; } - result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result, moneyRequestReports); - result.iouReportAmount = ReportUtils.getMoneyRequestTotal(result, moneyRequestReports); + result.isIOUReportOwner = ReportUtils.isIOUOwnedByCurrentUser(result, allReports); + result.iouReportAmount = ReportUtils.getMoneyRequestTotal(result, allReports); if (!hasMultipleParticipants) { result.login = personalDetail.login; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 0b429a6d098b..ccc133cc56bd 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -338,7 +338,7 @@ function requestMoney(report, amount, currency, payeeEmail, participant, comment ); let isNewReportPreviewAction = false; - let reportPreviewAction = ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); + let reportPreviewAction = isNewIOUReport ? null : ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); if (!reportPreviewAction) { isNewReportPreviewAction = true; reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport.reportID, iouReport.reportID); @@ -882,6 +882,8 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType optimisticTransaction.transactionID, paymentMethodType, optimisticIOUReport.reportID, + false, + true, ); // First, add data that will be used in all cases @@ -1037,6 +1039,8 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho lastVisibleActionCreated: optimisticIOUReportAction.created, hasOutstandingIOU: false, iouReportID: null, + lastMessageText: optimisticIOUReportAction.message[0].text, + lastMessageHtml: optimisticIOUReportAction.message[0].html, }, }, { @@ -1065,6 +1069,11 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, value: optimisticTransaction, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD, + value: {[iouReport.policyID]: paymentMethodType}, + }, ]; const successData = [ @@ -1173,44 +1182,23 @@ function sendMoneyViaPaypal(report, amount, currency, comment, managerEmail, rec } /** + * @param {String} paymentType * @param {Object} chatReport * @param {Object} iouReport - * @param {Object} recipient + * @param {String} reimbursementBankAccountState */ -function payMoneyRequestElsewhere(chatReport, iouReport, recipient) { - const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, CONST.IOU.PAYMENT_TYPE.ELSEWHERE); - - API.write('PayMoneyRequestElsewhere', params, {optimisticData, successData, failureData}); - - Navigation.navigate(ROUTES.getReportRoute(chatReport.reportID)); -} - -/** - * @param {Object} chatReport - * @param {Object} iouReport - * @param {Object} recipient - */ -function payMoneyRequestWithWallet(chatReport, iouReport, recipient) { - const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, CONST.IOU.PAYMENT_TYPE.EXPENSIFY); - - API.write('PayMoneyRequestWithWallet', params, {optimisticData, successData, failureData}); - - Navigation.navigate(ROUTES.getReportRoute(chatReport.reportID)); -} - -/** - * @param {Object} chatReport - * @param {Object} iouReport - * @param {Object} recipient - */ -function payMoneyRequestViaPaypal(chatReport, iouReport, recipient) { - const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, CONST.IOU.PAYMENT_TYPE.PAYPAL_ME); - - API.write('PayMoneyRequestViaPaypal', params, {optimisticData, successData, failureData}); +function payMoneyRequest(paymentType, chatReport, iouReport) { + const recipient = { + login: iouReport.ownerEmail, + payPalMeAddress: iouReport.submitterPayPalMeAddress, + }; + const {params, optimisticData, successData, failureData} = getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentType); + API.write('PayMoneyRequest', params, {optimisticData, successData, failureData}); Navigation.navigate(ROUTES.getReportRoute(chatReport.reportID)); - - asyncOpenURL(Promise.resolve(), buildPayPalPaymentUrl(iouReport.total, recipient.payPalMeAddress, iouReport.currency)); + if (paymentType === CONST.IOU.PAYMENT_TYPE.PAYPAL_ME) { + asyncOpenURL(Promise.resolve(), buildPayPalPaymentUrl(iouReport.total, recipient.payPalMeAddress, iouReport.currency)); + } } export { @@ -1220,10 +1208,8 @@ export { requestMoney, sendMoneyElsewhere, sendMoneyViaPaypal, - payMoneyRequestElsewhere, - payMoneyRequestViaPaypal, + payMoneyRequest, setIOUSelectedCurrency, setMoneyRequestDescription, sendMoneyWithWallet, - payMoneyRequestWithWallet, }; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 27ed1c85e7a0..89e542320d02 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -14,7 +14,6 @@ import * as OptionsListUtils from '../OptionsListUtils'; import DateUtils from '../DateUtils'; import * as ReportUtils from '../ReportUtils'; import Log from '../Log'; -import * as Report from './Report'; import Permissions from '../Permissions'; const allPolicies = {}; @@ -1148,42 +1147,6 @@ function setWorkspaceInviteMembersDraft(policyID, memberEmails) { Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${policyID}`, memberEmails); } -/** - * - * @param {String} reportID - */ -function leaveRoom(reportID) { - API.write( - 'LeaveRoom', - { - reportID, - }, - { - optimisticData: [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: { - stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, - statusNum: CONST.REPORT.STATUS.CLOSED, - }, - }, - ], - failureData: [ - { - onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - value: { - stateNum: CONST.REPORT.STATE_NUM.OPEN, - statusNum: CONST.REPORT.STATUS.OPEN, - }, - }, - ], - }, - ); - Report.navigateToConciergeChat(); -} - export { removeMembers, addMembersToWorkspace, @@ -1213,5 +1176,4 @@ export { removeWorkspace, setWorkspaceInviteMembersDraft, isPolicyOwner, - leaveRoom, }; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 268a052d4a4e..6a4cd9fe3050 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -906,7 +906,7 @@ const extractLinksInMarkdownComment = (comment) => { const matches = [...comment.matchAll(regex)]; // Element 1 from match is the regex group if it exists which contains the link URLs - const links = _.map(matches, (match) => match[1]); + const links = _.map(matches, (match) => Str.sanitizeURL(match[1])); return links; }; @@ -1095,7 +1095,11 @@ function saveReportActionDraftNumberOfLines(reportID, reportActionID, numberOfLi * @param {String} previousValue * @param {String} newValue */ -function updateNotificationPreference(reportID, previousValue, newValue) { +function updateNotificationPreferenceAndNavigate(reportID, previousValue, newValue) { + if (previousValue === newValue) { + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(reportID)); + return; + } const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1111,6 +1115,7 @@ function updateNotificationPreference(reportID, previousValue, newValue) { }, ]; API.write('UpdateReportNotificationPreference', {reportID, notificationPreference: newValue}, {optimisticData, failureData}); + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(reportID)); } /** @@ -1248,9 +1253,15 @@ function navigateToConciergeChatAndDeleteReport(reportID) { * @param {String} policyRoomReport.reportName * @param {String} policyRoomName The updated name for the policy room */ -function updatePolicyRoomName(policyRoomReport, policyRoomName) { +function updatePolicyRoomNameAndNavigate(policyRoomReport, policyRoomName) { const reportID = policyRoomReport.reportID; const previousName = policyRoomReport.reportName; + + // No change needed, navigate back + if (previousName === policyRoomName) { + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(reportID)); + return; + } const optimisticData = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -1287,6 +1298,7 @@ function updatePolicyRoomName(policyRoomReport, policyRoomName) { }, ]; API.write('UpdatePolicyRoomName', {reportID, policyRoomName}, {optimisticData, successData, failureData}); + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(reportID)); } /** @@ -1378,6 +1390,7 @@ function showReportActionNotification(reportID, action) { Log.info('[LocalNotification] Creating notification'); LocalNotification.showCommentNotification({ + report: allReports[reportID], reportAction: action, onClick: () => { // Navigate to this report onClick @@ -1585,11 +1598,48 @@ function openReportFromDeepLink(url) { }); } +/** + * Leave a report by setting the state to submitted and closed + * + * @param {String} reportID + */ +function leaveRoom(reportID) { + API.write( + 'LeaveRoom', + { + reportID, + }, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS.CLOSED, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: { + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS.OPEN, + }, + }, + ], + }, + ); + navigateToConciergeChat(); +} + export { addComment, addAttachment, reconnect, - updateNotificationPreference, + updateNotificationPreferenceAndNavigate, subscribeToReportTypingEvents, unsubscribeFromReportChannel, saveReportComment, @@ -1615,8 +1665,8 @@ export { navigateToAndOpenReport, navigateToAndOpenChildReport, openPaymentDetailsPage, + updatePolicyRoomNameAndNavigate, openMoneyRequestsReportPage, - updatePolicyRoomName, clearPolicyRoomNameErrors, clearIOUError, subscribeToNewActionEvent, @@ -1626,4 +1676,5 @@ export { toggleEmojiReaction, hasAccountIDReacted, shouldShowReportActionNotification, + leaveRoom, }; diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index 71a9ee50f1c0..ad1a18496b58 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -419,6 +419,14 @@ function validateSecondaryLogin(contactMethod, validateCode) { }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + ...CONST.DEFAULT_ACCOUNT_DATA, + isLoading: true, + }, + }, ]; const successData = [ { @@ -432,6 +440,11 @@ function validateSecondaryLogin(contactMethod, validateCode) { }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {isLoading: false}, + }, ]; const failureData = [ { @@ -450,6 +463,11 @@ function validateSecondaryLogin(contactMethod, validateCode) { }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {isLoading: false}, + }, ]; API.write( diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 9ed7bfa86092..f073abbb2616 100644 --- a/src/pages/ReportDetailsPage.js +++ b/src/pages/ReportDetailsPage.js @@ -2,7 +2,7 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import {View, ScrollView} from 'react-native'; +import {View, ScrollView, Pressable} from 'react-native'; import lodashGet from 'lodash/get'; import RoomHeaderAvatars from '../components/RoomHeaderAvatars'; import compose from '../libs/compose'; @@ -15,7 +15,8 @@ import styles from '../styles/styles'; import DisplayNames from '../components/DisplayNames'; import * as OptionsListUtils from '../libs/OptionsListUtils'; import * as ReportUtils from '../libs/ReportUtils'; -import * as Policy from '../libs/actions/Policy'; +import * as PolicyUtils from '../libs/PolicyUtils'; +import * as Report from '../libs/actions/Report'; import participantPropTypes from '../components/participantPropTypes'; import * as Expensicons from '../components/Icon/Expensicons'; import ROUTES from '../ROUTES'; @@ -56,6 +57,10 @@ const defaultProps = { }; class ReportDetailsPage extends Component { + getPolicy() { + return this.props.policies[`${ONYXKEYS.COLLECTION.POLICY}${this.props.report.policyID}`]; + } + getMenuItems() { const menuItems = [ { @@ -82,7 +87,7 @@ class ReportDetailsPage extends Component { }); } - if (ReportUtils.isPolicyExpenseChat(this.props.report) || ReportUtils.isChatRoom(this.props.report)) { + if (ReportUtils.isPolicyExpenseChat(this.props.report) || ReportUtils.isChatRoom(this.props.report) || ReportUtils.isThread(this.props.report)) { menuItems.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.SETTINGS, translationKey: 'common.settings', @@ -93,13 +98,14 @@ class ReportDetailsPage extends Component { }); } - const policy = this.props.policies[`${ONYXKEYS.COLLECTION.POLICY}${this.props.report.policyID}`]; - if (ReportUtils.isUserCreatedPolicyRoom(this.props.report) || ReportUtils.canLeaveRoom(this.props.report, !_.isEmpty(policy))) { + const policy = this.getPolicy(); + const isThread = ReportUtils.isThread(this.props.report); + if (ReportUtils.isUserCreatedPolicyRoom(this.props.report) || ReportUtils.canLeaveRoom(this.props.report, !_.isEmpty(policy)) || isThread) { menuItems.push({ key: CONST.REPORT_DETAILS_MENU_ITEM.LEAVE_ROOM, - translationKey: 'common.leaveRoom', + translationKey: isThread ? 'common.leaveThread' : 'common.leaveRoom', icon: Expensicons.Exit, - action: () => Policy.leaveRoom(this.props.report.reportID), + action: () => Report.leaveRoom(this.props.report.reportID), }); } @@ -109,6 +115,7 @@ class ReportDetailsPage extends Component { render() { const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(this.props.report); const isChatRoom = ReportUtils.isChatRoom(this.props.report); + const isThread = ReportUtils.isThread(this.props.report); const chatRoomSubtitle = ReportUtils.getChatRoomSubtitle(this.props.report); const participants = lodashGet(this.props.report, 'participants', []); const isMultipleParticipant = participants.length > 1; @@ -117,6 +124,15 @@ class ReportDetailsPage extends Component { isMultipleParticipant, ); const menuItems = this.getMenuItems(); + const isPolicyAdmin = PolicyUtils.isPolicyAdmin(this.getPolicy()); + const chatRoomSubtitleText = ( + + {chatRoomSubtitle} + + ); return ( @@ -139,15 +155,20 @@ class ReportDetailsPage extends Component { tooltipEnabled numberOfLines={1} textStyles={[styles.textHeadline, styles.mb2, styles.textAlignCenter, styles.pre]} - shouldUseFullTitle={isChatRoom || isPolicyExpenseChat} + shouldUseFullTitle={isChatRoom || isPolicyExpenseChat || isThread} /> - - {chatRoomSubtitle} - + {isPolicyAdmin ? ( + { + Navigation.navigate(ROUTES.getWorkspaceInitialRoute(this.props.report.policyID)); + }} + > + {chatRoomSubtitleText} + + ) : ( + chatRoomSubtitleText + )} diff --git a/src/pages/ReportParticipantsPage.js b/src/pages/ReportParticipantsPage.js index a4a4d4b8e17a..b8f988f78eaf 100755 --- a/src/pages/ReportParticipantsPage.js +++ b/src/pages/ReportParticipantsPage.js @@ -55,27 +55,30 @@ const defaultProps = { const getAllParticipants = (report, personalDetails) => { const {participants} = report; - return _.map(participants, (login) => { - const userLogin = Str.removeSMSDomain(login); - const userPersonalDetail = lodashGet(personalDetails, login, {displayName: userLogin, avatar: ''}); + return _.chain(participants) + .map((login) => { + const userLogin = Str.removeSMSDomain(login); + const userPersonalDetail = lodashGet(personalDetails, login, {displayName: userLogin, avatar: ''}); - return { - alternateText: userLogin, - displayName: userPersonalDetail.displayName, - icons: [ - { - source: ReportUtils.getAvatar(userPersonalDetail.avatar, login), - name: login, - type: CONST.ICON_TYPE_AVATAR, - }, - ], - keyForList: userLogin, - login, - text: userPersonalDetail.displayName, - tooltipText: userLogin, - participantsList: [{login, displayName: userPersonalDetail.displayName}], - }; - }); + return { + alternateText: userLogin, + displayName: userPersonalDetail.displayName, + icons: [ + { + source: ReportUtils.getAvatar(userPersonalDetail.avatar, login), + name: login, + type: CONST.ICON_TYPE_AVATAR, + }, + ], + keyForList: userLogin, + login, + text: userPersonalDetail.displayName, + tooltipText: userLogin, + participantsList: [{login, displayName: userPersonalDetail.displayName}], + }; + }) + .sortBy((participant) => participant.displayName.toLowerCase()) + .value(); }; const ReportParticipantsPage = (props) => { @@ -86,10 +89,12 @@ const ReportParticipantsPage = (props) => { {({safeAreaPaddingBottomStyle}) => ( policy && policy.id === this.props.report.policyID); - const shouldDisableRename = this.shouldDisableRename(linkedWorkspace); - - return ( - - - - !shouldDisableRename && this.updatePolicyRoomName(values)} - scrollContextEnabled - isSubmitButtonVisible={shouldShowRoomName && !shouldDisableRename} - enabledWhenOffline - > - - - { - if (this.props.report.notificationPreference === notificationPreference) { - return; - } - - Report.updateNotificationPreference(this.props.report.reportID, this.props.report.notificationPreference, notificationPreference); - }} - items={this.getNotificationPreferenceOptions()} - value={this.props.report.notificationPreference} - /> - - - {shouldShowRoomName && ( - - Report.clearPolicyRoomNameErrors(this.props.report.reportID)} - > - - - {shouldDisableRename ? ( - - - {this.props.translate('newRoomPage.roomName')} - - - {this.props.report.reportName} - - - ) : ( - - )} - - - - - )} - {Boolean(linkedWorkspace) && ( - - - {this.props.translate('workspace.common.workspace')} - - - {linkedWorkspace.name} - - - )} - {Boolean(this.props.report.visibility) && ( - - - {this.props.translate('newRoomPage.visibility')} - - - {this.props.translate(`newRoomPage.visibilityOptions.${this.props.report.visibility}`)} - - - {this.props.report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED - ? this.props.translate('newRoomPage.restrictedDescription') - : this.props.translate('newRoomPage.privateDescription')} - - - )} - - - - ); - } -} - -ReportSettingsPage.propTypes = propTypes; -ReportSettingsPage.defaultProps = defaultProps; -export default compose( - withLocalize, - withReportOrNotFound, - withOnyx({ - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, - }), -)(ReportSettingsPage); diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js index 8fbfabe9f524..c67c42520767 100644 --- a/src/pages/ShareCodePage.js +++ b/src/pages/ShareCodePage.js @@ -37,6 +37,7 @@ class ShareCodePage extends React.Component { render() { const isReport = this.props.report != null && this.props.report.reportID != null; + const subtitle = ReportUtils.getChatRoomSubtitle(this.props.report); const url = isReport ? `${CONST.NEW_EXPENSIFY_URL}r/${this.props.report.reportID}` : `${CONST.NEW_EXPENSIFY_URL}details?login=${this.props.session.email}`; @@ -58,7 +59,7 @@ class ShareCodePage extends React.Component { ref={this.qrCodeRef} url={url} title={isReport ? this.props.report.reportName : this.props.currentUserPersonalDetails.displayName} - subtitle={isReport ? ReportUtils.getPolicyName(this.props.report) : this.props.session.email} + subtitle={isReport ? subtitle : this.props.session.email} logo={isReport ? roomAvatar : this.props.currentUserPersonalDetails.avatar} /> diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 427c8a0d8003..bf63309b52dd 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -207,7 +207,7 @@ const HeaderView = (props) => { {shouldShowThreeDotsButton && ( )} diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 680b3d9dd3d3..5ae3b0e8e294 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -267,7 +267,7 @@ class ReportScreen extends React.Component { } > + shouldShow: (type, reportAction, isArchivedRoom, betas, menuTarget, isChronosReport, reportID) => // Until deleting parent threads is supported in FE, we will prevent the user from deleting a thread parent - type === CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canDeleteReportAction(reportAction) && !isArchivedRoom && !isChronosReport, + type === CONTEXT_MENU_TYPES.REPORT_ACTION && ReportUtils.canDeleteReportAction(reportAction, reportID) && !isArchivedRoom && !isChronosReport, onPress: (closePopover, {reportID, reportAction}) => { if (closePopover) { // Hide popover, then call showDeleteConfirmModal diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index dfdf559e79c7..2d255868c539 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -46,7 +46,6 @@ class PopoverReportActionContextMenu extends React.Component { this.contextMenuAnchor = undefined; this.showContextMenu = this.showContextMenu.bind(this); this.hideContextMenu = this.hideContextMenu.bind(this); - this.measureContent = this.measureContent.bind(this); this.measureContextMenuAnchorPosition = this.measureContextMenuAnchorPosition.bind(this); this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); this.hideDeleteModal = this.hideDeleteModal.bind(this); @@ -218,27 +217,6 @@ class PopoverReportActionContextMenu extends React.Component { }); } - /** - * Used to calculate the Context Menu Dimensions - * - * @returns {JSX} - */ - measureContent() { - return ( - - ); - } - /** * Run the callback and return a noop function to reset it * @param {Function} callback @@ -302,7 +280,6 @@ class PopoverReportActionContextMenu extends React.Component { animationIn="fadeIn" disableAnimation={false} animationOutTiming={1} - measureContent={this.measureContent} shouldSetModalVisibility={false} fullscreen > @@ -312,6 +289,7 @@ class PopoverReportActionContextMenu extends React.Component { reportID={this.state.reportID} reportAction={this.state.reportAction} draftMessage={this.state.reportActionDraftMessage} + selection={this.state.selection} isArchivedRoom={this.state.isArchivedRoom} isChronosReport={this.state.isChronosReport} anchor={this.contextMenuTargetNode} diff --git a/src/pages/home/report/ReactionList/PopoverReactionList.js b/src/pages/home/report/ReactionList/PopoverReactionList.js index 7538c6f44cad..48da70d363f8 100644 --- a/src/pages/home/report/ReactionList/PopoverReactionList.js +++ b/src/pages/home/report/ReactionList/PopoverReactionList.js @@ -82,7 +82,6 @@ class PopoverReactionList extends React.Component { this.reactionListAnchor = undefined; this.showReactionList = this.showReactionList.bind(this); this.hideReactionList = this.hideReactionList.bind(this); - this.measureContent = this.measureContent.bind(this); this.measureReactionListPosition = this.measureReactionListPosition.bind(this); this.getReactionListMeasuredLocation = this.getReactionListMeasuredLocation.bind(this); this.getSelectedReaction = this.getSelectedReaction.bind(this); @@ -266,26 +265,6 @@ class PopoverReactionList extends React.Component { }); } - /** - * Used to calculate the PopoverReactionList Dimensions - * - * @returns {JSX} - */ - measureContent() { - return ( - - ); - } - render() { return ( <> @@ -296,7 +275,6 @@ class PopoverReactionList extends React.Component { animationIn="fadeIn" disableAnimation={false} animationOutTiming={1} - measureContent={this.measureContent} shouldSetModalVisibility={false} fullscreen > diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index a0365a14bf4a..b234348384f9 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -963,7 +963,8 @@ class ReportActionCompose extends React.Component { isVisible={this.state.isMenuVisible} onClose={() => this.setMenuVisibility(false)} onItemSelected={() => this.setMenuVisibility(false)} - anchorPosition={styles.createMenuPositionReportActionCompose} + anchorPosition={styles.createMenuPositionReportActionCompose(this.props.windowHeight)} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM}} menuItems={[ ...this.getMoneyRequestOptions(reportParticipants), ...this.getTaskOption(reportParticipants), @@ -1021,7 +1022,10 @@ class ReportActionCompose extends React.Component { placeholderTextColor={themeColors.placeholderText} onChangeText={(comment) => this.updateComment(comment, true)} onKeyPress={this.triggerHotkeyActions} - style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + style={[ + this.props.numberOfLines > 1 ? styles.textInputComposeMultiLines : styles.textInputCompose, + this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4, + ]} maxLines={this.state.maxLines} onFocus={() => this.setIsFocused(true)} onBlur={() => { diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 00413dc859db..1b498a198ff1 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -187,17 +187,19 @@ class ReportActionItem extends Component { renderItemContent(hovered = false) { let children; const originalMessage = lodashGet(this.props.action, 'originalMessage', {}); + + // IOUDetails only exists when we are sending money + const isSendingMoney = originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && _.has(originalMessage, 'IOUDetails'); + // Show the IOUPreview for when request was created, bill was split or money was sent if ( this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && originalMessage && - (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || - originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || - (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && originalMessage.IOUDetails)) + // For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message + (originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || isSendingMoney) ) { // There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID const iouReportID = originalMessage.IOUReportID ? originalMessage.IOUReportID.toString() : '0'; - children = ( ) : ( @@ -282,8 +286,14 @@ class ReportActionItem extends Component { const reactions = _.get(this.props, ['action', 'message', 0, 'reactions'], []); const hasReactions = reactions.length > 0; + const numberOfThreadReplies = _.get(this.props, ['action', 'childVisibleActionCount'], 0); + const hasReplies = numberOfThreadReplies > 0; + const shouldDisplayThreadReplies = - this.props.action.childCommenterCount && Permissions.canUseThreads(this.props.betas) && !ReportUtils.isThreadFirstChat(this.props.action, this.props.report.reportID); + hasReplies && + this.props.action.childCommenterCount && + Permissions.canUseThreads(this.props.betas) && + !ReportUtils.isThreadFirstChat(this.props.action, this.props.report.reportID); const oldestFourEmails = lodashGet(this.props.action, 'childOldestFourEmails', '').split(','); return ( @@ -301,7 +311,7 @@ class ReportActionItem extends Component { {shouldDisplayThreadReplies && ( ( {this.props.shouldDisplayNewMarker && } + - )} diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index edd17118ef34..e134c327df55 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -18,6 +18,7 @@ import convertToLTR from '../../../libs/convertToLTR'; import {withNetwork} from '../../../components/OnyxProvider'; import CONST from '../../../CONST'; import applyStrikethrough from '../../../components/HTMLEngineProvider/applyStrikethrough'; +import editedLabelStyles from '../../../styles/editedLabelStyles'; const propTypes = { /** The message fragment needing to be displayed */ @@ -124,8 +125,15 @@ const ReportActionItemFragment = (props) => { - {` ${props.translate('reportActionCompose.edited')}`} + + {' '} + + {props.translate('reportActionCompose.edited')} )} diff --git a/src/pages/home/report/ReportActionItemThread.js b/src/pages/home/report/ReportActionItemThread.js index 07ab422b0586..63dfce693ef3 100644 --- a/src/pages/home/report/ReportActionItemThread.js +++ b/src/pages/home/report/ReportActionItemThread.js @@ -63,7 +63,7 @@ const ReportActionItemThread = (props) => { {`${props.translate('threads.lastReply')} ${timeStamp}`} diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js index dad2d29e5c5d..1a3b44c3f806 100644 --- a/src/pages/home/report/ReportActionsList.js +++ b/src/pages/home/report/ReportActionsList.js @@ -79,10 +79,10 @@ function keyExtractor(item) { const ReportActionsList = (props) => { const opacity = useSharedValue(0); const animatedStyles = useAnimatedStyle(() => ({ - opacity: withTiming(opacity.value, {duration: 100}), + opacity: opacity.value, })); useEffect(() => { - opacity.value = 1; + opacity.value = withTiming(1, {duration: 100}); }, [opacity]); const [skeletonViewHeight, setSkeletonViewHeight] = useState(0); @@ -126,7 +126,10 @@ const ReportActionsList = (props) => { action={reportAction} displayAsGroup={ReportActionsUtils.isConsecutiveActionMadeByPreviousActor(sortedReportActions, index)} shouldDisplayNewMarker={shouldDisplayNewMarker} - shouldShowSubscriptAvatar={ReportUtils.isPolicyExpenseChat(report) && reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU} + shouldShowSubscriptAvatar={ + (ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isExpenseReport(report)) && + _.contains([CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW], reportAction.actionName) + } isMostRecentIOUReportAction={reportAction.reportActionID === mostRecentIOUReportActionID} hasOutstandingIOU={hasOutstandingIOU} index={index} diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index cded77bb2f2c..6c0dd2cf6cd5 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -171,7 +171,7 @@ class FloatingActionButtonAndPopover extends React.Component { { - const isSelectedCurrency = currencyCode === this.props.iou.selectedCurrencyCode; + const isSelectedCurrency = currencyCode === this.getSelectedCurrencyCode(); return { text: `${currencyCode} - ${CurrencyUtils.getLocalizedCurrencySymbol(currencyCode)}`, currencyCode, @@ -122,14 +126,21 @@ class IOUCurrencySelection extends Component { } /** - * Confirms the selection of currency and sets values in Onyx + * Confirms the selection of currency * * @param {Object} option * @param {String} option.currencyCode */ confirmCurrencySelection(option) { - IOU.setIOUSelectedCurrency(option.currencyCode); - Navigation.goBack(); + const backTo = lodashGet(this.props.route, 'params.backTo', ''); + // When we refresh the web, the money request route gets cleared from the navigation stack. + // Navigating to "backTo" will result in forward navigation instead, causing disruption to the currency selection. + // To prevent any negative experience, we have made the decision to simply close the currency selection page. + if (_.isEmpty(backTo) || this.props.navigation.getState().routes.length === 1) { + Navigation.goBack(); + } else { + Navigation.navigate(`${this.props.route.params.backTo}?currency=${option.currencyCode}`); + } } render() { @@ -151,7 +162,7 @@ class IOUCurrencySelection extends Component { headerMessage={headerMessage} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} initiallyFocusedOptionKey={_.get( - _.find(this.state.currencyData, (currency) => currency.currencyCode === this.props.iou.selectedCurrencyCode), + _.find(this.state.currencyData, (currency) => currency.currencyCode === this.getSelectedCurrencyCode()), 'keyForList', )} shouldHaveOptionSeparator diff --git a/src/pages/iou/IOUDetailsModal.js b/src/pages/iou/IOUDetailsModal.js index f81fe53fb492..84de2a089242 100644 --- a/src/pages/iou/IOUDetailsModal.js +++ b/src/pages/iou/IOUDetailsModal.js @@ -10,7 +10,6 @@ import {withNetwork} from '../../components/OnyxProvider'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import Navigation from '../../libs/Navigation/Navigation'; import ScreenWrapper from '../../components/ScreenWrapper'; -import * as IOU from '../../libs/actions/IOU'; import * as Report from '../../libs/actions/Report'; import IOUPreview from '../../components/ReportActionItem/IOUPreview'; import IOUTransactions from './IOUTransactions'; @@ -24,6 +23,7 @@ import networkPropTypes from '../../components/networkPropTypes'; import reportActionPropTypes from '../home/report/reportActionPropTypes'; import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; import FullScreenLoadingIndicator from '../../components/FullscreenLoadingIndicator'; +import * as IOU from '../../libs/actions/IOU'; const propTypes = { /** URL Route params */ @@ -123,31 +123,6 @@ class IOUDetailsModal extends Component { Report.openPaymentDetailsPage(this.props.route.params.chatReportID, this.props.route.params.iouReportID); } - /** - * @param {String} paymentMethodType - */ - payMoneyRequest(paymentMethodType) { - const recipient = { - login: this.props.iouReport.ownerEmail, - payPalMeAddress: this.props.iouReport.submitterPayPalMeAddress, - }; - - if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { - IOU.payMoneyRequestElsewhere(this.props.chatReport, this.props.iouReport, recipient); - return; - } - - if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.PAYPAL_ME) { - IOU.payMoneyRequestViaPaypal(this.props.chatReport, this.props.iouReport, recipient); - return; - } - - if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - IOU.payMoneyRequestWithWallet(this.props.chatReport, this.props.iouReport, recipient); - Navigation.navigate(ROUTES.getReportRoute(this.props.route.params.chatReportID)); - } - } - // Finds if there is a reportAction pending for this IOU findPendingAction() { const reportActionWithPendingAction = _.find( @@ -200,13 +175,14 @@ class IOUDetailsModal extends Component { {hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail && ( this.payMoneyRequest(paymentMethodType)} + onPress={(paymentMethodType) => IOU.payMoneyRequest(paymentMethodType, this.props.chatReport, this.props.iouReport)} shouldShowPaypal={Boolean(lodashGet(this.props, 'iouReport.submitterPayPalMeAddress'))} currency={lodashGet(this.props, 'iouReport.currency')} enablePaymentsRoute={ROUTES.IOU_DETAILS_ENABLE_PAYMENTS} addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} addDebitCardRoute={ROUTES.IOU_DETAILS_ADD_DEBIT_CARD} chatReportID={this.props.route.params.chatReportID} + policyID={this.props.iouReport.policyID} /> )} diff --git a/src/pages/iou/MoneyRequestModal.js b/src/pages/iou/MoneyRequestModal.js index 02e31ede0527..6d8f82ea5b45 100644 --- a/src/pages/iou/MoneyRequestModal.js +++ b/src/pages/iou/MoneyRequestModal.js @@ -311,8 +311,9 @@ const MoneyRequestModal = (props) => { > {modalHeader} { - const amountInSmallestCurrencyUnits = CurrencyUtils.convertToSmallestUnit(props.iou.selectedCurrencyCode, Number.parseFloat(value)); + onStepComplete={(value, selectedCurrencyCode) => { + const amountInSmallestCurrencyUnits = CurrencyUtils.convertToSmallestUnit(selectedCurrencyCode, Number.parseFloat(value)); + IOU.setIOUSelectedCurrency(selectedCurrencyCode); setAmount(amountInSmallestCurrencyUnits); navigateToNextStep(); }} @@ -320,6 +321,7 @@ const MoneyRequestModal = (props) => { hasMultipleParticipants={props.hasMultipleParticipants} selectedAmount={CurrencyUtils.convertToWholeUnit(props.iou.selectedCurrencyCode, amount)} navigation={props.navigation} + route={props.route} iouType={props.iouType} buttonText={amountButtonText} /> @@ -367,6 +369,7 @@ const MoneyRequestModal = (props) => { // the floating-action-button (since it is something that exists outside the context of a report). canModifyParticipants={!_.isEmpty(reportID)} navigateToStep={navigateToStep} + policyID={props.report.policyID} /> )} diff --git a/src/pages/iou/steps/MoneyRequestAmountPage.js b/src/pages/iou/steps/MoneyRequestAmountPage.js index 07b6556a2011..3732e638dfe0 100755 --- a/src/pages/iou/steps/MoneyRequestAmountPage.js +++ b/src/pages/iou/steps/MoneyRequestAmountPage.js @@ -66,6 +66,7 @@ class MoneyRequestAmountPage extends React.Component { const selectedAmountAsString = props.selectedAmount ? props.selectedAmount.toString() : ''; this.state = { amount: selectedAmountAsString, + selectedCurrencyCode: props.iou.selectedCurrencyCode, shouldUpdateSelection: true, selection: { start: selectedAmountAsString.length, @@ -80,6 +81,7 @@ class MoneyRequestAmountPage extends React.Component { // Focus automatically after navigating back from currency selector this.unsubscribeNavFocus = this.props.navigation.addListener('focus', () => { this.focusTextInput(); + this.getCurrencyFromRouteParams(); }); } @@ -104,6 +106,13 @@ class MoneyRequestAmountPage extends React.Component { } } + getCurrencyFromRouteParams() { + const selectedCurrencyCode = lodashGet(this.props.route.params, 'currency', ''); + if (selectedCurrencyCode !== '') { + this.setState({selectedCurrencyCode}); + } + } + /** * Returns the new selection object based on the updated amount's length * @@ -289,13 +298,15 @@ class MoneyRequestAmountPage extends React.Component { } navigateToCurrencySelectionPage() { + // Remove query from the route and encode it. + const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); if (this.props.hasMultipleParticipants) { - return Navigation.navigate(ROUTES.getIouBillCurrencyRoute(this.props.reportID)); + return Navigation.navigate(ROUTES.getIouBillCurrencyRoute(this.props.reportID, this.state.selectedCurrencyCode, activeRoute)); } if (this.props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND) { - return Navigation.navigate(ROUTES.getIouSendCurrencyRoute(this.props.reportID)); + return Navigation.navigate(ROUTES.getIouSendCurrencyRoute(this.props.reportID, this.state.selectedCurrencyCode, activeRoute)); } - return Navigation.navigate(ROUTES.getIouRequestCurrencyRoute(this.props.reportID)); + return Navigation.navigate(ROUTES.getIouRequestCurrencyRoute(this.props.reportID, this.state.selectedCurrencyCode, activeRoute)); } render() { @@ -314,7 +325,7 @@ class MoneyRequestAmountPage extends React.Component { onCurrencyButtonPress={this.navigateToCurrencySelectionPage} placeholder={this.props.numberFormat(0)} ref={(el) => (this.textInput = el)} - selectedCurrencyCode={this.props.iou.selectedCurrencyCode} + selectedCurrencyCode={this.state.selectedCurrencyCode} selection={this.state.selection} onSelectionChange={(e) => { if (!this.state.shouldUpdateSelection) { @@ -342,7 +353,7 @@ class MoneyRequestAmountPage extends React.Component { this.props.onStepComplete(this.state.amount)} + onPress={() => this.props.onStepComplete(this.state.amount, this.state.selectedCurrencyCode)} pressOnEnter isDisabled={!this.state.amount.length || parseFloat(this.state.amount) < 0.01} text={this.props.buttonText} diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index d4726cf8b644..025d22b33b37 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -28,6 +28,9 @@ const propTypes = { /** Function to navigate to a given step in the parent MoneyRequestModal */ navigateToStep: PropTypes.func.isRequired, + + /** The policyID of the request */ + policyID: PropTypes.string.isRequired, }; const defaultProps = { @@ -45,6 +48,7 @@ const MoneyRequestConfirmPage = (props) => ( iouType={props.iouType} canModifyParticipants={props.canModifyParticipants} navigateToStep={props.navigateToStep} + policyID={props.policyID} /> ); diff --git a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js index 2389d3a3de6b..4b7459ea7cfa 100644 --- a/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js +++ b/src/pages/settings/Payments/PaymentsPage/BasePaymentsPage.js @@ -49,8 +49,9 @@ class BasePaymentsPage extends React.Component { title: '', }, selectedPaymentMethodType: null, + anchorPositionHorizontal: 0, + anchorPositionVertical: 0, anchorPositionTop: 0, - anchorPositionBottom: 0, anchorPositionRight: 0, addPaymentMethodButton: null, methodID: null, @@ -152,10 +153,11 @@ class BasePaymentsPage extends React.Component { setPositionAddPaymentMenu(position) { this.setState({ anchorPositionTop: position.top + position.height + variables.addPaymentPopoverTopSpacing, - anchorPositionBottom: this.props.windowHeight - position.top, // We want the position to be 13px to the right of the left border anchorPositionRight: this.props.windowWidth - position.right + variables.addPaymentPopoverRightSpacing, + anchorPositionHorizontal: position.x, + anchorPositionVertical: position.y, }); } @@ -417,8 +419,8 @@ class BasePaymentsPage extends React.Component { isVisible={this.state.shouldShowAddPaymentMenu} onClose={this.hideAddPaymentMenu} anchorPosition={{ - bottom: this.state.anchorPositionBottom, - right: this.state.anchorPositionRight - 10, + horizontal: this.state.anchorPositionHorizontal, + vertical: this.state.anchorPositionVertical - 10, }} onItemSelected={(method) => this.addPaymentMethodTypePressed(method)} /> diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js index e35b9408c7c0..7b3a90e7473f 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js @@ -20,13 +20,11 @@ import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; import DotIndicatorMessage from '../../../../components/DotIndicatorMessage'; import ConfirmModal from '../../../../components/ConfirmModal'; import * as User from '../../../../libs/actions/User'; -import TextInput from '../../../../components/TextInput'; import CONST from '../../../../CONST'; -import Button from '../../../../components/Button'; import * as ErrorUtils from '../../../../libs/ErrorUtils'; import themeColors from '../../../../styles/themes/default'; import NotFoundPage from '../../../ErrorPage/NotFoundPage'; -import * as ValidationUtils from '../../../../libs/ValidationUtils'; +import ValidateCodeForm from './ValidateCodeForm'; const propTypes = { /* Onyx Props */ @@ -94,15 +92,11 @@ class ContactMethodDetailsPage extends Component { this.deleteContactMethod = this.deleteContactMethod.bind(this); this.toggleDeleteModal = this.toggleDeleteModal.bind(this); this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this); - this.resendValidateCode = this.resendValidateCode.bind(this); this.getContactMethod = this.getContactMethod.bind(this); - this.validateAndSubmitCode = this.validateAndSubmitCode.bind(this); this.setAsDefault = this.setAsDefault.bind(this); this.state = { - formError: '', isDeleteModalOpen: false, - validateCode: '', }; } @@ -193,27 +187,6 @@ class ContactMethodDetailsPage extends Component { User.deleteContactMethod(this.getContactMethod(), this.props.loginList); } - /** - * Request a validate code / magic code be sent to verify this contact method - */ - resendValidateCode() { - User.requestContactMethodValidateCode(this.getContactMethod()); - } - - /** - * Attempt to validate this contact method - */ - validateAndSubmitCode() { - if (!this.state.validateCode) { - this.setState({formError: 'validateCodeForm.error.pleaseFillMagicCode'}); - } else if (!ValidationUtils.isValidValidateCode(this.state.validateCode)) { - this.setState({formError: 'validateCodeForm.error.incorrectMagicCode'}); - } else { - this.setState({formError: ''}); - User.validateSecondaryLogin(this.getContactMethod(), this.state.validateCode); - } - } - render() { const contactMethod = this.getContactMethod(); @@ -227,7 +200,6 @@ class ContactMethodDetailsPage extends Component { const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; const hasMagicCodeBeenSent = lodashGet(this.props.loginList, [contactMethod, 'validateCodeSent'], false); - const formErrorText = this.state.formError ? this.props.translate(this.state.formError) : ''; const isFailedAddContactMethod = Boolean(lodashGet(loginData, 'errorFields.addedLogin')); return ( @@ -262,50 +234,11 @@ class ContactMethodDetailsPage extends Component { style={[styles.mb3]} messages={{0: this.props.translate('contacts.enterMagicCode', {contactMethod: formattedContactMethod})}} /> - this.setState({validateCode: text})} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} - errorText={formErrorText} + - User.clearContactMethodErrors(contactMethod, 'validateCodeSent')} - > - - - {this.props.translate('contacts.resendMagicCode')} - - {hasMagicCodeBeenSent && ( - - )} - - - User.clearContactMethodErrors(contactMethod, 'validateLogin')} - > - - )} {this.canChangeDefaultContactMethod() ? ( diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js new file mode 100644 index 000000000000..0dc19aa7d4e3 --- /dev/null +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -0,0 +1,181 @@ +import React, {useCallback, useState, useEffect, useRef} from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import MagicCodeInput from '../../../../../components/MagicCodeInput'; +import * as ErrorUtils from '../../../../../libs/ErrorUtils'; +import withLocalize, {withLocalizePropTypes} from '../../../../../components/withLocalize'; +import ONYXKEYS from '../../../../../ONYXKEYS'; +import compose from '../../../../../libs/compose'; +import styles from '../../../../../styles/styles'; +import OfflineWithFeedback from '../../../../../components/OfflineWithFeedback'; +import * as ValidationUtils from '../../../../../libs/ValidationUtils'; +import * as User from '../../../../../libs/actions/User'; +import Button from '../../../../../components/Button'; +import DotIndicatorMessage from '../../../../../components/DotIndicatorMessage'; +import * as Session from '../../../../../libs/actions/Session'; +import Text from '../../../../../components/Text'; + +const propTypes = { + ...withLocalizePropTypes, + + /** The contact method being valdiated */ + contactMethod: PropTypes.string.isRequired, + + /** If the magic code has been resent previously */ + hasMagicCodeBeenSent: PropTypes.bool.isRequired, + + /** Login list for the user that is signed in */ + loginList: PropTypes.shape({ + /** Value of partner name */ + partnerName: PropTypes.string, + + /** Phone/Email associated with user */ + partnerUserID: PropTypes.string, + + /** Date when login was validated */ + validatedDate: PropTypes.string, + + /** Field-specific server side errors keyed by microtime */ + errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + + /** Field-specific pending states for offline UI status */ + pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + }).isRequired, + + /* Onyx Props */ + + /** The details about the account that the user is signing in with */ + account: PropTypes.shape({ + /** Whether or not a sign on form is loading (being submitted) */ + isLoading: PropTypes.bool, + }), + + /** Specifies autocomplete hints for the system, so it can provide autofill */ + autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired, +}; + +const defaultProps = { + account: {}, +}; + +function BaseValidateCodeForm(props) { + const [formError, setFormError] = useState({}); + const [validateCode, setValidateCode] = useState(''); + const loginData = props.loginList[props.contactMethod]; + const inputValidateCodeRef = useRef(); + + useEffect(() => { + if (!props.hasMagicCodeBeenSent) { + return; + } + setValidateCode(''); + inputValidateCodeRef.current.clear(); + }, [props.hasMagicCodeBeenSent]); + + /** + * Request a validate code / magic code be sent to verify this contact method + */ + const resendValidateCode = () => { + User.requestContactMethodValidateCode(props.contactMethod); + }; + + /** + * Handle text input and clear formError upon text change + * + * @param {String} text + */ + const onTextInput = useCallback( + (text) => { + setValidateCode(text); + setFormError({}); + + if (props.account.errors) { + Session.clearAccountMessages(); + } + }, + [props.account.errors], + ); + + /** + * Check that all the form fields are valid, then trigger the submit callback + */ + const validateAndSubmitForm = useCallback(() => { + if (!validateCode.trim()) { + setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}); + return; + } + + if (!ValidationUtils.isValidValidateCode(validateCode)) { + setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'}); + return; + } + + setFormError({}); + User.validateSecondaryLogin(props.contactMethod, validateCode); + }, [validateCode, props.contactMethod]); + + return ( + <> + + User.clearContactMethodErrors(props.contactMethod, 'validateCodeSent')} + > + + + {props.translate('contacts.resendMagicCode')} + + {props.hasMagicCodeBeenSent && ( + + )} + + + User.clearContactMethodErrors(props.contactMethod, 'validateLogin')} + > + + + > + ); +} + +BaseValidateCodeForm.propTypes = propTypes; +BaseValidateCodeForm.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + account: {key: ONYXKEYS.ACCOUNT}, + }), +)(BaseValidateCodeForm); diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js new file mode 100644 index 000000000000..8128b2077012 --- /dev/null +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.android.js @@ -0,0 +1,14 @@ +import React from 'react'; +import BaseValidateCodeForm from './BaseValidateCodeForm'; + +const ValidateCodeForm = (props) => ( + +); + +ValidateCodeForm.displayName = 'ValidateCodeForm'; + +export default ValidateCodeForm; diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js new file mode 100644 index 000000000000..80691a931185 --- /dev/null +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import BaseValidateCodeForm from './BaseValidateCodeForm'; + +const ValidateCodeForm = (props) => ( + +); + +ValidateCodeForm.displayName = 'ValidateCodeForm'; + +export default ValidateCodeForm; diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js index 0a3d892f7981..fd506cf82353 100644 --- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js +++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js @@ -15,6 +15,7 @@ import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; import compose from '../../../../libs/compose'; import NewDatePicker from '../../../../components/NewDatePicker'; import CONST from '../../../../CONST'; +import withNavigationFocus from '../../../../components/withNavigationFocus'; const propTypes = { /* Onyx Props */ @@ -49,11 +50,16 @@ class DateOfBirthPage extends Component { } componentDidMount() { - this.props.navigation.addListener('focus', this.getYearFromRouteParams); + this.getYearFromRouteParams(); } - componentWillUnmount() { - this.props.navigation.removeListener('focus', this.getYearFromRouteParams); + componentDidUpdate(prevProps) { + // When we're navigating back from Year page. We need to update the selected year from the URL + if (prevProps.isFocused || !this.props.isFocused) { + return; + } + + this.getYearFromRouteParams(); } /** @@ -135,6 +141,7 @@ DateOfBirthPage.defaultProps = defaultProps; export default compose( withLocalize, + withNavigationFocus, withOnyx({ privatePersonalDetails: { key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 88890bcd0f99..1a216fd0aacb 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -23,6 +23,7 @@ import styles from '../../../styles/styles'; import * as Expensicons from '../../../components/Icon/Expensicons'; import ONYXKEYS from '../../../ONYXKEYS'; import * as UserUtils from '../../../libs/UserUtils'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; const propTypes = { /* Onyx Props */ @@ -37,6 +38,7 @@ const propTypes = { }), ...withLocalizePropTypes, + ...windowDimensionsPropTypes, ...withCurrentUserPersonalDetailsPropTypes, }; @@ -100,7 +102,8 @@ const ProfilePage = (props) => { source={ReportUtils.getAvatar(lodashGet(currentUserDetails, 'avatar', ''), lodashGet(currentUserDetails, 'login', ''))} onImageSelected={PersonalDetails.updateAvatar} onImageRemoved={PersonalDetails.deleteAvatar} - anchorPosition={styles.createMenuPositionProfile} + anchorPosition={styles.createMenuPositionProfile(props.windowWidth)} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}} size={CONST.AVATAR_SIZE.LARGE} /> @@ -133,6 +136,7 @@ ProfilePage.displayName = 'ProfilePage'; export default compose( withLocalize, + withWindowDimensions, withCurrentUserPersonalDetails, withOnyx({ loginList: { diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js new file mode 100644 index 000000000000..95ea23eed666 --- /dev/null +++ b/src/pages/settings/Report/NotificationPreferencePage.js @@ -0,0 +1,66 @@ +import React from 'react'; +import _ from 'underscore'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import styles from '../../../styles/styles'; +import OptionsList from '../../../components/OptionsList'; +import Navigation from '../../../libs/Navigation/Navigation'; +import compose from '../../../libs/compose'; +import withReportOrNotFound from '../../home/report/withReportOrNotFound'; +import reportPropTypes from '../../reportPropTypes'; +import ROUTES from '../../../ROUTES'; +import * as Report from '../../../libs/actions/Report'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import themeColors from '../../../styles/themes/default'; + +const propTypes = { + ...withLocalizePropTypes, + + /** The report for which we are setting notification preferences */ + report: reportPropTypes.isRequired, +}; +const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; + +const NotificationPreferencePage = (props) => { + const notificationPreferenceOptions = _.map(props.translate('notificationPreferencesPage.notificationPreferences'), (preference, key) => ({ + value: key, + text: preference, + keyForList: key, + + // Include the green checkmark icon to indicate the currently selected value + customIcon: key === props.report.notificationPreference ? greenCheckmark : null, + + // This property will make the currently selected value have bold text + boldStyle: key === props.report.notificationPreference, + })); + + return ( + + Navigation.navigate(ROUTES.getReportSettingsRoute(props.report.reportID))} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + Report.updateNotificationPreferenceAndNavigate(props.report.reportID, props.report.notificationPreference, option.value)} + hideSectionHeaders + optionHoveredStyle={{ + ...styles.hoveredComponentBG, + ...styles.mhn5, + ...styles.ph5, + }} + shouldHaveOptionSeparator + shouldDisableRowInnerPadding + contentContainerStyles={[styles.ph5]} + /> + + ); +}; + +NotificationPreferencePage.displayName = 'NotificationPreferencePage'; +NotificationPreferencePage.propTypes = propTypes; + +export default compose(withLocalize, withReportOrNotFound)(NotificationPreferencePage); diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js new file mode 100644 index 000000000000..07ccf07d4db3 --- /dev/null +++ b/src/pages/settings/Report/ReportSettingsPage.js @@ -0,0 +1,189 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import CONST from '../../../CONST'; +import ONYXKEYS from '../../../ONYXKEYS'; +import styles from '../../../styles/styles'; +import compose from '../../../libs/compose'; +import Navigation from '../../../libs/Navigation/Navigation'; +import * as Report from '../../../libs/actions/Report'; +import * as Policy from '../../../libs/actions/Policy'; +import * as ReportUtils from '../../../libs/ReportUtils'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import Text from '../../../components/Text'; +import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; +import reportPropTypes from '../../reportPropTypes'; +import withReportOrNotFound from '../../home/report/withReportOrNotFound'; +import FullPageNotFoundView from '../../../components/BlockingViews/FullPageNotFoundView'; +import MenuItemWithTopDescription from '../../../components/MenuItemWithTopDescription'; +import ROUTES from '../../../ROUTES'; + +const propTypes = { + /** Route params */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** Report ID passed via route r/:reportID/settings */ + reportID: PropTypes.string, + }), + }).isRequired, + + ...withLocalizePropTypes, + + /* Onyx Props */ + + /** The active report */ + report: reportPropTypes.isRequired, + + /** The policies which the user has access to and which the report could be tied to */ + policies: PropTypes.shape({ + /** The policy name */ + name: PropTypes.string, + + /** ID of the policy */ + id: PropTypes.string, + }), +}; + +const defaultProps = { + policies: {}, +}; + +class ReportSettingsPage extends Component { + /** + * @param {Object|null} linkedWorkspace - the workspace the report is on, null if the user isn't a member of the workspace + * @returns {Boolean} + */ + shouldDisableRename(linkedWorkspace) { + if (ReportUtils.isDefaultRoom(this.props.report) || ReportUtils.isArchivedRoom(this.props.report)) { + return true; + } + + // The remaining checks only apply to public rooms + if (!ReportUtils.isPublicRoom(this.props.report)) { + return false; + } + + // if the linked workspace is null, that means the person isn't a member of the workspace the report is in + // which means this has to be a public room we want to disable renaming for + if (!linkedWorkspace) { + return true; + } + + // If there is a linked workspace, that means the user is a member of the workspace the report is in. + // Still, we only want policy owners and admins to be able to modify the name. + return !Policy.isPolicyOwner(linkedWorkspace) && linkedWorkspace.role !== CONST.POLICY.ROLE.ADMIN; + } + + render() { + const shouldShowRoomName = !ReportUtils.isPolicyExpenseChat(this.props.report) && !ReportUtils.isThread(this.props.report); + const linkedWorkspace = _.find(this.props.policies, (policy) => policy && policy.id === this.props.report.policyID); + const shouldDisableRename = this.shouldDisableRename(linkedWorkspace) || ReportUtils.isThread(this.props.report); + const notificationPreference = this.props.translate(`notificationPreferencesPage.notificationPreferences.${this.props.report.notificationPreference}`); + + return ( + + + Navigation.navigate(ROUTES.getReportDetailsRoute(this.props.report.reportID))} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + Navigation.navigate(ROUTES.getReportSettingsNotificationPreferencesRoute(this.props.report.reportID))} + /> + {shouldShowRoomName && ( + Report.clearPolicyRoomNameErrors(this.props.report.reportID)} + > + {shouldDisableRename ? ( + + + {this.props.translate('newRoomPage.roomName')} + + + {this.props.report.reportName} + + + ) : ( + Navigation.navigate(ROUTES.getReportSettingsRoomNameRoute(this.props.report.reportID))} + /> + )} + + )} + + {Boolean(linkedWorkspace) && ( + + + {this.props.translate('workspace.common.workspace')} + + + {linkedWorkspace.name} + + + )} + {Boolean(this.props.report.visibility) && ( + + + {this.props.translate('newRoomPage.visibility')} + + + {this.props.translate(`newRoomPage.visibilityOptions.${this.props.report.visibility}`)} + + + {this.props.report.visibility === CONST.REPORT.VISIBILITY.RESTRICTED + ? this.props.translate('newRoomPage.restrictedDescription') + : this.props.translate('newRoomPage.privateDescription')} + + + )} + + + + ); + } +} + +ReportSettingsPage.propTypes = propTypes; +ReportSettingsPage.defaultProps = defaultProps; +export default compose( + withLocalize, + withReportOrNotFound, + withOnyx({ + policies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + }), +)(ReportSettingsPage); diff --git a/src/pages/settings/Report/RoomNamePage.js b/src/pages/settings/Report/RoomNamePage.js new file mode 100644 index 000000000000..0c51a8ab79f9 --- /dev/null +++ b/src/pages/settings/Report/RoomNamePage.js @@ -0,0 +1,108 @@ +import React, {useCallback} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import CONST from '../../../CONST'; +import ScreenWrapper from '../../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import Form from '../../../components/Form'; +import ONYXKEYS from '../../../ONYXKEYS'; +import styles from '../../../styles/styles'; +import Navigation from '../../../libs/Navigation/Navigation'; +import compose from '../../../libs/compose'; +import * as ErrorUtils from '../../../libs/ErrorUtils'; +import * as ValidationUtils from '../../../libs/ValidationUtils'; +import withReportOrNotFound from '../../home/report/withReportOrNotFound'; +import reportPropTypes from '../../reportPropTypes'; +import ROUTES from '../../../ROUTES'; +import * as Report from '../../../libs/actions/Report'; +import RoomNameInput from '../../../components/RoomNameInput'; + +const propTypes = { + ...withLocalizePropTypes, + + /** The room report for which the name is being edited */ + report: reportPropTypes.isRequired, + + /** All reports shared with the user */ + reports: PropTypes.objectOf(reportPropTypes), +}; +const defaultProps = { + reports: {}, +}; + +const RoomNamePage = (props) => { + const report = props.report; + const reports = props.reports; + const translate = props.translate; + + const validate = useCallback( + (values) => { + const errors = {}; + + // We should skip validation hence we return an empty errors and we skip Form submission on the onSubmit method + if (values.roomName === report.reportName) { + return errors; + } + + if (!values.roomName || values.roomName === CONST.POLICY.ROOM_PREFIX) { + // We error if the user doesn't enter a room name or left blank + ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.pleaseEnterRoomName')); + } else if (!ValidationUtils.isValidRoomName(values.roomName)) { + // We error if the room name has invalid characters + ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameInvalidError')); + } else if (ValidationUtils.isReservedRoomName(values.roomName)) { + // Certain names are reserved for default rooms and should not be used for policy rooms. + ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.roomNameReservedError', {reservedName: values.roomName})); + } else if (ValidationUtils.isExistingRoomName(values.roomName, reports, report.policyID)) { + // The room name can't be set to one that already exists on the policy + ErrorUtils.addErrorMessage(errors, 'roomName', translate('newRoomPage.roomAlreadyExistsError')); + } + + return errors; + }, + [report, reports, translate], + ); + + return ( + + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(report.reportID))} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + Report.updatePolicyRoomNameAndNavigate(report, values.roomName)} + validate={validate} + submitButtonText={translate('common.save')} + enabledWhenOffline + > + + + + + + ); +}; + +RoomNamePage.propTypes = propTypes; +RoomNamePage.defaultProps = defaultProps; +RoomNamePage.displayName = 'RoomNamePage'; + +export default compose( + withLocalize, + withReportOrNotFound, + withOnyx({ + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + }), +)(RoomNamePage); diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 04fbe20cca61..25b7deaf0b03 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -14,7 +14,6 @@ import CONST from '../../../CONST'; import ChangeExpensifyLoginLink from '../ChangeExpensifyLoginLink'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import compose from '../../../libs/compose'; -import TextInput from '../../../components/TextInput'; import * as ValidationUtils from '../../../libs/ValidationUtils'; import withToggleVisibilityView, {toggleVisibilityViewPropTypes} from '../../../components/withToggleVisibilityView'; import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus'; @@ -85,7 +84,7 @@ class BaseValidateCodeForm extends React.Component { this.inputValidateCode.focus(); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps) { if (!prevProps.isVisible && this.props.isVisible) { this.inputValidateCode.focus(); } @@ -103,9 +102,6 @@ class BaseValidateCodeForm extends React.Component { if (!prevProps.account.requiresTwoFactorAuth && this.props.account.requiresTwoFactorAuth) { this.input2FA.focus(); } - if (prevState.twoFactorAuthCode !== this.state.twoFactorAuthCode && this.state.twoFactorAuthCode.length === CONST.TFA_CODE_LENGTH) { - this.validateAndSubmitForm(); - } } /** @@ -201,29 +197,26 @@ class BaseValidateCodeForm extends React.Component { {/* At this point, if we know the account requires 2FA we already successfully authenticated */} {this.props.account.requiresTwoFactorAuth ? ( - (this.input2FA = el)} label={this.props.translate('common.twoFactorCode')} + name="twoFactorAuthCode" value={this.state.twoFactorAuthCode} - placeholder={this.props.translate('validateCodeForm.requiredWhen2FAEnabled')} - placeholderTextColor={themeColors.placeholderText} onChangeText={(text) => this.onTextInput(text, 'twoFactorAuthCode')} - onSubmitEditing={this.validateAndSubmitForm} - keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} - blurOnSubmit={false} + onFulfill={this.validateAndSubmitForm} maxLength={CONST.TFA_CODE_LENGTH} errorText={this.state.formError.twoFactorAuthCode ? this.props.translate(this.state.formError.twoFactorAuthCode) : ''} hasError={hasError} + autoFocus /> ) : ( (this.inputValidateCode = el)} label={this.props.translate('common.magicCode')} - nativeID="validateCode" name="validateCode" value={this.state.validateCode} onChangeText={(text) => this.onTextInput(text, 'validateCode')} diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js index 0d61bf23d049..d6e13218423d 100644 --- a/src/pages/tasks/NewTaskPage.js +++ b/src/pages/tasks/NewTaskPage.js @@ -118,7 +118,7 @@ const NewTaskPage = (props) => { } return ( - + Navigation.dismissModal()} diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.js index bc4d6274534c..01a434fa5ae5 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.js @@ -28,6 +28,7 @@ import ONYXKEYS from '../../ONYXKEYS'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; import * as ReimbursementAccountProps from '../ReimbursementAccount/reimbursementAccountPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; +import withWindowDimensions from '../../components/withWindowDimensions'; const propTypes = { ...policyPropTypes, @@ -149,7 +150,7 @@ const WorkspaceInitialPage = (props) => { onSelected: () => setIsDeleteModalOpen(true), }, ]} - threeDotsAnchorPosition={styles.threeDotsPopoverOffset} + threeDotsAnchorPosition={styles.threeDotsPopoverOffset(props.windowWidth)} /> { + this.welcomeMessageInputRef.focus(); + // Below condition is needed for web, desktop and mweb only, for native cursor is set at end by default. + if (this.welcomeMessageInputRef.value && this.welcomeMessageInputRef.setSelectionRange) { + const length = this.welcomeMessageInputRef.value.length; + this.welcomeMessageInputRef.setSelectionRange(length, length); + } + }, CONST.ANIMATED_TRANSITION); + } + componentDidUpdate(prevProps) { if ( !( @@ -90,6 +101,13 @@ class WorkspaceInviteMessagePage extends React.Component { this.setState({welcomeNote: this.getDefaultWelcomeNote()}); } + componentWillUnmount() { + if (!this.focusTimeout) { + return; + } + clearTimeout(this.focusTimeout); + } + getDefaultWelcomeNote() { return this.props.translate('workspace.inviteMessage.welcomeNote', { workspaceName: this.props.policy.name, @@ -176,6 +194,7 @@ class WorkspaceInviteMessagePage extends React.Component { (this.welcomeMessageInputRef = el)} inputID="welcomeMessage" label={this.props.translate('workspace.inviteMessage.personalMessagePrompt')} autoCompleteType="off" diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index 1ba01ddeae34..0e9739a74f8e 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -387,21 +387,12 @@ class WorkspaceMembersPage extends React.Component { data = _.sortBy(data, (value) => value.displayName.toLowerCase()); data = this.getMemberOptions(data, this.state.searchValue.trim().toLowerCase()); - data = _.reject(data, (member) => { - if (!policyOwner || !currentUserLogin) { - return; - } - - // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails - if (PolicyUtils.isExpensifyTeam(policyOwner)) { - return; - } - - // We don't want to show guides as policy members unless the user is not a guide. Some customers get confused when they - // see random people added to their policy, but guides having access to the policies help set them up. - const isCurrentUserExpensifyTeam = PolicyUtils.isExpensifyTeam(currentUserLogin); - return !isCurrentUserExpensifyTeam && PolicyUtils.isExpensifyTeam(member.login); - }); + // If this policy is owned by Expensify then show all support (expensify.com or team.expensify.com) emails + // We don't want to show guides as policy members unless the user is a guide. Some customers get confused when they + // see random people added to their policy, but guides having access to the policies help set them up. + if (policyOwner && currentUserLogin && !PolicyUtils.isExpensifyTeam(policyOwner) && !PolicyUtils.isExpensifyTeam(currentUserLogin)) { + data = _.reject(data, (member) => PolicyUtils.isExpensifyTeam(member.login)); + } _.each(data, (member) => { if (member.login === this.props.session.email || member.login === this.props.policy.owner || member.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js index 5007d6212ad5..20bccddd9448 100644 --- a/src/pages/workspace/WorkspaceSettingsPage.js +++ b/src/pages/workspace/WorkspaceSettingsPage.js @@ -23,6 +23,7 @@ import * as ReportUtils from '../../libs/ReportUtils'; import Avatar from '../../components/Avatar'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; const propTypes = { // The currency list constant object from Onyx @@ -34,6 +35,7 @@ const propTypes = { ), ...policyPropTypes, ...withLocalizePropTypes, + ...windowDimensionsPropTypes, }; const defaultProps = { @@ -43,6 +45,7 @@ const defaultProps = { function WorkspaceSettingsPage(props) { const nameIsRequiredError = props.translate('workspace.editor.nameIsRequiredError'); + const nameIsTooLongError = props.translate('workspace.editor.nameIsTooLongError'); const currencyItems = useMemo(() => { const currencyListKeys = _.keys(props.currencyList); @@ -72,11 +75,15 @@ function WorkspaceSettingsPage(props) { if (!name || !name.length) { errors.name = nameIsRequiredError; + } else if ([...name].length > CONST.WORKSPACE_NAME_CHARACTER_LIMIT) { + // Uses the spread syntax to count the number of Unicode code points instead of the number of UTF-16 + // code units. + errors.name = nameIsTooLongError; } return errors; }, - [nameIsRequiredError], + [nameIsRequiredError, nameIsTooLongError], ); const policyName = lodashGet(props.policy, 'name', ''); @@ -120,7 +127,8 @@ function WorkspaceSettingsPage(props) { type={CONST.ICON_TYPE_WORKSPACE} fallbackIcon={Expensicons.FallbackWorkspaceAvatar} style={[styles.mb3]} - anchorPosition={{top: 172, right: 18}} + anchorPosition={styles.createMenuPositionProfile(props.windowWidth)} + anchorAlignment={{horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP}} isUsingDefaultAvatar={!lodashGet(props.policy, 'avatar', null)} onImageSelected={(file) => Policy.updateWorkspaceAvatar(lodashGet(props.policy, 'id', ''), file)} onImageRemoved={() => Policy.deleteWorkspaceAvatar(lodashGet(props.policy, 'id', ''))} @@ -158,6 +166,7 @@ WorkspaceSettingsPage.displayName = 'WorkspaceSettingsPage'; export default compose( withPolicy, + withWindowDimensions, withOnyx({ currencyList: {key: ONYXKEYS.CURRENCY_LIST}, }), diff --git a/src/setup/index.js b/src/setup/index.js index a5799d266ffc..92a84d45a571 100644 --- a/src/setup/index.js +++ b/src/setup/index.js @@ -53,11 +53,4 @@ export default function () { // Perform any other platform-specific setup platformSetup(); - - // Workaround to a reanimated issue -> https://github.com/software-mansion/react-native-reanimated/issues/3355 - // We can remove it as soon as we are on > reanimated 3.0.0+ - if (process.browser) { - // eslint-disable-next-line no-underscore-dangle - window._frameTimestamp = null; - } } diff --git a/src/stories/ButtonWithDropdown.stories.js b/src/stories/ButtonWithDropdownMenu.stories.js similarity index 64% rename from src/stories/ButtonWithDropdown.stories.js rename to src/stories/ButtonWithDropdownMenu.stories.js index ac716f44f7ec..54084be5b018 100644 --- a/src/stories/ButtonWithDropdown.stories.js +++ b/src/stories/ButtonWithDropdownMenu.stories.js @@ -1,5 +1,5 @@ import React from 'react'; -import ButtonWithDropdown from '../components/ButtonWithDropdown'; +import ButtonWithDropdownMenu from '../components/ButtonWithDropdownMenu'; /** * We use the Component Story Format for writing stories. Follow the docs here: @@ -7,18 +7,22 @@ import ButtonWithDropdown from '../components/ButtonWithDropdown'; * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format */ const story = { - title: 'Components/ButtonWithDropdown', - component: ButtonWithDropdown, + title: 'Components/ButtonWithDropdownMenu', + component: ButtonWithDropdownMenu, }; // eslint-disable-next-line react/jsx-props-no-spreading -const Template = (args) => ; +const Template = (args) => ; // Arguments can be passed to the component by binding // See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args const Default = Template.bind({}); Default.args = { buttonText: 'Pay with PayPal.me', + options: [ + {value: 1, text: 'One'}, + {value: 2, text: 'Two'}, + ], }; export default story; diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index 6839b4567e09..a85c2d527f17 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -819,13 +819,15 @@ function getKeyboardShortcutsModalWidth(isSmallScreenWidth) { } /** - * @param {Boolean} isHovered - * @param {Boolean} isPressed - * @param {Boolean} isInReportAction + * @param {Object} params + * @param {Boolean} params.isHovered + * @param {Boolean} params.isPressed + * @param {Boolean} params.isInReportAction + * @param {Boolean} params.shouldUseCardBackground * @returns {Object} */ -function getHorizontalStackedAvatarBorderStyle(isHovered, isPressed, isInReportAction = false) { - let backgroundColor = themeColors.appBG; +function getHorizontalStackedAvatarBorderStyle({isHovered, isPressed, isInReportAction = false, shouldUseCardBackground = false}) { + let backgroundColor = shouldUseCardBackground ? themeColors.cardBG : themeColors.appBG; if (isHovered) { backgroundColor = isInReportAction ? themeColors.highlightBG : themeColors.border; diff --git a/src/styles/editedLabelStyles/index.js b/src/styles/editedLabelStyles/index.js new file mode 100644 index 000000000000..172dcc152b74 --- /dev/null +++ b/src/styles/editedLabelStyles/index.js @@ -0,0 +1,3 @@ +import display from '../utilities/display'; + +export default {...display.dInlineFlex}; diff --git a/src/styles/editedLabelStyles/index.native.js b/src/styles/editedLabelStyles/index.native.js new file mode 100644 index 000000000000..ff8b4c56321a --- /dev/null +++ b/src/styles/editedLabelStyles/index.native.js @@ -0,0 +1 @@ +export default {}; diff --git a/src/styles/getTooltipStyles.js b/src/styles/getTooltipStyles.js index 42ad2df12474..b7b8210f36d1 100644 --- a/src/styles/getTooltipStyles.js +++ b/src/styles/getTooltipStyles.js @@ -56,22 +56,24 @@ function computeHorizontalShift(windowWidth, xOffset, componentWidth, tooltipWid * and the left edge of the wrapped component. * @param {Number} yOffset - The distance between the top edge of the window * and the top edge of the wrapped component. + * @param {Element} tooltip - The reference to the tooltip's root element * @returns {Boolean} */ -function isOverlappingAtTop(xOffset, yOffset) { +function isOverlappingAtTop(xOffset, yOffset, tooltip) { if (typeof document.elementFromPoint !== 'function') { return false; } const element = document.elementFromPoint(xOffset, yOffset); - if (!element) { + // Ensure it's not the already rendered element of this very tooltip, so the tooltip doesn't try to "avoid" itself + if (!element || tooltip.contains(element)) { return false; } const rect = element.getBoundingClientRect(); - // Ensure it's not itself + overlapping with another element by checking if the yOffset is greater than the top of the element + // Ensure it's not overlapping with another element by checking if the yOffset is greater than the top of the element // and less than the bottom of the element return yOffset > rect.top && yOffset < rect.bottom; } @@ -85,17 +87,17 @@ function isOverlappingAtTop(xOffset, yOffset) { * and the left edge of the wrapped component. * @param {Number} yOffset - The distance between the top edge of the window * and the top edge of the wrapped component. - * @param {Number} componentWidth - The width of the wrapped component. - * @param {Number} componentHeight - The height of the wrapped component. + * @param {Number} tooltipTargetWidth - The width of the tooltip's target + * @param {Number} tooltipTargetHeight - The height of the tooltip's target * @param {Number} maxWidth - The tooltip's max width. - * @param {Number} tooltipWidth - The width of the tooltip itself. - * @param {Number} tooltipHeight - The height of the tooltip itself. - * @param {Number} tooltipContentWidth - The tooltip's inner content width. + * @param {Number} tooltipContentWidth - The tooltip's inner content measured width. + * @param {Number} tooltipContentHeight - The tooltip's inner content measured height. * @param {Number} [manualShiftHorizontal] - Any additional amount to manually shift the tooltip to the left or right. * A positive value shifts it to the right, * and a negative value shifts it to the left. * @param {Number} [manualShiftVertical] - Any additional amount to manually shift the tooltip up or down. * A positive value shifts it down, and a negative value shifts it up. + * @param {Element} tooltip - The reference to the tooltip's root element * @returns {Object} */ export default function getTooltipStyles( @@ -103,50 +105,57 @@ export default function getTooltipStyles( windowWidth, xOffset, yOffset, - componentWidth, - componentHeight, + tooltipTargetWidth, + tooltipTargetHeight, maxWidth, - tooltipWidth, - tooltipHeight, tooltipContentWidth, + tooltipContentHeight, manualShiftHorizontal = 0, manualShiftVertical = 0, + tooltip, ) { - // Determine if the tooltip should display below the wrapped component. - // If either a tooltip will try to render within GUTTER_WIDTH logical pixels of the top of the screen, - // Or the wrapped component is overlapping at top-left with another element - // we'll display it beneath its wrapped component rather than above it as usual. - const shouldShowBelow = yOffset - tooltipHeight < GUTTER_WIDTH || isOverlappingAtTop(xOffset, yOffset); - - // Determine if we need to shift the tooltip horizontally to prevent it - // from displaying too near to the edge of the screen. - const horizontalShift = computeHorizontalShift(windowWidth, xOffset, componentWidth, tooltipWidth, manualShiftHorizontal); - - // Determine if we need to shift the pointer horizontally to prevent it from being too near to the edge of the tooltip - // We shift it to the right a bit if the tooltip is positioned on the extreme left - // and shift it to left a bit if the tooltip is positioned on the extreme right. - const horizontalShiftPointer = - horizontalShift > 0 - ? Math.max(-horizontalShift, -(tooltipWidth / 2) + POINTER_WIDTH / 2 + variables.componentBorderRadiusSmall) - : Math.min(-horizontalShift, tooltipWidth / 2 - POINTER_WIDTH / 2 - variables.componentBorderRadiusSmall); - const tooltipVerticalPadding = spacing.pv1; - const tooltipFontSize = variables.fontSizeSmall; - // We get wrapper width based on the tooltip's inner text width so the wrapper is just big enough to fit text and prevent white space. - // If the text width is less than the maximum available width, add horizontal padding. - // Note: tooltipContentWidth ignores the fractions (OffsetWidth) so add 1px to fit the text properly. - const wrapperWidth = tooltipContentWidth && tooltipContentWidth + spacing.ph2.paddingHorizontal * 2 + 1; + // We calculate tooltip width and height based on the tooltip's content width and height + // so the tooltip wrapper is just big enough to fit content and prevent white space. + const tooltipWidth = tooltipContentWidth && tooltipContentWidth + spacing.ph2.paddingHorizontal * 2; + const tooltipHeight = tooltipContentHeight && tooltipContentHeight + tooltipVerticalPadding.paddingVertical * 2; - // Hide the tooltip entirely if it's position hasn't finished measuring yet. This prevents UI jank where the tooltip flashes in the top left corner of the screen. - const opacity = xOffset === 0 && yOffset === 0 ? 0 : 1; + const isTooltipSizeReady = tooltipWidth !== undefined && tooltipHeight !== undefined; - const isTooltipSizeReady = tooltipWidth !== 0 && tooltipHeight !== 0; - const scale = !isTooltipSizeReady ? 1 : currentSize; - let wrapperTop = 0; - let wrapperLeft = 0; + // Set the scale to 1 to be able to measure the toolip size correctly when it's not ready yet. + let scale = 1; + let shouldShowBelow = false; + let horizontalShift = 0; + let horizontalShiftPointer = 0; + let rootWrapperTop = 0; + let rootWrapperLeft = 0; + let pointerWrapperTop = 0; + let pointerWrapperLeft = 0; + let pointerAdditionalStyle = {}; if (isTooltipSizeReady) { + // Determine if the tooltip should display below the wrapped component. + // If either a tooltip will try to render within GUTTER_WIDTH logical pixels of the top of the screen, + // Or the wrapped component is overlapping at top-left with another element + // we'll display it beneath its wrapped component rather than above it as usual. + shouldShowBelow = yOffset - tooltipHeight < GUTTER_WIDTH || isOverlappingAtTop(xOffset, yOffset, tooltip); + + // When the tooltip size is ready, we can start animating the scale. + scale = currentSize; + + // Determine if we need to shift the tooltip horizontally to prevent it + // from displaying too near to the edge of the screen. + horizontalShift = computeHorizontalShift(windowWidth, xOffset, tooltipTargetWidth, tooltipWidth, manualShiftHorizontal); + + // Determine if we need to shift the pointer horizontally to prevent it from being too near to the edge of the tooltip + // We shift it to the right a bit if the tooltip is positioned on the extreme left + // and shift it to left a bit if the tooltip is positioned on the extreme right. + horizontalShiftPointer = + horizontalShift > 0 + ? Math.max(-horizontalShift, -(tooltipWidth / 2) + POINTER_WIDTH / 2 + variables.componentBorderRadiusSmall) + : Math.min(-horizontalShift, tooltipWidth / 2 - POINTER_WIDTH / 2 - variables.componentBorderRadiusSmall); + // Because it uses fixed positioning, the top-left corner of the tooltip is aligned // with the top-left corner of the window by default. // we will use yOffset to position the tooltip relative to the Wrapped Component @@ -155,9 +164,9 @@ export default function getTooltipStyles( // First, we'll position it vertically. // To shift the tooltip down, we'll give `top` a positive value. // To shift the tooltip up, we'll give `top` a negative value. - wrapperTop = shouldShowBelow + rootWrapperTop = shouldShowBelow ? // We need to shift the tooltip down below the component. So shift the tooltip down (+) by... - yOffset + componentHeight + POINTER_HEIGHT + manualShiftVertical + yOffset + tooltipTargetHeight + POINTER_HEIGHT + manualShiftVertical : // We need to shift the tooltip up above the component. So shift the tooltip up (-) by... yOffset - (tooltipHeight + POINTER_HEIGHT) + manualShiftVertical; @@ -173,7 +182,28 @@ export default function getTooltipStyles( // so the tooltip's center lines up with the center of the wrapped component. // 3) Add the horizontal shift (left or right) computed above to keep it out of the gutters. // 4) Lastly, add the manual horizontal shift passed in as a parameter. - wrapperLeft = xOffset + (componentWidth / 2 - tooltipWidth / 2) + horizontalShift + manualShiftHorizontal; + rootWrapperLeft = xOffset + (tooltipTargetWidth / 2 - tooltipWidth / 2) + horizontalShift + manualShiftHorizontal; + + // By default, the pointer's top-left will align with the top-left of the tooltip wrapper. + // + // To align it vertically, we'll: + // If the pointer should be below the tooltip wrapper, shift the pointer down (+) by the tooltip height, + // so that the top of the pointer lines up with the bottom of the tooltip + // + // OR if the pointer should be above the tooltip wrapper, then the pointer up (-) by the pointer's height + // so that the bottom of the pointer lines up with the top of the tooltip + pointerWrapperTop = shouldShowBelow ? -POINTER_HEIGHT : tooltipHeight; + + // To align it horizontally, we'll: + // 1) Shift the pointer to the right (+) by the half the tooltipWidth's width, + // so the left edge of the pointer lines up with the tooltipWidth's center. + // 2) To the left (-) by half the pointer's width, + // so the pointer's center lines up with the tooltipWidth's center. + // 3) Remove the wrapper's horizontalShift to maintain the pointer + // at the center of the hovered component. + pointerWrapperLeft = horizontalShiftPointer + (tooltipWidth / 2 - POINTER_WIDTH / 2); + + pointerAdditionalStyle = shouldShowBelow ? styles.flipUpsideDown : {}; } return { @@ -183,52 +213,32 @@ export default function getTooltipStyles( // so Position fixed children will be relative to this new Local cordinate system transform: [{scale}], }, - tooltipWrapperStyle: { + rootWrapperStyle: { position: 'fixed', backgroundColor: themeColors.heading, borderRadius: variables.componentBorderRadiusSmall, ...tooltipVerticalPadding, ...spacing.ph2, zIndex: variables.tooltipzIndex, - width: wrapperWidth, + width: tooltipWidth, maxWidth, - top: wrapperTop, - left: wrapperLeft, - opacity, + top: rootWrapperTop, + left: rootWrapperLeft, // We are adding this to prevent the tooltip text from being selected and copied on CTRL + A. ...styles.userSelectNone, }, - tooltipTextStyle: { + textStyle: { color: themeColors.textReversed, fontFamily: fontFamily.EXP_NEUE, - fontSize: tooltipFontSize, + fontSize: variables.fontSizeSmall, overflow: 'hidden', lineHeight: variables.lineHeightSmall, }, pointerWrapperStyle: { position: 'fixed', - - // By default, the pointer's top-left will align with the top-left of the tooltip wrapper. - // - // To align it vertically, we'll: - // If the pointer should be below the tooltip wrapper, shift the pointer down (+) by the tooltip height, - // so that the top of the pointer lines up with the bottom of the tooltip - // - // OR if the pointer should be above the tooltip wrapper, then the pointer up (-) by the pointer's height - // so that the bottom of the pointer lines up with the top of the tooltip - top: shouldShowBelow ? -POINTER_HEIGHT : tooltipHeight, - - // To align it horizontally, we'll: - // 1) Shift the pointer to the right (+) by the half the tooltipWidth's width, - // so the left edge of the pointer lines up with the tooltipWidth's center. - // 2) To the left (-) by half the pointer's width, - // so the pointer's center lines up with the tooltipWidth's center. - // 3) Due to the tip start from the left edge of wrapper Tooltip so we have to remove the - // horizontalShift which is added to adjust it into the Window - left: horizontalShiftPointer + (tooltipWidth / 2 - POINTER_WIDTH / 2), - - opacity, + top: pointerWrapperTop, + left: pointerWrapperLeft, }, pointerStyle: { width: 0, @@ -241,7 +251,7 @@ export default function getTooltipStyles( borderLeftColor: colors.transparent, borderRightColor: colors.transparent, borderTopColor: themeColors.heading, - ...(shouldShowBelow ? styles.flipUpsideDown : {}), + ...pointerAdditionalStyle, }, }; } diff --git a/src/styles/styles.js b/src/styles/styles.js index e7b20f849636..823c6124ac0f 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -1209,20 +1209,20 @@ const styles = { lineHeight: variables.fontSizeOnlyEmojisHeight, }, - createMenuPositionSidebar: { - left: 18, - bottom: 100, - }, + createMenuPositionSidebar: (windowHeight) => ({ + horizontal: 18, + vertical: windowHeight - 100, + }), - createMenuPositionProfile: { - right: 18, - top: 180, - }, + createMenuPositionProfile: (windowWidth) => ({ + horizontal: windowWidth - 355, + vertical: 250, + }), - createMenuPositionReportActionCompose: { - left: 18 + variables.sideBarWidth, - bottom: 75, - }, + createMenuPositionReportActionCompose: (windowHeight) => ({ + horizontal: 18 + variables.sideBarWidth, + vertical: windowHeight - 75, + }), createMenuPositionRightSidepane: { right: 18, @@ -1547,8 +1547,27 @@ const styles = { // paddingVertical: 0, alignSelf: 'center', and textAlignVertical: 'center' paddingHorizontal: variables.avatarChatSpacing, - paddingTop: 0, - paddingBottom: 0, + paddingTop: 9, + paddingBottom: 9, + alignSelf: 'center', + textAlignVertical: 'center', + }, + 0, + ), + textInputComposeMultiLines: addOutlineWidth( + { + backgroundColor: themeColors.componentBG, + borderColor: themeColors.border, + color: themeColors.text, + fontFamily: fontFamily.EMOJI_TEXT_FONT, + fontSize: variables.fontSizeNormal, + borderWidth: 0, + height: 'auto', + lineHeight: variables.lineHeightXLarge, + ...overflowXHidden, + paddingHorizontal: variables.avatarChatSpacing, + paddingTop: 5, + paddingBottom: 5, alignSelf: 'center', textAlignVertical: 'center', }, @@ -1569,7 +1588,7 @@ const styles = { // composer padding should not be modified unless thoroughly tested against the cases in this PR: #12669 textInputComposeSpacing: { - paddingVertical: 5, + paddingVertical: 0, ...flex.flexRow, flex: 1, }, @@ -2456,12 +2475,7 @@ const styles = { iouPreviewBoxAvatar: { marginRight: -10, - marginBottom: -10, - }, - - iouPreviewBoxAvatarHover: { - borderColor: themeColors.border, - backgroundColor: themeColors.border, + marginBottom: 0, }, iouPreviewBoxCheckmark: { @@ -2876,15 +2890,15 @@ const styles = { flex: 1, }, - threeDotsPopoverOffset: { - top: 50, - right: 60, - }, + threeDotsPopoverOffset: (windowWidth) => ({ + vertical: 50, + horizontal: windowWidth - 60, + }), - threeDotsPopoverOffsetNoCloseButton: { - top: 50, - right: 10, - }, + threeDotsPopoverOffsetNoCloseButton: (windowWidth) => ({ + vertical: 50, + horizontal: windowWidth - 10, + }), invert: { // It's important to invert the Y AND X axis to prevent a react native issue that can lead to ANRs on android 13 @@ -3271,6 +3285,7 @@ const styles = { whisper: { backgroundColor: themeColors.cardBG, }, + contextMenuItemPopoverMaxWidth: { maxWidth: 375, }, @@ -3296,6 +3311,12 @@ const styles = { backgroundColor: themeColors.highlightBG, }, + splashScreenHider: { + backgroundColor: themeColors.splashBG, + alignItems: 'center', + justifyContent: 'center', + }, + headerEnvBadge: { marginLeft: 0, marginBottom: 2, diff --git a/src/styles/themes/default.js b/src/styles/themes/default.js index 86a89b38e695..54af4b52ce50 100644 --- a/src/styles/themes/default.js +++ b/src/styles/themes/default.js @@ -4,6 +4,7 @@ import colors from '../colors'; const darkTheme = { // Figma keys appBG: colors.greenAppBackground, + splashBG: colors.green, highlightBG: colors.greenHighlightBackground, border: colors.greenBorders, borderLighter: colors.greenBordersLighter, diff --git a/src/styles/utilities/display.js b/src/styles/utilities/display.js index 9e7e4107a937..bcef1c6b565f 100644 --- a/src/styles/utilities/display.js +++ b/src/styles/utilities/display.js @@ -20,6 +20,11 @@ export default { dInline: { display: 'inline', }, + + dInlineFlex: { + display: 'inline-flex', + }, + dBlock: { display: 'block', }, diff --git a/src/styles/variables.js b/src/styles/variables.js index 50b102619528..0eca03685f9c 100644 --- a/src/styles/variables.js +++ b/src/styles/variables.js @@ -99,6 +99,7 @@ export default { sliderBarHeight: 8, sliderKnobSize: 26, checkboxLabelActiveOpacity: 0.7, + checkboxLabelHoverOpacity: 1, avatarChatSpacing: 12, chatInputSpacing: 52, // 40 + avatarChatSpacing borderTopWidth: 1, diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index ade3f9617edd..74b1b6b9c3ab 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -1230,7 +1230,7 @@ describe('actions/IOU', () => { ) .then(() => { fetch.pause(); - IOU.payMoneyRequestElsewhere(chatReport, iouReport, {login: iouReport.ownerEmail}); + IOU.payMoneyRequest(CONST.IOU.PAYMENT_TYPE.ELSEWHERE, chatReport, iouReport); return waitForPromisesToResolve(); }) .then( diff --git a/tests/unit/OptionsListUtilsTest.js b/tests/unit/OptionsListUtilsTest.js index 2658115c81fa..7e6fb4146ca6 100644 --- a/tests/unit/OptionsListUtilsTest.js +++ b/tests/unit/OptionsListUtilsTest.js @@ -588,10 +588,10 @@ describe('OptionsListUtils', () => { // When we only pass personal details let results = OptionsListUtils.getMemberInviteOptions(PERSONAL_DETAILS, [], ''); - // We should expect personal details PERSONAL_DETAILS order - expect(results.personalDetails[0].text).toBe('Mister Fantastic'); - expect(results.personalDetails[1].text).toBe('Spider-Man'); - expect(results.personalDetails[2].text).toBe('Black Panther'); + // We should expect personal details to be sorted alphabetically + expect(results.personalDetails[0].text).toBe('Black Panther'); + expect(results.personalDetails[1].text).toBe('Black Widow'); + expect(results.personalDetails[2].text).toBe('Captain America'); expect(results.personalDetails[3].text).toBe('Invisible Woman'); // When we provide a search value that does not match any personal details diff --git a/tests/unit/ReportUtilsTest.js b/tests/unit/ReportUtilsTest.js index afdb5d1c88af..f0f4cc85b8f2 100644 --- a/tests/unit/ReportUtilsTest.js +++ b/tests/unit/ReportUtilsTest.js @@ -400,8 +400,8 @@ describe('ReportUtils', () => { }); }); - it('return both iou send and request money', () => { - const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({}, [currentUserEmail, participants], [CONST.BETAS.IOU, CONST.BETAS.IOU_SEND]); + it('return both iou send and request money in DM', () => { + const moneyRequestOptions = ReportUtils.getMoneyRequestOptions({type: 'chat'}, [currentUserEmail, participants[0]], [CONST.BETAS.IOU, CONST.BETAS.IOU_SEND]); expect(moneyRequestOptions.length).toBe(2); expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.REQUEST)).toBe(true); expect(moneyRequestOptions.includes(CONST.IOU.MONEY_REQUEST_TYPE.SEND)).toBe(true); diff --git a/web/index.html b/web/index.html index aa7735079384..344f048e55f8 100644 --- a/web/index.html +++ b/web/index.html @@ -15,6 +15,10 @@ + + <% if (htmlWebpackPlugin.options.isStaging) { %> + + <% } %> @@ -90,7 +124,9 @@ - + + <%= htmlWebpackPlugin.options.splashLogo %> +
Stack