diff --git a/.eslintrc.js b/.eslintrc.js index 35a7d397b09c..6c85f82df864 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,4 +16,18 @@ module.exports = { globals: { __DEV__: 'readonly', }, + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'react-native', + importNames: ['useWindowDimensions'], + message: 'Please use useWindowDimensions from src/hooks/useWindowDimensions instead', + }, + ], + }, + ], + }, }; diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml index 92a0d361981f..5fb2443698d8 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -158,13 +158,13 @@ jobs: bundler-cache: true - uses: actions/cache@v3 + id: cache-pods with: path: ios/Pods key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} - restore-keys: | - ${{ runner.os }}-pods- - name: Install cocoapods + if: ${{ !fromJSON(steps.cache-pods.outputs.cache-hit) }} uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 with: timeout_minutes: 10 diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index 428986d271a7..e31b294821b3 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -141,13 +141,13 @@ jobs: bundler-cache: true - uses: actions/cache@v3 + id: cache-pods with: path: ios/Pods key: ${{ runner.os }}-pods-${{ hashFiles('**/Podfile.lock') }} - restore-keys: | - ${{ runner.os }}-pods- - name: Install cocoapods + if: ${{ !fromJSON(steps.cache-pods.outputs.cache-hit) }} uses: nick-invision/retry@0711ba3d7808574133d713a0d92d2941be03a350 with: timeout_minutes: 10 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 a1a33b723a54..ad1bf218af7d 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 1001031603 - versionName "1.3.16-3" + versionCode 1001031703 + versionName "1.3.17-3" } splits { diff --git a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashDialog.java b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashDialog.java index db7c84bc9630..f5b1ceff60e2 100644 --- a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashDialog.java +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashDialog.java @@ -6,7 +6,6 @@ import android.view.Window; import android.view.WindowManager.LayoutParams; import androidx.annotation.NonNull; -import com.expensify.chat.R; public class BootSplashDialog extends Dialog { @@ -27,7 +26,6 @@ protected void onCreate(Bundle savedInstanceState) { if (window != null) { window.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); - window.setWindowAnimations(R.style.Theme_SplashScreen_Dialog); } super.onCreate(savedInstanceState); diff --git a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashModule.java b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashModule.java index c6b597975433..c286ebf7a935 100644 --- a/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashModule.java +++ b/android/app/src/main/java/com/expensify/chat/bootsplash/BootSplashModule.java @@ -1,9 +1,13 @@ package com.expensify.chat.bootsplash; +import android.annotation.SuppressLint; import android.app.Activity; +import android.content.Context; import android.content.DialogInterface; +import android.content.res.Resources; import android.os.Build; import android.view.View; +import android.view.ViewConfiguration; import android.view.ViewTreeObserver; import android.window.SplashScreen; import android.window.SplashScreenView; @@ -18,6 +22,9 @@ import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.common.ReactConstants; import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.PixelUtil; +import java.util.HashMap; +import java.util.Map; import java.util.Timer; import java.util.TimerTask; @@ -25,6 +32,7 @@ public class BootSplashModule extends ReactContextBaseJavaModule { public static final String NAME = "BootSplash"; + private static final BootSplashQueue 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/docs/articles/other/Everything-About-Chat.md b/docs/articles/other/Everything-About-Chat.md index 7a8bf5cf6554..8d4ad5f8740c 100644 --- a/docs/articles/other/Everything-About-Chat.md +++ b/docs/articles/other/Everything-About-Chat.md @@ -6,47 +6,47 @@ description: Everything you need to know about Expensify's Chat Features! # What is Expensify Chat? -Expensify Chat is the ideal way to collaborate on expenses or payment requests by communicating in real time with your accountant, clients, employees, friends, and family. +Expensify Chat is an ideal way to collaborate on expenses or payment requests by communicating in real-time with your accountant, clients, employees, or, friends. -With Expensify Chat, you can start a conversation about that missing receipt your employee forgot to submit or chat about splitting that electric bill with your roommates. Through eChat, you can even request money from your friends! +With Expensify Chat, you can start a conversation about that missing receipt your employee forgot to submit or chat about splitting that electric bill with your roommates. Through eChat, you can even request money from your friends after a night out on the town! # How to use Chat in Expensify Download NewExpensify from the [App Store](https://apps.apple.com/us/app/expensify-cash/id1530278510) or [Google Play](https://play.google.com/store/apps/details?id=com.expensify.chat) to use the chat function. You can also access your account at new.expensify.com from your favorite web browser. -After downloading the app, log into your new.expensify.com account (you’ll use the same username and password for your standard Expensify account). From there, you can customize your profile and start chatting immediately. +After downloading the app, log into your new.expensify.com account (you’ll use the same login information as your Expensify Classic account). From there, you can customize your profile and start chatting immediately. ## Start Chatting Select **New Chat** to chat one-on-one or **New Group** to start a group chat. ## Workspace Chat Rooms -In addition to 1:1 and group chat, members of a Workspace will have access to two additional rooms; the #announce and #admins rooms. +In addition to 1:1 and group chat, members of a Workspace or Policy will have access to two additional rooms; the #announce and #admins rooms. All workspace members are added to the #announce room by default. The #announce room lets you share important company announcements and have conversations between workspace members. -Workspace admins will have access to the #admins room. Use the #admins room to collaborate between admins and your dedicated Expensify Guide! +All workspace admins can access the #admins room. Use the #admins room to collaborate with the other admins on your policy, and chat with your dedicated Expensify Onboarding Guide. If you have a subscription of 10 or more users, you're automatically assigned an Account Manager. You can ask for help and collaborate with your Account Manager in this same #admins room. Anytime someone on your team, your dedicated setup specialist, or your dedicated account manager makes any changes to your Workspace settings, that update is logged in the #admins room. # FAQs ## How do I add more than one person to a chat? -Creating a Group chat with multiple people is easy. Start by clicking the green chat **+** button and select **New Group**. Search for the people you want to invite and check the circle to the far right. Once you’ve selected everyone, click the **Create Group** button at the bottom of your screen. +Start by clicking the green chat **+** button and select **New Group**. Search for the people you want to invite and check the circle to the far right. Once you’ve selected everyone you want in the group chat, click the **Create Group** button at the bottom of your screen. ## Can I add people to an existing Group chat? -Adding people to an existing group chat isn’t possible right now, so you’ll need to make a new group chat with the latest additions. +Adding people to an existing group chat isn’t possible right now, so you’ll want to make a new group chat instead. ## Someone I don’t recognize is in my #admins room for my workspace; who is it? -After creating your workspace, you’ll have a dedicated Expensify specialist who will help you onboard and answer your questions. You can chat with them directly in the #admins room or request a call to talk to them over the phone. +After creating your workspace, you’ll have a dedicated Expensify specialist who will help you onboard and answer your questions. You can chat with them directly in the #admins room or request a call to talk to them over the phone. Later, once you've finished onboarding, if you have a subscription of 10 or more users, a dedicated Account Manager is added to your #admins room for ongoing product support. ## Can I force a chat to stay at the top of the chats list? -You sure can! Click on the chat you want to keep at the top of the list, and then click the small **pin** icon. From now on, your chat will stay pinned to the top of the chat list. If you want to unpin a chat, just click the **pin** icon again. +You sure can! Click on the chat you want to keep at the top of the list, and then click the small **pin** icon. If you want to unpin a chat, just click the **pin** icon again. # Deep Dive ## Chat display, aka Priority Mode -The way your chats display in the left-hand menu is customizable, and we offer two different options; Most Recent mode and _#focus_ mode. +The way your chats display in the left-hand menu is customizable. We offer two different options; Most Recent mode and _#focus_ mode. -- Most Recent mode will display all chats by default, sorted by most recent, with your pinned chats at the top of the list. -- #focus mode will display only unread and pinned chats, all sorted alphabetically. This setting is perfect for when you need to heads down to focus on a crucial project. +- Most Recent mode will display all chats by default, sort them by the most recent, and keep your pinned chats at the top of the list. +- #focus mode will display only unread and pinned chats, and will sort them alphabetically. This setting is perfect for when you need to cut distractions and focus on a crucial project. You can find your display mode by clicking on your User Icon > Preferences > Priority Mode. ## Inviting someone to Expensify Chat -If the person you want to chat with doesn’t appear in your contact list, simply type their email or phone number to invite them to chat! They will receive an email with instructions and can reply directly to it to start chatting with you. +If the person you want to chat with doesn’t appear in your contact list, simply type their email or phone number to invite them to chat! From there, they will receive an email with instructions and a link to create an account. -All they have to do is click the link, and a new.expensify.com account will be set up automatically for them (if they don't have one already), and they can start chatting immediately! +Once they click the link, a new.expensify.com account is set up for them automatically (if they don't have one already), and they can start chatting with you immediately! diff --git a/docs/articles/other/Your-Expensify-Account-Manager.md b/docs/articles/other/Your-Expensify-Account-Manager.md index 318d03b510d8..70e0435e00e1 100644 --- a/docs/articles/other/Your-Expensify-Account-Manager.md +++ b/docs/articles/other/Your-Expensify-Account-Manager.md @@ -6,24 +6,25 @@ description: Everything you need to know about Having an Expensify account manag # What is an account manager? -An account manager is a dedicated point of contact to support customers with questions about their Expensify account. They will actively monitor open technical issues and be proactive with recommendations to increase efficiency and minimize time spent on expense management. +An account manager is a dedicated point of contact to support policy admins with questions about their Expensify account. They are available to help you and other policy admins review your account, advise on best practices, and make changes to your policy on your behalf whenever you need a hand. They will actively monitor open technical issues and be proactive with recommendations to increase efficiency and minimize time spent on expense management. Unlike Concierge, an account manager’s support will not be real-time, 24 hours a day. A benefit of Concierge is that you get real-time support every day. Your account manager will be super responsive when online, but anything sent when they’re offline will not be responded to until they’re online again. For real-time responses and simple troubleshooting issues, you can always message our general support by writing to Concierge via the in-product chat or by emailing concierge@expensify.com. # How do I know if I have an account manager? -If you are a policy or domain admin, you will hear from your account manager as soon as one gets assigned to your company. You’ll also have the option to contact them when you log in to your account and click the Concierge icon. +If you are a policy admin or domain admin, you will also hear from your account manager as soon as one gets assigned to your company. If you'd like a reminder who your account manager is, just click the Support link on the left side of Expensify - you'll see your account manager's name and photo, with an option to contact them for help. ## How do I contact my account manager? -You can contact your account manager by: -- Logging in to your Expensify account, opening Concierge, and clicking the “Chat with your account manager” button; -- Replying to or clicking the chat link on any email you get from your account manager; -- Signing in to new.expensify.com and searching for your account manager (this is still in a test phase, so it might not work for every customer). +We make it easy to contact your account manager: + +1. Log in to your Expensify account, click "Support" along the left side of the page, and click the “Account Manager” option +2. Reply to (or click the chat link on) any email you get from your account manager +3. Sign in to new.expensify.com and go to the #admins room for any of your policies. Your account manager is in your #admin rooms ready to help you, so you can ask for help here and your account manager will respond in the chat. # FAQs -## How can I request an account manager? -Not every customer will automatically be assigned an account manager. If you think you would benefit from having a dedicated account manager, please email a request to concierge@expensify.com, and we’ll do our best to assign one as soon as possible. +## Who gets an account manager? +Every customer with 10 or more paid subscribers is automatically assigned a dedicated account manager. If you have fewer than 10 active users each month, you can still get an account manager by increasing your subscription to 10 or more users, To get assigned an account manager immediately, log into your Expensify account and go to Settings > Policies > Group, then click Subscription and increase your subscription size to 10 or more. ## How do I know if my account manager is online? You will be able to see if they are online via their status, which will either say something like “online” or have their working hours. diff --git a/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups.md b/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups.md index 6c8d5314f718..34d09e813cd5 100644 --- a/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups.md +++ b/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Bootstrapped-Startups.md @@ -39,7 +39,7 @@ As a bootstrapped startup, you communicate with your team all day. Similarly, if 1. Click on your avatar 2. Select *Workspaces* 3. Click on your workspace -4. Select *Manage Members* +4. Select *Members* 5. Click *Invite*, and enter each team member’s email address By inviting your team, all members of your team will have access to unlimited receipt capture via SmartScan, and you’ll all have access to our free chat tool, which makes it easy to chat about your project and the expenses you’ve captured to help accelerate your project. @@ -52,7 +52,7 @@ Here’s how to set it up: 1. Click on your *avatar* 2. Select *Workspaces* 3. Click on your workspace name -4. At the bottom, select *Connect bank account* +4. At the bottom, select *Bank account* 5. Select your bank and enter your online login credentials Once this is done, you are all set to begin the process of enabling the Expensify Card. Not just for you, but if you have a co-founder, you can also issue them a card. @@ -65,7 +65,7 @@ Here’s how to enable the Expensify Card: 1. Click on your *avatar* 2. Select *Workspaces* 3. Click on your workspace -4. Select *Issue Cards* +4. Select *Cards* 5. Next, you’ll be redirected to expensify.com 6. Set a SmartLimit > $0 7. We’ll also ask you for your mailing address to send you a physical Expensify Card @@ -82,7 +82,7 @@ To view and pay bills: 1. Click on your *avatar* 2. Select *Workspaces* 3. Click on your workspace -4. Select *Pay bills* +4. Select *Bills* When you have bills to pay you can click *View all bills* under the *Manage your bills* box and we’ll keep a neatly organized list of all of the bills you can pay via ACH directly from your Expensify account. diff --git a/docs/articles/send-money/workspaces/The-Free-Plan.md b/docs/articles/send-money/workspaces/The-Free-Plan.md index 3037f71f5a5d..45c9d09d4777 100644 --- a/docs/articles/send-money/workspaces/The-Free-Plan.md +++ b/docs/articles/send-money/workspaces/The-Free-Plan.md @@ -25,12 +25,12 @@ Once you’ve created your Workspace, you will receive a message from Concierge Once you’ve completed your company setup, you should have completed the following tasks: -- Connected a business bank account (Settings menu > Click **_Connect bank account_** and follow the prompts). +- Connected a business bank account (Settings menu > Click **_Bank account_** and follow the prompts). - Invited members to the workspace - Assigned Expensify Cards # Inviting Members to the Free Plan: -- Navigate to the Settings Menu and click **_Manage members_** to invite your team. You can invite employees one at a time, or you can invite multiple users by listing out their email addresses separated by a comma +- Navigate to the Settings Menu and click **_Members_** to invite your team. You can invite employees one at a time, or you can invite multiple users by listing out their email addresses separated by a comma - To use the Expensify Card, you must invite them to your workspace via your company email address (i.e., admin@companyemail.com and NOT admin@gmail.com). # Managing the Free Plan 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 90b6ca439b3c..c2d80cefbc27 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.16 + 1.3.17 CFBundleSignature ???? CFBundleURLTypes @@ -30,7 +30,7 @@ CFBundleVersion - 1.3.16.3 + 1.3.17.3 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 bb36de4efe79..d95ce34ee8aa 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.16 + 1.3.17 CFBundleSignature ???? CFBundleVersion - 1.3.16.3 + 1.3.17.3 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 766a322607bd..43c64fa15997 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -516,7 +516,7 @@ PODS: - React-Core - react-native-key-command (1.0.0): - React-Core - - react-native-netinfo (8.3.1): + - react-native-netinfo (9.3.10): - React-Core - react-native-pdf (6.6.2): - React-Core @@ -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 @@ -1067,7 +1067,7 @@ SPEC CHECKSUMS: RCTTypeSafety: 9ae0e9206625e995f0df4d5b9ddc94411929fb30 React: a71c8e1380f07e01de721ccd52bcf9c03e81867d React-callinvoker: fc9f36c92c287c012d3fb45ea0f1b523c4f5aaa8 - React-Codegen: 7dcbda38b5b38a9354ef0ef00c420d6921d7bbb7 + React-Codegen: 47ad49a58fd95a9560a25f6054ee8984ff3afadb React-Core: aab8ea7f615a86b3a73ce87aa9be4c563e49648b React-CoreModules: f2a86b01c227e0137c83c13dd645fe69270cef80 React-cxxreact: 8adcafaeb0f02ae1282698c482ffa4c73fca4a35 @@ -1085,7 +1085,7 @@ SPEC CHECKSUMS: react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b react-native-key-command: 0b3aa7c9f5c052116413e81dce33a3b2153a6c5d - react-native-netinfo: 1a6035d3b9780221d407c277ebfb5722ace00658 + react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c @@ -1124,7 +1124,7 @@ SPEC CHECKSUMS: RNLocalize: d4b8af4e442d4bcca54e68fc687a2129b4d71a81 RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c - RNReanimated: fbc356493970e3acddc15586b1bccb5eab3ff1ec + RNReanimated: b1220a0e5168745283ff5d53bfc7d2144b2cee1b RNScreens: 0df01424e9e0ed7827200d6ed1087ddd06c493f9 RNSVG: 53c661b76829783cdaf9b7a57258f3d3b4c28315 SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d @@ -1135,4 +1135,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 4ed1c7b099741c82e2b0411b95f6468e72be6c76 -COCOAPODS: 1.12.0 +COCOAPODS: 1.12.1 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 aed1ca1ae49b..901d009abc29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.16-3", + "version": "1.3.17-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.16-3", + "version": "1.3.17-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -23,7 +23,7 @@ "@react-native-camera-roll/camera-roll": "5.4.0", "@react-native-community/clipboard": "^1.5.1", "@react-native-community/datetimepicker": "^3.5.2", - "@react-native-community/netinfo": "^8.3.0", + "@react-native-community/netinfo": "^9.3.10", "@react-native-community/progress-bar-android": "^1.0.4", "@react-native-community/progress-view": "^1.2.3", "@react-native-firebase/analytics": "^12.3.0", @@ -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#c20d572ebb4bcfc4963c1f2d58d133c24ec90f9f", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#68abe48ad71a98604fdbf5e8e960023ed5807ec2", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.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, @@ -7086,8 +7092,9 @@ "license": "MIT" }, "node_modules/@react-native-community/netinfo": { - "version": "8.3.1", - "license": "MIT", + "version": "9.3.10", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-9.3.10.tgz", + "integrity": "sha512-OwnqoJUp/4sa9e3ju+wQavAa8l0fiA3DheeLMKzKxtKeAe0CA7bNxWRM752JvRQ6A/igPnt1V0zSlu5owvQEuA==", "peerDependencies": { "react-native": ">=0.59" } @@ -7265,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", @@ -15135,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", @@ -15156,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", @@ -15188,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", @@ -23475,8 +23520,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#c20d572ebb4bcfc4963c1f2d58d133c24ec90f9f", - "integrity": "sha512-IEx2/dIBQDRAp3h/ekd47rbdTA+j6LgZwnFGWEeDBd18YR2J+d01Cfs5FiSP3nzC2dmJMAMv+IEwCrPWrqVcEg==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#68abe48ad71a98604fdbf5e8e960023ed5807ec2", + "integrity": "sha512-OhM2d7W++k0RVscwBpl0cvKXp3aZmflVawQ9Ebh2RBTdy98VZUwq2iZyCDISGNyETthqqsRq8vxy2lFQZZ19RA==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -25292,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, @@ -35154,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", @@ -37482,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", @@ -38358,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", @@ -43230,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, @@ -46430,7 +46472,9 @@ "dev": true }, "@react-native-community/netinfo": { - "version": "8.3.1", + "version": "9.3.10", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-9.3.10.tgz", + "integrity": "sha512-OwnqoJUp/4sa9e3ju+wQavAa8l0fiA3DheeLMKzKxtKeAe0CA7bNxWRM752JvRQ6A/igPnt1V0zSlu5owvQEuA==", "requires": {} }, "@react-native-community/progress-bar-android": { @@ -46537,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": { @@ -51728,8 +51803,7 @@ "dev": true }, "@types/prop-types": { - "version": "15.7.5", - "peer": true + "version": "15.7.5" }, "@types/qs": { "version": "6.9.7", @@ -51746,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, @@ -51773,8 +51856,7 @@ "dev": true }, "@types/scheduler": { - "version": "0.16.2", - "peer": true + "version": "0.16.2" }, "@types/seedrandom": { "version": "2.4.30", @@ -57319,9 +57401,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#c20d572ebb4bcfc4963c1f2d58d133c24ec90f9f", - "integrity": "sha512-IEx2/dIBQDRAp3h/ekd47rbdTA+j6LgZwnFGWEeDBd18YR2J+d01Cfs5FiSP3nzC2dmJMAMv+IEwCrPWrqVcEg==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#c20d572ebb4bcfc4963c1f2d58d133c24ec90f9f", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#68abe48ad71a98604fdbf5e8e960023ed5807ec2", + "integrity": "sha512-OhM2d7W++k0RVscwBpl0cvKXp3aZmflVawQ9Ebh2RBTdy98VZUwq2iZyCDISGNyETthqqsRq8vxy2lFQZZ19RA==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#68abe48ad71a98604fdbf5e8e960023ed5807ec2", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -58517,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, @@ -65081,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": { @@ -66555,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" @@ -67168,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 d8e3fe1fceb3..822ad9368d58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.16-3", + "version": "1.3.17-3", "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.", @@ -58,7 +58,7 @@ "@react-native-camera-roll/camera-roll": "5.4.0", "@react-native-community/clipboard": "^1.5.1", "@react-native-community/datetimepicker": "^3.5.2", - "@react-native-community/netinfo": "^8.3.0", + "@react-native-community/netinfo": "^9.3.10", "@react-native-community/progress-bar-android": "^1.0.4", "@react-native-community/progress-view": "^1.2.3", "@react-native-firebase/analytics": "^12.3.0", @@ -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#c20d572ebb4bcfc4963c1f2d58d133c24ec90f9f", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#68abe48ad71a98604fdbf5e8e960023ed5807ec2", "fbjs": "^3.0.2", "html-entities": "^1.3.1", "htmlparser2": "^7.2.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-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 988ded81a73d..3cb4b110be74 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -22,6 +22,8 @@ const keyInputEscape = lodashGet(KeyCommand, 'constants.keyInputEscape', 'keyInp const keyInputEnter = lodashGet(KeyCommand, 'constants.keyInputEnter', 'keyInputEnter'); const keyInputUpArrow = lodashGet(KeyCommand, 'constants.keyInputUpArrow', 'keyInputUpArrow'); const keyInputDownArrow = lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow'); +const keyInputLeftArrow = lodashGet(KeyCommand, 'constants.keyInputLeftArrow', 'keyInputLeftArrow'); +const keyInputRightArrow = lodashGet(KeyCommand, 'constants.keyInputRightArrow', 'keyInputRightArrow'); // describes if a shortcut key can cause navigation const KEYBOARD_SHORTCUT_NAVIGATION_TYPE = 'NAVIGATION_SHORTCUT'; @@ -282,6 +284,7 @@ const CONST = { MX: 'MX', AU: 'AU', CA: 'CA', + GB: 'GB', }, DESKTOP_DEEPLINK_APP_STATE: { CHECKING: 'checking', @@ -397,6 +400,26 @@ const CONST = { [PLATFORM_IOS]: {input: keyInputDownArrow}, }, }, + ARROW_LEFT: { + descriptionKey: null, + shortcutKey: 'ArrowLeft', + modifiers: [], + trigger: { + DEFAULT: {input: keyInputLeftArrow}, + [PLATFORM_OS_MACOS]: {input: keyInputLeftArrow}, + [PLATFORM_IOS]: {input: keyInputLeftArrow}, + }, + }, + ARROW_RIGHT: { + descriptionKey: null, + shortcutKey: 'ArrowRight', + modifiers: [], + trigger: { + DEFAULT: {input: keyInputRightArrow}, + [PLATFORM_OS_MACOS]: {input: keyInputRightArrow}, + [PLATFORM_IOS]: {input: keyInputRightArrow}, + }, + }, TAB: { descriptionKey: null, shortcutKey: 'Tab', @@ -578,6 +601,11 @@ const CONST = { DAILY: 'daily', ALWAYS: 'always', }, + // Options for which room members can post + WRITE_CAPABILITIES: { + ALL: 'all', + ADMINS: 'admins', + }, VISIBILITY: { PUBLIC: 'public', PUBLIC_ANNOUNCE: 'public_announce', @@ -700,6 +728,13 @@ const CONST = { DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {errors: null, success: '', isLoading: false}, DEFAULT_CLOSE_ACCOUNT_DATA: {error: '', success: '', isLoading: false}, + FORMS: { + LOGIN_FORM: 'LoginForm', + VALIDATE_CODE_FORM: 'ValidateCodeForm', + VALIDATE_TFA_CODE_FORM: 'ValidateTfaCodeForm', + RESEND_VALIDATION_FORM: 'ResendValidationForm', + UNLINK_LOGIN_FORM: 'UnlinkLoginForm', + }, APP_STATE: { ACTIVE: 'active', BACKGROUND: 'background', @@ -800,6 +835,7 @@ const CONST = { SMALL_CONTAINER_HEIGHT_FACTOR: 2.5, MIN_AMOUNT_OF_ITEMS: 3, MAX_AMOUNT_OF_ITEMS: 5, + HERE_TEXT: '@here', }, COMPOSER_MAX_HEIGHT: 125, CHAT_FOOTER_MIN_HEIGHT: 65, @@ -1048,7 +1084,7 @@ const CONST = { SPECIAL_CHARS_WITHOUT_NEWLINE: /((?!\n)[()-\s\t])/g, DIGITS_AND_PLUS: /^\+?[0-9]*$/, ALPHABETIC_CHARS: /[a-zA-Z]+/, - ALPHABETIC_CHARS_WITH_NUMBER: /^[a-zA-Z0-9 ]*$/, + ALPHABETIC_CHARS_WITH_NUMBER: /^[a-zA-ZÀ-ÿ0-9 ]*$/, POSITIVE_INTEGER: /^\d+$/, PO_BOX: /\b[P|p]?(OST|ost)?\.?\s*[O|o|0]?(ffice|FFICE)?\.?\s*[B|b][O|o|0]?[X|x]?\.?\s+[#]?(\d+)\b/, ANY_VALUE: /^.+$/, @@ -1079,14 +1115,17 @@ const CONST = { HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/, HAS_AT_MOST_TWO_AT_SIGNS: /^@[^@]*@?[^@]*$/, - // eslint-disable-next-line no-misleading-character-class - NEW_LINE_OR_WHITE_SPACE_OR_EMOJI: /[\n\s\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, + SPECIAL_CHAR_OR_EMOJI: + // eslint-disable-next-line no-misleading-character-class + /[\n\s,/?"{}[\]()&^%$#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, // Define the regular expression pattern to match a string starting with a colon and ending with a space or newline character EMOJI_REPLACER: /^:[^\n\r]+?(?=$|\s)/, // Define the regular expression pattern to match a string starting with an at sign and ending with a space or newline character - MENTION_REPLACER: /^@[^\n\r]*?(?=$|\s)/, + MENTION_REPLACER: + // eslint-disable-next-line no-misleading-character-class + /^@[^\n\r]*?(?=$|[\s,/?"{}[\]()&^%$#<>!*\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3)/u, MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/, }, @@ -2380,6 +2419,7 @@ const CONST = { ACTIVE: 'active', DISABLED: 'disabled', }, + SPACE_CHARACTER_WIDTH: 4, // This ID is used in SelectionScraper.js to query the DOM for UnreadActionIndicator's // div and then remove it from copied contents in the getHTMLOfSelection() method. 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/ROUTES.js b/src/ROUTES.js index 36af96003425..b6eea9b7a320 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -9,7 +9,6 @@ const REPORT = 'r'; const IOU_REQUEST = 'request/new'; const IOU_BILL = 'split/new'; const IOU_SEND = 'send/new'; -const IOU_DETAILS = 'iou/details'; const IOU_REQUEST_CURRENCY = `${IOU_REQUEST}/currency`; const IOU_BILL_CURRENCY = `${IOU_BILL}/currency`; const IOU_SEND_CURRENCY = `${IOU_SEND}/currency`; @@ -91,15 +90,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}`, - IOU_DETAILS, - IOU_DETAILS_ADD_BANK_ACCOUNT: `${IOU_DETAILS}/add-bank-account`, - IOU_DETAILS_ADD_DEBIT_CARD: `${IOU_DETAILS}/add-debit-card`, - IOU_DETAILS_ENABLE_PAYMENTS: `${IOU_DETAILS}/enable-payments`, - IOU_DETAILS_WITH_IOU_REPORT_ID: `${IOU_DETAILS}/:chatReportID/:iouReportID/`, - getIouDetailsRoute: (chatReportID, iouReportID) => `iou/details/${chatReportID}/${iouReportID}`, + 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}`, getNewTaskRoute: (reportID) => `${NEW_TASK}/${reportID}`, NEW_TASK_WITH_REPORT_ID: `${NEW_TASK}/:reportID?`, TASK_TITLE: 'r/:reportID/title', @@ -126,9 +119,11 @@ export default { REPORT_SETTINGS: 'r/:reportID/settings', REPORT_SETTINGS_ROOM_NAME: 'r/:reportID/settings/room-name', REPORT_SETTINGS_NOTIFICATION_PREFERENCES: 'r/:reportID/settings/notification-preferences', + REPORT_SETTINGS_WRITE_CAPABILITY: 'r/:reportID/settings/who-can-post', getReportSettingsRoute: (reportID) => `r/${reportID}/settings`, getReportSettingsRoomNameRoute: (reportID) => `r/${reportID}/settings/room-name`, getReportSettingsNotificationPreferencesRoute: (reportID) => `r/${reportID}/settings/notification-preferences`, + getReportSettingsWriteCapabilityRoute: (reportID) => `r/${reportID}/settings/who-can-post`, TRANSITION_FROM_OLD_DOT: 'transition', VALIDATE_LOGIN: 'v/:accountID/:validateCode', GET_ASSISTANCE: 'get-assistance/:taskID', 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/AttachmentCarousel/CarouselActions/index.js b/src/components/AttachmentCarousel/CarouselActions/index.js index 9144f0c7d0d1..4ec551daa252 100644 --- a/src/components/AttachmentCarousel/CarouselActions/index.js +++ b/src/components/AttachmentCarousel/CarouselActions/index.js @@ -1,4 +1,4 @@ -import React from 'react'; +import {useCallback, useEffect} from 'react'; import PropTypes from 'prop-types'; const propTypes = { @@ -6,42 +6,36 @@ const propTypes = { onCycleThroughAttachments: PropTypes.func.isRequired, }; -class Carousel extends React.Component { - constructor(props) { - super(props); - - this.handleKeyPress = this.handleKeyPress.bind(this); - } - - componentDidMount() { - document.addEventListener('keydown', this.handleKeyPress); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.handleKeyPress); - } - +const Carousel = (props) => { /** * Listens for keyboard shortcuts and applies the action * * @param {Object} e */ - handleKeyPress(e) { + const handleKeyPress = useCallback((e) => { // prevents focus from highlighting around the modal e.target.blur(); + if (e.key === 'ArrowLeft') { - this.props.onCycleThroughAttachments(-1); + props.onCycleThroughAttachments(-1); } if (e.key === 'ArrowRight') { - this.props.onCycleThroughAttachments(1); + props.onCycleThroughAttachments(1); } - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleKeyPress); - render() { - // This component is only used to listen for keyboard events - return null; - } -} + return () => { + document.removeEventListener('keydown', handleKeyPress); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +}; Carousel.propTypes = propTypes; diff --git a/src/components/AttachmentCarousel/CarouselActions/index.native.js b/src/components/AttachmentCarousel/CarouselActions/index.native.js index 69df7784141c..d12cd6bfbb60 100644 --- a/src/components/AttachmentCarousel/CarouselActions/index.native.js +++ b/src/components/AttachmentCarousel/CarouselActions/index.native.js @@ -1,4 +1,45 @@ -// No need to implement this in native, because all the native actions (swiping) are handled by the parent component -const Carousel = () => {}; +import {useEffect} from 'react'; +import PropTypes from 'prop-types'; +import KeyboardShortcut from '../../../libs/KeyboardShortcut'; +import CONST from '../../../CONST'; + +const propTypes = { + /** Callback to cycle through attachments */ + onCycleThroughAttachments: PropTypes.func.isRequired, +}; + +const Carousel = (props) => { + useEffect(() => { + const shortcutLeftConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT; + const unsubscribeLeftKey = KeyboardShortcut.subscribe( + shortcutLeftConfig.shortcutKey, + () => { + props.onCycleThroughAttachments(-1); + }, + shortcutLeftConfig.descriptionKey, + shortcutLeftConfig.modifiers, + ); + + const shortcutRightConfig = CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT; + const unsubscribeRightKey = KeyboardShortcut.subscribe( + shortcutRightConfig.shortcutKey, + () => { + props.onCycleThroughAttachments(1); + }, + shortcutRightConfig.descriptionKey, + shortcutRightConfig.modifiers, + ); + + return () => { + unsubscribeLeftKey(); + unsubscribeRightKey(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +}; + +Carousel.propTypes = propTypes; export default Carousel; diff --git a/src/components/AttachmentCarousel/index.js b/src/components/AttachmentCarousel/index.js index 380f865394ce..3258cd6da4fe 100644 --- a/src/components/AttachmentCarousel/index.js +++ b/src/components/AttachmentCarousel/index.js @@ -209,7 +209,7 @@ class AttachmentCarousel extends React.Component { const nextIndex = this.state.page - deltaSlide; const nextItem = this.state.attachments[nextIndex]; - if (!nextItem) { + if (!nextItem || !this.scrollRef.current) { return; } diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index ea5f5baa1558..642004913303 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -250,7 +250,7 @@ class AttachmentModal extends PureComponent { } render() { - const source = this.state.source; + const source = this.props.source || this.state.source; return ( <> this.downloadAttachment(source)} + onDownloadButtonPress={() => this.downloadAttachment(this.state.source)} onCloseButtonPress={() => this.setState({isModalOpen: false})} /> @@ -286,7 +286,7 @@ class AttachmentModal extends PureComponent { onToggleKeyboard={this.updateConfirmButtonVisibility} /> ) : ( - Boolean(this.state.source) && + Boolean(source) && this.state.shouldLoadAttachment && ( { + if (this.state.focusedIndex === -1) { + return; + } + this.selectItem(this.menuItemData[this.state.focusedIndex]); + this.setState({focusedIndex: -1}); // Reset the focusedIndex on selecting any menu + }, + shortcutConfig.descriptionKey, + shortcutConfig.modifiers, + true, + ); + } + + removeKeyboardListener() { + if (!this.unsubscribeEnterKey) { + return; + } + this.unsubscribeEnterKey(); } /** @@ -310,14 +355,21 @@ class AttachmentPicker extends Component { onModalHide={this.onModalHide} > - {_.map(this.menuItemData, (item) => ( - this.selectItem(item)} - /> - ))} + this.setState({focusedIndex: index})} + > + {_.map(this.menuItemData, (item, menuIndex) => ( + this.selectItem(item)} + focused={this.state.focusedIndex === menuIndex} + /> + ))} + {this.renderChildren()} diff --git a/src/components/Avatar.js b/src/components/Avatar.js index b2d90bddee17..66a1b60c3cef 100644 --- a/src/components/Avatar.js +++ b/src/components/Avatar.js @@ -11,7 +11,7 @@ import * as Expensicons from './Icon/Expensicons'; import Image from './Image'; import styles from '../styles/styles'; import * as ReportUtils from '../libs/ReportUtils'; -import useOnNetworkReconnect from './hooks/useOnNetworkReconnect'; +import useOnNetworkReconnect from '../hooks/useOnNetworkReconnect'; const propTypes = { /** Source for the avatar. Can be a URL or an icon. */ 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/BlockingViews/BlockingView.js b/src/components/BlockingViews/BlockingView.js index d8453cc8a91f..0cee6396c801 100644 --- a/src/components/BlockingViews/BlockingView.js +++ b/src/components/BlockingViews/BlockingView.js @@ -25,22 +25,26 @@ const propTypes = { /** Link message below the subtitle */ link: PropTypes.string, - /** Whether we should show a go back home link */ - shouldShowBackHomeLink: PropTypes.bool, + /** Whether we should show a link to navigate elsewhere */ + shouldShowLink: PropTypes.bool, /** The custom icon width */ iconWidth: PropTypes.number, /** The custom icon height */ iconHeight: PropTypes.number, + + /** Function to call when pressing the navigation link */ + onLinkPress: PropTypes.func, }; const defaultProps = { iconColor: themeColors.offline, - shouldShowBackHomeLink: false, + shouldShowLink: false, link: 'notFound.goBackHome', iconWidth: variables.iconSizeSuperLarge, iconHeight: variables.iconSizeSuperLarge, + onLinkPress: () => Navigation.dismissModal(true), }; const BlockingView = (props) => ( @@ -53,9 +57,9 @@ const BlockingView = (props) => ( /> {props.title} {props.subtitle} - {props.shouldShowBackHomeLink ? ( + {props.shouldShowLink ? ( Navigation.dismissModal(true)} + onPress={props.onLinkPress} style={[styles.link, styles.mt2]} > {props.link} diff --git a/src/components/BlockingViews/FullPageNotFoundView.js b/src/components/BlockingViews/FullPageNotFoundView.js index 9a6e4ebd3ee3..144d85176fe7 100644 --- a/src/components/BlockingViews/FullPageNotFoundView.js +++ b/src/components/BlockingViews/FullPageNotFoundView.js @@ -31,14 +31,17 @@ const propTypes = { /** Whether we should show a close button */ shouldShowCloseButton: PropTypes.bool, - /** Whether we should show a go back home link */ - shouldShowBackHomeLink: PropTypes.bool, + /** Whether we should show a link to navigate elsewhere */ + shouldShowLink: PropTypes.bool, /** The key in the translations file to use for the go back link */ linkKey: PropTypes.string, /** Method to trigger when pressing the back button of the header */ onBackButtonPress: PropTypes.func, + + /** Function to call when pressing the navigation link */ + onLinkPress: PropTypes.func, }; const defaultProps = { @@ -48,9 +51,10 @@ const defaultProps = { subtitleKey: 'notFound.pageNotFound', linkKey: 'notFound.goBackHome', shouldShowBackButton: true, - shouldShowBackHomeLink: false, + shouldShowLink: false, shouldShowCloseButton: true, onBackButtonPress: () => Navigation.dismissModal(), + onLinkPress: () => Navigation.dismissModal(true), }; // eslint-disable-next-line rulesdir/no-negated-variables @@ -72,7 +76,8 @@ const FullPageNotFoundView = (props) => { title={props.translate(props.titleKey)} subtitle={props.translate(props.subtitleKey)} link={props.translate(props.linkKey)} - shouldShowBackHomeLink={props.shouldShowBackHomeLink} + shouldShowLink={props.shouldShowLink} + onLinkPress={props.onLinkPress} /> diff --git a/src/components/Button/index.js b/src/components/Button/index.js index f9200d085b28..da72356bf53d 100644 --- a/src/components/Button/index.js +++ b/src/components/Button/index.js @@ -111,7 +111,8 @@ const propTypes = { 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])})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), }; const defaultProps = { @@ -253,7 +254,7 @@ class Button extends Component { if (this.props.shouldEnableHapticFeedback) { HapticFeedback.press(); } - this.props.onPress(e); + return this.props.onPress(e); }} onLongPress={(e) => { if (this.props.shouldEnableHapticFeedback) { diff --git a/src/components/ButtonWithDropdownMenu.js b/src/components/ButtonWithDropdownMenu.js index a518ce647c75..0d7cfc570670 100644 --- a/src/components/ButtonWithDropdownMenu.js +++ b/src/components/ButtonWithDropdownMenu.js @@ -52,7 +52,7 @@ 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 {windowWidth, windowHeight} = useWindowDimensions(); const caretButton = useRef(null); useEffect(() => { if (!caretButton.current) { diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js index 92b5f08b1581..298f1252d48a 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,17 @@ 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/Composer/index.android.js b/src/components/Composer/index.android.js index 311ab6ca3ce8..c043ab86381f 100644 --- a/src/components/Composer/index.android.js +++ b/src/components/Composer/index.android.js @@ -4,10 +4,12 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import RNTextInput from '../RNTextInput'; import themeColors from '../../styles/themes/default'; -import CONST from '../../CONST'; import * as ComposerUtils from '../../libs/ComposerUtils'; const propTypes = { + /** Maximum number of lines in the text input */ + maxLines: PropTypes.number, + /** If the input should clear, it actually gets intercepted instead of .clear() */ shouldClear: PropTypes.bool, @@ -55,6 +57,7 @@ const defaultProps = { end: 0, }, isFullComposerAvailable: false, + maxLines: -1, setIsFullComposerAvailable: () => {}, isComposerFullSize: false, style: null, @@ -96,10 +99,10 @@ class Composer extends React.Component { autoComplete="off" placeholderTextColor={themeColors.placeholderText} ref={(el) => (this.textInput = el)} - maxHeight={this.props.isComposerFullSize ? '100%' : CONST.COMPOSER_MAX_HEIGHT} onContentSizeChange={(e) => ComposerUtils.updateNumberOfLines(this.props, e)} rejectResponderTermination={false} textAlignVertical="center" + maximumNumberOfLines={!this.props.isComposerFullSize ? this.props.maxLines : undefined} style={this.state.propStyles} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...this.props} diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js index f75c331968c1..3ca2d7edf58a 100644 --- a/src/components/Composer/index.ios.js +++ b/src/components/Composer/index.ios.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; import RNTextInput from '../RNTextInput'; import themeColors from '../../styles/themes/default'; -import CONST from '../../CONST'; import * as ComposerUtils from '../../libs/ComposerUtils'; const propTypes = { @@ -33,6 +32,9 @@ const propTypes = { /** Whether the full composer can be opened */ isFullComposerAvailable: PropTypes.bool, + /** Maximum number of lines in the text input */ + maxLines: PropTypes.number, + /** Allow the full composer to be opened */ setIsFullComposerAvailable: PropTypes.func, @@ -54,6 +56,7 @@ const defaultProps = { start: 0, end: 0, }, + maxLines: -1, isFullComposerAvailable: false, setIsFullComposerAvailable: () => {}, isComposerFullSize: false, @@ -100,12 +103,14 @@ class Composer extends React.Component { (this.textInput = el)} - maxHeight={this.props.isComposerFullSize ? '100%' : CONST.COMPOSER_MAX_HEIGHT} onContentSizeChange={(e) => ComposerUtils.updateNumberOfLines(this.props, e)} rejectResponderTermination={false} textAlignVertical="center" + smartInsertDelete={false} style={this.state.propStyles} + maximumNumberOfLines={!this.props.isComposerFullSize ? this.props.maxLines : undefined} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...propsToPass} editable={!this.props.isDisabled} diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 0e198f03d036..1e27a1ff186e 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -1,5 +1,5 @@ import React from 'react'; -import {StyleSheet} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; @@ -15,7 +15,9 @@ import Clipboard from '../../libs/Clipboard'; import withWindowDimensions, {windowDimensionsPropTypes} from '../withWindowDimensions'; import compose from '../../libs/compose'; import styles from '../../styles/styles'; +import Text from '../Text'; import isEnterWhileComposition from '../../libs/KeyboardShortcut/isEnterWhileComposition'; +import CONST from '../../CONST'; const propTypes = { /** Maximum number of lines in the text input */ @@ -74,6 +76,9 @@ const propTypes = { /** Whether the composer is full size */ isComposerFullSize: PropTypes.bool, + /** Should we calculate the caret position */ + shouldCalculateCaretPosition: PropTypes.bool, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -100,6 +105,7 @@ const defaultProps = { isFullComposerAvailable: false, setIsFullComposerAvailable: () => {}, isComposerFullSize: false, + shouldCalculateCaretPosition: false, }; const IMAGE_EXTENSIONS = { @@ -128,6 +134,7 @@ class Composer extends React.Component { start: initialValue.length, end: initialValue.length, }, + valueBeforeCaret: '', }; this.paste = this.paste.bind(this); @@ -137,6 +144,8 @@ class Composer extends React.Component { this.handleWheel = this.handleWheel.bind(this); this.putSelectionInClipboard = this.putSelectionInClipboard.bind(this); this.shouldCallUpdateNumberOfLines = this.shouldCallUpdateNumberOfLines.bind(this); + this.addCursorPositionToSelectionChange = this.addCursorPositionToSelectionChange.bind(this); + this.textRef = React.createRef(null); } componentDidMount() { @@ -192,6 +201,57 @@ class Composer extends React.Component { this.textInput.removeEventListener('wheel', this.handleWheel); } + // Get characters from the cursor to the next space or new line + getNextChars(str, cursorPos) { + // Get the substring starting from the cursor position + const substr = str.substring(cursorPos); + + // Find the index of the next space or new line character + const spaceIndex = substr.search(/[ \n]/); + + if (spaceIndex === -1) { + return substr; + } + + // If there is a space or new line, return the substring up to the space or new line + return substr.substring(0, spaceIndex); + } + + /** + * Adds the cursor position to the selection change event. + * + * @param {Event} event + */ + addCursorPositionToSelectionChange(event) { + if (this.props.shouldCalculateCaretPosition) { + const newValueBeforeCaret = event.target.value.slice(0, event.nativeEvent.selection.start); + + this.setState( + { + valueBeforeCaret: newValueBeforeCaret, + caretContent: this.getNextChars(this.props.value, event.nativeEvent.selection.start), + }, + + () => { + const customEvent = { + nativeEvent: { + selection: { + start: event.nativeEvent.selection.start, + end: event.nativeEvent.selection.end, + positionX: this.textRef.current.offsetLeft - CONST.SPACE_CHARACTER_WIDTH, + positionY: this.textRef.current.offsetTop, + }, + }, + }; + this.props.onSelectionChange(customEvent); + }, + ); + return; + } + + this.props.onSelectionChange(event); + } + // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed handleKeyPress(e) { if (!this.props.onKeyPress || isEnterWhileComposition(e)) { @@ -359,6 +419,7 @@ class Composer extends React.Component { updateIsFullComposerAvailable(this.props, numberOfLines); this.setState({ numberOfLines, + width: computedStyle.width, }); this.props.onNumberOfLinesChange(numberOfLines); }); @@ -369,29 +430,57 @@ class Composer extends React.Component { propStyles.outline = 'none'; const propsWithoutStyles = _.omit(this.props, 'style'); + // This code creates a hidden text component that helps track the caret position in the visible input. + const renderElementForCaretPosition = ( + + + {`${this.state.valueBeforeCaret} `} + + {`${this.state.caretContent}`} + + + + ); + // We're disabling autoCorrect for iOS Safari until Safari fixes this issue. See https://github.com/Expensify/App/issues/8592 return ( - (this.textInput = el)} - selection={this.state.selection} - onChange={this.shouldCallUpdateNumberOfLines} - onSelectionChange={this.onSelectionChange} - style={[ - propStyles, - - // We are hiding the scrollbar to prevent it from reducing the text input width, - // so we can get the correct scroll height while calculating the number of lines. - this.state.numberOfLines < this.props.maxLines ? styles.overflowHidden : {}, - ]} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...propsWithoutStyles} - numberOfLines={this.state.numberOfLines} - disabled={this.props.isDisabled} - onKeyPress={this.handleKeyPress} - /> + <> + (this.textInput = el)} + selection={this.state.selection} + onChange={this.shouldCallUpdateNumberOfLines} + style={[ + propStyles, + + // We are hiding the scrollbar to prevent it from reducing the text input width, + // so we can get the correct scroll height while calculating the number of lines. + this.state.numberOfLines < this.props.maxLines ? styles.overflowHidden : {}, + ]} + /* eslint-disable-next-line react/jsx-props-no-spreading */ + {...propsWithoutStyles} + onSelectionChange={this.addCursorPositionToSelectionChange} + numberOfLines={this.state.numberOfLines} + disabled={this.props.isDisabled} + onKeyPress={this.handleKeyPress} + /> + {this.props.shouldCalculateCaretPosition && renderElementForCaretPosition} + ); } } diff --git a/src/components/DotIndicatorMessage.js b/src/components/DotIndicatorMessage.js index 9c2afdff8eb9..db8c66f311b8 100644 --- a/src/components/DotIndicatorMessage.js +++ b/src/components/DotIndicatorMessage.js @@ -7,6 +7,7 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import colors from '../styles/colors'; import Text from './Text'; +import * as Localize from '../libs/Localize'; const propTypes = { /** @@ -48,6 +49,7 @@ const DotIndicatorMessage = (props) => { // Using uniq here since some fields are wrapped by the same OfflineWithFeedback component (e.g. WorkspaceReimburseView) // and can potentially pass the same error. .uniq() + .map((message) => Localize.translateIfPhraseKey(message)) .value(); return ( diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index 1e15e9e20bf3..000b8f33ed04 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -141,8 +141,6 @@ class EmojiPicker extends React.Component { } render() { - // There is no way to disable animations and they are really laggy, because there are so many - // emojis. The best alternative is to set it to 1ms so it just "pops" in and out return ( - + this.fling(INACTIVE_POSITION_Y)} + > {this.state.bodyText} - + diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js index a7783449a473..478b03b40f0d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js @@ -5,6 +5,8 @@ import withLocalize, {withLocalizePropTypes} from '../../withLocalize'; import Text from '../../Text'; import variables from '../../../styles/variables'; import themeColors from '../../../styles/themes/default'; +import styles from '../../../styles/styles'; +import editedLabelStyles from '../../../styles/editedLabelStyles'; const propTypes = { ...htmlRendererPropTypes, @@ -19,8 +21,16 @@ const EditedRenderer = (props) => { {...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/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js index 8d063ea4a172..f3fdb7de5ff3 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.js +++ b/src/components/LHNOptionsList/OptionRowLHN.js @@ -77,7 +77,6 @@ const OptionRowLHN = (props) => { const hoveredBackgroundColor = props.hoverStyle && props.hoverStyle.backgroundColor ? props.hoverStyle.backgroundColor : themeColors.sidebar; const focusedBackgroundColor = styles.sidebarLinkActive.backgroundColor; - const avatarTooltips = !optionItem.isChatRoom && !optionItem.isArchivedRoom ? _.pluck(optionItem.displayNamesWithTooltips, 'tooltip') : undefined; const hasBrickError = optionItem.brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; const shouldShowGreenDotIndicator = !hasBrickError && (optionItem.isUnreadWithMention || (optionItem.hasOutstandingIOU && !optionItem.isIOUReportOwner)); @@ -138,7 +137,7 @@ const OptionRowLHN = (props) => { props.isFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, hovered && !props.isFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} - avatarTooltips={optionItem.isPolicyExpenseChat ? [optionItem.subtitle] : avatarTooltips} + shouldShowTooltip={!optionItem.isChatRoom && !optionItem.isArchivedRoom} /> ))} diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 2d4a31fde889..30c9b9ad4d9d 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 @@ -146,7 +163,6 @@ function MagicCodeInput(props) { */ const onFocus = (event) => { event.preventDefault(); - setInput(''); }; /** @@ -182,11 +198,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 +221,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 +237,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 +265,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 +288,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/index.ios.js b/src/components/Modal/index.ios.js index a44ae11f94ef..94bbfc8a7796 100644 --- a/src/components/Modal/index.ios.js +++ b/src/components/Modal/index.ios.js @@ -7,6 +7,8 @@ const Modal = (props) => ( {props.children} diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 8b6a43f47215..048744c9eca7 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -43,6 +43,9 @@ const propTypes = { /** Can the participants be modified or not */ canModifyParticipants: PropTypes.bool, + /** Depending on expense report or personal IOU report, respective bank account route */ + bankAccountRoute: PropTypes.string.isRequired, + ...windowDimensionsPropTypes, ...withLocalizePropTypes, @@ -69,7 +72,7 @@ const propTypes = { navigateToStep: PropTypes.func.isRequired, /** The policyID of the request */ - policyID: PropTypes.string.isRequired, + policyID: PropTypes.string, }; const defaultProps = { @@ -81,6 +84,7 @@ const defaultProps = { session: { email: null, }, + policyID: '', ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -288,7 +292,7 @@ class MoneyRequestConfirmationList extends Component { onPress={this.confirm} shouldShowPaypal={Boolean(recipient.payPalMeAddress)} enablePaymentsRoute={ROUTES.IOU_SEND_ENABLE_PAYMENTS} - addBankAccountRoute={ROUTES.IOU_SEND_ADD_BANK_ACCOUNT} + addBankAccountRoute={this.props.bankAccountRoute} addDebitCardRoute={ROUTES.IOU_SEND_ADD_DEBIT_CARD} currency={this.props.iou.selectedCurrencyCode} policyID={this.props.policyID} diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.js index 0a14315fbbd8..e6dff2e9ec00 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.js @@ -84,13 +84,14 @@ const MoneyRequestHeader = (props) => { ? ReportUtils.getWorkspaceAvatar(moneyRequestReport) : ReportUtils.getAvatar(lodashGet(props.personalDetails, [moneyRequestReport.managerEmail, 'avatar']), moneyRequestReport.managerEmail); const policy = props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`]; - const shouldShowSettlementButton = - !isSettled && (Policy.isAdminOfFreePolicy([policy]) || (ReportUtils.isMoneyRequestReport(props.report) && lodashGet(props.session, 'email', null) === props.report.managerEmail)); + const isPayer = Policy.isAdminOfFreePolicy([policy]) || (ReportUtils.isMoneyRequestReport(props.report) && lodashGet(props.session, 'email', null) === props.report.managerEmail); + const shouldShowSettlementButton = !isSettled && !props.isSingleTransactionView && isPayer; + const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport); return ( { currency={props.report.currency} policyID={props.report.policyID} shouldShowPaypal={Boolean(lodashGet(props.personalDetails, [moneyRequestReport.managerEmail, 'payPalMeAddress']))} - chatReportID={props.report.chatReportID} + chatReportID={props.chatReport.reportID} iouReport={props.report} onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.report)} enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} - addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + addBankAccountRoute={bankAccountRoute} shouldShowPaymentOptions /> @@ -169,7 +170,7 @@ const MoneyRequestHeader = (props) => { iouReport={props.report} onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.report)} enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} - addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + addBankAccountRoute={bankAccountRoute} shouldShowPaymentOptions /> )} diff --git a/src/components/MultipleAvatars.js b/src/components/MultipleAvatars.js index 31d4f9962b1f..a84e78076fde 100644 --- a/src/components/MultipleAvatars.js +++ b/src/components/MultipleAvatars.js @@ -23,9 +23,6 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types secondAvatarStyle: PropTypes.arrayOf(PropTypes.object), - /** Tooltip for the Avatar */ - avatarTooltips: PropTypes.arrayOf(PropTypes.string), - /** A fallback avatar icon to display when there is an error on loading avatar from remote URL. */ fallbackIcon: PropTypes.func, @@ -43,25 +40,33 @@ const propTypes = { /** Whether avatars are displayed within a reportAction */ isInReportAction: PropTypes.bool, + + /** Whether to show the toolip text */ + shouldShowTooltip: PropTypes.bool, + + /** Whether avatars are displayed with the highlighted background color instead of the app background color. This is primarily the case for IOU previews. */ + shouldUseCardBackground: PropTypes.bool, }; const defaultProps = { icons: [], size: CONST.AVATAR_SIZE.DEFAULT, secondAvatarStyle: [StyleUtils.getBackgroundAndBorderStyle(themeColors.componentBG)], - avatarTooltips: [], fallbackIcon: undefined, shouldStackHorizontally: false, isHovered: false, isPressed: false, isFocusMode: false, isInReportAction: false, + shouldShowTooltip: true, + shouldUseCardBackground: false, }; const MultipleAvatars = (props) => { let avatarContainerStyles = props.size === CONST.AVATAR_SIZE.SMALL ? [styles.emptyAvatarSmall, styles.emptyAvatarMarginSmall] : [styles.emptyAvatar, styles.emptyAvatarMargin]; const singleAvatarStyles = props.size === CONST.AVATAR_SIZE.SMALL ? styles.singleAvatarSmall : styles.singleAvatar; const secondAvatarStyles = [props.size === CONST.AVATAR_SIZE.SMALL ? styles.secondAvatarSmall : styles.secondAvatar, ...props.secondAvatarStyle]; + const tooltipTexts = props.shouldShowTooltip ? _.pluck(props.icons, 'name') : []; if (!props.icons.length) { return null; @@ -70,7 +75,7 @@ const MultipleAvatars = (props) => { if (props.icons.length === 1 && !props.shouldStackHorizontally) { return ( - + { {props.shouldStackHorizontally ? ( <> {_.map([...props.icons].splice(0, 4), (icon, index) => ( - - - + + + + ))} {props.icons.length > 4 && ( - - - {`+${props.icons.length - 4}`} + + + {`+${props.icons.length - 4}`} + - + )} ) : ( {/* View is necessary for tooltip to show for multiple avatars in LHN */} @@ -166,7 +194,7 @@ const MultipleAvatars = (props) => { {props.icons.length === 2 ? ( @@ -182,7 +210,7 @@ const MultipleAvatars = (props) => { ) : ( diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index 4beffb895814..2031bed27dc8 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -133,8 +133,6 @@ class OptionRow extends Component { // We only create tooltips for the first 10 users or so since some reports have hundreds of users, causing performance to degrade. const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips((this.props.option.participantsList || []).slice(0, 10), isMultipleParticipant); - const avatarTooltips = this.props.showTitleTooltip && !this.props.option.isChatRoom && !this.props.option.isArchivedRoom ? _.pluck(displayNamesWithTooltips, 'tooltip') : undefined; - let subscriptColor = themeColors.appBG; if (this.props.optionIsFocused) { subscriptColor = focusedBackgroundColor; @@ -197,7 +195,7 @@ class OptionRow extends Component { this.props.optionIsFocused ? StyleUtils.getBackgroundAndBorderStyle(focusedBackgroundColor) : undefined, hovered && !this.props.optionIsFocused ? StyleUtils.getBackgroundAndBorderStyle(hoveredBackgroundColor) : undefined, ]} - avatarTooltips={this.props.option.isPolicyExpenseChat ? [this.props.option.subtitle] : avatarTooltips} + shouldShowTooltip={this.props.showTitleTooltip && !this.props.option.isChatRoom && !this.props.option.isArchivedRoom} /> ))} diff --git a/src/components/PopoverMenu/index.js b/src/components/PopoverMenu/index.js index 5b5eadffcdce..17982c0d5684 100644 --- a/src/components/PopoverMenu/index.js +++ b/src/components/PopoverMenu/index.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React from 'react'; +import React, {useState} from 'react'; import PropTypes from 'prop-types'; import {View} from 'react-native'; import PopoverWithMeasuredContent from '../PopoverWithMeasuredContent'; @@ -40,11 +40,12 @@ const defaultProps = { const PopoverMenu = (props) => { const {isSmallScreenWidth} = useWindowDimensions(); + const [selectedItemIndex, setSelectedItemIndex] = useState(null); const selectItem = (index) => { const selectedItem = props.menuItems[index]; props.onItemSelected(selectedItem); - selectedItem.onSelected(); + setSelectedItemIndex(index); }; const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: props.menuItems.length - 1}); @@ -66,7 +67,13 @@ const PopoverMenu = (props) => { anchorAlignment={props.anchorOrigin} onClose={props.onClose} isVisible={props.isVisible} - onModalHide={() => setFocusedIndex(-1)} + onModalHide={() => { + setFocusedIndex(-1); + if (selectedItemIndex !== null) { + props.menuItems[selectedItemIndex].onSelected(); + setSelectedItemIndex(null); + } + }} animationIn={props.animationIn} animationOut={props.animationOut} animationInTiming={props.animationInTiming} diff --git a/src/components/Pressable/GenericPressable/BaseGenericPressable.js b/src/components/Pressable/GenericPressable/BaseGenericPressable.js index 6b9fa8654a29..8600ee3ac807 100644 --- a/src/components/Pressable/GenericPressable/BaseGenericPressable.js +++ b/src/components/Pressable/GenericPressable/BaseGenericPressable.js @@ -136,7 +136,7 @@ const GenericPressable = forwardRef((props, ref) => { 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/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index 037fb3afae73..efb64fbb5397 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -17,8 +17,12 @@ class PressableWithSecondaryInteraction extends Component { } componentDidMount() { - if (this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) { - this.props.forwardedRef(this.pressableRef); + if (this.props.forwardedRef) { + if (_.isFunction(this.props.forwardedRef)) { + this.props.forwardedRef(this.pressableRef); + } else if (_.isObject(this.props.forwardedRef)) { + this.props.forwardedRef.current = this.pressableRef; + } } this.pressableRef.addEventListener('contextmenu', this.executeSecondaryInteractionOnContextMenu); } diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index e54598cb1394..b036404e018d 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import refPropTypes from '../refPropTypes'; import stylePropTypes from '../../styles/stylePropTypes'; const propTypes = { @@ -18,7 +19,7 @@ const propTypes = { children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]).isRequired, /** The ref to the search input (may be null on small screen widths) */ - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + forwardedRef: refPropTypes, /** Prevent the default ContextMenu on web/Desktop */ preventDefaultContextMenu: PropTypes.bool, 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/Reactions/MiniQuickEmojiReactions.js b/src/components/Reactions/MiniQuickEmojiReactions.js index e4bc4664a70c..66f39e32fc67 100644 --- a/src/components/Reactions/MiniQuickEmojiReactions.js +++ b/src/components/Reactions/MiniQuickEmojiReactions.js @@ -16,7 +16,7 @@ import {baseQuickEmojiReactionsPropTypes} from './QuickEmojiReactions/BaseQuickE import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import compose from '../../libs/compose'; import ONYXKEYS from '../../ONYXKEYS'; -import getPreferredEmojiCode from './getPreferredEmojiCode'; +import * as EmojiUtils from '../../libs/EmojiUtils'; const propTypes = { ...baseQuickEmojiReactionsPropTypes, @@ -67,7 +67,7 @@ const MiniQuickEmojiReactions = (props) => { tooltipText={`:${emoji.name}:`} onPress={() => props.onEmojiSelected(emoji)} > - {getPreferredEmojiCode(emoji, props.preferredSkinTone)} + {EmojiUtils.getPreferredEmojiCode(emoji, props.preferredSkinTone)} ))} ( focusable={false} > { props.onEmojiSelected(emoji); diff --git a/src/components/Reactions/ReportActionItemReactions.js b/src/components/Reactions/ReportActionItemReactions.js index 63f79e2fecce..4388034991e2 100644 --- a/src/components/Reactions/ReportActionItemReactions.js +++ b/src/components/Reactions/ReportActionItemReactions.js @@ -7,31 +7,11 @@ import EmojiReactionBubble from './EmojiReactionBubble'; import emojis from '../../../assets/emojis'; import AddReactionBubble from './AddReactionBubble'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails'; -import getPreferredEmojiCode from './getPreferredEmojiCode'; import * as Report from '../../libs/actions/Report'; import * as ReactionList from '../../pages/home/report/ReactionList/ReactionList'; import Tooltip from '../Tooltip'; import ReactionTooltipContent from './ReactionTooltipContent'; - -/** - * Given an emoji object and a list of senders it will return an - * array of emoji codes, that represents all used variations of the - * emoji. - * @param {{ name: string, code: string, types: string[] }} emoji - * @param {Array} users - * @return {string[]} - * */ -const getUniqueEmojiCodes = (emoji, users) => { - const emojiCodes = []; - _.forEach(users, (user) => { - const emojiCode = getPreferredEmojiCode(emoji, user.skinTone); - - if (emojiCode && !emojiCodes.includes(emojiCode)) { - emojiCodes.push(emojiCode); - } - }); - return emojiCodes; -}; +import * as EmojiUtils from '../../libs/EmojiUtils'; const propTypes = { /** @@ -79,7 +59,7 @@ const ReportActionItemReactions = (props) => { const reactionCount = reaction.users.length; const reactionUsers = _.map(reaction.users, (sender) => sender.accountID.toString()); const emoji = _.find(emojis, (e) => e.name === reaction.emoji); - const emojiCodes = getUniqueEmojiCodes(emoji, reaction.users); + const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emoji, reaction.users); const hasUserReacted = Report.hasAccountIDReacted(props.currentUserPersonalDetails.accountID, reactionUsers); const onPress = () => { diff --git a/src/components/Reactions/getPreferredEmojiCode.js b/src/components/Reactions/getPreferredEmojiCode.js deleted file mode 100644 index 00c3184b59dc..000000000000 --- a/src/components/Reactions/getPreferredEmojiCode.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Given an emoji object it returns the correct emoji code - * based on the users preferred skin tone. - * @param {Object} emoji - * @param {String | Number} preferredSkinTone - * @returns {String} - */ -export default function getPreferredEmojiCode(emoji, preferredSkinTone) { - if (emoji.types) { - const emojiCodeWithSkinTone = emoji.types[preferredSkinTone]; - - // Note: it can happen that preferredSkinTone has a outdated format, - // so it makes sense to check if we actually got a valid emoji code back - if (emojiCodeWithSkinTone) { - return emojiCodeWithSkinTone; - } - } - - return emoji.code; -} diff --git a/src/components/RenderHTML.js b/src/components/RenderHTML.js index de3b15b8567e..203c78de4b25 100644 --- a/src/components/RenderHTML.js +++ b/src/components/RenderHTML.js @@ -1,7 +1,7 @@ import React from 'react'; -import {useWindowDimensions} from 'react-native'; import PropTypes from 'prop-types'; import {RenderHTMLSource} from 'react-native-render-html'; +import useWindowDimensions from '../hooks/useWindowDimensions'; const propTypes = { /** HTML string to render */ @@ -13,10 +13,10 @@ const propTypes = { // context to RenderHTMLSource components. See https://git.io/JRcZb // The provider is available at src/components/HTMLEngineProvider/ const RenderHTML = (props) => { - const {width} = useWindowDimensions(); + const {windowWidth} = useWindowDimensions(); return ( ); diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/IOUPreview.js index b9fb8f7432e8..ab706197351a 100644 --- a/src/components/ReportActionItem/IOUPreview.js +++ b/src/components/ReportActionItem/IOUPreview.js @@ -27,6 +27,7 @@ import * as OptionsListUtils from '../../libs/OptionsListUtils'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; import * as IOUUtils from '../../libs/IOUUtils'; import * as ReportUtils from '../../libs/ReportUtils'; +import refPropTypes from '../refPropTypes'; const propTypes = { /** The active IOUReport, used for Onyx subscription */ @@ -43,7 +44,7 @@ const propTypes = { action: PropTypes.shape(reportActionPropTypes), /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: PropTypes.shape({current: PropTypes.elementType}), + contextMenuAnchor: refPropTypes, /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: PropTypes.func, @@ -75,9 +76,6 @@ const propTypes = { /** True if this is this IOU is a split instead of a 1:1 request */ isBillSplit: PropTypes.bool.isRequired, - /** True if the IOU Preview is rendered within a single IOUAction */ - isIOUAction: PropTypes.bool, - /** True if the IOU Preview card is hovered */ isHovered: PropTypes.bool, @@ -118,7 +116,6 @@ const defaultProps = { containerStyles: [], walletTerms: {}, pendingAction: null, - isIOUAction: true, isHovered: false, personalDetails: {}, session: { @@ -134,9 +131,6 @@ const IOUPreview = (props) => { const sessionEmail = lodashGet(props.session, 'email', null); const managerEmail = props.iouReport.managerEmail || ''; const ownerEmail = props.iouReport.ownerEmail || ''; - - // When displaying within a IOUDetailsModal we cannot guarantee that participants are included in the originalMessage data - // Because an IOUPreview of type split can never be rendered within the IOUDetailsModal, manually building the email array is only needed for non-billSplit ious const participantEmails = props.isBillSplit ? lodashGet(props.action, 'originalMessage.participants', []) : [managerEmail, ownerEmail]; const participantAvatars = OptionsListUtils.getAvatarsForLogins(participantEmails, props.personalDetails); @@ -145,9 +139,8 @@ const IOUPreview = (props) => { const moneyRequestAction = ReportUtils.getMoneyRequestAction(props.action); - // If props.action is undefined then we are displaying within IOUDetailsModal and should use the full report amount - const requestAmount = props.isIOUAction ? moneyRequestAction.amount : ReportUtils.getMoneyRequestTotal(props.iouReport); - const requestCurrency = props.isIOUAction ? moneyRequestAction.currency : props.iouReport.currency; + const requestAmount = moneyRequestAction.amount; + const requestCurrency = moneyRequestAction.currency; const requestComment = Str.htmlDecode(moneyRequestAction.comment).trim(); const getSettledMessage = () => { @@ -164,12 +157,6 @@ const IOUPreview = (props) => { }; const showContextMenu = (event) => { - // Use action prop to check if we are in IOUDetailsModal, - // if it's true, do nothing when user long press, otherwise show context menu. - if (!props.action) { - return; - } - showContextMenuForReport(event, props.contextMenuAnchor, props.chatReportID, props.action, props.checkIfContextMenuActive); }; @@ -218,8 +205,10 @@ const IOUPreview = (props) => { )} diff --git a/src/components/ReportActionItem/MoneyRequestAction.js b/src/components/ReportActionItem/MoneyRequestAction.js index 85fa92267405..174e4c922327 100644 --- a/src/components/ReportActionItem/MoneyRequestAction.js +++ b/src/components/ReportActionItem/MoneyRequestAction.js @@ -20,6 +20,7 @@ import * as ReportUtils from '../../libs/ReportUtils'; import * as Report from '../../libs/actions/Report'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; import * as ReportActionsUtils from '../../libs/ReportActionsUtils'; +import refPropTypes from '../refPropTypes'; const propTypes = { /** All the data of the action */ @@ -35,7 +36,7 @@ const propTypes = { isMostRecentIOUReportAction: PropTypes.bool.isRequired, /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: PropTypes.shape({current: PropTypes.elementType}), + contextMenuAnchor: refPropTypes, /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: PropTypes.func, @@ -67,6 +68,10 @@ const propTypes = { email: PropTypes.string, }), + /** Styles to be assigned to Container */ + // eslint-disable-next-line react/forbid-prop-types + style: PropTypes.arrayOf(PropTypes.object), + ...withLocalizePropTypes, }; @@ -82,12 +87,15 @@ const defaultProps = { session: { email: null, }, + style: [], }; const MoneyRequestAction = (props) => { const hasMultipleParticipants = lodashGet(props.chatReport, 'participants', []).length > 1; + const isSplitBillAction = lodashGet(props.action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; + const onIOUPreviewPressed = () => { - if (lodashGet(props.action, 'originalMessage.type', '') === CONST.IOU.REPORT_ACTION_TYPE.SPLIT && hasMultipleParticipants) { + if (isSplitBillAction && hasMultipleParticipants) { Navigation.navigate(ROUTES.getReportParticipantsRoute(props.chatReportID)); return; } @@ -139,13 +147,13 @@ const MoneyRequestAction = (props) => { diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 814a38dba5b5..e7ba7fde70d5 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -24,6 +24,7 @@ import SettlementButton from '../SettlementButton'; import themeColors from '../../styles/themes/default'; import getButtonState from '../../libs/getButtonState'; import * as IOU from '../../libs/actions/IOU'; +import refPropTypes from '../refPropTypes'; const propTypes = { /** All the data of the action */ @@ -71,7 +72,7 @@ const propTypes = { }), /** Popover context menu anchor, used for showing context menu */ - contextMenuAnchor: PropTypes.shape({current: PropTypes.elementType}), + contextMenuAnchor: refPropTypes, /** Callback for updating context menu active state, used for showing context menu */ checkIfContextMenuActive: PropTypes.func, @@ -98,9 +99,10 @@ const ReportPreview = (props) => { const managerEmail = props.iouReport.managerEmail || ''; const managerName = ReportUtils.isPolicyExpenseChat(props.chatReport) ? ReportUtils.getPolicyName(props.chatReport) : ReportUtils.getDisplayNameForParticipant(managerEmail, true); const isCurrentUserManager = managerEmail === lodashGet(props.session, 'email', null); + const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport); return ( - - {_.map(props.action.message, (index) => ( + + {_.map(props.action.message, (message, index) => ( { @@ -114,16 +116,22 @@ 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 && ( - + + + )} )} @@ -142,7 +150,7 @@ const ReportPreview = (props) => { iouReport={props.iouReport} onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)} enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} - addBankAccountRoute={ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT} + addBankAccountRoute={bankAccountRoute} style={[styles.requestPreviewBox]} /> )} diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index 1c9c5ed3bcd7..6d27723ae4e6 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -49,7 +49,7 @@ const propTypes = { nvp_lastPaymentMethod: PropTypes.objectOf(PropTypes.string), /** The policyID of the report we are paying */ - policyID: PropTypes.string.isRequired, + policyID: PropTypes.string, /** Additional styles to add to the component */ style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), @@ -66,6 +66,7 @@ const defaultProps = { nvp_lastPaymentMethod: {}, style: [], iouReport: {}, + policyID: '', }; class SettlementButton extends React.Component { 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/SubscriptAvatar.js b/src/components/SubscriptAvatar.js index 65c016e29ff3..57965cf21883 100644 --- a/src/components/SubscriptAvatar.js +++ b/src/components/SubscriptAvatar.js @@ -47,7 +47,7 @@ const SubscriptAvatar = (props) => { const containerStyle = props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarSmall : styles.emptyAvatar; // Default the margin style to what is normal for small or normal sized avatars - let marginStyle = props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarMargin : styles.emptyAvatarMarginSmall; + let marginStyle = props.size === CONST.AVATAR_SIZE.SMALL ? styles.emptyAvatarMarginSmall : styles.emptyAvatarMargin; // Some views like the chat view require that there be no margins if (props.noMargin) { 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/TextLink.js b/src/components/TextLink.js index 4d4a9cdce307..f075002058ab 100644 --- a/src/components/TextLink.js +++ b/src/components/TextLink.js @@ -27,7 +27,7 @@ const defaultProps = { href: undefined, style: [], onPress: undefined, - onMouseDown: undefined, + onMouseDown: (event) => event.preventDefault(), }; const TextLink = (props) => { 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..69a056fdefb3 100644 --- a/src/components/Tooltip/index.js +++ b/src/components/Tooltip/index.js @@ -1,22 +1,29 @@ 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 component used to wrap an element intended for displaying a tooltip. The term "tooltip's target" refers to the + * wrapped element, which, upon hover, triggers the tooltip to be shown. + */ 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 +37,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 +66,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 +91,7 @@ class Tooltip extends PureComponent { */ hideTooltip() { this.animation.stopAnimation(); - this.shouldStartShowAnimation = false; + if (TooltipSense.isActive() && !this.isTooltipSenseInitiator) { this.animation.setValue(0); } else { @@ -142,7 +103,10 @@ class Tooltip extends PureComponent { useNativeDriver: false, }).start(); } + TooltipSense.deactivate(); + + this.setState({isVisible: false}); } render() { @@ -151,7 +115,7 @@ class Tooltip extends PureComponent { if ((_.isEmpty(this.props.text) && this.props.renderTooltipContent == null) || !this.hasHoverSupport) { return this.props.children; } - let child = ( + let target = ( (this.wrapperView = el)} onBlur={this.hideTooltip} @@ -163,7 +127,7 @@ class Tooltip extends PureComponent { ); if (this.props.absolute && React.isValidElement(this.props.children)) { - child = React.cloneElement(React.Children.only(this.props.children), { + target = React.cloneElement(React.Children.only(this.props.children), { ref: (el) => { this.wrapperView = el; @@ -192,8 +156,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 +169,24 @@ class Tooltip extends PureComponent { key={[this.props.text, ...this.props.renderTooltipContentKey]} /> )} - - {child} - + + {target} + + ); } } -Tooltip.propTypes = propTypes; -Tooltip.defaultProps = defaultProps; +Tooltip.propTypes = tooltipPropTypes.propTypes; +Tooltip.defaultProps = tooltipPropTypes.defaultProps; export default withWindowDimensions(Tooltip); diff --git a/src/components/Tooltip/tooltipPropTypes.js b/src/components/Tooltip/tooltipPropTypes.js index c52815901dfc..42b1acd5c115 100644 --- a/src/components/Tooltip/tooltipPropTypes.js +++ b/src/components/Tooltip/tooltipPropTypes.js @@ -7,7 +7,7 @@ const propTypes = { /** Enable support for the absolute positioned native(View|Text) children. It will only work for single native child */ absolute: PropTypes.bool, - /** The text to display in the tooltip. */ + /** The text to display in the tooltip. If text is ommitted, only children will be rendered. */ text: PropTypes.string, /** Maximum number of lines to show in tooltip */ diff --git a/src/components/ValidateCode/ValidateCodeModal.js b/src/components/ValidateCode/ValidateCodeModal.js index 9bd9dc49d51b..c356458dc877 100644 --- a/src/components/ValidateCode/ValidateCodeModal.js +++ b/src/components/ValidateCode/ValidateCodeModal.js @@ -39,7 +39,7 @@ const defaultProps = { }; function ValidateCodeModal(props) { - const signInHere = useCallback(() => Session.signInWithValidateCode(props.accountID, props.code), [props.accountID, props.code]); + const signInHere = useCallback(() => Session.signInWithValidateCode(props.accountID, props.code, null, props.preferredLocale), [props.accountID, props.code, props.preferredLocale]); return ( diff --git a/src/components/refPropTypes.js b/src/components/refPropTypes.js new file mode 100644 index 000000000000..a67f6323d1d9 --- /dev/null +++ b/src/components/refPropTypes.js @@ -0,0 +1,3 @@ +import PropTypes from 'prop-types'; + +export default PropTypes.oneOfType([PropTypes.func, PropTypes.object]); diff --git a/src/components/withNavigationFallback.js b/src/components/withNavigationFallback.js index 4bd5cfdeb5d8..3980ebe01e64 100644 --- a/src/components/withNavigationFallback.js +++ b/src/components/withNavigationFallback.js @@ -36,7 +36,8 @@ export default function (WrappedComponent) { WithNavigationFallback.contextType = NavigationContext; WithNavigationFallback.displayName = `WithNavigationFocusWithFallback(${getComponentDisplayName(WrappedComponent)})`; WithNavigationFallback.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.oneOfType([PropTypes.instanceOf(React.Component), PropTypes.func])})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), }; WithNavigationFallback.defaultProps = { forwardedRef: undefined, diff --git a/src/components/withNavigationFocus.js b/src/components/withNavigationFocus.js index 0ece1b658c65..0de770c09ba6 100644 --- a/src/components/withNavigationFocus.js +++ b/src/components/withNavigationFocus.js @@ -22,7 +22,8 @@ export default function withNavigationFocus(WrappedComponent) { WithNavigationFocus.displayName = `withNavigationFocus(${getComponentDisplayName(WrappedComponent)})`; WithNavigationFocus.propTypes = { - forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.oneOfType([PropTypes.instanceOf(React.Component), PropTypes.func])})]), + // eslint-disable-next-line react/forbid-prop-types + forwardedRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({current: PropTypes.object})]), }; WithNavigationFocus.defaultProps = { forwardedRef: undefined, diff --git a/src/components/hooks/useOnNetworkReconnect.js b/src/hooks/useOnNetworkReconnect.js similarity index 93% rename from src/components/hooks/useOnNetworkReconnect.js rename to src/hooks/useOnNetworkReconnect.js index c186789c0727..cbd30897a2ba 100644 --- a/src/components/hooks/useOnNetworkReconnect.js +++ b/src/hooks/useOnNetworkReconnect.js @@ -1,5 +1,5 @@ import {useRef, useContext, useEffect} from 'react'; -import {NetworkContext} from '../OnyxProvider'; +import {NetworkContext} from '../components/OnyxProvider'; /** * @param {Function} onNetworkReconnect diff --git a/src/hooks/useWindowDimensions.js b/src/hooks/useWindowDimensions.js index ea46be38246a..e3ae09d80fcd 100644 --- a/src/hooks/useWindowDimensions.js +++ b/src/hooks/useWindowDimensions.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line no-restricted-imports import {useWindowDimensions} from 'react-native'; import variables from '../styles/variables'; diff --git a/src/languages/en.js b/src/languages/en.js index 77d5d229a326..9e5b2ab146ff 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -285,6 +285,9 @@ export default { sayHello: 'Say hello!', usePlusButton: '\n\nYou can also use the + button below to send or request money!', }, + mentionSuggestions: { + hereAlternateText: 'Notify everyone online in this room', + }, newMessages: 'New messages', reportTypingIndicator: { isTyping: 'is typing...', @@ -300,6 +303,13 @@ export default { `This workspace chat is no longer active because ${displayName} is no longer a member of the ${policyName} workspace.`, [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}) => `This workspace chat is no longer active because ${policyName} is no longer an active workspace.`, }, + writeCapabilityPage: { + label: 'Who can post', + writeCapability: { + all: 'All members', + admins: 'Admins only', + }, + }, sidebarScreen: { fabAction: 'New chat', newChat: 'New chat', @@ -597,6 +607,8 @@ export default { transferDetailBankAccount: 'Your money should arrive in the next 1-3 business days.', transferDetailDebitCard: 'Your money should arrive immediately.', failedTransfer: 'Your balance isn’t fully settled. Please transfer to a bank account.', + notHereSubTitle: 'Please transfer your balance from the payments page', + goToPayment: 'Go to Payments', }, chooseTransferAccountPage: { chooseAccount: 'Choose account', @@ -715,7 +727,7 @@ export default { error: { dateShouldBeBefore: ({dateString}) => `Date should be before ${dateString}.`, dateShouldBeAfter: ({dateString}) => `Date should be after ${dateString}.`, - hasInvalidCharacter: 'Name can only include letters and numbers.', + hasInvalidCharacter: 'Name can only include latin letters and numbers.', incorrectZipFormat: ({zipFormat}) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, }, }, @@ -746,7 +758,7 @@ export default { getMeOutOfHere: 'Get me out of here', iouReportNotFound: 'The payment details you are looking for cannot be found.', notHere: "Hmm... it's not here", - pageNotFound: 'That page is nowhere to be found.', + pageNotFound: 'Oops, this page cannot be found', noAccess: "You don't have access to this chat", goBackHome: 'Go back to Home page', }, @@ -1024,17 +1036,18 @@ export default { }, workspace: { common: { - card: 'Issue cards', + card: 'Cards', workspace: 'Workspace', edit: 'Edit workspace', delete: 'Delete workspace', - settings: 'General settings', - reimburse: 'Reimburse expenses', - bills: 'Pay bills', - invoices: 'Send invoices', - travel: 'Book travel', - members: 'Manage members', - bankAccount: 'Connect bank account', + settings: 'Settings', + reimburse: 'Reimbursements', + bills: 'Bills', + invoices: 'Invoices', + travel: 'Travel', + members: 'Members', + bankAccount: 'Bank account', + connectBankAccount: 'Connect bank account', testTransactions: 'Test transactions', issueAndManageCards: 'Issue and manage cards', reconcileCards: 'Reconcile cards', @@ -1141,6 +1154,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 31852fc89b0d..fdd4706215c5 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -284,6 +284,9 @@ export default { sayHello: '¡Saluda!', usePlusButton: '\n\n¡También puedes usar el botón + de abajo para enviar o pedir dinero!', }, + mentionSuggestions: { + hereAlternateText: 'Notificar a todos los que estén en linea de esta sala', + }, newMessages: 'Mensajes nuevos', reportTypingIndicator: { isTyping: 'está escribiendo...', @@ -299,6 +302,13 @@ export default { `Este chat de espacio de trabajo esta desactivado porque ${displayName} ha dejado de ser miembro del espacio de trabajo ${policyName}.`, [CONST.REPORT.ARCHIVE_REASON.POLICY_DELETED]: ({policyName}) => `Este chat de espacio de trabajo esta desactivado porque el espacio de trabajo ${policyName} se ha eliminado.`, }, + writeCapabilityPage: { + label: 'Quién puede postear', + writeCapability: { + all: 'Todos los miembros', + admins: 'Solo administradores', + }, + }, sidebarScreen: { fabAction: 'Nuevo chat', newChat: 'Nuevo chat', @@ -597,6 +607,8 @@ export default { transferDetailBankAccount: 'Tu dinero debería llegar en 1-3 días laborables.', transferDetailDebitCard: 'Tu dinero debería llegar de inmediato.', failedTransfer: 'Tu saldo no se ha acreditado completamente. Por favor, transfiere los fondos a una cuenta bancaria.', + notHereSubTitle: 'Por favor, transfiere el saldo desde la página de pagos', + goToPayment: 'Ir a pagos', }, chooseTransferAccountPage: { chooseAccount: 'Elegir cuenta', @@ -717,7 +729,7 @@ export default { dateShouldBeBefore: ({dateString}) => `La fecha debe ser anterior a ${dateString}.`, dateShouldBeAfter: ({dateString}) => `La fecha debe ser posterior a ${dateString}.`, incorrectZipFormat: ({zipFormat}) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, - hasInvalidCharacter: 'El nombre solo puede contener letras y números.', + hasInvalidCharacter: 'El nombre solo puede contener números y caracteres latinos.', }, }, resendValidationForm: { @@ -747,7 +759,7 @@ export default { getMeOutOfHere: 'Sácame de aquí', iouReportNotFound: 'Los detalles del pago que estás buscando no se pudieron encontrar.', notHere: 'Hmm… no está aquí', - pageNotFound: 'La página que buscas no existe.', + pageNotFound: 'Ups, no deberías estar aquí', noAccess: 'No tienes acceso a este chat', goBackHome: 'Volver a la página principal', }, @@ -1029,17 +1041,18 @@ export default { }, workspace: { common: { - card: 'Emitir tarjetas', + card: 'Tarjetas', workspace: 'Espacio de trabajo', edit: 'Editar espacio de trabajo', delete: 'Eliminar espacio de trabajo', - settings: 'Configuración general', - reimburse: 'Reembolsar gastos', + settings: 'Configuración', + reimburse: 'Reembolsos', bills: 'Pagar facturas', invoices: 'Enviar facturas', - travel: 'Reservar viaje', - members: 'Gestionar miembros', - bankAccount: 'Conectar cuenta bancaria', + travel: 'Viajes', + members: 'Miembros', + bankAccount: 'Cuenta bancaria', + connectBankAccount: 'Conectar cuenta bancaria', testTransactions: 'Transacciones de prueba', issueAndManageCards: 'Emitir y gestionar tarjetas', reconcileCards: 'Reconciliar tarjetas', @@ -1147,6 +1160,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/EmojiUtils.js b/src/libs/EmojiUtils.js index a3133ce21006..a4325af27c9c 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -301,6 +301,47 @@ const getPreferredSkinToneIndex = (val) => { return CONST.EMOJI_DEFAULT_SKIN_TONE; }; +/** + * Given an emoji object it returns the correct emoji code + * based on the users preferred skin tone. + * @param {Object} emoji + * @param {String | Number} preferredSkinTone + * @returns {String} + */ +const getPreferredEmojiCode = (emoji, preferredSkinTone) => { + if (emoji.types) { + const emojiCodeWithSkinTone = emoji.types[preferredSkinTone]; + + // Note: it can happen that preferredSkinTone has a outdated format, + // so it makes sense to check if we actually got a valid emoji code back + if (emojiCodeWithSkinTone) { + return emojiCodeWithSkinTone; + } + } + + return emoji.code; +}; + +/** + * Given an emoji object and a list of senders it will return an + * array of emoji codes, that represents all used variations of the + * emoji. + * @param {{ name: string, code: string, types: string[] }} emoji + * @param {Array} users + * @return {string[]} + * */ +const getUniqueEmojiCodes = (emoji, users) => { + const emojiCodes = []; + _.forEach(users, (user) => { + const emojiCode = getPreferredEmojiCode(emoji, user.skinTone); + + if (emojiCode && !emojiCodes.includes(emojiCode)) { + emojiCodes.push(emojiCode); + } + }); + return emojiCodes; +}; + export { getHeaderEmojis, mergeEmojisWithFrequentlyUsedEmojis, @@ -311,4 +352,6 @@ export { trimEmojiUnicode, getEmojiCodeWithSkinColor, getPreferredSkinToneIndex, + getPreferredEmojiCode, + getUniqueEmojiCodes, }; diff --git a/src/libs/ErrorUtils.js b/src/libs/ErrorUtils.js index 73fbd30cb891..03a26d8fa14c 100644 --- a/src/libs/ErrorUtils.js +++ b/src/libs/ErrorUtils.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import CONST from '../CONST'; +import DateUtils from './DateUtils'; /** * @param {Object} response @@ -37,6 +38,16 @@ function getAuthenticateErrorMessage(response) { } } +/** + * Method used to get an error object with microsecond as the key. + * @param {String} error - error key or message to be saved + * @return {Object} + * + */ +function getMicroSecondOnyxError(error) { + return {[DateUtils.getMicroseconds()]: error}; +} + /** * @param {Object} onyxData * @param {Object} onyxData.errors @@ -95,4 +106,4 @@ function addErrorMessage(errors, inputID, message) { } } -export {getAuthenticateErrorMessage, getLatestErrorMessage, getLatestErrorField, addErrorMessage}; +export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, addErrorMessage}; diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js index 94add8525099..37d85c7bfbfc 100644 --- a/src/libs/KeyboardShortcut/index.js +++ b/src/libs/KeyboardShortcut/index.js @@ -18,7 +18,7 @@ const documentedShortcuts = {}; * @returns {Array} */ function getDocumentedShortcuts() { - return _.values(documentedShortcuts); + return _.sortBy(_.values(documentedShortcuts), 'displayName'); } /** @@ -43,6 +43,12 @@ function getDisplayName(key, modifiers) { if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow').toString().toLowerCase())) { return ['ARROWDOWN']; } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputLeftArrow', 'keyInputLeftArrow').toString().toLowerCase())) { + return ['ARROWLEFT']; + } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputRightArrow', 'keyInputRightArrow').toString().toLowerCase())) { + return ['ARROWRIGHT']; + } return [key.toUpperCase()]; })(); diff --git a/src/libs/Localize/index.js b/src/libs/Localize/index.js index db1e00f7615b..d9e1c2c21f26 100644 --- a/src/libs/Localize/index.js +++ b/src/libs/Localize/index.js @@ -91,6 +91,20 @@ function translateLocal(phrase, variables) { return translate(BaseLocaleListener.getPreferredLocale(), phrase, variables); } +/** + * Return translated string for given error. + * + * @param {String} phrase + * @returns {String} + */ +function translateIfPhraseKey(phrase) { + try { + return translateLocal(phrase); + } catch (error) { + return phrase; + } +} + /** * Format an array into a string with comma and "and" ("a dog, a cat and a chicken") * @@ -114,4 +128,4 @@ function getDevicePreferredLocale() { return lodashGet(RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES]), 'languageTag', CONST.LOCALES.DEFAULT); } -export {translate, translateLocal, arrayToString, getDevicePreferredLocale}; +export {translate, translateLocal, translateIfPhraseKey, arrayToString, getDevicePreferredLocale}; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 04bd397d5f64..7f48a2ffb625 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -334,12 +334,6 @@ class AuthScreens extends React.Component { component={ModalStackNavigators.EnablePaymentsStackNavigator} listeners={modalScreenListeners} /> - { - const IOUDetailsModal = require('../../../pages/iou/IOUDetailsModal').default; - return IOUDetailsModal; - }, - name: 'IOU_Details_Root', - }, - { - getComponent: () => { - const AddPersonalBankAccountPage = require('../../../pages/AddPersonalBankAccountPage').default; - return AddPersonalBankAccountPage; - }, - name: 'IOU_Details_Add_Bank_Account', - }, - { - getComponent: () => { - const AddDebitCardPage = require('../../../pages/settings/Payments/AddDebitCardPage').default; - return AddDebitCardPage; - }, - name: 'IOU_Details_Add_Debit_Card', - }, - { - getComponent: () => { - const EnablePaymentsPage = require('../../../pages/EnablePayments/EnablePaymentsPage').default; - return EnablePaymentsPage; - }, - name: 'IOU_Details_Enable_Payments', - }, -]); - const DetailsModalStackNavigator = createModalStackNavigator([ { getComponent: () => { @@ -191,6 +160,13 @@ const ReportSettingsModalStackNavigator = createModalStackNavigator([ }, name: 'Report_Settings_Notification_Preferences', }, + { + getComponent: () => { + const WriteCapabilityPage = require('../../../pages/settings/Report/WriteCapabilityPage').default; + return WriteCapabilityPage; + }, + name: 'Report_Settings_Write_Capability', + }, ]); const TaskModalStackNavigator = createModalStackNavigator([ @@ -703,7 +679,6 @@ export { IOUBillStackNavigator, IOURequestModalStackNavigator, IOUSendModalStackNavigator, - IOUDetailsModalStackNavigator, DetailsModalStackNavigator, ReportDetailsModalStackNavigator, TaskModalStackNavigator, 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 1535880e8997..b0471671b96c 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -224,6 +224,9 @@ export default { Report_Settings_Notification_Preferences: { path: ROUTES.REPORT_SETTINGS_NOTIFICATION_PREFERENCES, }, + Report_Settings_Write_Capability: { + path: ROUTES.REPORT_SETTINGS_WRITE_CAPABILITY, + }, }, }, NewGroup: { @@ -284,14 +287,6 @@ export default { IOU_Send_Add_Debit_Card: ROUTES.IOU_SEND_ADD_DEBIT_CARD, }, }, - IOU_Details: { - screens: { - IOU_Details_Root: ROUTES.IOU_DETAILS_WITH_IOU_REPORT_ID, - IOU_Details_Enable_Payments: ROUTES.IOU_DETAILS_ENABLE_PAYMENTS, - IOU_Details_Add_Bank_Account: ROUTES.IOU_DETAILS_ADD_BANK_ACCOUNT, - IOU_Details_Add_Debit_Card: ROUTES.IOU_DETAILS_ADD_DEBIT_CARD, - }, - }, Task_Details: { screens: { Task_Title: ROUTES.TASK_TITLE, diff --git a/src/libs/NetworkConnection.js b/src/libs/NetworkConnection.js index f3b16927becc..9ca09c9154bc 100644 --- a/src/libs/NetworkConnection.js +++ b/src/libs/NetworkConnection.js @@ -81,8 +81,17 @@ function subscribeToNetInfo() { // By default, NetInfo uses `/` for `reachabilityUrl` // When App is served locally (or from Electron) this address is always reachable - even offline // Using the API url ensures reachability is tested over internet - reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api`, - reachabilityTest: (response) => Promise.resolve(response.status === 200), + reachabilityUrl: `${CONFIG.EXPENSIFY.DEFAULT_API_ROOT}api?command=Ping`, + reachabilityMethod: 'GET', + reachabilityTest: (response) => { + if (!response.ok) { + return Promise.resolve(false); + } + return response + .json() + .then((json) => Promise.resolve(json.jsonCode === 200)) + .catch(() => Promise.resolve(false)); + }, // If a check is taking longer than this time we're considered offline reachabilityRequestTimeout: CONST.NETWORK.MAX_PENDING_TIME_MS, 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/Pusher/pusher.js b/src/libs/Pusher/pusher.js index c4d973c3786a..60587a68e173 100644 --- a/src/libs/Pusher/pusher.js +++ b/src/libs/Pusher/pusher.js @@ -165,7 +165,6 @@ function bindEventToChannel(channel, eventName, eventCallback = () => {}) { // Only call the event callback if we've received the last packet and we don't have any holes in the complete // packet. if (chunkedEvent.receivedFinal && chunkedEvent.chunks.length === _.keys(chunkedEvent.chunks).length) { - eventCallback(JSON.parse(chunkedEvent.chunks.join(''))); try { eventCallback(JSON.parse(chunkedEvent.chunks.join(''))); } catch (err) { diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index 6cb4e475f2db..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'; @@ -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 d17d83c185bf..e8fd18b7bcf4 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -331,6 +331,16 @@ function getPolicyType(report, policies) { return lodashGet(policies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'type'], ''); } +/** + * If the report is a policy expense, the route should be for adding bank account for that policy + * else since the report is a personal IOU, the route should be for personal bank account. + * @param {Object} report + * @returns {String} + */ +function getBankAccountRoute(report) { + return isPolicyExpenseChat(report) ? ROUTES.getBankAccountRoute('', report.policyID) : ROUTES.SETTINGS_ADD_BANK_ACCOUNT; +} + /** * Returns true if there are any guides accounts (team.expensify.com) in emails * @param {Array} emails @@ -432,6 +442,26 @@ function getPolicyName(report) { return policy.name || report.oldPolicyName || Localize.translateLocal('workspace.common.unavailable'); } +/** + * Checks if the current user is allowed to comment on the given report. + * @param {Object} report + * @param {String} [report.writeCapability] + * @returns {Boolean} + */ +function isAllowedToComment(report) { + // Default to allowing all users to post + const capability = lodashGet(report, 'writeCapability', CONST.REPORT.WRITE_CAPABILITIES.ALL) || CONST.REPORT.WRITE_CAPABILITIES.ALL; + + if (capability === CONST.REPORT.WRITE_CAPABILITIES.ALL) { + return true; + } + + // If we've made it here, commenting on this report is restricted. + // If the user is an admin, allow them to post. + const policy = allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; + return lodashGet(policy, 'role', '') === CONST.POLICY.ROLE.ADMIN; +} + /** * Checks if the current user is the admin of the policy given the policy expense chat. * @param {Object} report @@ -1383,9 +1413,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); @@ -1401,7 +1432,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]; }); @@ -2302,5 +2333,7 @@ export { shouldReportShowSubscript, isReportDataReady, isSettled, + isAllowedToComment, getMoneyRequestAction, + getBankAccountRoute, }; 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 4e3dd5ce0a65..af21019c1e2d 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,7 +248,7 @@ 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); @@ -329,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/BankAccounts.js b/src/libs/actions/BankAccounts.js index 2a716801b4e8..8ee14e693ba7 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -2,8 +2,7 @@ import Onyx from 'react-native-onyx'; import CONST from '../../CONST'; import * as API from '../API'; import ONYXKEYS from '../../ONYXKEYS'; -import * as Localize from '../Localize'; -import DateUtils from '../DateUtils'; +import * as ErrorUtils from '../ErrorUtils'; import * as PlaidDataProps from '../../pages/ReimbursementAccount/plaidDataPropTypes'; import Navigation from '../Navigation/Navigation'; import ROUTES from '../../ROUTES'; @@ -80,9 +79,7 @@ function getVBBADataForOnyx() { key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, value: { isLoading: false, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('paymentsPage.addBankAccountFailure'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('paymentsPage.addBankAccountFailure'), }, }, ], @@ -159,9 +156,7 @@ function addPersonalBankAccount(account) { key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, value: { isLoading: false, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('paymentsPage.addBankAccountFailure'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('paymentsPage.addBankAccountFailure'), }, }, ], diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 33af1acd67c5..d606f668cb06 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -15,6 +15,7 @@ import * as IOUUtils from '../IOUUtils'; import * as OptionsListUtils from '../OptionsListUtils'; import DateUtils from '../DateUtils'; import TransactionUtils from '../TransactionUtils'; +import * as ErrorUtils from '../ErrorUtils'; const chatReports = {}; const iouReports = {}; @@ -186,9 +187,7 @@ function buildOnyxDataForMoneyRequest( ...(isNewChatReport ? { errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, } : {}), @@ -201,9 +200,7 @@ function buildOnyxDataForMoneyRequest( key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, value: { errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, }, }, @@ -213,9 +210,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, { @@ -225,9 +220,7 @@ function buildOnyxDataForMoneyRequest( ...(isNewChatReport ? { [chatCreatedAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, } : {}), @@ -243,16 +236,12 @@ function buildOnyxDataForMoneyRequest( ...(isNewIOUReport ? { [iouCreatedAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, } : { [iouAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }), }, @@ -488,9 +477,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${groupChatReport.reportID}`, value: { [groupIOUReportAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, }, @@ -498,9 +485,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${groupTransaction.transactionID}`, value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, ]; @@ -511,9 +496,7 @@ function createSplitsAndOnyxData(participants, currentUserLogin, amount, comment key: `${ONYXKEYS.COLLECTION.REPORT}${groupChatReport.reportID}`, value: { errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, }, }); @@ -770,9 +753,7 @@ function deleteMoneyRequest(chatReportID, iouReportID, moneyRequestAction, shoul key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReportID}`, value: { [optimisticIOUAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericDeleteFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericDeleteFailureMessage'), }, }, }, @@ -882,6 +863,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 @@ -937,9 +920,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticIOUReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), }, }, }, @@ -947,9 +928,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), }, }, ]; @@ -974,9 +953,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType key: optimisticChatReportData.key, value: { errorFields: { - createChat: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericCreateReportFailureMessage'), - }, + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, }, }); @@ -1014,7 +991,6 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType * @returns {Object} */ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMethodType) { - const reportPreviewAction = ReportActionsUtils.getReportPreviewAction(chatReport.reportID, iouReport.reportID); const optimisticTransaction = TransactionUtils.buildOptimisticTransaction(iouReport.total, iouReport.currency, iouReport.reportID); const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.PAY, @@ -1042,15 +1018,6 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho lastMessageHtml: optimisticIOUReportAction.message[0].html, }, }, - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, - value: { - [reportPreviewAction.reportActionID]: { - created: DateUtils.getDBTime(), - }, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, @@ -1104,23 +1071,12 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho ]; const failureData = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport.reportID}`, - value: { - [reportPreviewAction.reportActionID]: { - created: reportPreviewAction.created, - }, - }, - }, { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, value: { [optimisticIOUReportAction.reportActionID]: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.other'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.other'), }, }, }, @@ -1128,9 +1084,7 @@ function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMetho onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${optimisticTransaction.transactionID}`, value: { - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericCreateFailureMessage'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), }, }, ]; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 89e542320d02..2b8e58995b23 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -7,11 +7,10 @@ import {escapeRegExp} from 'lodash'; import * as API from '../API'; import ONYXKEYS from '../../ONYXKEYS'; import CONST from '../../CONST'; -import * as Localize from '../Localize'; -import Navigation from '../Navigation/Navigation'; +import Navigation, {navigationRef} from '../Navigation/Navigation'; import ROUTES from '../../ROUTES'; import * as OptionsListUtils from '../OptionsListUtils'; -import DateUtils from '../DateUtils'; +import * as ErrorUtils from '../ErrorUtils'; import * as ReportUtils from '../ReportUtils'; import Log from '../Log'; import Permissions from '../Permissions'; @@ -214,7 +213,7 @@ function removeMembers(members, policyID) { { onyxMethod: Onyx.METHOD.MERGE, key: membersListKey, - value: _.object(members, Array(members.length).fill({errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.people.error.genericRemove')}})), + value: _.object(members, Array(members.length).fill({errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericRemove')})), }, ]; API.write( @@ -369,9 +368,7 @@ function addMembersToWorkspace(memberLogins, welcomeNote, policyID, betas) { value: _.object( logins, Array(logins.length).fill({ - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.people.error.genericAdd'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.people.error.genericAdd'), }), ), }, @@ -481,9 +478,7 @@ function deleteWorkspaceAvatar(policyID) { avatar: null, }, errorFields: { - avatar: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('avatarWithImagePicker.deleteWorkspaceError'), - }, + avatar: ErrorUtils.getMicroSecondOnyxError('avatarWithImagePicker.deleteWorkspaceError'), }, }, }, @@ -553,9 +548,7 @@ function updateGeneralSettings(policyID, name, currency) { generalSettings: null, }, errorFields: { - generalSettings: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.editor.genericFailureMessage'), - }, + generalSettings: ErrorUtils.getMicroSecondOnyxError('workspace.editor.genericFailureMessage'), }, }, }, @@ -741,9 +734,7 @@ function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, new rates: { [currentCustomUnitRate.customUnitRateID]: { ...currentCustomUnitRate, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.reimburse.updateCustomUnitError'), - }, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.reimburse.updateCustomUnitError'), }, }, }, @@ -1082,6 +1073,11 @@ function createWorkspace(ownerEmail = '', makeMeAdmin = false, policyName = '', if (transitionFromOldDot) { Navigation.dismissModal(); // Dismiss /transition route for OldDot to NewDot transitions } + + // Get the reportID associated with the newly created #admins room and route the user to that chat + const routeKey = lodashGet(navigationRef.getState(), 'routes[0].state.routes[0].key'); + Navigation.setParams({reportID: adminsChatReportID}, routeKey); + Navigation.navigate(ROUTES.getWorkspaceInitialRoute(policyID)); }); } diff --git a/src/libs/actions/ReimbursementAccount/errors.js b/src/libs/actions/ReimbursementAccount/errors.js index 7b6547ae27f7..54d881cc4516 100644 --- a/src/libs/actions/ReimbursementAccount/errors.js +++ b/src/libs/actions/ReimbursementAccount/errors.js @@ -1,6 +1,6 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../../../ONYXKEYS'; -import DateUtils from '../../DateUtils'; +import * as ErrorUtils from '../../ErrorUtils'; /** * Set the current fields with errors. @@ -40,10 +40,7 @@ function resetReimbursementAccount() { */ function showBankAccountFormValidationError(error) { Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, { - // eslint-disable-next-line rulesdir/prefer-localization - errors: { - [DateUtils.getMicroseconds()]: error, - }, + errors: ErrorUtils.getMicroSecondOnyxError(error), }); } diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 6a4cd9fe3050..a67ae9343b71 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -19,9 +19,10 @@ import * as ReportUtils from '../ReportUtils'; import DateUtils from '../DateUtils'; import * as ReportActionsUtils from '../ReportActionsUtils'; import * as OptionsListUtils from '../OptionsListUtils'; -import * as Localize from '../Localize'; import * as CollectionUtils from '../CollectionUtils'; import * as EmojiUtils from '../EmojiUtils'; +import * as ErrorUtils from '../ErrorUtils'; +import * as Welcome from './Welcome'; let currentUserEmail; let currentUserAccountID; @@ -262,7 +263,7 @@ function addActions(reportID, text = '', file) { key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, value: _.mapObject(optimisticReportActions, (action) => ({ ...action, - errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal('report.genericAddCommentFailureMessage')}, + errors: ErrorUtils.getMicroSecondOnyxError('report.genericAddCommentFailureMessage'), })), }, ]; @@ -897,32 +898,6 @@ function deleteReportComment(reportID, reportAction) { API.write('DeleteComment', parameters, {optimisticData, successData, failureData}); } -/** - * @param {String} comment - * @returns {Array} - */ -const extractLinksInMarkdownComment = (comment) => { - const regex = /\[[^[\]]*\]\(([^()]*)\)/gm; - 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) => Str.sanitizeURL(match[1])); - return links; -}; - -/** - * Compares two markdown comments and returns a list of the links removed in a new comment. - * - * @param {String} oldComment - * @param {String} newComment - * @returns {Array} - */ -const getRemovedMarkdownLinks = (oldComment, newComment) => { - const linksInOld = extractLinksInMarkdownComment(oldComment); - const linksInNew = extractLinksInMarkdownComment(newComment); - return _.difference(linksInOld, linksInNew); -}; - /** * Removes the links in html of a comment. * example: @@ -958,7 +933,7 @@ const handleUserDeletedLinksInHtml = (newCommentText, originalHtml) => { } const markdownOriginalComment = parser.htmlToMarkdown(originalHtml).trim(); const htmlForNewComment = parser.replace(newCommentText); - const removedLinks = getRemovedMarkdownLinks(markdownOriginalComment, newCommentText); + const removedLinks = parser.getRemovedMarkdownLinks(markdownOriginalComment, newCommentText); return removeLinksFromHtml(htmlForNewComment, removedLinks); }; @@ -1118,17 +1093,53 @@ function updateNotificationPreferenceAndNavigate(reportID, previousValue, newVal Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(reportID)); } +/** + * @param {Object} report + * @param {String} newValue + */ +function updateWriteCapabilityAndNavigate(report, newValue) { + if (report.writeCapability === newValue) { + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(report.reportID)); + return; + } + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: {writeCapability: newValue}, + }, + ]; + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, + value: {writeCapability: report.writeCapability}, + }, + ]; + API.write('UpdateReportWriteCapability', {reportID: report.reportID, writeCapability: newValue}, {optimisticData, failureData}); + // Return to the report settings page since this field utilizes push-to-page + Navigation.drawerGoBack(ROUTES.getReportSettingsRoute(report.reportID)); +} + /** * Navigates to the 1:1 report with Concierge */ function navigateToConciergeChat() { - // If we don't have a chat with Concierge then create it if (!conciergeChatReportID) { - navigateToAndOpenReport([CONST.EMAIL.CONCIERGE]); - return; + // In order not to delay the report life cycle, we first navigate to the unknown report + if (_.isEmpty(Navigation.getReportIDFromRoute())) { + Navigation.navigate(ROUTES.REPORT); + } + // In order to avoid creating concierge repeatedly, + // we need to ensure that the server data has been successfully pulled + Welcome.serverDataIsReadyPromise().then(() => { + // If we don't have a chat with Concierge then create it + navigateToAndOpenReport([CONST.EMAIL.CONCIERGE]); + }); + } else { + Navigation.navigate(ROUTES.getReportRoute(conciergeChatReportID)); } - - Navigation.navigate(ROUTES.getReportRoute(conciergeChatReportID)); } /** @@ -1639,6 +1650,7 @@ export { addComment, addAttachment, reconnect, + updateWriteCapabilityAndNavigate, updateNotificationPreferenceAndNavigate, subscribeToReportTypingEvents, unsubscribeFromReportChannel, diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index 348cafc13d8f..7f18d2861a35 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -15,11 +15,11 @@ import * as Authentication from '../../Authentication'; import * as Welcome from '../Welcome'; import * as API from '../../API'; import * as NetworkStore from '../../Network/NetworkStore'; -import DateUtils from '../../DateUtils'; import Navigation from '../../Navigation/Navigation'; import * as Device from '../Device'; import subscribeToReportCommentPushNotifications from '../../Notification/PushNotification/subscribeToReportCommentPushNotifications'; import ROUTES from '../../../ROUTES'; +import * as ErrorUtils from '../../ErrorUtils'; let credentials = {}; Onyx.connect({ @@ -98,6 +98,7 @@ function resendValidationLink(login = credentials.login) { isLoading: true, errors: null, message: null, + loadingForm: CONST.FORMS.RESEND_VALIDATION_FORM, }, }, ]; @@ -108,6 +109,7 @@ function resendValidationLink(login = credentials.login) { value: { isLoading: false, message: 'resendValidationForm.linkHasBeenResent', + loadingForm: null, }, }, ]; @@ -118,6 +120,7 @@ function resendValidationLink(login = credentials.login) { value: { isLoading: false, message: null, + loadingForm: null, }, }, ]; @@ -139,6 +142,7 @@ function resendValidateCode(login = credentials.login) { isLoading: true, errors: null, message: null, + loadingForm: CONST.FORMS.VALIDATE_CODE_FORM, }, }, ]; @@ -149,6 +153,7 @@ function resendValidateCode(login = credentials.login) { value: { isLoading: false, message: 'validateCodeForm.codeSent', + loadingForm: null, }, }, ]; @@ -159,6 +164,7 @@ function resendValidateCode(login = credentials.login) { value: { isLoading: false, message: null, + loadingForm: null, }, }, ]; @@ -218,6 +224,7 @@ function beginSignIn(login) { ...CONST.DEFAULT_ACCOUNT_DATA, isLoading: true, message: null, + loadingForm: CONST.FORMS.LOGIN_FORM, }, }, ]; @@ -228,6 +235,7 @@ function beginSignIn(login) { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, { @@ -245,9 +253,8 @@ function beginSignIn(login) { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('loginForm.cannotGetAccountDetails'), - }, + loadingForm: null, + errors: ErrorUtils.getMicroSecondOnyxError('loginForm.cannotGetAccountDetails'), }, }, ]; @@ -335,6 +342,7 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON value: { ...CONST.DEFAULT_ACCOUNT_DATA, isLoading: true, + loadingForm: twoFactorAuthCode ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM, }, }, ]; @@ -345,6 +353,7 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, { @@ -362,6 +371,7 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, ]; @@ -383,7 +393,7 @@ function signIn(password, validateCode, twoFactorAuthCode, preferredLocale = CON API.write('SigninUser', params, {optimisticData, successData, failureData}); } -function signInWithValidateCode(accountID, code, twoFactorAuthCode) { +function signInWithValidateCode(accountID, code, twoFactorAuthCode, preferredLocale = CONST.LOCALES.DEFAULT) { // If this is called from the 2fa step, get the validateCode directly from onyx // instead of the one passed from the component state because the state is changing when this method is called. const validateCode = twoFactorAuthCode ? credentials.validateCode : code; @@ -395,6 +405,7 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode) { value: { ...CONST.DEFAULT_ACCOUNT_DATA, isLoading: true, + loadingForm: twoFactorAuthCode ? CONST.FORMS.VALIDATE_TFA_CODE_FORM : CONST.FORMS.VALIDATE_CODE_FORM, }, }, { @@ -408,7 +419,10 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode) { { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, - value: {isLoading: false}, + value: { + isLoading: false, + loadingForm: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -429,7 +443,10 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode) { { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.ACCOUNT, - value: {isLoading: false}, + value: { + isLoading: false, + loadingForm: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -444,14 +461,15 @@ function signInWithValidateCode(accountID, code, twoFactorAuthCode) { accountID, validateCode, twoFactorAuthCode, + preferredLocale, deviceInfo: getDeviceInfoForLogin(), }, {optimisticData, successData, failureData}, ); } -function signInWithValidateCodeAndNavigate(accountID, validateCode, twoFactorAuthCode) { - signInWithValidateCode(accountID, validateCode, twoFactorAuthCode); +function signInWithValidateCodeAndNavigate(accountID, validateCode, twoFactorAuthCode, preferredLocale = CONST.LOCALES.DEFAULT) { + signInWithValidateCode(accountID, validateCode, twoFactorAuthCode, preferredLocale); Navigation.navigate(ROUTES.HOME); } @@ -467,7 +485,9 @@ function signInWithValidateCodeAndNavigate(accountID, validateCode, twoFactorAut */ function initAutoAuthState(cachedAutoAuthState) { Onyx.merge(ONYXKEYS.SESSION, { - autoAuthState: cachedAutoAuthState === CONST.AUTO_AUTH_STATE.SIGNING_IN ? CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN : CONST.AUTO_AUTH_STATE.NOT_STARTED, + autoAuthState: _.contains([CONST.AUTO_AUTH_STATE.SIGNING_IN, CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN], cachedAutoAuthState) + ? CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN + : CONST.AUTO_AUTH_STATE.NOT_STARTED, }); } @@ -512,6 +532,7 @@ function resendResetPassword() { forgotPassword: true, message: null, errors: null, + loadingForm: CONST.FORMS.RESEND_VALIDATION_FORM, }, }, ], @@ -522,6 +543,7 @@ function resendResetPassword() { value: { isLoading: false, message: 'resendValidationForm.linkHasBeenResent', + loadingForm: null, }, }, ], @@ -531,6 +553,7 @@ function resendResetPassword() { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, ], @@ -708,6 +731,7 @@ function requestUnlinkValidationLink() { isLoading: true, errors: null, message: null, + loadingForm: CONST.FORMS.UNLINK_LOGIN_FORM, }, }, ]; @@ -718,6 +742,7 @@ function requestUnlinkValidationLink() { value: { isLoading: false, message: Localize.translateLocal('unlinkLoginForm.linkSent'), + loadingForm: null, }, }, ]; @@ -727,6 +752,7 @@ function requestUnlinkValidationLink() { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, + loadingForm: null, }, }, ]; diff --git a/src/libs/actions/SignInRedirect.js b/src/libs/actions/SignInRedirect.js index 998d21a73894..4d398b898318 100644 --- a/src/libs/actions/SignInRedirect.js +++ b/src/libs/actions/SignInRedirect.js @@ -3,14 +3,13 @@ import lodashGet from 'lodash/get'; import _ from 'underscore'; import ONYXKEYS from '../../ONYXKEYS'; import * as MainQueue from '../Network/MainQueue'; -import DateUtils from '../DateUtils'; -import * as Localize from '../Localize'; import * as PersistedRequests from './PersistedRequests'; import NetworkConnection from '../NetworkConnection'; import HttpUtils from '../HttpUtils'; import navigationRef from '../Navigation/navigationRef'; import SCREENS from '../../SCREENS'; import Navigation from '../Navigation/Navigation'; +import * as ErrorUtils from '../ErrorUtils'; let currentIsOffline; let currentShouldForceOffline; @@ -49,7 +48,7 @@ function clearStorageAndRedirect(errorMessage) { } // `Onyx.clear` reinitializes the Onyx instance with initial values so use `Onyx.merge` instead of `Onyx.set` - Onyx.merge(ONYXKEYS.SESSION, {errors: {[DateUtils.getMicroseconds()]: Localize.translateLocal(errorMessage)}}); + Onyx.merge(ONYXKEYS.SESSION, {errors: ErrorUtils.getMicroSecondOnyxError(errorMessage)}); }); } diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index 0de8dd3bb30c..f491e31253e3 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -286,7 +286,7 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { const editTaskReportAction = ReportUtils.buildOptimisticEditedTaskReportAction(ownerEmail); // Sometimes title is undefined, so we need to check for that, and we provide it to multiple functions - const reportName = title || report.reportName; + const reportName = (title || report.reportName).trim(); // If we make a change to the assignee, we want to add a comment to the assignee's chat let optimisticAssigneeAddComment; @@ -307,7 +307,7 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { key: `${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, value: { reportName, - description: description || report.description, + description: (description || report.description).trim(), managerEmail: assignee || report.managerEmail, }, }, @@ -362,7 +362,7 @@ function editTaskAndNavigate(report, ownerEmail, title, description, assignee) { { taskReportID: report.reportID, title: reportName, - description: description || report.description, + description: (description || report.description).trim(), assignee: assignee || report.assignee, editedTaskReportActionID: editTaskReportAction.reportActionID, assigneeChatReportActionID: optimisticAssigneeAddComment ? optimisticAssigneeAddComment.reportAction.reportActionID : 0, @@ -390,7 +390,7 @@ function setTaskReport(report) { function setDetailsValue(title, description) { // This is only needed for creation of a new task and so it should only be stored locally - Onyx.merge(ONYXKEYS.TASK, {title, description}); + Onyx.merge(ONYXKEYS.TASK, {title: title.trim(), description: description.trim()}); } /** @@ -398,7 +398,7 @@ function setDetailsValue(title, description) { * @param {string} title */ function setTitleValue(title) { - Onyx.merge(ONYXKEYS.TASK, {title}); + Onyx.merge(ONYXKEYS.TASK, {title: title.trim()}); } /** @@ -406,7 +406,7 @@ function setTitleValue(title) { * @param {string} description */ function setDescriptionValue(description) { - Onyx.merge(ONYXKEYS.TASK, {description}); + Onyx.merge(ONYXKEYS.TASK, {description: description.trim()}); } /** diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js index 71a9ee50f1c0..809ca4e2c2f6 100644 --- a/src/libs/actions/User.js +++ b/src/libs/actions/User.js @@ -15,7 +15,7 @@ import * as SequentialQueue from '../Network/SequentialQueue'; import PusherUtils from '../PusherUtils'; import * as Report from './Report'; import * as ReportActionsUtils from '../ReportActionsUtils'; -import DateUtils from '../DateUtils'; +import * as ErrorUtils from '../ErrorUtils'; import * as Session from './Session'; import * as PersonalDetails from './PersonalDetails'; @@ -165,9 +165,7 @@ function requestContactMethodValidateCode(contactMethod) { [contactMethod]: { validateCodeSent: false, errorFields: { - validateCodeSent: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.requestContactMethodValidateCode'), - }, + validateCodeSent: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.requestContactMethodValidateCode'), }, pendingFields: { validateCodeSent: null, @@ -266,9 +264,7 @@ function deleteContactMethod(contactMethod, loginList) { [contactMethod]: { ...oldLoginData, errorFields: { - deletedLogin: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.deleteContactMethod'), - }, + deletedLogin: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.deleteContactMethod'), }, pendingFields: { deletedLogin: null, @@ -352,9 +348,7 @@ function addNewContactMethodAndNavigate(contactMethod, password) { value: { [contactMethod]: { errorFields: { - addedLogin: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.addContactMethod'), - }, + addedLogin: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.addContactMethod'), }, pendingFields: { addedLogin: null, @@ -419,6 +413,14 @@ function validateSecondaryLogin(contactMethod, validateCode) { }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: { + ...CONST.DEFAULT_ACCOUNT_DATA, + isLoading: true, + }, + }, ]; const successData = [ { @@ -432,6 +434,11 @@ function validateSecondaryLogin(contactMethod, validateCode) { }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {isLoading: false}, + }, ]; const failureData = [ { @@ -440,9 +447,7 @@ function validateSecondaryLogin(contactMethod, validateCode) { value: { [contactMethod]: { errorFields: { - validateLogin: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.validateSecondaryLogin'), - }, + validateLogin: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.validateSecondaryLogin'), }, pendingFields: { validateLogin: null, @@ -450,6 +455,11 @@ function validateSecondaryLogin(contactMethod, validateCode) { }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.ACCOUNT, + value: {isLoading: false}, + }, ]; API.write( @@ -578,6 +588,10 @@ function subscribeToUserEventsUsingMultipleEventType() { // Handles Onyx updates coming from Pusher through the mega multipleEvents. PusherUtils.subscribeToMultiEvent(Pusher.TYPE.MULTIPLE_EVENT_TYPE.ONYX_API_UPDATE, (pushJSON) => { SequentialQueue.getCurrentRequest().then(() => { + // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher + if (!currentUserAccountID) { + return; + } Onyx.update(pushJSON); triggerNotifications(pushJSON); }); @@ -594,6 +608,10 @@ function subscribeToUserDeprecatedEvents() { // Receive any relevant Onyx updates from the server PusherUtils.subscribeToPrivateUserChannelEvent(Pusher.TYPE.ONYX_API_UPDATE, currentUserAccountID, (pushJSON) => { SequentialQueue.getCurrentRequest().then(() => { + // If we don't have the currentUserAccountID (user is logged out) we don't want to update Onyx with data from Pusher + if (!currentUserAccountID) { + return; + } Onyx.update(pushJSON); triggerNotifications(pushJSON); }); @@ -817,9 +835,7 @@ function setContactMethodAsDefault(newDefaultContactMethod) { defaultLogin: null, }, errorFields: { - defaultLogin: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('contacts.genericFailureMessages.setDefaultContactMethod'), - }, + defaultLogin: ErrorUtils.getMicroSecondOnyxError('contacts.genericFailureMessages.setDefaultContactMethod'), }, }, }, diff --git a/src/libs/actions/Welcome.js b/src/libs/actions/Welcome.js index c3e8599a0169..ddd0d872ce97 100644 --- a/src/libs/actions/Welcome.js +++ b/src/libs/actions/Welcome.js @@ -151,4 +151,8 @@ function resetReadyCheck() { }); } -export {show, resetReadyCheck}; +function serverDataIsReadyPromise() { + return isReadyPromise; +} + +export {show, serverDataIsReadyPromise, resetReadyCheck}; diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index baee3381673b..93bf382d7182 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -48,9 +48,13 @@ const propTypes = { /** Route params */ route: matchType.isRequired, - /** Session of currently logged in user */ - session: PropTypes.shape({ - email: PropTypes.string.isRequired, + /** Login list for the user that is signed in */ + loginList: PropTypes.shape({ + /** Date login was validated, used to show info indicator status */ + validatedDate: PropTypes.string, + + /** Field-specific server side errors keyed by microtime */ + errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), }), ...withLocalizePropTypes, @@ -59,9 +63,7 @@ const propTypes = { const defaultProps = { // When opening someone else's profile (via deep link) before login, this is empty personalDetails: {}, - session: { - email: null, - }, + loginList: {}, }; /** @@ -113,6 +115,8 @@ class DetailsPage extends React.PureComponent { const phoneNumber = getPhoneNumber(details); const phoneOrEmail = isSMSLogin ? getPhoneNumber(details) : details.login; + const isCurrentUser = _.keys(this.props.loginList).includes(details.login); + return ( @@ -187,7 +191,7 @@ class DetailsPage extends React.PureComponent { ) : null} {shouldShowLocalTime && } - {details.login !== this.props.session.email && ( + {!isCurrentUser && ( ( ); diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js index 0cb6a706abf8..6d12b5db81a6 100644 --- a/src/pages/ReimbursementAccount/BankAccountManualStep.js +++ b/src/pages/ReimbursementAccount/BankAccountManualStep.js @@ -69,7 +69,7 @@ class BankAccountManualStep extends React.Component { return ( { { { includeSafeAreaPaddingBottom={false} > diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js index 11badcede40b..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,6 +15,7 @@ import styles from '../styles/styles'; import DisplayNames from '../components/DisplayNames'; import * as OptionsListUtils from '../libs/OptionsListUtils'; import * as ReportUtils from '../libs/ReportUtils'; +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'; @@ -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 = [ { @@ -93,7 +98,7 @@ class ReportDetailsPage extends Component { }); } - const policy = this.props.policies[`${ONYXKEYS.COLLECTION.POLICY}${this.props.report.policyID}`]; + 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({ @@ -119,6 +124,15 @@ class ReportDetailsPage extends Component { isMultipleParticipant, ); const menuItems = this.getMenuItems(); + const isPolicyAdmin = PolicyUtils.isPolicyAdmin(this.getPolicy()); + const chatRoomSubtitleText = ( + + {chatRoomSubtitle} + + ); return ( @@ -144,12 +158,17 @@ class ReportDetailsPage extends Component { shouldUseFullTitle={isChatRoom || isPolicyExpenseChat || isThread} /> - - {chatRoomSubtitle} - + {isPolicyAdmin ? ( + { + Navigation.navigate(ROUTES.getWorkspaceInitialRoute(this.props.report.policyID)); + }} + > + {chatRoomSubtitleText} + + ) : ( + chatRoomSubtitleText + )} diff --git a/src/pages/ShareCodePage.js b/src/pages/ShareCodePage.js index c67c42520767..cec57c6e8aa9 100644 --- a/src/pages/ShareCodePage.js +++ b/src/pages/ShareCodePage.js @@ -39,7 +39,7 @@ class ShareCodePage extends React.Component { 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}`; + const url = isReport ? `${CONST.NEW_EXPENSIFY_URL}r/${this.props.report.reportID}` : `${CONST.NEW_EXPENSIFY_URL}details?login=${encodeURIComponent(this.props.session.email)}`; const platform = getPlatform(); const isNative = platform === CONST.PLATFORM.IOS || platform === CONST.PLATFORM.ANDROID; diff --git a/src/pages/ValidateLoginPage/index.js b/src/pages/ValidateLoginPage/index.js index 25d7f16123ce..2f6e9c07e6e4 100644 --- a/src/pages/ValidateLoginPage/index.js +++ b/src/pages/ValidateLoginPage/index.js @@ -9,6 +9,9 @@ import ONYXKEYS from '../../ONYXKEYS'; import * as Session from '../../libs/actions/Session'; import Permissions from '../../libs/Permissions'; import Navigation from '../../libs/Navigation/Navigation'; +import withLocalize from '../../components/withLocalize'; +import CONST from '../../CONST'; +import compose from '../../libs/compose'; const propTypes = { /** The accountID and validateCode are passed via the URL */ @@ -22,6 +25,9 @@ const propTypes = { /** Currently logged in user authToken */ authToken: PropTypes.string, }), + + /** Indicates which locale the user currently has selected */ + preferredLocale: PropTypes.string, }; const defaultProps = { @@ -30,6 +36,7 @@ const defaultProps = { session: { authToken: null, }, + preferredLocale: CONST.LOCALES.DEFAULT, }; class ValidateLoginPage extends Component { @@ -42,7 +49,7 @@ class ValidateLoginPage extends Component { // because we don't want to block the user with the interstitial page. Navigation.goBack(false); } else { - Session.signInWithValidateCodeAndNavigate(accountID, validateCode); + Session.signInWithValidateCodeAndNavigate(accountID, validateCode, null, this.props.preferredLocale); } } else { User.validateLogin(accountID, validateCode); @@ -57,7 +64,10 @@ class ValidateLoginPage extends Component { ValidateLoginPage.propTypes = propTypes; ValidateLoginPage.defaultProps = defaultProps; -export default withOnyx({ - betas: {key: ONYXKEYS.BETAS}, - session: {key: ONYXKEYS.SESSION}, -})(ValidateLoginPage); +export default compose( + withLocalize, + withOnyx({ + betas: {key: ONYXKEYS.BETAS}, + session: {key: ONYXKEYS.SESSION}, + }), +)(ValidateLoginPage); diff --git a/src/pages/ValidateLoginPage/index.website.js b/src/pages/ValidateLoginPage/index.website.js index 00b459a20a65..4f048dc30659 100644 --- a/src/pages/ValidateLoginPage/index.website.js +++ b/src/pages/ValidateLoginPage/index.website.js @@ -83,7 +83,7 @@ class ValidateLoginPage extends Component { } // The user has initiated the sign in process on the same browser, in another tab. - Session.signInWithValidateCode(this.getAccountID(), this.getValidateCode()); + Session.signInWithValidateCode(this.getAccountID(), this.getValidateCode(), null, this.props.preferredLocale); } componentDidUpdate() { diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index bf63309b52dd..90a0756a83c8 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -116,7 +116,6 @@ const HeaderView = (props) => { } const shouldShowThreeDotsButton = !!threeDotMenuItems.length; - const avatarTooltip = isChatRoom ? undefined : _.pluck(displayNamesWithTooltips, 'tooltip'); const shouldShowSubscript = isPolicyExpenseChat && !props.report.isOwnPolicyExpenseChat && !ReportUtils.isArchivedRoom(props.report) && !isTaskReport; const icons = ReportUtils.getIcons(reportHeaderData, props.personalDetails); const brickRoadIndicator = ReportUtils.hasReportNameError(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; @@ -157,7 +156,7 @@ const HeaderView = (props) => { ) : ( )} diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index 5ae3b0e8e294..223606dc30c3 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -359,6 +359,7 @@ class ReportScreen extends React.Component { report={this.props.report} isComposerFullSize={this.props.isComposerFullSize} onSubmitComment={this.onSubmitComment} + policies={this.props.policies} /> )} diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js index 2d255868c539..1cc5a6e3b220 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js @@ -95,7 +95,7 @@ class PopoverReportActionContextMenu extends React.Component { getContextMenuMeasuredLocation() { return new Promise((resolve) => { if (this.contextMenuAnchor) { - this.contextMenuAnchor.measureInWindow((x, y) => resolve({x, y})); + this.contextMenuAnchor.current.measureInWindow((x, y) => resolve({x, y})); } else { resolve({x: 0, y: 0}); } @@ -279,7 +279,6 @@ class PopoverReportActionContextMenu extends React.Component { anchorPosition={this.state.popoverAnchorPosition} animationIn="fadeIn" disableAnimation={false} - animationOutTiming={1} shouldSetModalVisibility={false} fullscreen > diff --git a/src/pages/home/report/ReactionList/HeaderReactionList.js b/src/pages/home/report/ReactionList/HeaderReactionList.js index 546b364b7ac8..ed6be9ab956f 100644 --- a/src/pages/home/report/ReactionList/HeaderReactionList.js +++ b/src/pages/home/report/ReactionList/HeaderReactionList.js @@ -1,15 +1,13 @@ -import React from 'react'; -import {View, TouchableOpacity} from 'react-native'; import PropTypes from 'prop-types'; -import styles from '../../../../styles/styles'; -import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import React from 'react'; +import {View} from 'react-native'; import Text from '../../../../components/Text'; -import Icon from '../../../../components/Icon'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; +import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; +import compose from '../../../../libs/compose'; import * as StyleUtils from '../../../../styles/StyleUtils'; +import styles from '../../../../styles/styles'; import reactionPropTypes from './reactionPropTypes'; -import compose from '../../../../libs/compose'; -import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions'; const propTypes = { ...reactionPropTypes, @@ -35,16 +33,6 @@ const HeaderReactionList = (props) => ( {`:${props.emojiName}:`} - - {props.isSmallScreenWidth && ( - - - - )} ); diff --git a/src/pages/home/report/ReactionList/PopoverReactionList.js b/src/pages/home/report/ReactionList/PopoverReactionList.js index 48da70d363f8..b6e5813a947f 100644 --- a/src/pages/home/report/ReactionList/PopoverReactionList.js +++ b/src/pages/home/report/ReactionList/PopoverReactionList.js @@ -1,27 +1,20 @@ import React from 'react'; import {Dimensions} from 'react-native'; - +import _ from 'underscore'; import lodashGet from 'lodash/get'; -import lodashMap from 'lodash/map'; -import lodashFilter from 'lodash/filter'; -import lodashFind from 'lodash/find'; -import lodashEach from 'lodash/each'; -import lodashIsEqual from 'lodash/isEqual'; - import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import * as Report from '../../../../libs/actions/Report'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; import PopoverWithMeasuredContent from '../../../../components/PopoverWithMeasuredContent'; - import BaseReactionList from './BaseReactionList'; import compose from '../../../../libs/compose'; import reportActionPropTypes from '../reportActionPropTypes'; import ONYXKEYS from '../../../../ONYXKEYS'; -import getPreferredEmojiCode from '../../../../components/Reactions/getPreferredEmojiCode'; import withCurrentUserPersonalDetails from '../../../../components/withCurrentUserPersonalDetails'; import * as PersonalDetailsUtils from '../../../../libs/PersonalDetailsUtils'; import emojis from '../../../../../assets/emojis'; +import * as EmojiUtils from '../../../../libs/EmojiUtils'; const propTypes = { /** Actions from the ChatReport */ @@ -34,26 +27,6 @@ const defaultProps = { reportActions: {}, }; -/** - * Given an emoji object and a list of senders it will return an - * array of emoji codes, that represents all used variations of the - * emoji. - * @param {{ name: string, code: string, types: string[] }} emoji - * @param {Array} users - * @return {string[]} - * */ -const getUniqueEmojiCodes = (emoji, users) => { - const emojiCodes = []; - lodashEach(users, (user) => { - const emojiCode = getPreferredEmojiCode(emoji, user.skinTone); - - if (emojiCode && !emojiCodes.includes(emojiCode)) { - emojiCodes.push(emojiCode); - } - }); - return emojiCodes; -}; - class PopoverReactionList extends React.Component { constructor(props) { super(props); @@ -105,13 +78,13 @@ class PopoverReactionList extends React.Component { this.state.popoverAnchorPosition !== nextState.popoverAnchorPosition || previousLocale !== nextLocale || (this.state.isPopoverVisible && - (!lodashIsEqual(prevSelectedReaction, selectedReaction) || + (!_.isEqual(prevSelectedReaction, selectedReaction) || this.state.emojiName !== nextState.emojiName || this.state.emojiCount !== nextState.emojiCount || this.state.hasUserReacted !== nextState.hasUserReacted || this.state.reportActionID !== nextState.reportActionID || - !lodashIsEqual(this.state.emojiCodes, nextState.emojiCodes) || - !lodashIsEqual(this.state.users, nextState.users))) + !_.isEqual(this.state.emojiCodes, nextState.emojiCodes) || + !_.isEqual(this.state.users, nextState.users))) ); } @@ -167,10 +140,10 @@ class PopoverReactionList extends React.Component { * @returns {Object} */ getSelectedReaction(reportActions, reportActionID, emojiName) { - const reportAction = lodashFind(reportActions, (action) => action.reportActionID === reportActionID); + const reportAction = _.find(reportActions, (action) => action.reportActionID === reportActionID); const reactions = lodashGet(reportAction, ['message', 0, 'reactions'], []); - const reactionsWithCount = lodashFilter(reactions, (reaction) => reaction.users.length > 0); - return lodashFind(reactionsWithCount, (reaction) => reaction.emoji === emojiName); + const reactionsWithCount = _.filter(reactions, (reaction) => reaction.users.length > 0); + return _.find(reactionsWithCount, (reaction) => reaction.emoji === emojiName); } /** @@ -189,9 +162,9 @@ class PopoverReactionList extends React.Component { }; } const emojiCount = selectedReaction.users.length; - const reactionUsers = lodashMap(selectedReaction.users, (sender) => sender.accountID.toString()); - const emoji = lodashFind(emojis, (e) => e.name === selectedReaction.emoji); - const emojiCodes = getUniqueEmojiCodes(emoji, selectedReaction.users); + const reactionUsers = _.map(selectedReaction.users, (sender) => sender.accountID.toString()); + const emoji = _.find(emojis, (e) => e.name === selectedReaction.emoji); + const emojiCodes = EmojiUtils.getUniqueEmojiCodes(emoji, selectedReaction.users); const hasUserReacted = Report.hasAccountIDReacted(this.props.currentUserPersonalDetails.accountID, reactionUsers); const users = PersonalDetailsUtils.getPersonalDetailsByIDs(reactionUsers); return { @@ -274,7 +247,6 @@ class PopoverReactionList extends React.Component { anchorPosition={this.state.popoverAnchorPosition} animationIn="fadeIn" disableAnimation={false} - animationOutTiming={1} shouldSetModalVisibility={false} fullscreen > diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index b234348384f9..7a7cdf4a45ad 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -53,7 +53,6 @@ import * as ComposerActions from '../../../libs/actions/Composer'; import * as Welcome from '../../../libs/actions/Welcome'; import Permissions from '../../../libs/Permissions'; import * as TaskUtils from '../../../libs/actions/Task'; -import * as OptionsListUtils from '../../../libs/OptionsListUtils'; const propTypes = { /** Beta features list */ @@ -116,9 +115,6 @@ const propTypes = { /** The type of action that's pending */ pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), - /** Collection of recent reports, used to calculate the mention suggestions */ - reports: PropTypes.objectOf(reportPropTypes), - ...windowDimensionsPropTypes, ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, @@ -137,7 +133,6 @@ const defaultProps = { preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, isComposerFullSize: false, pendingAction: null, - reports: {}, shouldShowComposeInput: true, ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -280,6 +275,10 @@ class ReportActionCompose extends React.Component { onSelectionChange(e) { LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); this.setState({selection: e.nativeEvent.selection}); + if (!this.state.value || e.nativeEvent.selection.end < 1) { + this.resetSuggestions(); + return; + } this.calculateEmojiSuggestion(); this.calculateMentionSuggestion(); } @@ -446,6 +445,53 @@ class ReportActionCompose extends React.Component { ]; } + /** + * Build the suggestions for mentions + * @param {Object} personalDetails + * @param {String} [searchValue] + * @returns {Object} + */ + getMentionOptions(personalDetails, searchValue = '') { + const suggestions = []; + + if (CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT.includes(searchValue)) { + suggestions.push({ + text: CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT, + alternateText: this.props.translate('mentionSuggestions.hereAlternateText'), + icons: [ + { + source: Expensicons.Megaphone, + type: 'avatar', + }, + ], + }); + } + + const filteredPersonalDetails = _.filter(_.values(personalDetails), (detail) => { + if (searchValue && !`${detail.displayName} ${detail.login}`.toLowerCase().includes(searchValue.toLowerCase())) { + return false; + } + return true; + }); + + const sortedPersonalDetails = _.sortBy(filteredPersonalDetails, (detail) => detail.displayName || detail.login); + _.each(_.first(sortedPersonalDetails, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_ITEMS - suggestions.length), (detail) => { + suggestions.push({ + text: detail.displayName, + alternateText: detail.login, + icons: [ + { + name: detail.login, + source: detail.avatar, + type: 'avatar', + }, + ], + }); + }); + + return suggestions; + } + /** * Clean data related to EmojiSuggestions and MentionSuggestions */ @@ -459,10 +505,6 @@ class ReportActionCompose extends React.Component { * Calculates and cares about the content of an Emoji Suggester */ calculateEmojiSuggestion() { - if (!this.state.value) { - this.resetSuggestions(); - return; - } if (this.state.shouldBlockEmojiCalc) { this.setState({shouldBlockEmojiCalc: false}); return; @@ -493,12 +535,8 @@ class ReportActionCompose extends React.Component { } calculateMentionSuggestion() { - if (this.state.selection.end < 1) { - return; - } - const valueAfterTheCursor = this.state.value.substring(this.state.selection.end); - const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); let indexOfLastNonWhitespaceCharAfterTheCursor; if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { @@ -509,7 +547,7 @@ class ReportActionCompose extends React.Component { } const leftString = this.state.value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); - const words = leftString.split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const words = leftString.split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); const lastWord = _.last(words); let atSignIndex; @@ -529,9 +567,7 @@ class ReportActionCompose extends React.Component { const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); if (!isCursorBeforeTheMention && this.isMentionCode(lastWord)) { - const options = OptionsListUtils.getNewChatOptions(this.props.reports, this.props.personalDetails, this.props.betas, prefix); - const suggestions = _.filter([...options.recentReports, options.userToInvite], (x) => !!x); - + const suggestions = this.getMentionOptions(this.props.personalDetails, prefix); nextState.suggestedMentions = suggestions; nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); } @@ -546,7 +582,7 @@ class ReportActionCompose extends React.Component { * @returns {Boolean} */ isEmojiCode(str, pos) { - const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const leftWords = str.slice(0, pos).split(CONST.REGEX.SPECIAL_CHAR_OR_EMOJI); const leftWord = _.last(leftWords); return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; @@ -561,6 +597,15 @@ class ReportActionCompose extends React.Component { return CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); } + /** + * Trims first character of the string if it is a space + * @param {String} str + * @returns {String} + */ + trimLeadingSpace(str) { + return str.slice(0, 1) === ' ' ? str.slice(1) : str; + } + /** * Replace the code of emoji and update selection * @param {Number} highlightedEmojiIndex @@ -571,7 +616,7 @@ class ReportActionCompose extends React.Component { const emojiCode = emojiObject.types && emojiObject.types[this.props.preferredSkinTone] ? emojiObject.types[this.props.preferredSkinTone] : emojiObject.code; const commentAfterColonWithEmojiNameRemoved = this.state.value.slice(this.state.selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - this.updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); + this.updateComment(`${commentBeforeColon}${emojiCode} ${this.trimLeadingSpace(commentAfterColonWithEmojiNameRemoved)}`, true); // In some Android phones keyboard, the text to search for the emoji is not cleared // will be added after the user starts typing again on the keyboard. This package is // a workaround to reset the keyboard natively. @@ -596,10 +641,10 @@ class ReportActionCompose extends React.Component { insertSelectedMention(highlightedMentionIndex) { const commentBeforeAtSign = this.state.value.slice(0, this.state.atSignIndex); const mentionObject = this.state.suggestedMentions[highlightedMentionIndex]; - const mentionCode = `@${mentionObject.alternateText}`; + const mentionCode = mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${mentionObject.alternateText}`; const commentAfterAtSignWithMentionRemoved = this.state.value.slice(this.state.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); - this.updateComment(`${commentBeforeAtSign}${mentionCode} ${commentAfterAtSignWithMentionRemoved}`, true); + this.updateComment(`${commentBeforeAtSign}${mentionCode} ${this.trimLeadingSpace(commentAfterAtSignWithMentionRemoved)}`, true); this.setState((prevState) => ({ selection: { start: prevState.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, @@ -1022,10 +1067,7 @@ class ReportActionCompose extends React.Component { placeholderTextColor={themeColors.placeholderText} onChangeText={(comment) => this.updateComment(comment, true)} onKeyPress={this.triggerHotkeyActions} - style={[ - this.props.numberOfLines > 1 ? styles.textInputComposeMultiLines : styles.textInputCompose, - this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4, - ]} + style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} maxLines={this.state.maxLines} onFocus={() => this.setIsFocused(true)} onBlur={() => { @@ -1045,6 +1087,7 @@ class ReportActionCompose extends React.Component { value={this.state.value} numberOfLines={this.props.numberOfLines} onNumberOfLinesChange={this.updateNumberOfLines} + shouldCalculateCaretPosition onLayout={(e) => { const composerHeight = e.nativeEvent.layout.height; if (this.state.composerHeight === composerHeight) { @@ -1195,9 +1238,6 @@ export default compose( key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, selector: EmojiUtils.getPreferredSkinToneIndex, }, - reports: { - key: ONYXKEYS.COLLECTION.REPORT, - }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS, }, diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 41fcf102a138..a318d181e460 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; -import React, {Component} from 'react'; +import React, {useState, useRef, useEffect, memo, useCallback} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; @@ -66,9 +66,6 @@ const propTypes = { /** Is this the most recent IOU Action? */ isMostRecentIOUReportAction: PropTypes.bool.isRequired, - /** Whether there is an outstanding amount in IOU */ - hasOutstandingIOU: PropTypes.bool, - /** Should we display the new marker on top of the comment? */ shouldDisplayNewMarker: PropTypes.bool.isRequired, @@ -81,6 +78,10 @@ const propTypes = { /** Draft message - if this is set the comment is in 'edit' mode */ draftMessage: PropTypes.string, + /* Whether the option has an outstanding IOU */ + // eslint-disable-next-line react/no-unused-prop-types + hasOutstandingIOU: PropTypes.bool, + /** Stores user's preferred skin tone */ preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), @@ -95,184 +96,170 @@ const propTypes = { const defaultProps = { draftMessage: '', - hasOutstandingIOU: false, preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, personalDetails: {}, shouldShowSubscriptAvatar: false, + hasOutstandingIOU: false, betas: [], }; -class ReportActionItem extends Component { - constructor(props) { - super(props); - this.popoverAnchor = undefined; - this.state = { - isContextMenuActive: ReportActionContextMenu.isActiveReportAction(props.action.reportActionID), - }; - this.checkIfContextMenuActive = this.checkIfContextMenuActive.bind(this); - this.showPopover = this.showPopover.bind(this); - this.renderItemContent = this.renderItemContent.bind(this); - this.toggleReaction = this.toggleReaction.bind(this); - } - - shouldComponentUpdate(nextProps, nextState) { - return ( - this.props.displayAsGroup !== nextProps.displayAsGroup || - this.props.draftMessage !== nextProps.draftMessage || - this.props.isMostRecentIOUReportAction !== nextProps.isMostRecentIOUReportAction || - this.props.hasOutstandingIOU !== nextProps.hasOutstandingIOU || - this.props.shouldDisplayNewMarker !== nextProps.shouldDisplayNewMarker || - !_.isEqual(this.props.action, nextProps.action) || - this.state.isContextMenuActive !== nextState.isContextMenuActive || - lodashGet(this.props.report, 'statusNum') !== lodashGet(nextProps.report, 'statusNum') || - lodashGet(this.props.report, 'stateNum') !== lodashGet(nextProps.report, 'stateNum') || - this.props.translate !== nextProps.translate - ); - } +function ReportActionItem(props) { + const [isContextMenuActive, setIsContextMenuActive] = useState(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); + const textInputRef = useRef(); + const popoverAnchorRef = useRef(); - componentDidUpdate(prevProps) { - if (prevProps.draftMessage || !this.props.draftMessage) { + const isDraftEmpty = !props.draftMessage; + useEffect(() => { + if (isDraftEmpty) { return; } // Only focus the input when user edits a message, skip it for existing drafts being edited of the report. // There is an animation when the comment is hidden and the edit form is shown, and there can be bugs on different mobile platforms // if the input is given focus in the middle of that animation which can prevent the keyboard from opening. - focusTextInputAfterAnimation(this.textInput, 100); - } + focusTextInputAfterAnimation(textInputRef.current, 100); + }, [isDraftEmpty]); - checkIfContextMenuActive() { - this.setState({isContextMenuActive: ReportActionContextMenu.isActiveReportAction(this.props.action.reportActionID)}); - } + const toggleContextMenuFromActiveReportAction = useCallback(() => { + setIsContextMenuActive(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID)); + }, [props.action.reportActionID]); /** * Show the ReportActionContextMenu modal popover. * * @param {Object} [event] - A press event. */ - showPopover(event) { - // Block menu on the message being Edited or if the report action item has errors - if (this.props.draftMessage || !_.isEmpty(this.props.action.errors)) { - return; - } - - this.setState({isContextMenuActive: true}); - - const selection = SelectionScraper.getCurrentSelection(); - ReportActionContextMenu.showContextMenu( - ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION, - event, - selection, - this.popoverAnchor, - this.props.report.reportID, - this.props.action, - this.props.draftMessage, - undefined, - this.checkIfContextMenuActive, - ReportUtils.isArchivedRoom(this.props.report), - ReportUtils.chatIncludesChronos(this.props.report), - this.props.action.childReportID, - ); - } + const showPopover = useCallback( + (event) => { + // Block menu on the message being Edited or if the report action item has errors + if (props.draftMessage || !_.isEmpty(props.action.errors)) { + return; + } + + setIsContextMenuActive(true); + + const selection = SelectionScraper.getCurrentSelection(); + ReportActionContextMenu.showContextMenu( + ContextMenuActions.CONTEXT_MENU_TYPES.REPORT_ACTION, + event, + selection, + popoverAnchorRef, + props.report.reportID, + props.action, + props.draftMessage, + () => {}, + toggleContextMenuFromActiveReportAction, + ReportUtils.isArchivedRoom(props.report), + ReportUtils.chatIncludesChronos(props.report), + props.action.childReportID, + ); + }, + [props.draftMessage, props.action, props.report, toggleContextMenuFromActiveReportAction], + ); - toggleReaction(emoji) { - Report.toggleEmojiReaction(this.props.report.reportID, this.props.action, emoji); - } + const toggleReaction = useCallback( + (emoji) => { + Report.toggleEmojiReaction(props.report.reportID, props.action, emoji); + }, + [props.report, props.action], + ); /** * Get the content of ReportActionItem * @param {Boolean} hovered whether the ReportActionItem is hovered * @returns {Object} child component(s) */ - renderItemContent(hovered = false) { + const renderItemContent = (hovered = false) => { let children; - const originalMessage = lodashGet(this.props.action, 'originalMessage', {}); + const originalMessage = lodashGet(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 && + 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 = ( ); - } else if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { + } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW) { children = ( ); } else if ( - this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || - this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELED || - this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED + props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED || + props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKCANCELED || + props.action.actionName === CONST.REPORT.ACTIONS.TYPE.TASKREOPENED ) { children = ( ); - } else if (ReportActionsUtils.isCreatedTaskReportAction(this.props.action)) { + } else if (ReportActionsUtils.isCreatedTaskReportAction(props.action)) { children = ( ); } else { - const message = _.last(lodashGet(this.props.action, 'message', [{}])); - const isAttachment = _.has(this.props.action, 'isAttachment') ? this.props.action.isAttachment : ReportUtils.isReportMessageAttachment(message); + const message = _.last(lodashGet(props.action, 'message', [{}])); + const isAttachment = _.has(props.action, 'isAttachment') ? props.action.isAttachment : ReportUtils.isReportMessageAttachment(message); children = ( - {!this.props.draftMessage ? ( + {!props.draftMessage ? ( ) : ( (this.textInput = el)} - report={this.props.report} + action={props.action} + draftMessage={props.draftMessage} + reportID={props.report.reportID} + index={props.index} + ref={textInputRef} + report={props.report} // Avoid defining within component due to an existing Onyx bug - preferredSkinTone={this.props.preferredSkinTone} + preferredSkinTone={props.preferredSkinTone} shouldDisableEmojiPicker={ - (ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge)) || - ReportUtils.isArchivedRoom(this.props.report) + (ReportUtils.chatIncludesConcierge(props.report) && User.isBlockedFromConcierge(props.blockedFromConcierge)) || ReportUtils.isArchivedRoom(props.report) } /> )} @@ -280,42 +267,39 @@ class ReportActionItem extends Component { ); } - const reactions = _.get(this.props, ['action', 'message', 0, 'reactions'], []); + const reactions = _.get(props, ['action', 'message', 0, 'reactions'], []); const hasReactions = reactions.length > 0; - const numberOfThreadReplies = _.get(this.props, ['action', 'childVisibleActionCount'], 0); + const numberOfThreadReplies = _.get(props, ['action', 'childVisibleActionCount'], 0); const hasReplies = numberOfThreadReplies > 0; const shouldDisplayThreadReplies = - 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(','); + hasReplies && props.action.childCommenterCount && Permissions.canUseThreads(props.betas) && !ReportUtils.isThreadFirstChat(props.action, props.report.reportID); + const oldestFourEmails = lodashGet(props.action, 'childOldestFourEmails', '').split(','); return ( <> {children} {hasReactions && ( - + )} {shouldDisplayThreadReplies && ( )} ); - } + }; /** * Get ReportActionItem with a proper wrapper @@ -323,21 +307,21 @@ class ReportActionItem extends Component { * @param {Boolean} isWhisper whether the ReportActionItem is a whisper * @returns {Object} report action item */ - renderReportActionItem(hovered, isWhisper) { - const content = this.renderItemContent(hovered || this.state.isContextMenuActive); + const renderReportActionItem = (hovered, isWhisper) => { + const content = renderItemContent(hovered || isContextMenuActive); - if (this.props.draftMessage) { + if (props.draftMessage) { return {content}; } - if (!this.props.displayAsGroup) { + if (!props.displayAsGroup) { return ( {content} @@ -345,103 +329,101 @@ class ReportActionItem extends Component { } return {content}; - } - - render() { - if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - return ; - } - if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { - return ; - } - if (this.props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST) { - return ( - - ); - } + }; - const hasErrors = !_.isEmpty(this.props.action.errors); - const whisperedTo = lodashGet(this.props.action, 'whisperedTo', []); - const isWhisper = whisperedTo.length > 0; - const isMultipleParticipant = whisperedTo.length > 1; - const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedTo); - const whisperedToPersonalDetails = isWhisper ? _.filter(this.props.personalDetails, (details) => _.includes(whisperedTo, details.login)) : []; - const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return ; + } + if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.RENAMED) { + return ; + } + if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CHRONOSOOOLIST) { return ( - (this.popoverAnchor = el)} - onPressIn={() => this.props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={() => ControlSelection.unblock()} - onSecondaryInteraction={this.showPopover} - preventDefaultContextMenu={!this.props.draftMessage && !hasErrors} - withoutFocusOnSecondaryInteraction - > - - {(hovered) => ( - - {this.props.shouldDisplayNewMarker && } - + ); + } + + const hasErrors = !_.isEmpty(props.action.errors); + const whisperedTo = lodashGet(props.action, 'whisperedTo', []); + const isWhisper = whisperedTo.length > 0; + const isMultipleParticipant = whisperedTo.length > 1; + const isWhisperOnlyVisibleByUser = isWhisper && ReportUtils.isCurrentUserTheOnlyParticipant(whisperedTo); + const whisperedToPersonalDetails = isWhisper ? _.filter(props.personalDetails, (details) => _.includes(whisperedTo, details.login)) : []; + const displayNamesWithTooltips = isWhisper ? ReportUtils.getDisplayNamesWithTooltips(whisperedToPersonalDetails, isMultipleParticipant) : []; + return ( + props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={() => ControlSelection.unblock()} + onSecondaryInteraction={showPopover} + preventDefaultContextMenu={!props.draftMessage && !hasErrors} + withoutFocusOnSecondaryInteraction + > + + {(hovered) => ( + + {props.shouldDisplayNewMarker && } + + + ReportActions.clearReportActionErrors(props.report.reportID, props.action)} + pendingAction={props.draftMessage ? null : props.action.pendingAction} + errors={props.action.errors} + errorRowStyles={[styles.ml10, styles.mr2]} + needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(props.action)} > - ReportActions.clearReportActionErrors(this.props.report.reportID, this.props.action)} - pendingAction={this.props.draftMessage ? null : this.props.action.pendingAction} - errors={this.props.action.errors} - errorRowStyles={[styles.ml10, styles.mr2]} - needsOffscreenAlphaCompositing={ReportActionsUtils.isMoneyRequestAction(this.props.action)} - > - {isWhisper && ( - - - - - - {this.props.translate('reportActionContextMenu.onlyVisible')} -   - - + + - )} - {this.renderReportActionItem(hovered, isWhisper)} - - - + + {props.translate('reportActionContextMenu.onlyVisible')} +   + + + + )} + {renderReportActionItem(hovered, isWhisper)} + - )} - - - - - - ); - } + + )} + + + + + + ); } + ReportActionItem.propTypes = propTypes; ReportActionItem.defaultProps = defaultProps; @@ -466,4 +448,18 @@ export default compose( key: ONYXKEYS.BETAS, }, }), -)(ReportActionItem); +)( + memo( + ReportActionItem, + (prevProps, nextProps) => + prevProps.displayAsGroup === nextProps.displayAsGroup && + prevProps.draftMessage === nextProps.draftMessage && + prevProps.isMostRecentIOUReportAction !== nextProps.isMostRecentIOUReportAction && + prevProps.hasOutstandingIOU === nextProps.hasOutstandingIOU && + prevProps.shouldDisplayNewMarker === nextProps.shouldDisplayNewMarker && + !_.isEqual(prevProps.action, nextProps.action) && + lodashGet(prevProps.report, 'statusNum') === lodashGet(nextProps.report, 'statusNum') && + lodashGet(prevProps.report, 'stateNum') === lodashGet(nextProps.report, 'stateNum') && + prevProps.translate === nextProps.translate, + ), +); 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/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 1718f51bbf33..214eff33844e 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -30,6 +30,7 @@ import CONST from '../../../CONST'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; +import refPropTypes from '../../../components/refPropTypes'; import * as ComposerUtils from '../../../libs/ComposerUtils'; import * as ComposerActions from '../../../libs/actions/Composer'; import * as User from '../../../libs/actions/User'; @@ -48,7 +49,7 @@ const propTypes = { index: PropTypes.number.isRequired, /** A ref to forward to the text input */ - forwardedRef: PropTypes.func, + forwardedRef: refPropTypes, /** The report currently being looked at */ // eslint-disable-next-line react/no-unused-prop-types @@ -299,13 +300,13 @@ class ReportActionItemMessageEdit extends React.Component { multiline ref={(el) => { this.textInput = el; - this.props.forwardedRef(el); + this.props.forwardedRef.current = el; }} nativeID={this.messageEditInput} onChangeText={this.updateDraft} // Debounced saveDraftComment onKeyPress={this.triggerSaveOrCancel} value={this.state.draft} - maxLines={16} // This is the same that slack has + maxLines={this.props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES} // This is the same that slack has style={[styles.textInputCompose, styles.flex1, styles.bgTransparent]} onFocus={() => { this.setState({isFocused: true}); diff --git a/src/pages/home/report/ReportActionItemThread.js b/src/pages/home/report/ReportActionItemThread.js index 07ab422b0586..41cbf8c96d89 100644 --- a/src/pages/home/report/ReportActionItemThread.js +++ b/src/pages/home/report/ReportActionItemThread.js @@ -1,7 +1,6 @@ import React from 'react'; import {View, Pressable, Text} from 'react-native'; import PropTypes from 'prop-types'; -import _ from 'underscore'; import styles from '../../../styles/styles'; import * as Report from '../../../libs/actions/Report'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; @@ -49,7 +48,6 @@ const ReportActionItemThread = (props) => { size={CONST.AVATAR_SIZE.SMALL} icons={props.icons} shouldStackHorizontally - avatarTooltips={_.map(props.icons, (icon) => icon.name)} isHovered={props.isHovered} isInReportAction /> @@ -63,7 +61,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/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js index 92811fa7b0fc..cdaad9b5ffc2 100644 --- a/src/pages/home/report/ReportFooter.js +++ b/src/pages/home/report/ReportFooter.js @@ -65,7 +65,8 @@ class ReportFooter extends React.Component { render() { const isArchivedRoom = ReportUtils.isArchivedRoom(this.props.report); - const hideComposer = isArchivedRoom || !_.isEmpty(this.props.errors); + const isAllowedToComment = ReportUtils.isAllowedToComment(this.props.report); + const hideComposer = isArchivedRoom || !_.isEmpty(this.props.errors) || !isAllowedToComment; return ( <> diff --git a/src/pages/home/report/withReportOrNotFound.js b/src/pages/home/report/withReportOrNotFound.js index 204965e25bd8..ec561db20c81 100644 --- a/src/pages/home/report/withReportOrNotFound.js +++ b/src/pages/home/report/withReportOrNotFound.js @@ -6,6 +6,7 @@ import getComponentDisplayName from '../../../libs/getComponentDisplayName'; import NotFoundPage from '../../ErrorPage/NotFoundPage'; import ONYXKEYS from '../../../ONYXKEYS'; import reportPropTypes from '../../reportPropTypes'; +import FullscreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator'; export default function (WrappedComponent) { const propTypes = { @@ -15,15 +16,22 @@ export default function (WrappedComponent) { /** The report currently being looked at */ report: reportPropTypes, + + /** Indicated whether the report data is loading */ + isLoadingReportData: PropTypes.bool, }; const defaultProps = { forwardedRef: () => {}, report: {}, + isLoadingReportData: true, }; class WithReportOrNotFound extends Component { render() { + if (this.props.isLoadingReportData && (_.isEmpty(this.props.report) || !this.props.report.reportID)) { + return ; + } if (_.isEmpty(this.props.report) || !this.props.report.reportID) { return ; } @@ -56,5 +64,8 @@ export default function (WrappedComponent) { report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, }, + isLoadingReportData: { + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + }, })(withReportOrNotFound); } diff --git a/src/pages/iou/IOUCurrencySelection.js b/src/pages/iou/IOUCurrencySelection.js index 20182afc4fff..2c799bb72d92 100644 --- a/src/pages/iou/IOUCurrencySelection.js +++ b/src/pages/iou/IOUCurrencySelection.js @@ -2,6 +2,7 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import lodashGet from 'lodash/get'; import Str from 'expensify-common/lib/str'; import ONYXKEYS from '../../ONYXKEYS'; import OptionsSelector from '../../components/OptionsSelector'; @@ -10,7 +11,6 @@ import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import compose from '../../libs/compose'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import * as IOU from '../../libs/actions/IOU'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; import {withNetwork} from '../../components/OnyxProvider'; import CONST from '../../CONST'; @@ -89,12 +89,16 @@ class IOUCurrencySelection extends Component { return sections; } + getSelectedCurrencyCode() { + return lodashGet(this.props.route, 'params.currency', this.props.iou.selectedCurrencyCode); + } + /** * @returns {Object} */ getCurrencyOptions() { return _.map(this.props.currencyList, (currencyInfo, currencyCode) => { - 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 deleted file mode 100644 index 84de2a089242..000000000000 --- a/src/pages/iou/IOUDetailsModal.js +++ /dev/null @@ -1,222 +0,0 @@ -import React, {Component} from 'react'; -import {View, ScrollView} from 'react-native'; -import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import lodashGet from 'lodash/get'; -import _ from 'underscore'; -import styles from '../../styles/styles'; -import ONYXKEYS from '../../ONYXKEYS'; -import {withNetwork} from '../../components/OnyxProvider'; -import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; -import Navigation from '../../libs/Navigation/Navigation'; -import ScreenWrapper from '../../components/ScreenWrapper'; -import * as Report from '../../libs/actions/Report'; -import IOUPreview from '../../components/ReportActionItem/IOUPreview'; -import IOUTransactions from './IOUTransactions'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import compose from '../../libs/compose'; -import CONST from '../../CONST'; -import SettlementButton from '../../components/SettlementButton'; -import ROUTES from '../../ROUTES'; -import FixedFooter from '../../components/FixedFooter'; -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 */ - route: PropTypes.shape({ - /** Params from the URL path */ - params: PropTypes.shape({ - /** chatReportID passed via route: /iou/details/:chatReportID/:iouReportID */ - chatReportID: PropTypes.string, - - /** iouReportID passed via route: /iou/details/:chatReportID/:iouReportID */ - iouReportID: PropTypes.string, - }), - }).isRequired, - - /* Onyx Props */ - /** Holds data related to IOU view state, rather than the underlying IOU data. */ - iou: PropTypes.shape({ - /** Is the IOU Report currently being loaded? */ - loading: PropTypes.bool, - - /** Error message, empty represents no error */ - error: PropTypes.bool, - }), - - /** IOU Report data object */ - iouReport: PropTypes.shape({ - /** ID for the chatReport that this IOU is linked to */ - chatReportID: PropTypes.string, - - /** Manager is the person who currently owes money */ - managerEmail: PropTypes.string, - - /** Owner is the person who is owed money */ - ownerEmail: PropTypes.string, - - /** Does the iouReport have an outstanding IOU? */ - hasOutstandingIOU: PropTypes.bool, - }), - - /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user email */ - email: PropTypes.string, - }), - - /** Actions from the ChatReport */ - reportActions: PropTypes.shape(reportActionPropTypes), - - /** Information about the network */ - network: networkPropTypes.isRequired, - - /** chatReport associated with iouReport */ - chatReport: PropTypes.shape({ - /** Report ID associated with the transaction */ - reportID: PropTypes.string, - - /** The participants of this report */ - participants: PropTypes.arrayOf(PropTypes.string), - - /** Whether the chat report has an outstanding IOU */ - hasOutstandingIOU: PropTypes.bool.isRequired, - }), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - iou: {}, - reportActions: {}, - iouReport: undefined, - session: { - email: null, - }, - chatReport: { - participants: [], - }, -}; - -class IOUDetailsModal extends Component { - componentDidMount() { - if (this.props.network.isOffline) { - return; - } - - this.fetchData(); - } - - componentDidUpdate(prevProps) { - if (!prevProps.network.isOffline || this.props.network.isOffline) { - return; - } - - this.fetchData(); - } - - fetchData() { - Report.openPaymentDetailsPage(this.props.route.params.chatReportID, this.props.route.params.iouReportID); - } - - // Finds if there is a reportAction pending for this IOU - findPendingAction() { - const reportActionWithPendingAction = _.find( - this.props.reportActions, - (reportAction) => - reportAction.originalMessage && Number(reportAction.originalMessage.IOUReportID) === Number(this.props.route.params.iouReportID) && !_.isEmpty(reportAction.pendingAction), - ); - return reportActionWithPendingAction ? reportActionWithPendingAction.pendingAction : undefined; - } - - render() { - const sessionEmail = lodashGet(this.props.session, 'email', null); - const pendingAction = this.findPendingAction(); - const iouReportStateNum = lodashGet(this.props.iouReport, 'stateNum'); - const hasOutstandingIOU = lodashGet(this.props.iouReport, 'hasOutstandingIOU'); - const hasFixedFooter = hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail; - return ( - - {({safeAreaPaddingBottomStyle}) => ( - - - {this.props.iou.loading ? ( - - - - ) : ( - - - 1} - isIOUAction={false} - pendingAction={pendingAction} - /> - - - {hasOutstandingIOU && this.props.iouReport.managerEmail === sessionEmail && ( - - 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} - /> - - )} - - )} - - )} - - ); - } -} - -IOUDetailsModal.propTypes = propTypes; -IOUDetailsModal.defaultProps = defaultProps; - -export default compose( - withLocalize, - withNetwork(), - withOnyx({ - iou: { - key: ONYXKEYS.IOU, - }, - chatReport: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.chatReportID}`, - }, - iouReport: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.iouReportID}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, - reportActions: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.chatReportID}`, - canEvict: false, - }, - }), -)(IOUDetailsModal); diff --git a/src/pages/iou/MoneyRequestModal.js b/src/pages/iou/MoneyRequestModal.js index a55ecec665da..efd53b22ce4d 100644 --- a/src/pages/iou/MoneyRequestModal.js +++ b/src/pages/iou/MoneyRequestModal.js @@ -23,7 +23,7 @@ import withCurrentUserPersonalDetails from '../../components/withCurrentUserPers import reportPropTypes from '../reportPropTypes'; import * as ReportUtils from '../../libs/ReportUtils'; import * as ReportScrollManager from '../../libs/ReportScrollManager'; -import useOnNetworkReconnect from '../../components/hooks/useOnNetworkReconnect'; +import useOnNetworkReconnect from '../../hooks/useOnNetworkReconnect'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; @@ -113,11 +113,18 @@ const MoneyRequestModal = (props) => { useEffect(() => { PersonalDetails.openMoneyRequestModalPage(); - IOU.setIOUSelectedCurrency(props.currentUserPersonalDetails.localCurrencyCode); IOU.setMoneyRequestDescription(''); - // eslint-disable-next-line react-hooks/exhaustive-deps -- props.currentUserPersonalDetails will always exist from Onyx and we don't want this effect to run again }, []); + // We update selected currency when PersonalDetails.openMoneyRequestModalPage finishes + // props.currentUserPersonalDetails might be stale data or might not exist if user is signing in + useEffect(() => { + if (_.isUndefined(props.currentUserPersonalDetails.localCurrencyCode)) { + return; + } + IOU.setIOUSelectedCurrency(props.currentUserPersonalDetails.localCurrencyCode); + }, [props.currentUserPersonalDetails.localCurrencyCode]); + // User came back online, so let's refetch the currency details based on location useOnNetworkReconnect(PersonalDetails.openMoneyRequestModalPage); @@ -292,6 +299,7 @@ const MoneyRequestModal = (props) => { ); const amountButtonText = isEditingAmountAfterConfirm ? props.translate('common.save') : props.translate('common.next'); const enableMaxHeight = DeviceCapabilities.canUseTouchScreen() && currentStep === Steps.MoneyRequestParticipants; + const bankAccountRoute = ReportUtils.getBankAccountRoute(props.report); return ( { > {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 +329,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} /> @@ -368,6 +378,7 @@ const MoneyRequestModal = (props) => { canModifyParticipants={!_.isEmpty(reportID)} navigateToStep={navigateToStep} policyID={props.report.policyID} + bankAccountRoute={bankAccountRoute} /> )} diff --git a/src/pages/iou/steps/MoneyRequestAmountPage.js b/src/pages/iou/steps/MoneyRequestAmountPage.js index 07b6556a2011..39495c6a5f2c 100755 --- a/src/pages/iou/steps/MoneyRequestAmountPage.js +++ b/src/pages/iou/steps/MoneyRequestAmountPage.js @@ -44,9 +44,7 @@ const propTypes = { }; const defaultProps = { - iou: { - selectedCurrencyCode: CONST.CURRENCY.USD, - }, + iou: {}, }; class MoneyRequestAmountPage extends React.Component { constructor(props) { @@ -66,6 +64,7 @@ class MoneyRequestAmountPage extends React.Component { const selectedAmountAsString = props.selectedAmount ? props.selectedAmount.toString() : ''; this.state = { amount: selectedAmountAsString, + selectedCurrencyCode: _.isUndefined(props.iou.selectedCurrencyCode) ? CONST.CURRENCY.USD : props.iou.selectedCurrencyCode, shouldUpdateSelection: true, selection: { start: selectedAmountAsString.length, @@ -80,9 +79,18 @@ class MoneyRequestAmountPage extends React.Component { // Focus automatically after navigating back from currency selector this.unsubscribeNavFocus = this.props.navigation.addListener('focus', () => { this.focusTextInput(); + this.getCurrencyFromRouteParams(); }); } + componentDidUpdate(prevProps) { + if (prevProps.iou.selectedCurrencyCode === this.props.iou.selectedCurrencyCode) { + return; + } + + this.setState({selectedCurrencyCode: this.props.iou.selectedCurrencyCode}); + } + componentWillUnmount() { this.unsubscribeNavFocus(); } @@ -104,6 +112,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 * @@ -223,7 +238,7 @@ class MoneyRequestAmountPage extends React.Component { * @param {String} key */ updateAmountNumberPad(key) { - if (!this.textInput.isFocused()) { + if (this.state.shouldUpdateSelection && !this.textInput.isFocused()) { this.textInput.focus(); } @@ -252,6 +267,9 @@ class MoneyRequestAmountPage extends React.Component { */ updateLongPressHandlerState(value) { this.setState({shouldUpdateSelection: !value}); + if (!value && !this.textInput.isFocused()) { + this.textInput.focus(); + } } /** @@ -289,13 +307,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 +334,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 +362,7 @@ class MoneyRequestAmountPage extends React.Component {