diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 029d32f8589c..e0fd082f0b8b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -84,7 +84,7 @@ This is a checklist for PR authors. Please make sure to complete all tasks and c - [ ] I verified any copy / text shown in the product is localized by adding it to `src/languages/*` files and using the [translation method](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60) - [ ] If any non-english text was added/modified, I verified the translation was requested/reviewed in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message: - [ ] I verified all numbers, amounts, dates and phone numbers shown in the product are using the [localization methods](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60-L68) - - [ ] I verified any copy / text that was added to the app is correct English and approved by marketing by adding the `Waiting for Copy` label for a copy review on the original GH to get the correct copy. + - [ ] I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is approved by marketing by adding the `Waiting for Copy` label for a copy review on the original GH to get the correct copy. - [ ] I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README. - [ ] I verified the JSDocs style guidelines (in [`STYLE.md`](https://github.com/Expensify/App/blob/main/contributingGuides/STYLE.md#jsdocs)) were followed - [ ] If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers diff --git a/__mocks__/react-native-key-command.js b/__mocks__/react-native-key-command.js new file mode 100644 index 000000000000..092ab120a142 --- /dev/null +++ b/__mocks__/react-native-key-command.js @@ -0,0 +1,13 @@ +const registerKeyCommands = () => {}; +const unregisterKeyCommands = () => {}; +const constants = {}; +const eventEmitter = () => {}; +const addListener = () => {}; + +export { + registerKeyCommands, + unregisterKeyCommands, + constants, + eventEmitter, + addListener, +}; diff --git a/android/app/build.gradle b/android/app/build.gradle index 1f6cef81a2e7..5bd5feb9db6a 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 1001030000 - versionName "1.3.0-0" + versionCode 1001030001 + versionName "1.3.0-1" } splits { diff --git a/android/app/src/main/java/com/expensify/chat/MainActivity.java b/android/app/src/main/java/com/expensify/chat/MainActivity.java index bd90ee9abd02..3e6381000409 100644 --- a/android/app/src/main/java/com/expensify/chat/MainActivity.java +++ b/android/app/src/main/java/com/expensify/chat/MainActivity.java @@ -2,7 +2,9 @@ import android.os.Bundle; import android.content.pm.ActivityInfo; +import android.view.KeyEvent; import com.expensify.chat.bootsplash.BootSplash; +import com.expensify.reactnativekeycommand.KeyCommandModule; import com.facebook.react.ReactActivity; import com.facebook.react.ReactActivityDelegate; import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; @@ -44,4 +46,34 @@ protected void onCreate(Bundle savedInstanceState) { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } } -} \ No newline at end of file + + /** + * This method is called when a key down event has occurred. + * Forwards the event to the KeyCommandModule + */ + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Disabling hardware ESCAPE support which is handled by Android + if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { + return false; + } + KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + // Disabling hardware ESCAPE support which is handled by Android + if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { return false; } + KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); + return super.onKeyLongPress(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + // Disabling hardware ESCAPE support which is handled by Android + if (event.getKeyCode() == KeyEvent.KEYCODE_ESCAPE) { return false; } + KeyCommandModule.getInstance().onKeyDownEvent(keyCode, event); + return super.onKeyUp(keyCode, event); + } +} diff --git a/assets/images/expensify-logo--adhoc.svg b/assets/images/expensify-logo--adhoc.svg new file mode 100644 index 000000000000..63b6f896e3a5 --- /dev/null +++ b/assets/images/expensify-logo--adhoc.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/expensify-logo--dev.svg b/assets/images/expensify-logo--dev.svg new file mode 100644 index 000000000000..42a0f1d8e952 --- /dev/null +++ b/assets/images/expensify-logo--dev.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/expensify-logo--staging.svg b/assets/images/expensify-logo--staging.svg new file mode 100644 index 000000000000..335c41a294e3 --- /dev/null +++ b/assets/images/expensify-logo--staging.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/home-background--desktop.svg b/assets/images/home-background--desktop.svg new file mode 100644 index 000000000000..7241c46036c0 --- /dev/null +++ b/assets/images/home-background--desktop.svg @@ -0,0 +1,8839 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/home-background--mobile.svg b/assets/images/home-background--mobile.svg new file mode 100644 index 000000000000..0af3aac59146 --- /dev/null +++ b/assets/images/home-background--mobile.svg @@ -0,0 +1,6556 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/home-fade-gradient--mobile.svg b/assets/images/home-fade-gradient--mobile.svg new file mode 100644 index 000000000000..ca03eb3323af --- /dev/null +++ b/assets/images/home-fade-gradient--mobile.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/assets/images/home-fade-gradient.svg b/assets/images/home-fade-gradient.svg new file mode 100644 index 000000000000..6aada6633a8b --- /dev/null +++ b/assets/images/home-fade-gradient.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/assets/images/product-illustrations/home-illustration-hands.svg b/assets/images/product-illustrations/home-illustration-hands.svg new file mode 100644 index 000000000000..9a70d8cc6363 --- /dev/null +++ b/assets/images/product-illustrations/home-illustration-hands.svg @@ -0,0 +1,545 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/contributingGuides/REVIEWER_CHECKLIST.md b/contributingGuides/REVIEWER_CHECKLIST.md index 5beafc1dd542..135be96f9d79 100644 --- a/contributingGuides/REVIEWER_CHECKLIST.md +++ b/contributingGuides/REVIEWER_CHECKLIST.md @@ -23,7 +23,7 @@ - [ ] I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing. - [ ] I verified any copy / text shown in the product is localized by adding it to `src/languages/*` files and using the [translation method](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60) - [ ] I verified all numbers, amounts, dates and phone numbers shown in the product are using the [localization methods](https://github.com/Expensify/App/blob/4bd99402cebdf4d7394e0d1f260879ea238197eb/src/components/withLocalize.js#L60-L68) - - [ ] I verified any copy / text that was added to the app is correct English and approved by marketing by adding the `Waiting for Copy` label for a copy review on the original GH to get the correct copy. + - [ ] I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is approved by marketing by adding the `Waiting for Copy` label for a copy review on the original GH to get the correct copy. - [ ] I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README. - [ ] I verified the JSDocs style guidelines (in [`STYLE.md`](https://github.com/Expensify/App/blob/main/contributingGuides/STYLE.md#jsdocs)) were followed - [ ] If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers diff --git a/docs/_data/routes.yml b/docs/_data/routes.yml index 1393c94c51a8..7f10a60381a9 100644 --- a/docs/_data/routes.yml +++ b/docs/_data/routes.yml @@ -38,8 +38,8 @@ hubs: title: Expensify Playbook for US-Based VC-Backed Startups - href: Expensify-Playbook-for-US-Based-Bootstrapped-Startups title: Expensify Playbook for US-Based Bootstrapped Startups - - href: Expensify-Playbook-for-US-Based-Small-Businesses - title: Expensify Playbook for US-Based Small Businesses + - href: Expensify-Playbook-for-Small-to-Medium-Sized-Businesses + title: Expensify Playbook for Small to Medium-Sized Businesses - href: other title: Other diff --git a/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Small-Businesses.md b/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md similarity index 99% rename from docs/articles/playbooks/Expensify-Playbook-for-US-Based-Small-Businesses.md rename to docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md index cb656d7a5d08..c07a987e8bf7 100644 --- a/docs/articles/playbooks/Expensify-Playbook-for-US-Based-Small-Businesses.md +++ b/docs/articles/playbooks/Expensify-Playbook-for-Small-to-Medium-Sized-Businesses.md @@ -1,5 +1,5 @@ --- -title: Expensify Playbook for US Based Small to Medium-Sized Businesses +title: Expensify Playbook for Small to Medium-Sized Businesses description: Best practices for how to deploy Expensify for your business --- This guide provides practical tips and recommendations for small businesses with 100 to 250 employees to effectively use Expensify to improve spend visibility, facilitate employee reimbursements, and reduce the risk of fraudulent expenses. diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 8d218b39f973..84aa8cd47d56 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -30,7 +30,7 @@ CFBundleVersion - 1.3.0.0 + 1.3.0.1 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index c7d7076eb1b3..3e41767a8d70 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.3.0.0 + 1.3.0.1 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 28de3a7fa527..86707162d74c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -514,6 +514,8 @@ PODS: - React - react-native-image-picker (5.1.0): - React-Core + - react-native-key-command (0.9.1): + - React-Core - react-native-netinfo (8.3.1): - React-Core - react-native-pdf (6.6.2): @@ -765,6 +767,7 @@ DEPENDENCIES: - react-native-flipper (from `../node_modules/react-native-flipper`) - "react-native-image-manipulator (from `../node_modules/@oguzhnatly/react-native-image-manipulator`)" - react-native-image-picker (from `../node_modules/react-native-image-picker`) + - react-native-key-command (from `../node_modules/react-native-key-command`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" - react-native-pdf (from `../node_modules/react-native-pdf`) - react-native-performance (from `../node_modules/react-native-performance`) @@ -916,6 +919,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@oguzhnatly/react-native-image-manipulator" react-native-image-picker: :path: "../node_modules/react-native-image-picker" + react-native-key-command: + :path: "../node_modules/react-native-key-command" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" react-native-pdf: @@ -1002,7 +1007,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Airship: 19ead2c0bdc791c1b9d6ebb7940aaac99614414e AirshipFrameworkProxy: 037a0ad6491757c45de2c70a6cc47bae5fcfa32b - boost: 57d2868c099736d80fcd648bf211b4431e51a558 + boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: ff54429f0110d3c722630a98096ba689c39f6d5f @@ -1045,7 +1050,7 @@ SPEC CHECKSUMS: Permission-LocationWhenInUse: 3ba99e45c852763f730eabecec2870c2382b7bd4 Plaid: 7d340abeadb46c7aa1a91f896c5b22395a31fcf2 PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef - RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 + RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda RCTRequired: e9e7b8b45aa9bedb2fdad71740adf07a7265b9be RCTTypeSafety: 9ae0e9206625e995f0df4d5b9ddc94411929fb30 React: a71c8e1380f07e01de721ccd52bcf9c03e81867d @@ -1067,6 +1072,7 @@ SPEC CHECKSUMS: react-native-flipper: dc5290261fbeeb2faec1bdc57ae6dd8d562e1de4 react-native-image-manipulator: c48f64221cfcd46e9eec53619c4c0374f3328a56 react-native-image-picker: c33d4e79f0a14a2b66e5065e14946ae63749660b + react-native-key-command: e49d6e44d44705779696d8d3a5ac6b9e3a198941 react-native-netinfo: 1a6035d3b9780221d407c277ebfb5722ace00658 react-native-pdf: 33c622cbdf776a649929e8b9d1ce2d313347c4fa react-native-performance: 224bd53e6a835fda4353302cf891d088a0af7406 diff --git a/package-lock.json b/package-lock.json index 1bd773abf334..d1c30ca218ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.0-0", + "version": "1.3.0-1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.0-0", + "version": "1.3.0-1", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -70,13 +70,14 @@ "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972", + "react-native-key-command": "^0.9.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.38", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#c92aab10ffe24acde04f1c205e19decb9fa4e188", "react-native-plaid-link-sdk": "^10.0.0", "react-native-quick-sqlite": "^8.0.0-beta.2", "react-native-reanimated": "3.0.0-rc.10", @@ -34670,6 +34671,21 @@ "integrity": "sha512-jNNpW5byieb7pb/l0HRvmCav4BtzpTzgC+ybT+Wbi2yyroOukveVvnjwWnmoOeuGynsYB4Yt5eGrWZnPnJSwqQ==", "license": "MIT" }, + "node_modules/react-native-key-command": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-0.9.1.tgz", + "integrity": "sha512-di7G5q66eI0xL14B4kcVfm7azGET07henwu21N8hb71sZpDZGsAJ1WFuR32SwbnkLVNhEk7FJAIH/5Sh+dDQoA==", + "dependencies": { + "events": "^3.3.0", + "underscore": "^1.13.4" + }, + "peerDependencies": { + "react": "^18.1.0", + "react-dom": "18.1.0", + "react-native": "^0.70.4", + "react-native-web": "^0.18.1" + } + }, "node_modules/react-native-localize": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/react-native-localize/-/react-native-localize-2.2.6.tgz", @@ -34778,8 +34794,8 @@ }, "node_modules/react-native-picker-select": { "version": "8.0.4", - "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", - "integrity": "sha512-AvrO+pSH5mjJCosqPuFKB9kBmyTFXS+OWXEYBXVFBDzAYpq18U2DDYlpZoA54xhPbamDCz3xT3UDx0iFoG2GTg==", + "resolved": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#c92aab10ffe24acde04f1c205e19decb9fa4e188", + "integrity": "sha512-AEJ8URKcSR3UrJWln8ISgAuluBYYJgQY/dTOVVMWC62Mhf9w/vVA8TU9YKDHaJKspKUizY0b1HrplggmoZxPuQ==", "license": "MIT", "dependencies": { "lodash.isequal": "^4.5.0" @@ -34912,22 +34928,22 @@ } }, "node_modules/react-native-web": { - "version": "0.18.12", - "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.18.12.tgz", - "integrity": "sha512-fboP7yqobJ8InSr4fP+bQ3scOtSQtUoPcR+HWasH8b/fk/RO+mWcJs/8n+lewy9WTZc2D68ha7VwRDviUshEWA==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.18.1.tgz", + "integrity": "sha512-kWZKJU/OIvVriC7R7WN2Wpai6QllD9f7RDUuaBj2G9FdXaybSQFgmuhey4n6naqbLnj720im2PtcnH54Cn/UXw==", "peer": true, "dependencies": { - "@babel/runtime": "^7.18.6", "create-react-class": "^15.7.0", - "fbjs": "^3.0.4", - "inline-style-prefixer": "^6.0.1", + "fbjs": "^3.0.0", + "inline-style-prefixer": "^6.0.0", "normalize-css-color": "^1.0.2", "postcss-value-parser": "^4.2.0", + "prop-types": "^15.6.0", "styleq": "^0.1.2" }, "peerDependencies": { - "react": "^17.0.2 || ^18.0.0", - "react-dom": "^17.0.2 || ^18.0.0" + "react": ">=17.0.2", + "react-dom": ">=17.0.2" } }, "node_modules/react-native-web-lottie": { @@ -64334,6 +64350,15 @@ "integrity": "sha512-jNNpW5byieb7pb/l0HRvmCav4BtzpTzgC+ybT+Wbi2yyroOukveVvnjwWnmoOeuGynsYB4Yt5eGrWZnPnJSwqQ==", "from": "react-native-image-size@git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972" }, + "react-native-key-command": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/react-native-key-command/-/react-native-key-command-0.9.1.tgz", + "integrity": "sha512-di7G5q66eI0xL14B4kcVfm7azGET07henwu21N8hb71sZpDZGsAJ1WFuR32SwbnkLVNhEk7FJAIH/5Sh+dDQoA==", + "requires": { + "events": "^3.3.0", + "underscore": "^1.13.4" + } + }, "react-native-localize": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/react-native-localize/-/react-native-localize-2.2.6.tgz", @@ -64379,9 +64404,9 @@ "requires": {} }, "react-native-picker-select": { - "version": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", - "integrity": "sha512-AvrO+pSH5mjJCosqPuFKB9kBmyTFXS+OWXEYBXVFBDzAYpq18U2DDYlpZoA54xhPbamDCz3xT3UDx0iFoG2GTg==", - "from": "react-native-picker-select@git+https://github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", + "version": "git+ssh://git@github.com/Expensify/react-native-picker-select.git#c92aab10ffe24acde04f1c205e19decb9fa4e188", + "integrity": "sha512-AEJ8URKcSR3UrJWln8ISgAuluBYYJgQY/dTOVVMWC62Mhf9w/vVA8TU9YKDHaJKspKUizY0b1HrplggmoZxPuQ==", + "from": "react-native-picker-select@git+https://github.com/Expensify/react-native-picker-select.git#c92aab10ffe24acde04f1c205e19decb9fa4e188", "requires": { "lodash.isequal": "^4.5.0" } @@ -64470,17 +64495,17 @@ } }, "react-native-web": { - "version": "0.18.12", - "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.18.12.tgz", - "integrity": "sha512-fboP7yqobJ8InSr4fP+bQ3scOtSQtUoPcR+HWasH8b/fk/RO+mWcJs/8n+lewy9WTZc2D68ha7VwRDviUshEWA==", + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.18.1.tgz", + "integrity": "sha512-kWZKJU/OIvVriC7R7WN2Wpai6QllD9f7RDUuaBj2G9FdXaybSQFgmuhey4n6naqbLnj720im2PtcnH54Cn/UXw==", "peer": true, "requires": { - "@babel/runtime": "^7.18.6", "create-react-class": "^15.7.0", - "fbjs": "^3.0.4", - "inline-style-prefixer": "^6.0.1", + "fbjs": "^3.0.0", + "inline-style-prefixer": "^6.0.0", "normalize-css-color": "^1.0.2", "postcss-value-parser": "^4.2.0", + "prop-types": "^15.6.0", "styleq": "^0.1.2" } }, diff --git a/package.json b/package.json index 435e5e657206..45dcf37f3ab8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.0-0", + "version": "1.3.0-1", "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.", @@ -101,13 +101,14 @@ "react-native-image-pan-zoom": "^2.1.12", "react-native-image-picker": "^5.1.0", "react-native-image-size": "git+https://github.com/Expensify/react-native-image-size#6b5ab5110dc3ed554f8eafbc38d7d87c17147972", + "react-native-key-command": "^0.9.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.38", "react-native-pdf": "^6.6.2", "react-native-performance": "^4.0.0", "react-native-permissions": "^3.0.1", - "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#84ee97dec11c2e65609511eb5a757d61bbeeab79", + "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#c92aab10ffe24acde04f1c205e19decb9fa4e188", "react-native-plaid-link-sdk": "^10.0.0", "react-native-quick-sqlite": "^8.0.0-beta.2", "react-native-reanimated": "3.0.0-rc.10", diff --git a/src/CONFIG.js b/src/CONFIG.js index d430f9d9883a..3ba8e267d503 100644 --- a/src/CONFIG.js +++ b/src/CONFIG.js @@ -64,6 +64,7 @@ export default { PARTNER_PASSWORD: lodashGet(Config, 'EXPENSIFY_PARTNER_PASSWORD', 'e21965746fd75f82bb66'), EXPENSIFY_CASH_REFERER: 'ecash', CONCIERGE_URL_PATHNAME: 'concierge/', + DEVPORTAL_URL_PATHNAME: '_devportal/', CONCIERGE_URL: `${expensifyURL}concierge/`, }, IS_IN_PRODUCTION: Platform.OS === 'web' ? process.env.NODE_ENV === 'production' : !__DEV__, diff --git a/src/CONST.js b/src/CONST.js index 56adbbe7fe32..20e28c18e928 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -1,5 +1,6 @@ import lodashGet from 'lodash/get'; import Config from 'react-native-config'; +import * as KeyCommand from 'react-native-key-command'; import * as Url from './libs/Url'; const CLOUDFRONT_DOMAIN = 'cloudfront.net'; @@ -7,10 +8,20 @@ const CLOUDFRONT_URL = `https://d2k5nsl2zxldvw.${CLOUDFRONT_DOMAIN}`; const ACTIVE_EXPENSIFY_URL = Url.addTrailingForwardSlash(lodashGet(Config, 'NEW_EXPENSIFY_URL', 'https://new.expensify.com')); const USE_EXPENSIFY_URL = 'https://use.expensify.com'; const PLATFORM_OS_MACOS = 'Mac OS'; +const PLATFORM_IOS = 'iOS'; const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; const USA_COUNTRY_NAME = 'United States'; const CURRENT_YEAR = new Date().getFullYear(); +const keyModifierControl = lodashGet(KeyCommand, 'constants.keyModifierControl', 'keyModifierControl'); +const keyModifierCommand = lodashGet(KeyCommand, 'constants.keyModifierCommand', 'keyModifierCommand'); +const keyModifierShiftControl = lodashGet(KeyCommand, 'constants.keyModifierShiftControl', 'keyModifierShiftControl'); +const keyModifierShiftCommand = lodashGet(KeyCommand, 'constants.keyModifierShiftCommand', 'keyModifierShiftCommand'); +const keyInputEscape = lodashGet(KeyCommand, 'constants.keyInputEscape', 'keyInputEscape'); +const keyInputEnter = lodashGet(KeyCommand, 'constants.keyInputEnter', 'keyInputEnter'); +const keyInputUpArrow = lodashGet(KeyCommand, 'constants.keyInputUpArrow', 'keyInputUpArrow'); +const keyInputDownArrow = lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow'); + const CONST = { ANDROID_PACKAGE_NAME, ANIMATED_TRANSITION: 300, @@ -223,6 +234,7 @@ const CONST = { CTRL: { DEFAULT: 'control', [PLATFORM_OS_MACOS]: 'meta', + [PLATFORM_IOS]: 'meta', }, SHIFT: { DEFAULT: 'shift', @@ -233,46 +245,91 @@ const CONST = { descriptionKey: 'search', shortcutKey: 'K', modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: 'k', modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: 'k', modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: 'k', modifierFlags: keyModifierCommand}, + }, }, NEW_GROUP: { descriptionKey: 'newGroup', shortcutKey: 'K', modifiers: ['CTRL', 'SHIFT'], + trigger: { + DEFAULT: {input: 'k', modifierFlags: keyModifierShiftControl}, + [PLATFORM_OS_MACOS]: {input: 'k', modifierFlags: keyModifierShiftCommand}, + [PLATFORM_IOS]: {input: 'k', modifierFlags: keyModifierShiftCommand}, + }, }, SHORTCUT_MODAL: { descriptionKey: 'openShortcutDialog', shortcutKey: 'I', modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: 'i', modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: 'i', modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: 'i', modifierFlags: keyModifierCommand}, + }, }, ESCAPE: { descriptionKey: 'escape', shortcutKey: 'Escape', modifiers: [], + trigger: { + DEFAULT: {input: keyInputEscape}, + [PLATFORM_OS_MACOS]: {input: keyInputEscape}, + [PLATFORM_IOS]: {input: keyInputEscape}, + }, }, ENTER: { descriptionKey: null, shortcutKey: 'Enter', modifiers: [], + trigger: { + DEFAULT: {input: keyInputEnter}, + [PLATFORM_OS_MACOS]: {input: keyInputEnter}, + [PLATFORM_IOS]: {input: keyInputEnter}, + }, }, CTRL_ENTER: { descriptionKey: null, shortcutKey: 'Enter', modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: keyInputEnter, modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: keyInputEnter, modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: keyInputEnter, modifierFlags: keyModifierCommand}, + }, }, COPY: { descriptionKey: 'copy', shortcutKey: 'C', modifiers: ['CTRL'], + trigger: { + DEFAULT: {input: 'c', modifierFlags: keyModifierControl}, + [PLATFORM_OS_MACOS]: {input: 'c', modifierFlags: keyModifierCommand}, + [PLATFORM_IOS]: {input: 'c', modifierFlags: keyModifierCommand}, + }, }, ARROW_UP: { descriptionKey: null, shortcutKey: 'ArrowUp', modifiers: [], + trigger: { + DEFAULT: {input: keyInputUpArrow}, + [PLATFORM_OS_MACOS]: {input: keyInputUpArrow}, + [PLATFORM_IOS]: {input: keyInputUpArrow}, + }, }, ARROW_DOWN: { descriptionKey: null, shortcutKey: 'ArrowDown', modifiers: [], + trigger: { + DEFAULT: {input: keyInputDownArrow}, + [PLATFORM_OS_MACOS]: {input: keyInputDownArrow}, + [PLATFORM_IOS]: {input: keyInputDownArrow}, + }, }, TAB: { descriptionKey: null, @@ -504,6 +561,7 @@ const CONST = { SUCCESS: 200, NOT_AUTHENTICATED: 407, EXP_ERROR: 666, + MANY_WRITES_ERROR: 665, UNABLE_TO_RETRY: 'unableToRetry', }, HTTP_STATUS: { @@ -780,7 +838,7 @@ const CONST = { WINDOWS: 'Windows', MAC_OS: PLATFORM_OS_MACOS, ANDROID: 'Android', - IOS: 'iOS', + IOS: PLATFORM_IOS, LINUX: 'Linux', NATIVE: 'Native', }, @@ -2201,6 +2259,12 @@ const CONST = { PATHS_TO_TREAT_AS_EXTERNAL: [ 'NewExpensify.dmg', ], + PAYPAL_SUPPORTED_CURRENCIES: [ + 'AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', + 'ILS', 'JPY', 'MYR', 'MXN', 'TWD', 'NZD', 'NOK', 'PHP', + 'PLN', 'GBP', 'RUB', 'SGD', 'SEK', 'CHF', 'THB', 'USD', + ], + CONCIERGE_TRAVEL_URL: 'https://community.expensify.com/discussion/7066/introducing-concierge-travel', }; export default CONST; diff --git a/src/Expensify.js b/src/Expensify.js index b73954be6c77..5702bf0cf05d 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -1,7 +1,9 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {PureComponent} from 'react'; +import React, { + useCallback, useState, useEffect, useRef, useLayoutEffect, useMemo, +} from 'react'; import {AppState, Linking} from 'react-native'; import Onyx, {withOnyx} from 'react-native-onyx'; @@ -84,27 +86,52 @@ const defaultProps = { screenShareRequest: null, }; -class Expensify extends PureComponent { - constructor(props) { - super(props); +function Expensify(props) { + const appStateChangeListener = useRef(null); + const [isNavigationReady, setIsNavigationReady] = useState(false); + const [isOnyxMigrated, setIsOnyxMigrated] = useState(false); + const [isSplashShown, setIsSplashShown] = useState(true); + const isAuthenticated = useMemo(() => Boolean(lodashGet(props.session, 'authToken', null)), [props.session]); + + const initializeClient = () => { + if (!Visibility.isVisible()) { + return; + } + + ActiveClientManager.init(); + }; + + const setNavigationReady = useCallback(() => { + setIsNavigationReady(true); + + // Navigate to any pending routes now that the NavigationContainer is ready + Navigation.setIsNavigationReady(); + }, []); + + useLayoutEffect(() => { // Initialize this client as being an active client ActiveClientManager.init(); - this.setNavigationReady = this.setNavigationReady.bind(this); - this.initializeClient = this.initializeClient.bind(true); - this.appStateChangeListener = null; - this.state = { - isNavigationReady: false, - isOnyxMigrated: false, - isSplashShown: true, - }; // Used for the offline indicator appearing when someone is offline NetworkConnection.subscribeToNetInfo(); - } - - componentDidMount() { - setTimeout(() => this.reportBootSplashStatus(), 30 * 1000); + }, []); + + useEffect(() => { + setTimeout(() => { + BootSplash + .getVisibilityStatus() + .then((status) => { + const appState = AppState.currentState; + Log.info('[BootSplash] splash screen status', false, {appState, status}); + + if (status === 'visible') { + const propsToLog = _.omit(props, ['children', 'session']); + propsToLog.isAuthenticated = isAuthenticated; + Log.alert('[BootSplash] splash screen is still visible', {propsToLog}, false); + } + }); + }, 30 * 1000); // This timer is set in the native layer when launching the app and we stop it here so we can measure how long // it took for the main app itself to load. @@ -114,118 +141,78 @@ class Expensify extends PureComponent { migrateOnyx() .then(() => { // In case of a crash that led to disconnection, we want to remove all the push notifications. - if (!this.isAuthenticated()) { + if (!isAuthenticated) { PushNotification.clearNotifications(); } - this.setState({isOnyxMigrated: true}); + setIsOnyxMigrated(true); }); - this.appStateChangeListener = AppState.addEventListener('change', this.initializeClient); + appStateChangeListener.current = AppState.addEventListener('change', initializeClient); // Open chat report from a deep link (only mobile native) Linking.addEventListener('url', state => Report.openReportFromDeepLink(state.url)); - } - componentDidUpdate() { - if (!this.state.isNavigationReady || !this.state.isSplashShown) { + return () => { + if (!appStateChangeListener.current) { return; } + appStateChangeListener.current.remove(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again + }, []); + + useEffect(() => { + if (!isNavigationReady || !isSplashShown) { return; } - const shouldHideSplash = !this.isAuthenticated() || this.props.isSidebarLoaded; + const shouldHideSplash = !isAuthenticated || props.isSidebarLoaded; if (shouldHideSplash) { BootSplash.hide(); - // eslint-disable-next-line react/no-did-update-set-state - this.setState({isSplashShown: false}); + setIsSplashShown(false); // If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report Linking.getInitialURL().then(url => Report.openReportFromDeepLink(url)); } - } + }, [props.isSidebarLoaded, isNavigationReady, isSplashShown, isAuthenticated]); - componentWillUnmount() { - if (!this.appStateChangeListener) { return; } - this.appStateChangeListener.remove(); + // Display a blank page until the onyx migration completes + if (!isOnyxMigrated) { + return null; } - setNavigationReady() { - this.setState({isNavigationReady: true}); - - // Navigate to any pending routes now that the NavigationContainer is ready - Navigation.setIsNavigationReady(); - } - - /** - * @returns {boolean} - */ - isAuthenticated() { - const authToken = lodashGet(this.props, 'session.authToken', null); - return Boolean(authToken); - } - - initializeClient() { - if (!Visibility.isVisible()) { - return; - } - - ActiveClientManager.init(); - } - - reportBootSplashStatus() { - BootSplash - .getVisibilityStatus() - .then((status) => { - const appState = AppState.currentState; - Log.info('[BootSplash] splash screen status', false, {appState, status}); - - if (status === 'visible') { - const props = _.omit(this.props, ['children', 'session']); - props.isAuthenticated = this.isAuthenticated(); - Log.alert('[BootSplash] splash screen is still visible', {props}, false); - } - }); - } - - render() { - // Display a blank page until the onyx migration completes - if (!this.state.isOnyxMigrated) { - return null; - } - - return ( - - {!this.state.isSplashShown && ( - <> - - - + {!isSplashShown && ( + <> + + + + {/* We include the modal for showing a new update at the top level so the option is always present. */} + {props.updateAvailable ? : null} + {props.screenShareRequest ? ( + User.joinScreenShare(props.screenShareRequest.accessToken, props.screenShareRequest.roomName)} + onCancel={User.clearScreenShareRequest} + prompt={props.translate('guides.screenShareRequest')} + confirmText={props.translate('common.join')} + cancelText={props.translate('common.decline')} + isVisible /> - {/* We include the modal for showing a new update at the top level so the option is always present. */} - {this.props.updateAvailable ? : null} - {this.props.screenShareRequest ? ( - User.joinScreenShare(this.props.screenShareRequest.accessToken, this.props.screenShareRequest.roomName)} - onCancel={User.clearScreenShareRequest} - prompt={this.props.translate('guides.screenShareRequest')} - confirmText={this.props.translate('common.join')} - cancelText={this.props.translate('common.decline')} - isVisible - /> - ) : null} - - )} - - - - ); - } + ) : null} + + )} + + + + ); } Expensify.propTypes = propTypes; diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index 6fe8a0625cd8..49d5435c8ebd 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -102,16 +102,17 @@ export default { // Collection Keys COLLECTION: { + DOWNLOAD: 'download_', + POLICY: 'policy_', + POLICY_MEMBER_LIST: 'policyMemberList_', REPORT: 'report_', REPORT_ACTIONS: 'reportActions_', + REPORT_ACTIONS_DRAFTS: 'reportActionsDrafts_', REPORT_DRAFT_COMMENT: 'reportDraftComment_', REPORT_DRAFT_COMMENT_NUMBER_OF_LINES: 'reportDraftCommentNumberOfLines_', - REPORT_ACTIONS_DRAFTS: 'reportActionsDrafts_', - REPORT_USER_IS_TYPING: 'reportUserIsTyping_', - POLICY: 'policy_', REPORT_IS_COMPOSER_FULL_SIZE: 'reportIsComposerFullSize_', - POLICY_MEMBER_LIST: 'policyMemberList_', - DOWNLOAD: 'download_', + REPORT_USER_IS_TYPING: 'reportUserIsTyping_', + TRANSACTION: 'transactions_', }, // Indicates which locale should be used @@ -187,6 +188,7 @@ export default { HOME_ADDRESS_FORM: 'homeAddressForm', NEW_ROOM_FORM: 'newRoomForm', ROOM_SETTINGS_FORM: 'roomSettingsForm', + MONEY_REQUEST_DESCRIPTION_FORM: 'moneyRequestDescriptionForm', }, // Whether we should show the compose input or not diff --git a/src/ROUTES.js b/src/ROUTES.js index 470768a594ac..423169d6dd3e 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -75,6 +75,7 @@ export default { getIOUSendRoute: reportID => `${IOU_SEND}/${reportID}`, IOU_BILL_CURRENCY: `${IOU_BILL_CURRENCY}/:reportID?`, IOU_REQUEST_CURRENCY: `${IOU_REQUEST_CURRENCY}/:reportID?`, + MONEY_REQUEST_DESCRIPTION: `${IOU_REQUEST}/description`, IOU_SEND_CURRENCY: `${IOU_SEND_CURRENCY}/:reportID?`, IOU_SEND_ADD_BANK_ACCOUNT: `${IOU_SEND}/add-bank-account`, IOU_SEND_ADD_DEBIT_CARD: `${IOU_SEND}/add-debit-card`, diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js new file mode 100644 index 000000000000..dc3a722c2331 --- /dev/null +++ b/src/components/Alert/index.js @@ -0,0 +1,24 @@ +import _ from 'underscore'; + +/** + * Shows an alert modal with ok and cancel options. + * + * @param {String} title The title of the alert + * @param {String} description The description of the alert + * @param {Object[]} options An array of objects with `style` and `onPress` properties + */ +export default (title, description, options) => { + const result = _.filter(window.confirm([title, description], Boolean)).join('\n'); + + if (result) { + const confirmOption = _.find(options, ({style}) => style !== 'cancel'); + if (confirmOption && confirmOption.onPress) { + confirmOption.onPress(); + } + } else { + const cancelOption = _.find(options, ({style}) => style === 'cancel'); + if (cancelOption && cancelOption.onPress) { + cancelOption.onPress(); + } + } +}; diff --git a/src/components/Alert/index.native.js b/src/components/Alert/index.native.js new file mode 100644 index 000000000000..31c837a7dd6b --- /dev/null +++ b/src/components/Alert/index.native.js @@ -0,0 +1,3 @@ +import {Alert} from 'react-native'; + +export default Alert.alert; diff --git a/src/components/AvatarCropModal/AvatarCropModal.js b/src/components/AvatarCropModal/AvatarCropModal.js index 97d1ba0368d0..761c453c1964 100644 --- a/src/components/AvatarCropModal/AvatarCropModal.js +++ b/src/components/AvatarCropModal/AvatarCropModal.js @@ -78,6 +78,9 @@ const AvatarCropModal = (props) => { const translateSlider = useSharedValue(0); const isPressableEnabled = useSharedValue(true); + // Check if image cropping, saving or uploading is in progress + const isLoading = useSharedValue(false); + // The previous offset values are maintained to recalculate the offset value in proportion // to the container size, especially when the window size is first decreased and then increased const prevMaxOffsetX = useSharedValue(0); @@ -114,11 +117,12 @@ const AvatarCropModal = (props) => { translateSlider.value = 0; prevMaxOffsetX.value = 0; prevMaxOffsetY.value = 0; + isLoading.value = false; setImageContainerSize(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); setSliderContainerSize(CONST.AVATAR_CROP_MODAL.INITIAL_SIZE); setIsImageContainerInitialized(false); setIsImageInitialized(false); - }, [originalImageHeight, originalImageWidth, prevMaxOffsetX, prevMaxOffsetY, rotation, scale, translateSlider, translateX, translateY]); + }, [originalImageHeight, originalImageWidth, prevMaxOffsetX, prevMaxOffsetY, rotation, scale, translateSlider, translateX, translateY, isLoading]); // In order to calculate proper image position/size/animation, we have to know its size. // And we have to update image size if image url changes. @@ -305,6 +309,10 @@ const AvatarCropModal = (props) => { // Crops an image that was provided in the imageUri prop, using the current position/scale // then calls onSave and onClose callbacks const cropAndSaveImage = useCallback(() => { + if (isLoading.value) { + return; + } + isLoading.value = true; const smallerSize = Math.min(originalImageHeight.value, originalImageWidth.value); const size = smallerSize / scale.value; const imageCenterX = originalImageWidth.value / 2; @@ -334,8 +342,11 @@ const AvatarCropModal = (props) => { .then((newImage) => { props.onClose(); props.onSave(newImage); + }) + .catch(() => { + isLoading.value = false; }); - }, [originalImageHeight.value, originalImageWidth.value, scale.value, translateX.value, imageContainerSize, translateY.value, props, rotation.value]); + }, [originalImageHeight.value, originalImageWidth.value, scale.value, translateX.value, imageContainerSize, translateY.value, props, rotation.value, isLoading]); /** * @param {Number} locationX @@ -346,7 +357,7 @@ const AvatarCropModal = (props) => { 'worklet'; - if (!isPressableEnabled.value) { + if (!locationX || !isPressableEnabled.value) { return; } const newSliderValue = clamp(locationX, [0, sliderContainerSize]); diff --git a/src/components/Button.js b/src/components/Button/index.js similarity index 93% rename from src/components/Button.js rename to src/components/Button/index.js index 329153d1993b..b19e405211d6 100644 --- a/src/components/Button.js +++ b/src/components/Button/index.js @@ -1,19 +1,20 @@ import React, {Component} from 'react'; import {Pressable, ActivityIndicator, View} from 'react-native'; import PropTypes from 'prop-types'; -import styles from '../styles/styles'; -import themeColors from '../styles/themes/default'; -import OpacityView from './OpacityView'; -import Text from './Text'; -import KeyboardShortcut from '../libs/KeyboardShortcut'; -import Icon from './Icon'; -import CONST from '../CONST'; -import * as StyleUtils from '../styles/StyleUtils'; -import HapticFeedback from '../libs/HapticFeedback'; -import withNavigationFallback from './withNavigationFallback'; -import compose from '../libs/compose'; -import * as Expensicons from './Icon/Expensicons'; -import withNavigationFocus from './withNavigationFocus'; +import styles from '../../styles/styles'; +import themeColors from '../../styles/themes/default'; +import OpacityView from '../OpacityView'; +import Text from '../Text'; +import KeyboardShortcut from '../../libs/KeyboardShortcut'; +import Icon from '../Icon'; +import CONST from '../../CONST'; +import * as StyleUtils from '../../styles/StyleUtils'; +import HapticFeedback from '../../libs/HapticFeedback'; +import withNavigationFallback from '../withNavigationFallback'; +import compose from '../../libs/compose'; +import * as Expensicons from '../Icon/Expensicons'; +import withNavigationFocus from '../withNavigationFocus'; +import validateSubmitShortcut from './validateSubmitShortcut'; const propTypes = { /** The text for the button label */ @@ -157,10 +158,10 @@ class Button extends Component { // Setup and attach keypress handler for pressing the button with Enter key this.unsubscribe = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, (e) => { - if (!this.props.isFocused || this.props.isDisabled || this.props.isLoading || (e && e.target.nodeName === 'TEXTAREA')) { + if (!validateSubmitShortcut(this.props.isFocused, this.props.isDisabled, this.props.isLoading, e)) { return; } - e.preventDefault(); + this.props.onPress(); }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, false, this.props.enterKeyEventListenerPriority, false); } diff --git a/src/components/Button/validateSubmitShortcut/index.js b/src/components/Button/validateSubmitShortcut/index.js new file mode 100644 index 000000000000..bfe5c79483fa --- /dev/null +++ b/src/components/Button/validateSubmitShortcut/index.js @@ -0,0 +1,19 @@ +/** + * Validate if the submit shortcut should be triggered depending on the button state + * + * @param {boolean} isFocused Whether Button is on active screen + * @param {boolean} isDisabled Indicates whether the button should be disabled + * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state + * @param {Object} event Focused input event + * @returns {boolean} Returns `true` if the shortcut should be triggered + */ +function validateSubmitShortcut(isFocused, isDisabled, isLoading, event) { + if (!isFocused || isDisabled || isLoading || (event && event.target.nodeName === 'TEXTAREA')) { + return false; + } + + event.preventDefault(); + return true; +} + +export default validateSubmitShortcut; diff --git a/src/components/Button/validateSubmitShortcut/index.native.js b/src/components/Button/validateSubmitShortcut/index.native.js new file mode 100644 index 000000000000..2822fa56d590 --- /dev/null +++ b/src/components/Button/validateSubmitShortcut/index.native.js @@ -0,0 +1,17 @@ +/** + * Validate if the submit shortcut should be triggered depending on the button state + * + * @param {boolean} isFocused Whether Button is on active screen + * @param {boolean} isDisabled Indicates whether the button should be disabled + * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state + * @returns {boolean} Returns `true` if the shortcut should be triggered + */ +function validateSubmitShortcut(isFocused, isDisabled, isLoading) { + if (!isFocused || isDisabled || isLoading) { + return false; + } + + return true; +} + +export default validateSubmitShortcut; diff --git a/src/components/ContextMenuItem.js b/src/components/ContextMenuItem.js index 9dace75bec03..07059a92c5ed 100644 --- a/src/components/ContextMenuItem.js +++ b/src/components/ContextMenuItem.js @@ -55,10 +55,9 @@ class ContextMenuItem extends Component { * Method to call parent onPress and toggleDelayButtonState */ triggerPressAndUpdateSuccess() { - if (this.props.isDelayButtonStateComplete) { - return; + if (!this.props.isDelayButtonStateComplete) { + this.props.onPress(); } - this.props.onPress(); // We only set the success state when we have icon or text to represent the success state // We may want to replace this check by checking the Result from OnPress Callback in future. diff --git a/src/components/ExpensifyWordmark.js b/src/components/ExpensifyWordmark.js new file mode 100644 index 000000000000..11999dad22d2 --- /dev/null +++ b/src/components/ExpensifyWordmark.js @@ -0,0 +1,49 @@ +import React from 'react'; +import {View} from 'react-native'; +import ProductionLogo from '../../assets/images/expensify-wordmark.svg'; +import DevLogo from '../../assets/images/expensify-logo--dev.svg'; +import StagingLogo from '../../assets/images/expensify-logo--staging.svg'; +import AdHocLogo from '../../assets/images/expensify-logo--adhoc.svg'; +import CONST from '../CONST'; +import withEnvironment, {environmentPropTypes} from './withEnvironment'; +import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; +import compose from '../libs/compose'; +import themeColors from '../styles/themes/default'; +import styles from '../styles/styles'; +import * as StyleUtils from '../styles/StyleUtils'; +import variables from '../styles/variables'; + +const propTypes = { + ...environmentPropTypes, + ...windowDimensionsPropTypes, +}; + +const logoComponents = { + [CONST.ENVIRONMENT.DEV]: DevLogo, + [CONST.ENVIRONMENT.STAGING]: StagingLogo, + [CONST.ENVIRONMENT.PRODUCTION]: ProductionLogo, +}; + +const ExpensifyWordmark = (props) => { + // PascalCase is required for React components, so capitalize the const here + const LogoComponent = logoComponents[props.environment] || AdHocLogo; + return ( + <> + + + + + ); +}; + +ExpensifyWordmark.displayName = 'ExpensifyWordmark'; +ExpensifyWordmark.propTypes = propTypes; +export default compose( + withEnvironment, + withWindowDimensions, +)(ExpensifyWordmark); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js index 92c327bc6053..2d400d31fe12 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.js @@ -32,6 +32,7 @@ const AnchorRenderer = (props) => { && !CONST.PATHS_TO_TREAT_AS_EXTERNAL.includes(attrPath) ? attrPath : ''; const internalExpensifyPath = hasExpensifyOrigin && !attrPath.startsWith(CONFIG.EXPENSIFY.CONCIERGE_URL_PATHNAME) + && !attrPath.startsWith(CONFIG.EXPENSIFY.DEVPORTAL_URL_PATHNAME) && attrPath; const navigateToLink = () => { // There can be messages from Concierge with links to specific NewDot reports. Those URLs look like this: diff --git a/src/components/Icon/Illustrations.js b/src/components/Icon/Illustrations.js index 167d8e90f29f..0523fdbbc1e7 100644 --- a/src/components/Icon/Illustrations.js +++ b/src/components/Icon/Illustrations.js @@ -39,6 +39,7 @@ import ConciergeNew from '../../../assets/images/simple-illustrations/simple-ill import MoneyBadge from '../../../assets/images/simple-illustrations/simple-illustration__moneybadge.svg'; import TreasureChest from '../../../assets/images/simple-illustrations/simple-illustration__treasurechest.svg'; import ThumbsUpStars from '../../../assets/images/simple-illustrations/simple-illustration__thumbsupstars.svg'; +import Hands from '../../../assets/images/product-illustrations/home-illustration-hands.svg'; export { Abracadabra, @@ -82,4 +83,5 @@ export { MoneyBadge, TreasureChest, ThumbsUpStars, + Hands, }; diff --git a/src/components/KeyboardShortcutsModal.js b/src/components/KeyboardShortcutsModal.js index 81ad2f642831..a5454e280f0d 100644 --- a/src/components/KeyboardShortcutsModal.js +++ b/src/components/KeyboardShortcutsModal.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {View} from 'react-native'; +import {View, ScrollView} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import HeaderWithCloseButton from './HeaderWithCloseButton'; @@ -34,18 +34,26 @@ const defaultProps = { class KeyboardShortcutsModal extends React.Component { componentDidMount() { - const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUT_MODAL; - this.unsubscribeShortcutModal = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { + const openShortcutModalConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUT_MODAL; + this.unsubscribeShortcutModal = KeyboardShortcut.subscribe(openShortcutModalConfig.shortcutKey, () => { ModalActions.close(); KeyboardShortcutsActions.showKeyboardShortcutModal(); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); + }, openShortcutModalConfig.descriptionKey, openShortcutModalConfig.modifiers, true); + + const closeShortcutModalConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; + this.unsubscribeEscapeModal = KeyboardShortcut.subscribe(closeShortcutModalConfig.shortcutKey, () => { + ModalActions.close(); + KeyboardShortcutsActions.hideKeyboardShortcutModal(); + }, closeShortcutModalConfig.descriptionKey, closeShortcutModalConfig.modifiers, true, true); } componentWillUnmount() { - if (!this.unsubscribeShortcutModal) { - return; + if (this.unsubscribeShortcutModal) { + this.unsubscribeShortcutModal(); + } + if (this.unsubscribeEscapeModal) { + this.unsubscribeEscapeModal(); } - this.unsubscribeShortcutModal(); } /** @@ -85,7 +93,7 @@ class KeyboardShortcutsModal extends React.Component { onClose={KeyboardShortcutsActions.hideKeyboardShortcutModal} > - + {this.props.translate('keyboardShortcutModal.subtitle')} @@ -95,7 +103,7 @@ class KeyboardShortcutsModal extends React.Component { })} - + ); } diff --git a/src/components/LocalePicker.js b/src/components/LocalePicker.js index 49aa459b728d..e61cef1379df 100644 --- a/src/components/LocalePicker.js +++ b/src/components/LocalePicker.js @@ -48,7 +48,7 @@ const LocalePicker = (props) => { size={props.size} value={props.preferredLocale} containerStyles={props.size === 'small' ? [styles.pickerContainerSmall] : []} - backgroundColor={themeColors.midtone} + backgroundColor={themeColors.signInPage} /> ); }; diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 527b2620e557..c3e823715a33 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -1,8 +1,6 @@ import _ from 'underscore'; import React from 'react'; -import { - View, Pressable, -} from 'react-native'; +import {View} from 'react-native'; import Text from './Text'; import styles from '../styles/styles'; import * as StyleUtils from '../styles/StyleUtils'; @@ -18,9 +16,14 @@ import colors from '../styles/colors'; import variables from '../styles/variables'; import MultipleAvatars from './MultipleAvatars'; import * as defaultWorkspaceAvatars from './Icon/WorkspaceDefaultAvatars'; +import PressableWithSecondaryInteraction from './PressableWithSecondaryInteraction'; +import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; +import * as DeviceCapabilities from '../libs/DeviceCapabilities'; +import ControlSelection from '../libs/ControlSelection'; const propTypes = { ...menuItemPropTypes, + ...windowDimensionsPropTypes, }; const defaultProps = { @@ -30,7 +33,8 @@ const defaultProps = { shouldShowBasicTitle: false, shouldShowDescriptionOnTop: false, wrapperStyle: [], - style: {}, + style: styles.popoverMenuItem, + titleStyle: {}, success: false, icon: undefined, iconWidth: undefined, @@ -45,11 +49,13 @@ const defaultProps = { subtitle: undefined, iconType: CONST.ICON_TYPE_ICON, onPress: () => {}, + onSecondaryInteraction: undefined, interactive: true, fallbackIcon: Expensicons.FallbackAvatar, brickRoadIndicator: '', floatRightAvatars: [], shouldStackHorizontally: false, + shouldBlockSelection: false, }; const MenuItem = (props) => { @@ -59,7 +65,7 @@ const MenuItem = (props) => { (props.shouldShowBasicTitle ? undefined : styles.textStrong), (props.interactive && props.disabled ? {...styles.disabledText, ...styles.userSelectNone} : undefined), styles.pre, - ], props.style); + ], props.titleStyle); const descriptionVerticalMargin = props.shouldShowDescriptionOnTop ? styles.mb1 : styles.mt1; const descriptionTextStyle = StyleUtils.combineStyles([ styles.textLabelSupporting, @@ -67,10 +73,10 @@ const MenuItem = (props) => { styles.breakWord, styles.lineHeightNormal, props.title ? descriptionVerticalMargin : undefined, - ], props.style); + ]); return ( - { if (props.disabled) { return; @@ -82,12 +88,16 @@ const MenuItem = (props) => { props.onPress(e); }} + onPressIn={() => props.shouldBlockSelection && props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} + onPressOut={ControlSelection.unblock} + onSecondaryInteraction={props.onSecondaryInteraction} style={({hovered, pressed}) => ([ - styles.popoverMenuItem, + props.style, StyleUtils.getButtonBackgroundColorStyle(getButtonState(props.focused || hovered, pressed, props.success, props.disabled, props.interactive), true), ..._.isArray(props.wrapperStyle) ? props.wrapperStyle : [props.wrapperStyle], ])} disabled={props.disabled} + ref={props.forwardedRef} > {({hovered, pressed}) => ( <> @@ -210,12 +220,14 @@ const MenuItem = (props) => { )} - + ); }; MenuItem.propTypes = propTypes; MenuItem.defaultProps = defaultProps; MenuItem.displayName = 'MenuItem'; - -export default MenuItem; +export default withWindowDimensions(React.forwardRef((props, ref) => ( + // eslint-disable-next-line react/jsx-props-no-spreading + +))); diff --git a/src/components/MenuItemList.js b/src/components/MenuItemList.js index e442d3766893..fe7d40b582f8 100644 --- a/src/components/MenuItemList.js +++ b/src/components/MenuItemList.js @@ -3,6 +3,8 @@ import _ from 'underscore'; import PropTypes from 'prop-types'; import MenuItem from './MenuItem'; import menuItemPropTypes from './menuItemPropTypes'; +import * as ReportActionContextMenu from '../pages/home/report/ContextMenu/ReportActionContextMenu'; +import {CONTEXT_MENU_TYPES} from '../pages/home/report/ContextMenu/ContextMenuActions'; const propTypes = { /** An array of props that are pass to individual MenuItem components */ @@ -12,17 +14,38 @@ const defaultProps = { menuItems: [], }; -const MenuItemList = props => ( - <> - {_.map(props.menuItems, menuItemProps => ( - - ))} - -); +const MenuItemList = (props) => { + let popoverAnchor; + + /** + * Handle the secondary interaction for a menu item. + * + * @param {*} link the menu item link or function to get the link + * @param {Event} e the interaction event + */ + const secondaryInteraction = (link, e) => { + if (typeof link === 'function') { + link().then(url => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, url, popoverAnchor)); + } else if (!_.isEmpty(link)) { + ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, link, popoverAnchor); + } + }; + + return ( + <> + {_.map(props.menuItems, menuItemProps => ( + secondaryInteraction(menuItemProps.link, e) : undefined} + ref={el => popoverAnchor = el} + shouldBlockSelection={Boolean(menuItemProps.link)} + // eslint-disable-next-line react/jsx-props-no-spreading + {...menuItemProps} + /> + ))} + + ); +}; MenuItemList.displayName = 'MenuItemList'; MenuItemList.propTypes = propTypes; diff --git a/src/components/IOUConfirmationList.js b/src/components/MoneyRequestConfirmationList.js similarity index 82% rename from src/components/IOUConfirmationList.js rename to src/components/MoneyRequestConfirmationList.js index ad91b036e682..09732a485ce7 100755 --- a/src/components/IOUConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -16,7 +16,9 @@ import SettlementButton from './SettlementButton'; import ROUTES from '../ROUTES'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from './withCurrentUserPersonalDetails'; import * as IOUUtils from '../libs/IOUUtils'; -import avatarPropTypes from './avatarPropTypes'; +import MenuItemWithTopDescription from './MenuItemWithTopDescription'; +import Navigation from '../libs/Navigation/Navigation'; +import optionPropTypes from './optionPropTypes'; const propTypes = { /** Callback to inform parent modal of success */ @@ -25,12 +27,6 @@ const propTypes = { /** Callback to parent modal to send money */ onSendMoney: PropTypes.func.isRequired, - /** Callback to update comment from MoneyRequestModal */ - onUpdateComment: PropTypes.func, - - /** Comment value from MoneyRequestModal */ - comment: PropTypes.string, - /** Should we request a single or multiple participant selection from user */ hasMultipleParticipants: PropTypes.bool.isRequired, @@ -41,20 +37,7 @@ const propTypes = { iouType: PropTypes.string, /** Selected participants from MoneyRequestModal with login */ - participants: PropTypes.arrayOf(PropTypes.shape({ - login: PropTypes.string.isRequired, - alternateText: PropTypes.string, - hasDraftComment: PropTypes.bool, - icons: PropTypes.arrayOf(avatarPropTypes), - searchText: PropTypes.string, - text: PropTypes.string, - keyForList: PropTypes.string, - reportID: PropTypes.string, - // eslint-disable-next-line react/forbid-prop-types - participantsList: PropTypes.arrayOf(PropTypes.object), - payPalMeAddress: PropTypes.string, - phoneNumber: PropTypes.string, - })).isRequired, + participants: PropTypes.arrayOf(optionPropTypes).isRequired, /** Can the participants be modified or not */ canModifyParticipants: PropTypes.bool, @@ -81,14 +64,15 @@ const propTypes = { session: PropTypes.shape({ email: PropTypes.string.isRequired, }), + + /** Callback function to navigate to a provided step in the MoneyRequestModal flow */ + navigateToStep: PropTypes.func.isRequired, }; const defaultProps = { iou: { selectedCurrencyCode: CONST.CURRENCY.USD, }, - onUpdateComment: null, - comment: '', iouType: CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, canModifyParticipants: false, session: { @@ -97,7 +81,7 @@ const defaultProps = { ...withCurrentUserPersonalDetailsDefaultProps, }; -class IOUConfirmationList extends Component { +class MoneyRequestConfirmationList extends Component { constructor(props) { super(props); @@ -194,24 +178,19 @@ class IOUConfirmationList extends Component { ); sections.push({ - title: this.props.translate('iOUConfirmationList.whoPaid'), + title: this.props.translate('moneyRequestConfirmationList.whoPaid'), data: [formattedMyPersonalDetails], shouldShow: true, indexOffset: 0, isDisabled: true, }, { - title: this.props.translate('iOUConfirmationList.whoWasThere'), + title: this.props.translate('moneyRequestConfirmationList.whoWasThere'), data: formattedParticipants, shouldShow: true, indexOffset: 1, }); } else { - const formattedParticipants = OptionsListUtils.getIOUConfirmationOptionsFromParticipants(this.props.participants, - this.props.numberFormat(this.props.iouAmount, { - style: 'currency', - currency: this.props.iou.selectedCurrencyCode, - })); - + const formattedParticipants = this.getParticipantsWithoutAmount(this.props.participants); sections.push({ title: this.props.translate('common.to'), data: formattedParticipants, @@ -285,24 +264,25 @@ class IOUConfirmationList extends Component { const shouldDisableButton = selectedParticipants.length === 0; const recipient = this.state.participants[0]; const canModifyParticipants = this.props.canModifyParticipants && this.props.hasMultipleParticipants; + const formattedAmount = this.props.numberFormat(this.props.iouAmount, { + style: 'currency', + currency: this.props.iou.selectedCurrencyCode, + }); return ( )} - /> + > + this.props.navigateToStep(0)} + style={styles.moneyRequestMenuItem} + titleStyle={styles.moneyRequestConfirmationAmount} + /> + Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION)} + style={styles.moneyRequestMenuItem} + /> + ); } } -IOUConfirmationList.propTypes = propTypes; -IOUConfirmationList.defaultProps = defaultProps; +MoneyRequestConfirmationList.propTypes = propTypes; +MoneyRequestConfirmationList.defaultProps = defaultProps; export default compose( withLocalize, @@ -340,4 +338,4 @@ export default compose( key: ONYXKEYS.SESSION, }, }), -)(IOUConfirmationList); +)(MoneyRequestConfirmationList); diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 66b613a1ce33..326072b73a1b 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -99,10 +99,12 @@ class BaseOptionsSelector extends Component { return; } - if (this.props.shouldDelayFocus) { - this.focusTimeout = setTimeout(() => this.textInput.focus(), CONST.ANIMATED_TRANSITION); - } else { - this.textInput.focus(); + if (this.props.shouldShowTextInput) { + if (this.props.shouldDelayFocus) { + this.focusTimeout = setTimeout(() => this.textInput.focus(), CONST.ANIMATED_TRANSITION); + } else { + this.textInput.focus(); + } } } @@ -238,7 +240,7 @@ class BaseOptionsSelector extends Component { */ selectRow(option, ref) { return new Promise((resolve) => { - if (this.props.shouldFocusOnSelectRow) { + if (this.props.shouldShowTextInput && this.props.shouldFocusOnSelectRow) { if (this.relatedTarget && ref === this.relatedTarget) { this.textInput.focus(); this.relatedTarget = null; @@ -328,13 +330,15 @@ class BaseOptionsSelector extends Component { {optionsList} - {textInput} + {this.props.children} + {this.props.shouldShowTextInput && textInput} ) : ( <> - {textInput} + {this.props.children} + {this.props.shouldShowTextInput && textInput} {optionsList} diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 851b95f05c6e..c9eba7e5f362 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -28,7 +28,7 @@ const propTypes = { value: PropTypes.string.isRequired, /** Callback fired when text changes */ - onChangeText: PropTypes.func.isRequired, + onChangeText: PropTypes.func, /** Limits the maximum number of characters that can be entered in input field */ maxLength: PropTypes.number, @@ -84,6 +84,9 @@ const propTypes = { /** If true, the text input will be below the options in the selector, not above. */ shouldTextInputAppearBelowOptions: PropTypes.bool, + /** If false, the text input will not be shown at all. Defaults to true */ + shouldShowTextInput: PropTypes.bool, + /** Custom content to display in the footer instead of the default button. */ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), @@ -125,6 +128,8 @@ const defaultProps = { shouldHaveOptionSeparator: false, initiallyFocusedOptionKey: undefined, maxLength: undefined, + shouldShowTextInput: true, + onChangeText: () => {}, }; export {propTypes, defaultProps}; diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index f3ea25a471fd..412755a4ccef 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -4,6 +4,7 @@ import {Pressable} from 'react-native'; import * as pressableWithSecondaryInteractionPropTypes from './pressableWithSecondaryInteractionPropTypes'; import styles from '../../styles/styles'; import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; +import * as StyleUtils from '../../styles/StyleUtils'; /** * This is a special Pressable that calls onSecondaryInteraction when LongPressed, or right-clicked. @@ -11,6 +12,7 @@ import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; class PressableWithSecondaryInteraction extends Component { constructor(props) { super(props); + this.executeSecondaryInteraction = this.executeSecondaryInteraction.bind(this); this.executeSecondaryInteractionOnContextMenu = this.executeSecondaryInteractionOnContextMenu.bind(this); } @@ -25,11 +27,28 @@ class PressableWithSecondaryInteraction extends Component { this.pressableRef.removeEventListener('contextmenu', this.executeSecondaryInteractionOnContextMenu); } + /** + * @param {Event} e - the secondary interaction event + */ + executeSecondaryInteraction(e) { + if (DeviceCapabilities.hasHoverSupport()) { + return; + } + if (this.props.withoutFocusOnSecondaryInteraction && this.pressableRef) { + this.pressableRef.blur(); + } + this.props.onSecondaryInteraction(e); + } + /** * @param {contextmenu} e - A right-click MouseEvent. * https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event */ executeSecondaryInteractionOnContextMenu(e) { + if (!this.props.onSecondaryInteraction) { + return; + } + e.stopPropagation(); if (this.props.preventDefaultContentMenu) { e.preventDefault(); @@ -54,17 +73,9 @@ class PressableWithSecondaryInteraction extends Component { // On Web, Text does not support LongPress events thus manage inline mode with styling instead of using Text. return ( { - if (DeviceCapabilities.hasHoverSupport()) { - return; - } - if (this.props.withoutFocusOnSecondaryInteraction && this.pressableRef) { - this.pressableRef.blur(); - } - this.props.onSecondaryInteraction(e); - }} + onLongPress={this.props.onSecondaryInteraction ? this.executeSecondaryInteraction : undefined} onPressOut={this.props.onPressOut} onPress={this.props.onPress} ref={el => this.pressableRef = el} diff --git a/src/components/PressableWithSecondaryInteraction/index.native.js b/src/components/PressableWithSecondaryInteraction/index.native.js index c51671ba835c..744c92e4f81b 100644 --- a/src/components/PressableWithSecondaryInteraction/index.native.js +++ b/src/components/PressableWithSecondaryInteraction/index.native.js @@ -19,6 +19,9 @@ const PressableWithSecondaryInteraction = (props) => { ref={props.forwardedRef} onPress={props.onPress} onLongPress={(e) => { + if (!props.onSecondaryInteraction) { + return; + } e.preventDefault(); HapticFeedback.longPress(); props.onSecondaryInteraction(e); diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index bffe11bc4cd8..0964edaa9506 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -1,4 +1,5 @@ import PropTypes from 'prop-types'; +import stylePropTypes from '../../styles/stylePropTypes'; const propTypes = { /** The function that should be called when this pressable is pressed */ @@ -11,13 +12,19 @@ const propTypes = { onPressOut: PropTypes.func, /** The function that should be called when this pressable is LongPressed or right-clicked. */ - onSecondaryInteraction: PropTypes.func.isRequired, + onSecondaryInteraction: PropTypes.func, /** The children which should be contained in this wrapper component. */ - children: PropTypes.node.isRequired, + children: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.node, + ]).isRequired, /** The ref to the search input (may be null on small screen widths) */ - forwardedRef: PropTypes.func, + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.object, + ]), /** Prevent the default ContextMenu on web/Desktop */ preventDefaultContentMenu: PropTypes.bool, @@ -34,6 +41,9 @@ const propTypes = { /** Disable focus trap for the element on secondary interaction */ withoutFocusOnSecondaryInteraction: PropTypes.bool, + + /** Used to apply styles to the Pressable */ + style: stylePropTypes, }; const defaultProps = { diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js index e47e101c3ea7..28cb56ecbfa2 100644 --- a/src/components/ScreenWrapper/index.js +++ b/src/components/ScreenWrapper/index.js @@ -36,7 +36,7 @@ class ScreenWrapper extends React.Component { } Navigation.dismissModal(); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, true); this.unsubscribeTransitionEnd = this.props.navigation.addListener('transitionEnd', (event) => { // Prevent firing the prop callback when user is exiting the page. diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index d9e0db3b2e61..343d2fd6edba 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -1,3 +1,4 @@ +import _ from 'underscore'; import React from 'react'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; @@ -59,7 +60,7 @@ class SettlementButton extends React.Component { }); } - if (this.props.shouldShowPaypal) { + if (this.props.shouldShowPaypal && _.includes(CONST.PAYPAL_SUPPORTED_CURRENCIES, this.props.currency)) { buttonOptions.push({ text: this.props.translate('iou.settlePaypalMe'), icon: Expensicons.PayPal, diff --git a/src/components/Tooltip/TooltipRenderedOnPageBody.js b/src/components/Tooltip/TooltipRenderedOnPageBody.js index 43bf5f49cead..13af49751c57 100644 --- a/src/components/Tooltip/TooltipRenderedOnPageBody.js +++ b/src/components/Tooltip/TooltipRenderedOnPageBody.js @@ -86,6 +86,12 @@ class TooltipRenderedOnPageBody extends React.PureComponent { } componentDidUpdate(prevProps) { + // We need to re-calculate the tooltipContentWidth if it is greater than maxWidth. + // So that the wrapperWidth still be updated again with correct value + if (this.state.tooltipContentWidth > prevProps.maxWidth) { + this.updateTooltipContentWidth(); + } + if (prevProps.text === this.props.text && prevProps.renderTooltipContent === this.props.renderTooltipContent) { return; } @@ -118,11 +124,6 @@ class TooltipRenderedOnPageBody extends React.PureComponent { } render() { - // We need to re-calculate the tooltipContentWidth if it is greater than maxWidth. - // So that the wrapperWidth still be updated again with correct value - if (this.state.tooltipContentWidth > this.props.maxWidth) { - this.updateTooltipContentWidth(); - } const { animationStyle, tooltipWrapperStyle, diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js index a3dd7939e143..64119c475de7 100755 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js +++ b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.js @@ -34,7 +34,6 @@ class BaseVideoChatButtonAndMenu extends Component { this.dimensionsEventListener = null; - this.toggleVideoChatMenu = this.toggleVideoChatMenu.bind(this); this.measureVideoChatIconPosition = this.measureVideoChatIconPosition.bind(this); this.videoChatIconWrapper = null; this.menuItemData = [ @@ -42,7 +41,7 @@ class BaseVideoChatButtonAndMenu extends Component { icon: ZoomIcon, text: props.translate('videoChatButtonAndMenu.zoom'), onPress: () => { - this.toggleVideoChatMenu(); + this.setMenuVisibility(false); Linking.openURL(CONST.NEW_ZOOM_MEETING_URL); }, }, @@ -50,7 +49,7 @@ class BaseVideoChatButtonAndMenu extends Component { icon: GoogleMeetIcon, text: props.translate('videoChatButtonAndMenu.googleMeet'), onPress: () => { - this.toggleVideoChatMenu(); + this.setMenuVisibility(false); Linking.openURL(this.props.googleMeetURL); }, }, @@ -74,12 +73,11 @@ class BaseVideoChatButtonAndMenu extends Component { } /** - * Toggles the state variable isVideoChatMenuActive + * Set the state variable isVideoChatMenuActive + * @param {Boolean} isVideoChatMenuActive */ - toggleVideoChatMenu() { - this.setState(prevState => ({ - isVideoChatMenuActive: !prevState.isVideoChatMenuActive, - })); + setMenuVisibility(isVideoChatMenuActive) { + this.setState({isVideoChatMenuActive}); } /** @@ -114,7 +112,7 @@ class BaseVideoChatButtonAndMenu extends Component { Linking.openURL(this.props.guideCalendarLink); return; } - this.toggleVideoChatMenu(); + this.setMenuVisibility(true); }} style={[styles.touchableButtonImage]} > @@ -126,7 +124,7 @@ class BaseVideoChatButtonAndMenu extends Component { this.setMenuVisibility(false)} isVisible={this.state.isVideoChatMenuActive} anchorPosition={{ left: this.state.videoChatIconPosition.x - 150, diff --git a/src/components/WelcomeText.js b/src/components/WelcomeText.js deleted file mode 100755 index 0624f4061fbc..000000000000 --- a/src/components/WelcomeText.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from '../styles/styles'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import Text from './Text'; - -const propTypes = { - - /** Fontsize */ - smallFontSize: PropTypes.bool, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - smallFontSize: false, -}; - -const WelcomeText = (props) => { - const textSize = props.smallFontSize ? styles.textLabel : undefined; - return ( - <> - - {props.translate('welcomeText.welcome')} - - - {props.translate('welcomeText.phrase2')} - {' '} - {props.translate('welcomeText.phrase3')} - - - ); -}; - -WelcomeText.displayName = 'WelcomeText'; -WelcomeText.propTypes = propTypes; -WelcomeText.defaultProps = defaultProps; - -export default withLocalize(WelcomeText); diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js index 9f3b40a55b43..78486cb5a848 100644 --- a/src/components/menuItemPropTypes.js +++ b/src/components/menuItemPropTypes.js @@ -14,6 +14,9 @@ const propTypes = { /** Used to apply offline styles to child text components */ style: stylePropTypes, + /** Used to apply styles specifically to the title */ + titleStyle: stylePropTypes, + /** Function to fire when component is pressed */ onPress: PropTypes.func, @@ -85,6 +88,18 @@ const propTypes = { /** Prop to identify if we should load avatars vertically instead of diagonally */ shouldStackHorizontally: PropTypes.bool, + + /** The function that should be called when this component is LongPressed or right-clicked. */ + onSecondaryInteraction: PropTypes.func, + + /** Flag to indicate whether or not text selection should be disabled from long-pressing the menu item. */ + shouldBlockSelection: PropTypes.bool, + + /** The ref to the menu item */ + forwardedRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.object, + ]), }; export default propTypes; diff --git a/src/components/optionPropTypes.js b/src/components/optionPropTypes.js index b5966c13f676..bb1a7a073b61 100644 --- a/src/components/optionPropTypes.js +++ b/src/components/optionPropTypes.js @@ -66,4 +66,8 @@ export default PropTypes.shape({ /** If we need to show a brick road indicator or not */ brickRoadIndicator: PropTypes.oneOf([CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, '']), + + phoneNumber: PropTypes.string, + + payPalMeAddress: PropTypes.string, }); diff --git a/src/components/withDelayToggleButtonState.js b/src/components/withDelayToggleButtonState.js index c312d18fba74..9ba0452d1ea5 100644 --- a/src/components/withDelayToggleButtonState.js +++ b/src/components/withDelayToggleButtonState.js @@ -41,6 +41,11 @@ export default function (WrappedComponent) { return; } + // Clear existing timer + if (this.resetButtonStateCompleteTimer) { + clearTimeout(this.resetButtonStateCompleteTimer); + } + this.resetButtonStateCompleteTimer = setTimeout(() => { this.setState({ isDelayButtonStateComplete: false, diff --git a/src/languages/en.js b/src/languages/en.js index 8bc794e41955..913d4669aac1 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -131,6 +131,7 @@ export default { yesContinue: 'Yes, continue', websiteExample: 'e.g. https://www.expensify.com', zipCodeExampleFormat: ({zipSampleFormat}) => (zipSampleFormat ? `e.g. ${zipSampleFormat}` : ''), + description: 'Description', }, attachmentPicker: { cameraPermissionRequired: 'Camera access', @@ -180,7 +181,7 @@ export default { tfaRequiredTitle: 'Two factor authentication\nrequired', tfaRequiredDescription: 'Please enter the two-factor authentication code\nwhere you are trying to sign in.', }, - iOUConfirmationList: { + moneyRequestConfirmationList: { whoPaid: 'Who paid?', whoWasThere: 'Who was there?', whatsItFor: 'What\'s it for?', @@ -200,12 +201,20 @@ export default { hello: 'Hello', phoneCountryCode: '1', welcomeText: { - welcome: 'Welcome to New Expensify! Enter your phone number or email to continue.', - welcomeEnterMagicCode: ({login}) => `It's always great to see a new face around here! Please enter the magic code sent to ${login}`, + getStarted: 'Get started below.', + welcomeBack: 'Welcome back!', + welcome: 'Welcome!', phrase2: 'Money talks. And now that chat and payments are in one place, it\'s also easy.', phrase3: 'Your payments get to you as fast as you can get your point across.', - welcomeBack: 'Welcome back to the New Expensify! Please enter your password.', - welcomeBackEnterMagicCode: ({login}) => `Welcome back! Please enter the magic code sent to ${login}`, + enterPassword: 'Please enter your password', + newFaceEnterMagicCode: ({login}) => `It's always great to see a new face around here! Please enter the magic code sent to ${login}`, + welcomeEnterMagicCode: ({login}) => `Please enter the magic code sent to ${login}`, + }, + login: { + hero: { + header: 'Split bills, request payments, and chat with friends.', + body: 'Welcome to the future of Expensify, your new go-to place for financial collaboration with friends and teammates alike.', + }, }, reportActionCompose: { addAction: 'Actions', @@ -474,12 +483,14 @@ export default { gotIt: 'Got it', }, addPayPalMePage: { - enterYourUsernameToGetPaidViaPayPal: 'Enter your username to get paid back via PayPal.', + enterYourUsernameToGetPaidViaPayPal: 'Get paid back via PayPal.', payPalMe: 'PayPal.me/', yourPayPalUsername: 'Your PayPal username', addPayPalAccount: 'Add PayPal account', growlMessageOnSave: 'Your PayPal username was successfully added', formatError: 'Invalid PayPal.me username', + checkListOf: 'Check the list of ', + supportedCurrencies: 'supported currencies', }, addDebitCardPage: { addADebitCard: 'Add a debit card', @@ -757,6 +768,7 @@ export default { }, messages: { errorMessageInvalidPhone: `Please enter a valid phone number without brackets or dashes. If you're outside the US please include your country code (e.g. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + errorMessageInvalidEmail: 'Invalid email', }, onfidoStep: { acceptTerms: 'By continuing with the request to activate your Expensify wallet, you confirm that you have read, understand and accept ', diff --git a/src/languages/es.js b/src/languages/es.js index ea4bb1fc7a76..ba6e38232047 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -130,6 +130,7 @@ export default { yesContinue: 'Sí, Continuar', websiteExample: 'p. ej. https://www.expensify.com', zipCodeExampleFormat: ({zipSampleFormat}) => (zipSampleFormat ? `p. ej. ${zipSampleFormat}` : ''), + description: 'Descripción', }, attachmentPicker: { cameraPermissionRequired: 'Permiso para acceder a la cámara', @@ -179,7 +180,7 @@ export default { tfaRequiredTitle: 'Se requiere autenticación\nde dos factores', tfaRequiredDescription: 'Por favor, introduce el código de autenticación de dos factores\ndonde estás intentando iniciar sesión.', }, - iOUConfirmationList: { + moneyRequestConfirmationList: { whoPaid: '¿Quién pago?', whoWasThere: '¿Quién asistió?', whatsItFor: '¿Para qué es?', @@ -199,12 +200,20 @@ export default { hello: 'Hola', phoneCountryCode: '34', welcomeText: { - welcome: '¡Bienvenido al Nuevo Expensify! Por favor, introduce tu número de teléfono o email para continuar.', - welcomeEnterMagicCode: ({login}) => `¡Siempre es genial ver una cara nueva por aquí! Por favor, introduce el código mágico enviado a ${login}`, + getStarted: 'Comience a continuación.', + welcomeBack: '¡Bienvenido de nuevo!', + welcome: '¡Bienvenido!', phrase2: 'El dinero habla. Y ahora que chat y pagos están en un mismo lugar, es también fácil.', phrase3: 'Tus pagos llegan tan rápido como tus mensajes.', - welcomeBack: '¡Bienvenido de nuevo al Nuevo Expensify! Por favor, introduce tu contraseña.', - welcomeBackEnterMagicCode: ({login}) => `¡Bienvenido de nuevo! Por favor, introduce el código mágico enviado a ${login}`, + enterPassword: 'Por favor, introduce tu contraseña', + newFaceEnterMagicCode: ({login}) => `¡Siempre es genial ver una cara nueva por aquí! Por favor ingresa el código mágico enviado a ${login}`, + welcomeEnterMagicCode: ({login}) => `Por favor, introduce el código mágico enviado a ${login}`, + }, + login: { + hero: { + header: 'Divida las facturas, solicite pagos y chatee con sus amigos.', + body: 'Bienvenido al futuro de Expensify, tu nuevo lugar de referencia para la colaboración financiera con amigos y compañeros de equipo por igual.', + }, }, reportActionCompose: { addAction: 'Acción', @@ -473,12 +482,14 @@ export default { gotIt: 'Ok, entendido', }, addPayPalMePage: { - enterYourUsernameToGetPaidViaPayPal: 'Escribe tu nombre de usuario para que otros puedan pagarte a través de PayPal.', + enterYourUsernameToGetPaidViaPayPal: 'Recibe pagos vía PayPal.', payPalMe: 'PayPal.me/', yourPayPalUsername: 'Tu usuario de PayPal', addPayPalAccount: 'Agregar cuenta de PayPal', growlMessageOnSave: 'Tu nombre de usuario de PayPal se agregó correctamente', formatError: 'Usuario PayPal.me no válido', + checkListOf: 'Consulta la lista de ', + supportedCurrencies: 'monedas admitidas', }, addDebitCardPage: { addADebitCard: 'Agregar una tarjeta de débito', @@ -755,7 +766,8 @@ export default { }, }, messages: { - errorMessageInvalidPhone: `Por favor, introduce un número de teléfono válido sin paréntesis o guiones. Si reside fuera de Estados Unidos, incluye el prefijo internacional (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + errorMessageInvalidPhone: `Por favor, introduce un número de teléfono válido sin paréntesis o guiones. Si reside fuera de Estados Unidos, por favor incluye el prefijo internacional (p. ej. ${CONST.EXAMPLE_PHONE_NUMBER}).`, + errorMessageInvalidEmail: 'Email inválido', }, onfidoStep: { acceptTerms: 'Al continuar con la solicitud para activar su billetera Expensify, confirma que ha leído, comprende y acepta ', diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.js index d3322733e253..99df2b448f7c 100644 --- a/src/libs/HttpUtils.js +++ b/src/libs/HttpUtils.js @@ -4,6 +4,7 @@ import CONST from '../CONST'; import ONYXKEYS from '../ONYXKEYS'; import HttpsError from './Errors/HttpsError'; import * as ApiUtils from './ApiUtils'; +import alert from '../components/Alert'; let shouldFailAllRequests = false; let shouldForceOffline = false; @@ -94,6 +95,9 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true, title: CONST.ERROR_TITLE.SOCKET, }); } + if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) { + alert('Too many auth writes', 'The API call did more auth write requests than allowed. Check the APIWriteCommands class in Web-Expensify'); + } return response; }); } diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js new file mode 100644 index 000000000000..338ce921221e --- /dev/null +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js @@ -0,0 +1,54 @@ +import _ from 'underscore'; +import getKeyEventModifiers from '../getKeyEventModifiers'; + +/** + * Checks if an event for that key is configured and if so, runs it. + * @param {Function} getDisplayName + * @param {Object} eventHandlers + * @param {Object} keycommandEvent + * @param {Event} event + * @private + */ +function bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event) { + if (!(event instanceof KeyboardEvent)) { + return; + } + + const eventModifiers = getKeyEventModifiers(keycommandEvent); + const displayName = getDisplayName(keycommandEvent.input, eventModifiers); + + // Loop over all the callbacks + _.every(eventHandlers[displayName], (callback) => { + // Early return for excludedNodes + if (_.contains(callback.excludedNodes, event.target.nodeName)) { + return true; + } + + // If configured to do so, prevent input text control to trigger this event + if (!callback.captureOnInputs && ( + event.target.nodeName === 'INPUT' + || event.target.nodeName === 'TEXTAREA' + || event.target.contentEditable === 'true' + )) { + return true; + } + + // Determine if the event should bubble before executing the callback (which may have side-effects) + let shouldBubble = callback.shouldBubble || false; + if (_.isFunction(callback.shouldBubble)) { + shouldBubble = callback.shouldBubble(); + } + + if (_.isFunction(callback.callback)) { + callback.callback(event); + } + if (callback.shouldPreventDefault) { + event.preventDefault(); + } + + // If the event should not bubble, short-circuit the loop + return shouldBubble; + }); +} + +export default bindHandlerToKeydownEvent; diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js new file mode 100644 index 000000000000..de59c819c504 --- /dev/null +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js @@ -0,0 +1,33 @@ +import _ from 'underscore'; +import getKeyEventModifiers from '../getKeyEventModifiers'; + +/** + * Checks if an event for that key is configured and if so, runs it. + * @param {Function} getDisplayName + * @param {Object} eventHandlers + * @param {Object} keycommandEvent + * @param {Event} event + * @private + */ +function bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event) { + const eventModifiers = getKeyEventModifiers(keycommandEvent); + const displayName = getDisplayName(keycommandEvent.input, eventModifiers); + + // Loop over all the callbacks + _.every(eventHandlers[displayName], (callback) => { + // Determine if the event should bubble before executing the callback (which may have side-effects) + let shouldBubble = callback.shouldBubble || false; + if (_.isFunction(callback.shouldBubble)) { + shouldBubble = callback.shouldBubble(); + } + + if (_.isFunction(callback.callback)) { + callback.callback(event); + } + + // If the event should not bubble, short-circuit the loop + return shouldBubble; + }); +} + +export default bindHandlerToKeydownEvent; diff --git a/src/libs/KeyboardShortcut/getKeyEventModifiers.js b/src/libs/KeyboardShortcut/getKeyEventModifiers.js new file mode 100644 index 000000000000..7865d51a0507 --- /dev/null +++ b/src/libs/KeyboardShortcut/getKeyEventModifiers.js @@ -0,0 +1,27 @@ +import * as KeyCommand from 'react-native-key-command'; +import lodashGet from 'lodash/get'; + +/** + * Gets modifiers from a keyboard event. + * + * @param {Event} event + * @returns {Array} + */ +function getKeyEventModifiers(event) { + if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierControl', 'keyModifierControl')) { + return ['CONTROL']; + } + if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierCommand', 'keyModifierCommand')) { + return ['META']; + } + if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierShiftControl', 'keyModifierShiftControl')) { + return ['CONTROL', 'Shift']; + } + if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierShiftCommand', 'keyModifierShiftCommand')) { + return ['META', 'Shift']; + } + + return []; +} + +export default getKeyEventModifiers; diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js index 39c3a49e0609..9ffd01bbc406 100644 --- a/src/libs/KeyboardShortcut/index.js +++ b/src/libs/KeyboardShortcut/index.js @@ -1,9 +1,13 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import Str from 'expensify-common/lib/str'; +import * as KeyCommand from 'react-native-key-command'; +import bindHandlerToKeydownEvent from './bindHandlerToKeydownEvent'; import getOperatingSystem from '../getOperatingSystem'; import CONST from '../../CONST'; +const operatingSystem = getOperatingSystem(); + // Handlers for the various keyboard listeners we set up const eventHandlers = {}; @@ -17,29 +21,6 @@ function getDocumentedShortcuts() { return _.values(documentedShortcuts); } -/** - * Gets modifiers from a keyboard event. - * - * @param {Event} event - * @returns {Array} - */ -function getKeyEventModifiers(event) { - const modifiers = []; - if (event.shiftKey) { - modifiers.push('SHIFT'); - } - if (event.ctrlKey) { - modifiers.push('CONTROL'); - } - if (event.altKey) { - modifiers.push('ALT'); - } - if (event.metaKey) { - modifiers.push('META'); - } - return modifiers; -} - /** * Generates the normalized display name for keyboard shortcuts. * @@ -48,7 +29,23 @@ function getKeyEventModifiers(event) { * @returns {String} */ function getDisplayName(key, modifiers) { - let displayName = [key.toUpperCase()]; + let displayName = (() => { + // Type of key is string and the type of KeyCommand.constants.* is number | string. Use _.isEqual to match different types. + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputEnter', 'keyInputEnter').toString().toLowerCase())) { + return ['ENTER']; + } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputEscape', 'keyInputEscape').toString().toLowerCase())) { + return ['ESCAPE']; + } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputUpArrow', 'keyInputUpArrow').toString().toLowerCase())) { + return ['ARROWUP']; + } + if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow').toString().toLowerCase())) { + return ['ARROWDOWN']; + } + return [key.toUpperCase()]; + })(); + if (_.isString(modifiers)) { displayName.unshift(modifiers); } else if (_.isArray(modifiers)) { @@ -60,56 +57,19 @@ function getDisplayName(key, modifiers) { return displayName.join(' + '); } -/** - * Checks if an event for that key is configured and if so, runs it. - * @param {Event} event - * @private - */ -function bindHandlerToKeydownEvent(event) { - if (!(event instanceof KeyboardEvent)) { +_.each(CONST.KEYBOARD_SHORTCUTS, (shortcut) => { + const shortcutTrigger = lodashGet(shortcut, ['trigger', operatingSystem], lodashGet(shortcut, 'trigger.DEFAULT')); + + // If there is no trigger for the current OS nor a default trigger, then we don't need to do anything + if (!shortcutTrigger) { return; } - const eventModifiers = getKeyEventModifiers(event); - const displayName = getDisplayName(event.key, eventModifiers); - - // Loop over all the callbacks - _.every(eventHandlers[displayName], (callback) => { - // Early return for excludedNodes - if (_.contains(callback.excludedNodes, event.target.nodeName)) { - return true; - } - - // If configured to do so, prevent input text control to trigger this event - if (!callback.captureOnInputs && ( - event.target.nodeName === 'INPUT' - || event.target.nodeName === 'TEXTAREA' - || event.target.contentEditable === 'true' - )) { - return true; - } - - // Determine if the event should bubble before executing the callback (which may have side-effects) - let shouldBubble = callback.shouldBubble || false; - if (_.isFunction(callback.shouldBubble)) { - shouldBubble = callback.shouldBubble(); - } - - if (_.isFunction(callback.callback)) { - callback.callback(event); - } - if (callback.shouldPreventDefault) { - event.preventDefault(); - } - - // If the event should not bubble, short-circuit the loop - return shouldBubble; - }); -} - -// Make sure we don't add multiple listeners -document.removeEventListener('keydown', bindHandlerToKeydownEvent, {capture: true}); -document.addEventListener('keydown', bindHandlerToKeydownEvent, {capture: true}); + KeyCommand.addListener( + shortcutTrigger, + (keycommandEvent, event) => bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event), + ); +}); /** * Unsubscribes a keyboard event handler. @@ -129,7 +89,6 @@ function unsubscribe(displayName, callbackID) { * @returns {Array} */ function getPlatformEquivalentForKeys(keys) { - const operatingSystem = getOperatingSystem(); return _.map(keys, (key) => { if (!_.has(CONST.PLATFORM_SPECIFIC_KEYS, key)) { return key; diff --git a/src/libs/KeyboardShortcut/index.native.js b/src/libs/KeyboardShortcut/index.native.js deleted file mode 100644 index 8c97f2daf343..000000000000 --- a/src/libs/KeyboardShortcut/index.native.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * This is a no-op component for native devices because they wouldn't be able to support keyboard shortcuts like - * a website. - */ -const KeyboardShortcut = { - subscribe() { - return () => {}; - }, - getDocumentedShortcuts() { return []; }, -}; - -export default KeyboardShortcut; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 3fa206018cf8..957fa8cb6087 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -63,6 +63,13 @@ const IOURequestModalStackNavigator = createModalStackNavigator([{ return IOUCurrencySelection; }, name: 'IOU_Request_Currency', +}, +{ + getComponent: () => { + const MoneyRequestDescriptionPage = require('../../../pages/iou/MoneyRequestDescriptionPage').default; + return MoneyRequestDescriptionPage; + }, + name: 'Money_Request_Description', }]); const IOUSendModalStackNavigator = createModalStackNavigator([{ diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 76e00c675852..71caa196abd0 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -226,6 +226,7 @@ export default { screens: { IOU_Request_Root: ROUTES.IOU_REQUEST_WITH_REPORT_ID, IOU_Request_Currency: ROUTES.IOU_REQUEST_CURRENCY, + Money_Request_Description: ROUTES.MONEY_REQUEST_DESCRIPTION, }, }, IOU_Bill: { diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 7db2e875660c..617b84b83f6f 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -2,6 +2,7 @@ import _ from 'underscore'; import Onyx from 'react-native-onyx'; import lodashOrderBy from 'lodash/orderBy'; +import lodashGet from 'lodash/get'; import Str from 'expensify-common/lib/str'; import ONYXKEYS from '../ONYXKEYS'; import CONST from '../CONST'; @@ -86,6 +87,45 @@ Onyx.connect({ }, }); +const policyExpenseReports = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + callback: (report, key) => { + if (!ReportUtils.isPolicyExpenseChat(report)) { + return; + } + policyExpenseReports[key] = report; + }, +}); + +/** + * Get the options for a policy expense report. + * @param {Object} report + * @returns {Array} + */ +function getPolicyExpenseReportOptions(report) { + if (!ReportUtils.isPolicyExpenseChat(report)) { + return []; + } + const filteredPolicyExpenseReports = _.filter(policyExpenseReports, policyExpenseReport => policyExpenseReport.policyID === report.policyID); + return _.map(filteredPolicyExpenseReports, (expenseReport) => { + const policyExpenseChatAvatarSource = lodashGet(policies, [ + `${ONYXKEYS.COLLECTION.POLICY}${expenseReport.policyID}`, 'avatar', + ]) || ReportUtils.getDefaultWorkspaceAvatar(expenseReport.displayName); + return { + ...expenseReport, + keyForList: expenseReport.policyID, + text: expenseReport.displayName, + alternateText: Localize.translateLocal('workspace.common.workspace'), + icons: [{ + source: policyExpenseChatAvatarSource, + name: expenseReport.displayName, + type: CONST.ICON_TYPE_WORKSPACE, + }], + }; + }); +} + /** * Adds expensify SMS domain (@expensify.sms) if login is a phone number and if it's not included yet * @@ -135,6 +175,31 @@ function getPersonalDetailsForLogins(logins, personalDetails) { return personalDetailsForLogins; } +/** + * Get the participant options for a report. + * @param {Object} report + * @param {Array} personalDetails + * @returns {Array} + */ +function getParticipantsOptions(report, personalDetails) { + const participants = lodashGet(report, 'participants', []); + return _.map(getPersonalDetailsForLogins(participants, personalDetails), details => ({ + keyForList: details.login, + login: details.login, + text: details.displayName, + firstName: lodashGet(details, 'firstName', ''), + lastName: lodashGet(details, 'lastName', ''), + alternateText: Str.isSMSLogin(details.login) ? Str.removeSMSDomain(details.login) : details.login, + icons: [{ + source: ReportUtils.getAvatar(details.avatar, details.login), + name: details.login, + type: CONST.ICON_TYPE_AVATAR, + }], + payPalMeAddress: lodashGet(details, 'payPalMeAddress', ''), + phoneNumber: lodashGet(details, 'phoneNumber', ''), + })); +} + /** * Constructs a Set with all possible names (displayName, firstName, lastName, email) for all participants in a report, * to be used in isSearchStringMatch. @@ -799,6 +864,8 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma const isValidPhone = Str.isValidPhone(LoginUtils.appendCountryCode(searchValue)); + const isValidEmail = Str.isValidEmail(searchValue); + if (searchValue && CONST.REGEX.DIGITS_AND_PLUS.test(searchValue) && !isValidPhone) { return Localize.translate(preferredLocale, 'messages.errorMessageInvalidPhone'); } @@ -809,6 +876,9 @@ function getHeaderMessage(hasSelectableOptions, hasUserToInvite, searchValue, ma if (/^\d+$/.test(searchValue) && !isValidPhone) { return Localize.translate(preferredLocale, 'messages.errorMessageInvalidPhone'); } + if (/@/.test(searchValue) && !isValidEmail) { + return Localize.translate(preferredLocale, 'messages.errorMessageInvalidEmail'); + } return Localize.translate(preferredLocale, 'common.noResultsFound'); } @@ -828,4 +898,6 @@ export { getIOUConfirmationOptionsFromParticipants, getSearchText, getAllReportErrors, + getPolicyExpenseReportOptions, + getParticipantsOptions, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 954f248840aa..f35f2ceb7784 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1088,9 +1088,13 @@ function getIOUReportActionMessage(type, total, participants, comment, currency, function buildOptimisticIOUReportAction(type, amount, currency, comment, participants, paymentType = '', iouTransactionID = '', iouReportID = '', isSettlingUp = false) { const IOUTransactionID = iouTransactionID || NumberUtils.rand64(); const IOUReportID = iouReportID || generateReportID(); + const parser = new ExpensiMark(); + const commentText = getParsedComment(comment); + const textForNewComment = parser.htmlToText(commentText); + const textForNewCommentDecoded = Str.htmlDecode(textForNewComment); const originalMessage = { amount, - comment, + comment: textForNewComment, currency, IOUTransactionID, IOUReportID, @@ -1119,7 +1123,7 @@ function buildOptimisticIOUReportAction(type, amount, currency, comment, partici avatar: lodashGet(currentUserPersonalDetails, 'avatar', getDefaultAvatar(currentUserEmail)), isAttachment: false, originalMessage, - message: getIOUReportActionMessage(type, amount, participants, comment, currency, paymentType, isSettlingUp), + message: getIOUReportActionMessage(type, amount, participants, textForNewCommentDecoded, currency, paymentType, isSettlingUp), person: [{ style: 'strong', text: lodashGet(currentUserPersonalDetails, 'displayName', currentUserEmail), diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index 061bc0a837a7..7ca56320904b 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -258,7 +258,7 @@ function getOptionData(reportID) { if (ReportUtils.isReportMessageAttachment({text: report.lastMessageText, html: report.lastMessageHtml})) { lastMessageTextFromReport = `[${Localize.translateLocal('common.attachment')}]`; } else { - lastMessageTextFromReport = Str.htmlDecode(report ? report.lastMessageText : ''); + lastMessageTextFromReport = report ? report.lastMessageText || '' : ''; } // If the last actor's details are not currently saved in Onyx Collection, diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 3ada54cc294b..6e739253dd27 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -553,7 +553,7 @@ function cancelMoneyRequest(chatReportID, iouReportID, type, moneyRequestAction) type, amount, moneyRequestAction.originalMessage.currency, - moneyRequestAction.originalMessage.comment, + Str.htmlDecode(moneyRequestAction.originalMessage.comment), [], '', transactionID, @@ -636,6 +636,15 @@ function setIOUSelectedCurrency(selectedCurrencyCode) { Onyx.merge(ONYXKEYS.IOU, {selectedCurrencyCode}); } +/** + * Sets Money Request description + * + * @param {String} comment + */ +function setMoneyRequestDescription(comment) { + Onyx.merge(ONYXKEYS.IOU, {comment}); +} + /** * @param {Number} amount * @param {String} submitterPayPalMeAddress @@ -1002,6 +1011,7 @@ export { payMoneyRequestElsewhere, payMoneyRequestViaPaypal, setIOUSelectedCurrency, + setMoneyRequestDescription, sendMoneyWithWallet, payMoneyRequestWithWallet, }; diff --git a/src/libs/actions/Link.js b/src/libs/actions/Link.js index c38f901bb391..2cccfc447f6b 100644 --- a/src/libs/actions/Link.js +++ b/src/libs/actions/Link.js @@ -34,33 +34,35 @@ function showGrowlIfOffline() { } /** - * @param {String} url + * @param {String} [url] the url path + * @param {String} [shortLivedAuthToken] + * + * @returns {Promise} */ -function openOldDotLink(url) { - /** - * @param {String} [shortLivedAuthToken] - * @returns {Promise} - */ - function buildOldDotURL(shortLivedAuthToken) { - const hasHashParams = url.indexOf('#') !== -1; - const hasURLParams = url.indexOf('?') !== -1; +function buildOldDotURL(url, shortLivedAuthToken) { + const hasHashParams = url.indexOf('#') !== -1; + const hasURLParams = url.indexOf('?') !== -1; - const authTokenParam = shortLivedAuthToken ? `authToken=${shortLivedAuthToken}` : ''; - const emailParam = `email=${encodeURIComponent(currentUserEmail)}`; + const authTokenParam = shortLivedAuthToken ? `authToken=${shortLivedAuthToken}` : ''; + const emailParam = `email=${encodeURIComponent(currentUserEmail)}`; - const params = _.compact([authTokenParam, emailParam]).join('&'); + const params = _.compact([authTokenParam, emailParam]).join('&'); - return Environment.getOldDotEnvironmentURL() - .then((environmentURL) => { - const oldDotDomain = Url.addTrailingForwardSlash(environmentURL); + return Environment.getOldDotEnvironmentURL() + .then((environmentURL) => { + const oldDotDomain = Url.addTrailingForwardSlash(environmentURL); - // If the URL contains # or ?, we can assume they don't need to have the `?` token to start listing url parameters. - return `${oldDotDomain}${url}${hasHashParams || hasURLParams ? '&' : '?'}${params}`; - }); - } + // If the URL contains # or ?, we can assume they don't need to have the `?` token to start listing url parameters. + return `${oldDotDomain}${url}${hasHashParams || hasURLParams ? '&' : '?'}${params}`; + }); +} +/** + * @param {String} url the url path + */ +function openOldDotLink(url) { if (isNetworkOffline) { - buildOldDotURL().then(oldDotURL => Linking.openURL(oldDotURL)); + buildOldDotURL(url).then(oldDotURL => Linking.openURL(oldDotURL)); return; } @@ -69,11 +71,11 @@ function openOldDotLink(url) { API.makeRequestWithSideEffects( 'OpenOldDotLink', {}, {}, ).then((response) => { - buildOldDotURL(response.shortLivedAuthToken).then((oldDotUrl) => { + buildOldDotURL(url, response.shortLivedAuthToken).then((oldDotUrl) => { Linking.openURL(oldDotUrl); }); }).catch(() => { - buildOldDotURL().then((oldDotUrl) => { + buildOldDotURL(url).then((oldDotUrl) => { Linking.openURL(oldDotUrl); }); }); @@ -92,6 +94,7 @@ function openExternalLink(url, shouldSkipCustomSafariLogic = false) { } export { + buildOldDotURL, openOldDotLink, openExternalLink, }; diff --git a/src/libs/actions/PersonalDetails.js b/src/libs/actions/PersonalDetails.js index 138b70da78f0..f23b246280e1 100644 --- a/src/libs/actions/PersonalDetails.js +++ b/src/libs/actions/PersonalDetails.js @@ -181,7 +181,7 @@ function updateDateOfBirth(dob) { */ function updateAddress(street, street2, city, state, zip, country) { const parameters = { - addressStreet: street, + homeAddressStreet: street, addressStreet2: street2, addressCity: city, addressState: state, diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index f090af06be1f..8069e12d69c8 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -515,7 +515,7 @@ function hideWorkspaceAlertMessage(policyID) { function updateWorkspaceCustomUnit(policyID, currentCustomUnit, newCustomUnit, lastModified) { const optimisticData = [ { - onyxMethod: 'merge', + onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { @@ -530,7 +530,7 @@ function updateWorkspaceCustomUnit(policyID, currentCustomUnit, newCustomUnit, l const successData = [ { - onyxMethod: 'merge', + onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { @@ -545,7 +545,7 @@ function updateWorkspaceCustomUnit(policyID, currentCustomUnit, newCustomUnit, l const failureData = [ { - onyxMethod: 'merge', + onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { @@ -553,9 +553,6 @@ function updateWorkspaceCustomUnit(policyID, currentCustomUnit, newCustomUnit, l customUnitID: currentCustomUnit.customUnitID, name: currentCustomUnit.name, attributes: currentCustomUnit.attributes, - errors: { - [DateUtils.getMicroseconds()]: Localize.translateLocal('workspace.reimburse.updateCustomUnitError'), - }, }, }, }, @@ -579,7 +576,7 @@ function updateWorkspaceCustomUnit(policyID, currentCustomUnit, newCustomUnit, l function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, newCustomUnitRate, lastModified) { const optimisticData = [ { - onyxMethod: 'merge', + onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { @@ -599,7 +596,7 @@ function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, new const successData = [ { - onyxMethod: 'merge', + onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { @@ -617,7 +614,7 @@ function updateCustomUnitRate(policyID, currentCustomUnitRate, customUnitID, new const failureData = [ { - onyxMethod: 'merge', + onyxMethod: CONST.ONYX.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, value: { customUnits: { diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 0e7ccaea53e6..9d771f006732 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -3,6 +3,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Onyx from 'react-native-onyx'; +import Str from 'expensify-common/lib/str'; import ONYXKEYS from '../../ONYXKEYS'; import * as Pusher from '../Pusher/pusher'; import LocalNotification from '../Notification/LocalNotification'; @@ -201,9 +202,11 @@ function addActions(reportID, text = '', file) { const currentTime = DateUtils.getDBTime(); + const lastCommentText = ReportUtils.formatReportLastMessageText(lastAction.message[0].text); + const optimisticReport = { lastVisibleActionCreated: currentTime, - lastMessageText: ReportUtils.formatReportLastMessageText(lastAction.message[0].text), + lastMessageText: Str.htmlDecode(lastCommentText), lastActorEmail: currentUserEmail, lastReadTime: currentTime, }; @@ -521,6 +524,47 @@ function openPaymentDetailsPage(chatReportID, iouReportID) { }); } +/** + * Gets transactions and data associated with the linked report (expense or IOU report) + * + * @param {String} chatReportID + * @param {String} linkedReportID + */ +function openMoneyRequestsReportPage(chatReportID, linkedReportID) { + API.read('OpenMoneyRequestsReportPage', { + reportID: chatReportID, + linkedReportID, + }, { + optimisticData: [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.IOU, + value: { + loading: true, + }, + }, + ], + successData: [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.IOU, + value: { + loading: false, + }, + }, + ], + failureData: [ + { + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: ONYXKEYS.IOU, + value: { + loading: false, + }, + }, + ], + }); +} + /** * Marks the new report actions as read * @@ -684,6 +728,7 @@ function deleteReportComment(reportID, reportAction) { pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, previousMessage: reportAction.message, message: deletedMessage, + errors: null, }, }; @@ -877,6 +922,21 @@ function editReportComment(reportID, originalReportAction, textForNewComment) { }, ]; + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(reportID, optimisticReportActions); + if (reportActionID === lastVisibleAction.reportActionID) { + const optimisticReport = { + lastMessageHtml: lodashGet(lastVisibleAction, 'message[0].html'), + lastMessageText: lodashGet(lastVisibleAction, 'message[0].text'), + lastVisibleActionCreated: lastVisibleAction.created, + lastActorEmail: lastVisibleAction.actorEmail, + }; + optimisticData.push({ + onyxMethod: CONST.ONYX.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + value: optimisticReport, + }); + } + const failureData = [ { onyxMethod: CONST.ONYX.METHOD.MERGE, @@ -1432,6 +1492,7 @@ export { openReportFromDeepLink, navigateToAndOpenReport, openPaymentDetailsPage, + openMoneyRequestsReportPage, updatePolicyRoomName, clearPolicyRoomNameErrors, clearIOUError, diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index b0de89d816db..893ac21c64dc 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -92,7 +92,7 @@ function resendValidationLink(login = credentials.login) { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, - message: Localize.translateLocal('resendValidationForm.linkHasBeenResent'), + message: 'resendValidationForm.linkHasBeenResent', }, }]; const failureData = [{ @@ -127,7 +127,7 @@ function resendValidateCode(login = credentials.login) { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, - message: Localize.translateLocal('validateCodeForm.codeSent'), + message: 'validateCodeForm.codeSent', }, }]; const failureData = [{ @@ -473,7 +473,7 @@ function resendResetPassword() { key: ONYXKEYS.ACCOUNT, value: { isLoading: false, - message: Localize.translateLocal('resendValidationForm.linkHasBeenResent'), + message: 'resendValidationForm.linkHasBeenResent', }, }, ], diff --git a/src/pages/GetAssistancePage.js b/src/pages/GetAssistancePage.js index e2115c6d7814..89544b25dcfe 100644 --- a/src/pages/GetAssistancePage.js +++ b/src/pages/GetAssistancePage.js @@ -57,6 +57,7 @@ const GetAssistancePage = (props) => { shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, wrapperStyle: [styles.cardMenuItem], + link: CONST.NEWHELP_URL, }]; // If the user is eligible for calls with their Guide, add the 'Schedule a setup call' item at the second position in the list diff --git a/src/pages/ReimbursementAccount/Enable2FAPrompt.js b/src/pages/ReimbursementAccount/Enable2FAPrompt.js index 51c62b8957c1..cb7bc1f004d3 100644 --- a/src/pages/ReimbursementAccount/Enable2FAPrompt.js +++ b/src/pages/ReimbursementAccount/Enable2FAPrompt.js @@ -13,31 +13,36 @@ import themeColors from '../../styles/themes/default'; const propTypes = { ...withLocalizePropTypes, }; -const Enable2FAPrompt = props => ( -
{ - Link.openOldDotLink(encodeURI(`settings?param={"section":"account","action":"enableTwoFactorAuth","exitTo":"${ROUTES.getBankAccountRoute()}","isFromNewDot":"true"}`)); +const Enable2FAPrompt = (props) => { + const secureYourAccountUrl = encodeURI(`settings?param={"section":"account","action":"enableTwoFactorAuth","exitTo":"${ROUTES.getBankAccountRoute()}","isFromNewDot":"true"}`); + + return ( +
{ + Link.openOldDotLink(secureYourAccountUrl); + }, + icon: Expensicons.Shield, + shouldShowRightIcon: true, + iconRight: Expensicons.NewWindow, + iconFill: themeColors.success, + wrapperStyle: [styles.cardMenuItem], + link: () => Link.buildOldDotURL(secureYourAccountUrl), }, - icon: Expensicons.Shield, - shouldShowRightIcon: true, - iconRight: Expensicons.NewWindow, - iconFill: themeColors.success, - wrapperStyle: [styles.cardMenuItem], - }, - ]} - > - - - {props.translate('validationStep.enable2FAText')} - - -
-); + ]} + > + + + {props.translate('validationStep.enable2FAText')} + + +
+ ); +}; Enable2FAPrompt.propTypes = propTypes; Enable2FAPrompt.displayName = 'Enable2FAPrompt'; diff --git a/src/pages/SetPasswordPage.js b/src/pages/SetPasswordPage.js index 63ad2392109b..1d38e7f87f78 100755 --- a/src/pages/SetPasswordPage.js +++ b/src/pages/SetPasswordPage.js @@ -21,7 +21,6 @@ import NewPasswordForm from './settings/NewPasswordForm'; import FormAlertWithSubmitButton from '../components/FormAlertWithSubmitButton'; import FormSubmit from '../components/FormSubmit'; import * as ErrorUtils from '../libs/ErrorUtils'; -import OfflineIndicator from '../components/OfflineIndicator'; const propTypes = { /* Onyx Props */ @@ -121,7 +120,6 @@ class SetPasswordPage extends Component { /> - ); diff --git a/src/pages/home/report/ReportActionCompose/index.js b/src/pages/home/report/ReportActionCompose/index.js index 741bf50d1056..466fbfed49fa 100644 --- a/src/pages/home/report/ReportActionCompose/index.js +++ b/src/pages/home/report/ReportActionCompose/index.js @@ -52,6 +52,7 @@ import withKeyboardState, {keyboardStatePropTypes} from '../../../../components/ import ArrowKeyFocusManager from '../../../../components/ArrowKeyFocusManager'; import KeyboardShortcut from '../../../../libs/KeyboardShortcut'; import KeyDownAction from './keyDownAction'; +import OfflineWithFeedback from '../../../../components/OfflineWithFeedback'; const propTypes = { /** Beta features list */ @@ -114,6 +115,9 @@ const propTypes = { keywords: PropTypes.arrayOf(PropTypes.string), })), + /** The type of action that's pending */ + pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, @@ -132,6 +136,7 @@ const defaultProps = { preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, frequentlyUsedEmojis: [], isComposerFullSize: false, + pendingAction: null, ...withCurrentUserPersonalDetailsDefaultProps, }; @@ -228,7 +233,7 @@ class ReportActionCompose extends React.Component { } this.updateComment('', true); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, true); this.setMaxLines(); this.updateComment(this.comment); @@ -741,70 +746,75 @@ class ReportActionCompose extends React.Component { this.props.isComposerFullSize && styles.chatItemFullComposeRow, ]} > - {shouldShowReportRecipientLocalTime && } - - } + - {({displayFileInModal}) => ( - <> - - {({openPicker}) => ( - <> - - {this.props.isComposerFullSize && ( - - { - e.preventDefault(); - this.setShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(this.props.reportID, false); - }} - - // Keep focus on the composer when Collapse button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} - > - - - - - )} - {(!this.props.isComposerFullSize && this.state.isFullComposerAvailable) && ( - - { - e.preventDefault(); - this.setShouldShowSuggestionMenuToFalse(); - Report.setIsComposerFullSize(this.props.reportID, true); - }} - - // Keep focus on the composer when Expand button is clicked. - onMouseDown={e => e.preventDefault()} - style={styles.composerSizeButton} - disabled={isBlockedFromConcierge || this.props.disabled} - > - - - - )} - - + + {({displayFileInModal}) => ( + <> + + {({openPicker}) => ( + <> + + {this.props.isComposerFullSize && ( + + { + e.preventDefault(); + this.setShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(this.props.reportID, false); + }} + + // Keep focus on the composer when Collapse button is clicked. + onMouseDown={e => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || this.props.disabled} + > + + + + + )} + {(!this.props.isComposerFullSize && this.state.isFullComposerAvailable) && ( + + { + e.preventDefault(); + this.setShouldShowSuggestionMenuToFalse(); + Report.setIsComposerFullSize(this.props.reportID, true); + }} + + // Keep focus on the composer when Expand button is clicked. + onMouseDown={e => e.preventDefault()} + style={styles.composerSizeButton} + disabled={isBlockedFromConcierge || this.props.disabled} + > + + + + )} + this.actionButton = el} onPress={(e) => { @@ -819,136 +829,136 @@ class ReportActionCompose extends React.Component { > - - - - this.setMenuVisibility(false)} - onItemSelected={() => this.setMenuVisibility(false)} - anchorPosition={styles.createMenuPositionReportActionCompose} - menuItems={[...this.getMoneyRequestOptions(reportParticipants), - { - icon: Expensicons.Paperclip, - text: this.props.translate('reportActionCompose.addAttachment'), - onSelected: () => { - openPicker({ - onPicked: displayFileInModal, - }); + + + this.setMenuVisibility(false)} + onItemSelected={() => this.setMenuVisibility(false)} + anchorPosition={styles.createMenuPositionReportActionCompose} + menuItems={[...this.getMoneyRequestOptions(reportParticipants), + { + icon: Expensicons.Paperclip, + text: this.props.translate('reportActionCompose.addAttachment'), + onSelected: () => { + openPicker({ + onPicked: displayFileInModal, + }); + }, }, - }, - ]} - /> - - )} - - - { - this.setState({isDraggingOver: true}); - }} - onDragLeave={() => { - this.setState({isDraggingOver: false}); - }} - onDrop={(e) => { - e.preventDefault(); - - const file = lodashGet(e, ['dataTransfer', 'files', 0]); - - displayFileInModal(file); - - this.setState({isDraggingOver: false}); - }} - disabled={this.props.disabled} - > - this.updateComment(comment, true)} - onKeyPress={this.triggerHotkeyActions} - style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} - maxLines={this.state.maxLines} - onFocus={() => this.setIsFocused(true)} - onBlur={() => { - this.setIsFocused(false); - this.resetSuggestedEmojis(); + ]} + /> + + )} + + + { + this.setState({isDraggingOver: true}); }} - onPasteFile={displayFileInModal} - shouldClear={this.state.textInputShouldClear} - onClear={() => this.setTextInputShouldClear(false)} - isDisabled={isComposeDisabled || isBlockedFromConcierge || this.props.disabled} - selection={this.state.selection} - onSelectionChange={this.onSelectionChange} - isFullComposerAvailable={this.state.isFullComposerAvailable} - setIsFullComposerAvailable={this.setIsFullComposerAvailable} - isComposerFullSize={this.props.isComposerFullSize} - value={this.state.value} - numberOfLines={this.props.numberOfLines} - onNumberOfLinesChange={this.updateNumberOfLines} - onLayout={(e) => { - const composerHeight = e.nativeEvent.layout.height; - if (this.state.composerHeight === composerHeight) { - return; - } - this.setState({composerHeight}); + onDragLeave={() => { + this.setState({isDraggingOver: false}); }} - onScroll={() => this.setShouldShowSuggestionMenuToFalse()} - /> - - - - )} - - {DeviceCapabilities.canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( - { - this.focus(true); - this.setState({isEmojiPickerVisible: false}); - }} - onEmojiSelected={this.addEmojiToTextBox} - onWillShow={() => this.setState({isEmojiPickerVisible: true})} - /> - )} - { + e.preventDefault(); - // Keep focus on the composer when Send message is clicked. - onMouseDown={e => e.preventDefault()} - > - - + this.updateComment(comment, true)} + onKeyPress={this.triggerHotkeyActions} + style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]} + maxLines={this.state.maxLines} + onFocus={() => this.setIsFocused(true)} + onBlur={() => { + this.setIsFocused(false); + this.resetSuggestedEmojis(); + }} + onPasteFile={displayFileInModal} + shouldClear={this.state.textInputShouldClear} + onClear={() => this.setTextInputShouldClear(false)} + isDisabled={isComposeDisabled || isBlockedFromConcierge || this.props.disabled} + selection={this.state.selection} + onSelectionChange={this.onSelectionChange} + isFullComposerAvailable={this.state.isFullComposerAvailable} + setIsFullComposerAvailable={this.setIsFullComposerAvailable} + isComposerFullSize={this.props.isComposerFullSize} + value={this.state.value} + numberOfLines={this.props.numberOfLines} + onNumberOfLinesChange={this.updateNumberOfLines} + onLayout={(e) => { + const composerHeight = e.nativeEvent.layout.height; + if (this.state.composerHeight === composerHeight) { + return; + } + this.setState({composerHeight}); + }} + onScroll={() => this.setShouldShowSuggestionMenuToFalse()} + /> + + + + )} + + {DeviceCapabilities.canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( + { + this.focus(true); + this.setState({isEmojiPickerVisible: false}); }} - > - - - + onEmojiSelected={this.addEmojiToTextBox} + onWillShow={() => this.setState({isEmojiPickerVisible: true})} + /> + )} + e.preventDefault()} + > + + + + + + - - - {!this.props.isSmallScreenWidth && } - - - + + {!this.props.isSmallScreenWidth && } + + + + {this.state.isDraggingOver && } {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowSuggestionMenu && ( - - - + isComposerFullSize={this.props.isComposerFullSize} + disabled={this.props.shouldDisableCompose} + /> )} diff --git a/src/pages/iou/MoneyRequestDescriptionPage.js b/src/pages/iou/MoneyRequestDescriptionPage.js new file mode 100644 index 000000000000..eb5281ae8230 --- /dev/null +++ b/src/pages/iou/MoneyRequestDescriptionPage.js @@ -0,0 +1,97 @@ +import React, {Component} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import PropTypes from 'prop-types'; +import TextInput from '../../components/TextInput'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Form from '../../components/Form'; +import ONYXKEYS from '../../ONYXKEYS'; +import styles from '../../styles/styles'; +import Navigation from '../../libs/Navigation/Navigation'; +import compose from '../../libs/compose'; +import * as IOU from '../../libs/actions/IOU'; + +const propTypes = { + ...withLocalizePropTypes, + + /** Onyx Props */ + /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ + iou: PropTypes.shape({ + comment: PropTypes.string, + }), +}; + +const defaultProps = { + iou: { + comment: '', + }, +}; + +class MoneyRequestDescriptionPage extends Component { + constructor(props) { + super(props); + + this.updateComment = this.updateComment.bind(this); + } + + /** + * Closes the modal and clears the description from Onyx. + */ + onCloseButtonPress() { + IOU.setMoneyRequestDescription(''); + Navigation.dismissModal(); + } + + /** + * Sets the money request comment by saving it to Onyx. + * + * @param {Object} value + * @param {String} value.moneyRequestComment + */ + updateComment(value) { + IOU.setMoneyRequestDescription(value.moneyRequestComment); + Navigation.goBack(); + } + + render() { + return ( + + +
({})} + enabledWhenOffline + > + + + +
+
+ ); + } +} + +MoneyRequestDescriptionPage.propTypes = propTypes; +MoneyRequestDescriptionPage.defaultProps = defaultProps; + +export default compose( + withLocalize, + withOnyx({ + iou: {key: ONYXKEYS.IOU}, + }), +)(MoneyRequestDescriptionPage); diff --git a/src/pages/iou/MoneyRequestModal.js b/src/pages/iou/MoneyRequestModal.js index 13a167a56e13..14ff7fd682cf 100644 --- a/src/pages/iou/MoneyRequestModal.js +++ b/src/pages/iou/MoneyRequestModal.js @@ -6,10 +6,9 @@ import {View} from 'react-native'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; -import Str from 'expensify-common/lib/str'; -import IOUAmountPage from './steps/IOUAmountPage'; -import IOUParticipantsPage from './steps/IOUParticipantsPage/IOUParticipantsPage'; -import IOUConfirmPage from './steps/IOUConfirmPage'; +import MoneyRequestAmountPage from './steps/MoneyRequestAmountPage'; +import MoneyRequestParticipantsPage from './steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage'; +import MoneyRequestConfirmPage from './steps/MoneyRequestConfirmPage'; import ModalHeader from './ModalHeader'; import styles from '../../styles/styles'; import * as IOU from '../../libs/actions/IOU'; @@ -99,44 +98,34 @@ const defaultProps = { // Determines type of step to display within Modal, value provides the title for that page. const Steps = { - IOUAmount: 'iou.amount', - IOUParticipants: 'iou.participants', - IOUConfirm: 'iou.confirm', + MoneyRequestAmount: 'moneyRequest.amount', + MoneyRequestParticipants: 'moneyRequest.participants', + MoneyRequestConfirm: 'moneyRequest.confirm', }; const MoneyRequestModal = (props) => { + // Skip MoneyRequestParticipants step if participants are passed in const reportParticipants = lodashGet(props, 'report.participants', []); - const participantsWithDetails = _.map(OptionsListUtils.getPersonalDetailsForLogins(reportParticipants, props.personalDetails), personalDetails => ({ - login: personalDetails.login, - text: personalDetails.displayName, - firstName: lodashGet(personalDetails, 'firstName', ''), - lastName: lodashGet(personalDetails, 'lastName', ''), - alternateText: Str.isSMSLogin(personalDetails.login) ? Str.removeSMSDomain(personalDetails.login) : personalDetails.login, - icons: [{ - source: ReportUtils.getAvatar(personalDetails.avatar, personalDetails.login), - name: personalDetails.login, - type: CONST.ICON_TYPE_AVATAR, - }], - keyForList: personalDetails.login, - payPalMeAddress: lodashGet(personalDetails, 'payPalMeAddress', ''), - phoneNumber: lodashGet(personalDetails, 'phoneNumber', ''), - })); - - // Skip IOUParticipants step if participants are passed in - const steps = reportParticipants.length ? [Steps.IOUAmount, Steps.IOUConfirm] : [Steps.IOUAmount, Steps.IOUParticipants, Steps.IOUConfirm]; - + const steps = useMemo(() => (reportParticipants.length + ? [Steps.MoneyRequestAmount, Steps.MoneyRequestConfirm] + : [Steps.MoneyRequestAmount, Steps.MoneyRequestParticipants, Steps.MoneyRequestConfirm]), + [reportParticipants.length]); const prevCreatingIOUTransactionStatusRef = useRef(lodashGet(props.iou, 'creatingIOUTransaction')); const prevNetworkStatusRef = useRef(props.network.isOffline); - const [previousStepIndex, setPreviousStepIndex] = useState(0); + const [previousStepIndex, setPreviousStepIndex] = useState(-1); const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [participants, setParticipants] = useState(participantsWithDetails); + const [selectedOptions, setSelectedOptions] = useState( + ReportUtils.isPolicyExpenseChat(props.report) + ? OptionsListUtils.getPolicyExpenseReportOptions(props.report) + : OptionsListUtils.getParticipantsOptions(props.report, props.personalDetails), + ); const [amount, setAmount] = useState(''); - const [comment, setComment] = useState(''); 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 }, []); @@ -175,6 +164,17 @@ const MoneyRequestModal = (props) => { * @returns {String|null} */ const direction = useMemo(() => { + // If we're going to the "amount" step from the "confirm" step, push it in and pop it out like we're moving + // forward instead of backwards. + const amountIndex = _.indexOf(steps, Steps.MoneyRequestAmount); + const confirmIndex = _.indexOf(steps, Steps.MoneyRequestConfirm); + if (previousStepIndex === confirmIndex && currentStepIndex === amountIndex) { + return 'in'; + } + if (previousStepIndex === amountIndex && currentStepIndex === confirmIndex) { + return 'out'; + } + if (previousStepIndex < currentStepIndex) { return 'in'; } @@ -186,7 +186,7 @@ const MoneyRequestModal = (props) => { if (previousStepIndex === currentStepIndex) { return null; } - }, [previousStepIndex, currentStepIndex]); + }, [previousStepIndex, currentStepIndex, steps]); /** * Retrieve title for current step, based upon current step and type of request @@ -195,6 +195,10 @@ const MoneyRequestModal = (props) => { */ const titleForStep = useMemo(() => { if (currentStepIndex === 0) { + const confirmIndex = _.indexOf(steps, Steps.MoneyRequestConfirm); + if (previousStepIndex === confirmIndex) { + return props.translate('iou.amount'); + } if (props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND) { return props.translate('iou.sendMoney'); } @@ -202,19 +206,38 @@ const MoneyRequestModal = (props) => { } return props.translate('iou.cash'); // eslint-disable-next-line react-hooks/exhaustive-deps -- props does not need to be a dependency as it will always exist - }, [currentStepIndex, props.translate]); + }, [currentStepIndex, props.translate, steps]); + + /** + * Navigate to a provided step. + * + * @param {Number} stepIndex + * @type {(function(*): void)|*} + */ + const navigateToStep = useCallback((stepIndex) => { + if (stepIndex < 0 || stepIndex > steps.length) { + return; + } + + if (currentStepIndex === stepIndex) { + return; + } + + setPreviousStepIndex(currentStepIndex); + setCurrentStepIndex(stepIndex); + }, [currentStepIndex, steps.length]); /** * Navigate to the previous request step if possible */ const navigateToPreviousStep = useCallback(() => { - if (currentStepIndex <= 0) { + if (currentStepIndex <= 0 && previousStepIndex < 0) { return; } setPreviousStepIndex(currentStepIndex); setCurrentStepIndex(currentStepIndex - 1); - }, [currentStepIndex]); + }, [currentStepIndex, previousStepIndex]); /** * Navigate to the next request step if possible @@ -224,9 +247,16 @@ const MoneyRequestModal = (props) => { return; } + // If we're coming from the confirm step, it means we were editing something so go back to the confirm step. + const confirmIndex = _.indexOf(steps, Steps.MoneyRequestConfirm); + if (previousStepIndex === confirmIndex) { + navigateToStep(confirmIndex); + return; + } + setPreviousStepIndex(currentStepIndex); setCurrentStepIndex(currentStepIndex + 1); - }, [currentStepIndex, steps.length]); + }, [currentStepIndex, previousStepIndex, navigateToStep, steps]); /** * Checks if user has a GOLD wallet then creates a paid IOU report on the fly @@ -236,8 +266,8 @@ const MoneyRequestModal = (props) => { const sendMoney = useCallback((paymentMethodType) => { const amountInDollars = Math.round(amount * 100); const currency = props.iou.selectedCurrencyCode; - const trimmedComment = comment.trim(); - const participant = participants[0]; + const trimmedComment = props.iou.comment.trim(); + const participant = selectedOptions[0]; if (paymentMethodType === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { IOU.sendMoneyElsewhere( @@ -273,14 +303,14 @@ const MoneyRequestModal = (props) => { participant, ); } - }, [amount, comment, participants, props.currentUserPersonalDetails.login, props.iou.selectedCurrencyCode, props.report]); + }, [amount, props.iou.comment, selectedOptions, props.currentUserPersonalDetails.login, props.iou.selectedCurrencyCode, props.report]); /** * @param {Array} selectedParticipants */ const createTransaction = useCallback((selectedParticipants) => { const reportID = lodashGet(props.route, 'params.reportID', ''); - const trimmedComment = comment.trim(); + const trimmedComment = props.iou.comment.trim(); // IOUs created from a group report will have a reportID param in the route. // Since the user is already viewing the report, we don't need to navigate them to the report @@ -309,6 +339,11 @@ const MoneyRequestModal = (props) => { ); return; } + if (!selectedParticipants[0].login) { + // TODO - request to the policy expense chat. Not implemented yet! + // Will be implemented here: https://github.com/Expensify/Expensify/issues/270581 + return; + } IOU.requestMoney( props.report, Math.round(amount * 100), @@ -317,12 +352,21 @@ const MoneyRequestModal = (props) => { selectedParticipants[0], trimmedComment, ); - }, [amount, comment, props.currentUserPersonalDetails.login, props.hasMultipleParticipants, props.iou.selectedCurrencyCode, props.preferredLocale, props.report, props.route]); + }, [amount, props.iou.comment, props.currentUserPersonalDetails.login, props.hasMultipleParticipants, props.iou.selectedCurrencyCode, props.preferredLocale, props.report, props.route]); const currentStep = steps[currentStepIndex]; + const moneyRequestStepIndex = _.indexOf(steps, Steps.MoneyRequestConfirm); + const isEditingAmountAfterConfirm = currentStepIndex === 0 && previousStepIndex === _.indexOf(steps, Steps.MoneyRequestConfirm); const reportID = lodashGet(props, 'route.params.reportID', ''); - const shouldShowBackButton = currentStepIndex > 0; - const modalHeader = ; + const shouldShowBackButton = currentStepIndex > 0 || isEditingAmountAfterConfirm; + const modalHeader = ( + navigateToStep(moneyRequestStepIndex) : navigateToPreviousStep} + /> + ); + const amountButtonText = isEditingAmountAfterConfirm ? props.translate('common.save') : props.translate('common.next'); return ( {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( @@ -331,13 +375,13 @@ const MoneyRequestModal = (props) => { {!didScreenTransitionEnd && } {didScreenTransitionEnd && ( <> - {currentStep === Steps.IOUAmount && ( + {currentStep === Steps.MoneyRequestAmount && ( {modalHeader} - { setAmount(value); navigateToNextStep(); @@ -347,32 +391,33 @@ const MoneyRequestModal = (props) => { selectedAmount={amount} navigation={props.navigation} iouType={props.iouType} + buttonText={amountButtonText} /> )} - {currentStep === Steps.IOUParticipants && ( + {currentStep === Steps.MoneyRequestParticipants && ( {modalHeader} - setParticipants(selectedParticipants)} + onAddParticipants={setSelectedOptions} onStepComplete={navigateToNextStep} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} iouType={props.iouType} /> )} - {currentStep === Steps.IOUConfirm && ( + {currentStep === Steps.MoneyRequestConfirm && ( {modalHeader} - { // TODO: ADD HANDLING TO DISABLE BUTTON FUNCTIONALITY WHILE REQUEST IS IN FLIGHT createTransaction(selectedParticipants); @@ -384,10 +429,8 @@ const MoneyRequestModal = (props) => { ReportScrollManager.scrollToBottom(); }} hasMultipleParticipants={props.hasMultipleParticipants} - participants={_.filter(participants, email => props.currentUserPersonalDetails.login !== email.login)} + participants={_.filter(selectedOptions, email => props.currentUserPersonalDetails.login !== email.login)} iouAmount={amount} - comment={comment} - onUpdateComment={value => setComment(value)} iouType={props.iouType} // The participants can only be modified when the action is initiated from directly within a group chat and not the floating-action-button. @@ -396,6 +439,7 @@ const MoneyRequestModal = (props) => { // split rather than forcing the user to create a new group, just for that expense. The reportID is empty, when the action was initiated from // the floating-action-button (since it is something that exists outside the context of a report). canModifyParticipants={!_.isEmpty(reportID)} + navigateToStep={navigateToStep} /> )} diff --git a/src/pages/iou/steps/IOUConfirmPage.js b/src/pages/iou/steps/IOUConfirmPage.js deleted file mode 100644 index a409daf9479a..000000000000 --- a/src/pages/iou/steps/IOUConfirmPage.js +++ /dev/null @@ -1,75 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import IOUConfirmationList from '../../../components/IOUConfirmationList'; -import CONST from '../../../CONST'; -import avatarPropTypes from '../../../components/avatarPropTypes'; - -const propTypes = { - /** Callback to inform parent modal of success */ - onConfirm: PropTypes.func.isRequired, - - /** Callback to to parent modal to send money */ - onSendMoney: PropTypes.func.isRequired, - - /** Callback to update comment from MoneyRequestModal */ - onUpdateComment: PropTypes.func, - - /** Comment value from MoneyRequestModal */ - comment: PropTypes.string, - - /** Should we request a single or multiple participant selection from user */ - hasMultipleParticipants: PropTypes.bool.isRequired, - - /** IOU amount */ - iouAmount: PropTypes.string.isRequired, - - /** Selected participants from MoneyRequestModal with login */ - participants: PropTypes.arrayOf(PropTypes.shape({ - login: PropTypes.string.isRequired, - alternateText: PropTypes.string, - hasDraftComment: PropTypes.bool, - icons: PropTypes.arrayOf(avatarPropTypes), - searchText: PropTypes.string, - text: PropTypes.string, - keyForList: PropTypes.string, - isPinned: PropTypes.bool, - reportID: PropTypes.string, - // eslint-disable-next-line react/forbid-prop-types - participantsList: PropTypes.arrayOf(PropTypes.object), - payPalMeAddress: PropTypes.string, - phoneNumber: PropTypes.string, - })).isRequired, - - /** IOU type */ - iouType: PropTypes.string, - - /** Can the participants be modified or not */ - canModifyParticipants: PropTypes.bool, -}; - -const defaultProps = { - onUpdateComment: null, - comment: '', - iouType: CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, - canModifyParticipants: false, -}; - -const IOUConfirmPage = props => ( - -); - -IOUConfirmPage.displayName = 'IOUConfirmPage'; -IOUConfirmPage.propTypes = propTypes; -IOUConfirmPage.defaultProps = defaultProps; - -export default IOUConfirmPage; diff --git a/src/pages/iou/steps/IOUAmountPage.js b/src/pages/iou/steps/MoneyRequestAmountPage.js similarity index 97% rename from src/pages/iou/steps/IOUAmountPage.js rename to src/pages/iou/steps/MoneyRequestAmountPage.js index 04a003e973c8..10c260895490 100755 --- a/src/pages/iou/steps/IOUAmountPage.js +++ b/src/pages/iou/steps/MoneyRequestAmountPage.js @@ -32,6 +32,9 @@ const propTypes = { /** Previously selected amount to show if the user comes back to this screen */ selectedAmount: PropTypes.string.isRequired, + /** Text to display on the button that "saves" the amount */ + buttonText: PropTypes.string.isRequired, + /* Onyx Props */ /** Holds data related to IOU view state, rather than the underlying IOU data. */ @@ -48,7 +51,7 @@ const defaultProps = { selectedCurrencyCode: CONST.CURRENCY.USD, }, }; -class IOUAmountPage extends React.Component { +class MoneyRequestAmountPage extends React.Component { constructor(props) { super(props); @@ -336,7 +339,7 @@ class IOUAmountPage extends React.Component { onPress={() => this.props.onStepComplete(this.state.amount)} pressOnEnter isDisabled={!this.state.amount.length || parseFloat(this.state.amount) < 0.01} - text={this.props.translate('common.next')} + text={this.props.buttonText} /> @@ -344,12 +347,12 @@ class IOUAmountPage extends React.Component { } } -IOUAmountPage.propTypes = propTypes; -IOUAmountPage.defaultProps = defaultProps; +MoneyRequestAmountPage.propTypes = propTypes; +MoneyRequestAmountPage.defaultProps = defaultProps; export default compose( withLocalize, withOnyx({ iou: {key: ONYXKEYS.IOU}, }), -)(IOUAmountPage); +)(MoneyRequestAmountPage); diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js new file mode 100644 index 000000000000..673c229ad9c9 --- /dev/null +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -0,0 +1,55 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import MoneyRequestConfirmationList from '../../../components/MoneyRequestConfirmationList'; +import CONST from '../../../CONST'; +import optionPropTypes from '../../../components/optionPropTypes'; + +const propTypes = { + /** Callback to inform parent modal of success */ + onConfirm: PropTypes.func.isRequired, + + /** Callback to parent modal to send money */ + onSendMoney: PropTypes.func.isRequired, + + /** Should we request a single or multiple participant selection from user */ + hasMultipleParticipants: PropTypes.bool.isRequired, + + /** IOU amount */ + iouAmount: PropTypes.string.isRequired, + + /** Selected participants from MoneyRequestModal with login */ + participants: PropTypes.arrayOf(optionPropTypes).isRequired, + + /** IOU type */ + iouType: PropTypes.string, + + /** Can the participants be modified or not */ + canModifyParticipants: PropTypes.bool, + + /** Function to navigate to a given step in the parent MoneyRequestModal */ + navigateToStep: PropTypes.func.isRequired, +}; + +const defaultProps = { + iouType: CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, + canModifyParticipants: false, +}; + +const MoneyRequestConfirmPage = props => ( + +); + +MoneyRequestConfirmPage.displayName = 'IOUConfirmPage'; +MoneyRequestConfirmPage.propTypes = propTypes; +MoneyRequestConfirmPage.defaultProps = defaultProps; + +export default MoneyRequestConfirmPage; diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js similarity index 69% rename from src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js rename to src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js index 0b6b08f38975..e94a6330f2b5 100644 --- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsPage.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage.js @@ -3,11 +3,11 @@ import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import {View} from 'react-native'; import ONYXKEYS from '../../../../ONYXKEYS'; -import IOUParticipantsSplit from './IOUParticipantsSplit'; -import IOUParticipantsRequest from './IOUParticipantsRequest'; +import MoneyRequestParticipantsSplitSelector from './MoneyRequestParticipantsSplitSelector'; +import MoneyRequestParticipantsSelector from './MoneyRequestParticipantsSelector'; import styles from '../../../../styles/styles'; import FullScreenLoadingIndicator from '../../../../components/FullscreenLoadingIndicator'; -import avatarPropTypes from '../../../../components/avatarPropTypes'; +import optionPropTypes from '../../../../components/optionPropTypes'; const propTypes = { /** Callback to inform parent modal of success */ @@ -20,19 +20,7 @@ const propTypes = { onAddParticipants: PropTypes.func.isRequired, /** Selected participants from MoneyRequestModal with login */ - participants: PropTypes.arrayOf(PropTypes.shape({ - login: PropTypes.string.isRequired, - alternateText: PropTypes.string, - hasDraftComment: PropTypes.bool, - icons: PropTypes.arrayOf(avatarPropTypes), - searchText: PropTypes.string, - text: PropTypes.string, - keyForList: PropTypes.string, - isPinned: PropTypes.bool, - reportID: PropTypes.string, - phoneNumber: PropTypes.string, - payPalMeAddress: PropTypes.string, - })), + participants: PropTypes.arrayOf(optionPropTypes), /* Onyx Props */ @@ -59,7 +47,7 @@ const defaultProps = { safeAreaPaddingBottomStyle: {}, }; -const IOUParticipantsPage = (props) => { +const MoneyRequestParticipantsPage = (props) => { if (props.iou.loading) { return ( @@ -70,7 +58,7 @@ const IOUParticipantsPage = (props) => { return (props.hasMultipleParticipants ? ( - { /> ) : ( - { ); }; -IOUParticipantsPage.displayName = 'IOUParticipantsPage'; -IOUParticipantsPage.propTypes = propTypes; -IOUParticipantsPage.defaultProps = defaultProps; +MoneyRequestParticipantsPage.displayName = 'IOUParticipantsPage'; +MoneyRequestParticipantsPage.propTypes = propTypes; +MoneyRequestParticipantsPage.defaultProps = defaultProps; export default withOnyx({ iou: {key: ONYXKEYS.IOU}, -})(IOUParticipantsPage); +})(MoneyRequestParticipantsPage); diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js similarity index 96% rename from src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js rename to src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 697642d3c8f4..cdb903212876 100755 --- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsRequest.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -46,7 +46,7 @@ const defaultProps = { betas: [], }; -class IOUParticipantsRequest extends Component { +class MoneyRequestParticipantsSelector extends Component { constructor(props) { super(props); @@ -169,8 +169,8 @@ class IOUParticipantsRequest extends Component { } } -IOUParticipantsRequest.propTypes = propTypes; -IOUParticipantsRequest.defaultProps = defaultProps; +MoneyRequestParticipantsSelector.propTypes = propTypes; +MoneyRequestParticipantsSelector.defaultProps = defaultProps; export default compose( withLocalize, @@ -185,4 +185,4 @@ export default compose( key: ONYXKEYS.BETAS, }, }), -)(IOUParticipantsRequest); +)(MoneyRequestParticipantsSelector); diff --git a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js similarity index 97% rename from src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js rename to src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js index 4d7c05c461e1..961ec62bd395 100755 --- a/src/pages/iou/steps/IOUParticipantsPage/IOUParticipantsSplit.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSplitSelector.js @@ -60,7 +60,7 @@ const defaultProps = { safeAreaPaddingBottomStyle: {}, }; -class IOUParticipantsSplit extends Component { +class MoneyRequestParticipantsSplitSelector extends Component { constructor(props) { super(props); @@ -244,8 +244,8 @@ class IOUParticipantsSplit extends Component { } } -IOUParticipantsSplit.propTypes = propTypes; -IOUParticipantsSplit.defaultProps = defaultProps; +MoneyRequestParticipantsSplitSelector.propTypes = propTypes; +MoneyRequestParticipantsSplitSelector.defaultProps = defaultProps; export default compose( withLocalize, @@ -260,4 +260,4 @@ export default compose( key: ONYXKEYS.BETAS, }, }), -)(IOUParticipantsSplit); +)(MoneyRequestParticipantsSplitSelector); diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index 7c7840fdbbdb..52ae1f2e0cfc 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -17,8 +17,10 @@ import Logo from '../../../../assets/images/new-expensify.svg'; import pkg from '../../../../package.json'; import * as Report from '../../../libs/actions/Report'; import * as Link from '../../../libs/actions/Link'; -import getPlatformSpecificMenuItems from './getPlatformSpecificMenuItems'; import compose from '../../../libs/compose'; +import * as KeyboardShortcuts from '../../../libs/actions/KeyboardShortcuts'; +import * as ReportActionContextMenu from '../../home/report/ContextMenu/ReportActionContextMenu'; +import {CONTEXT_MENU_TYPES} from '../../home/report/ContextMenu/ContextMenuActions'; const propTypes = { ...withLocalizePropTypes, @@ -26,7 +28,7 @@ const propTypes = { }; const AboutPage = (props) => { - const platformSpecificMenuItems = getPlatformSpecificMenuItems(props.isSmallScreenWidth); + let popoverAnchor; const menuItems = [ { @@ -36,7 +38,11 @@ const AboutPage = (props) => { Navigation.navigate(ROUTES.SETTINGS_APP_DOWNLOAD_LINKS); }, }, - ...platformSpecificMenuItems, + { + translationKey: 'initialSettingsPage.aboutPage.viewKeyboardShortcuts', + icon: Expensicons.Keyboard, + action: KeyboardShortcuts.showKeyboardShortcutModal, + }, { translationKey: 'initialSettingsPage.aboutPage.viewTheCode', icon: Expensicons.Eye, @@ -44,6 +50,7 @@ const AboutPage = (props) => { action: () => { Link.openExternalLink(CONST.GITHUB_URL); }, + link: CONST.GITHUB_URL, }, { translationKey: 'initialSettingsPage.aboutPage.viewOpenJobs', @@ -52,6 +59,7 @@ const AboutPage = (props) => { action: () => { Link.openExternalLink(CONST.UPWORK_URL); }, + link: CONST.UPWORK_URL, }, { translationKey: 'initialSettingsPage.aboutPage.reportABug', @@ -107,6 +115,10 @@ const AboutPage = (props) => { icon={item.icon} iconRight={item.iconRight} onPress={() => item.action()} + shouldBlockSelection={Boolean(item.link)} + onSecondaryInteraction={!_.isEmpty(item.link) + ? e => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, popoverAnchor) : undefined} + ref={el => popoverAnchor = el} shouldShowRightIcon /> ))} diff --git a/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.js b/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.js deleted file mode 100644 index 52f8ffa2250f..000000000000 --- a/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as KeyboardShortcuts from '../../../../libs/actions/KeyboardShortcuts'; -import * as Expensicons from '../../../../components/Icon/Expensicons'; - -export default (isSmallScreenWidth) => { - if (isSmallScreenWidth) { - return []; - } - return [ - { - translationKey: 'initialSettingsPage.aboutPage.viewKeyboardShortcuts', - icon: Expensicons.Keyboard, - action: KeyboardShortcuts.showKeyboardShortcutModal, - }, - ]; -}; diff --git a/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.native.js b/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.native.js deleted file mode 100644 index 4ba9480748fc..000000000000 --- a/src/pages/settings/AboutPage/getPlatformSpecificMenuItems/index.native.js +++ /dev/null @@ -1 +0,0 @@ -export default () => []; diff --git a/src/pages/settings/AppDownloadLinks.js b/src/pages/settings/AppDownloadLinks.js index 9b47bdb644ef..8acd2e95dafb 100644 --- a/src/pages/settings/AppDownloadLinks.js +++ b/src/pages/settings/AppDownloadLinks.js @@ -11,12 +11,9 @@ import compose from '../../libs/compose'; import MenuItem from '../../components/MenuItem'; import styles from '../../styles/styles'; import * as Link from '../../libs/actions/Link'; -import PressableWithSecondaryInteraction from '../../components/PressableWithSecondaryInteraction'; -import ControlSelection from '../../libs/ControlSelection'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; -import * as DeviceCapabilities from '../../libs/DeviceCapabilities'; import * as ReportActionContextMenu from '../home/report/ContextMenu/ReportActionContextMenu'; -import * as ContextMenuActions from '../home/report/ContextMenu/ContextMenuActions'; +import {CONTEXT_MENU_TYPES} from '../home/report/ContextMenu/ContextMenuActions'; const propTypes = { ...withLocalizePropTypes, @@ -56,21 +53,6 @@ const AppDownloadLinksPage = (props) => { }, ]; - /** - * Show the ReportActionContextMenu modal popover. - * - * @param {Object} [event] - A press event. - * @param {String} [selection] - Copied content. - */ - const showPopover = (event, selection) => { - ReportActionContextMenu.showContextMenu( - ContextMenuActions.CONTEXT_MENU_TYPES.LINK, - event, - selection, - popoverAnchor, - ); - }; - return ( { /> {_.map(menuItems, item => ( - props.isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen() && ControlSelection.block()} - onPressOut={() => ControlSelection.unblock()} - onSecondaryInteraction={e => showPopover(e, item.link)} - ref={el => popoverAnchor = el} + onPress={() => item.action()} + onSecondaryInteraction={e => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, popoverAnchor)} onKeyDown={(event) => { event.target.blur(); }} - > - item.action()} - shouldShowRightIcon - /> - + ref={el => popoverAnchor = el} + title={props.translate(item.translationKey)} + icon={item.icon} + iconRight={item.iconRight} + shouldBlockSelection + shouldShowRightIcon + /> ))} diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 71578843b7a3..ef001593a7c0 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -36,6 +36,8 @@ import * as Link from '../../libs/actions/Link'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; import * as UserUtils from '../../libs/UserUtils'; import policyMemberPropType from '../policyMemberPropType'; +import * as ReportActionContextMenu from '../home/report/ContextMenu/ReportActionContextMenu'; +import {CONTEXT_MENU_TYPES} from '../home/report/ContextMenu/ContextMenuActions'; const propTypes = { /* Onyx Props */ @@ -117,6 +119,8 @@ class InitialSettingsPage extends React.Component { constructor(props) { super(props); + this.popoverAnchor = React.createRef(); + this.getWalletBalance = this.getWalletBalance.bind(this); this.getDefaultMenuItems = this.getDefaultMenuItems.bind(this); this.getMenuItem = this.getMenuItem.bind(this); @@ -203,6 +207,7 @@ class InitialSettingsPage extends React.Component { action: () => { Link.openExternalLink(CONST.NEWHELP_URL); }, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, + link: CONST.NEWHELP_URL, }, { translationKey: 'initialSettingsPage.about', @@ -236,6 +241,9 @@ class InitialSettingsPage extends React.Component { brickRoadIndicator={item.brickRoadIndicator} floatRightAvatars={item.floatRightAvatars} shouldStackHorizontally={item.shouldStackHorizontally} + ref={this.popoverAnchor} + shouldBlockSelection={Boolean(item.link)} + onSecondaryInteraction={!_.isEmpty(item.link) ? e => ReportActionContextMenu.showContextMenu(CONTEXT_MENU_TYPES.LINK, e, item.link, this.popoverAnchor.current) : undefined} /> ); } diff --git a/src/pages/settings/Payments/AddPayPalMePage.js b/src/pages/settings/Payments/AddPayPalMePage.js index c800b49cd804..11a617efc56b 100644 --- a/src/pages/settings/Payments/AddPayPalMePage.js +++ b/src/pages/settings/Payments/AddPayPalMePage.js @@ -1,9 +1,12 @@ import React from 'react'; -import {View} from 'react-native'; +import { + View, TouchableWithoutFeedback, Linking, +} from 'react-native'; import _ from 'underscore'; import CONST from '../../../CONST'; import ROUTES from '../../../ROUTES'; import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton'; +import TextLink from '../../../components/TextLink'; import Text from '../../../components/Text'; import ScreenWrapper from '../../../components/ScreenWrapper'; import Navigation from '../../../libs/Navigation/Navigation'; @@ -15,6 +18,9 @@ import Growl from '../../../libs/Growl'; import TextInput from '../../../components/TextInput'; import * as ValidationUtils from '../../../libs/ValidationUtils'; import * as User from '../../../libs/actions/User'; +import Icon from '../../../components/Icon'; +import * as Expensicons from '../../../components/Icon/Expensicons'; +import variables from '../../../styles/variables'; class AddPayPalMePage extends React.Component { constructor(props) { @@ -78,6 +84,28 @@ class AddPayPalMePage extends React.Component { hasError={this.state.payPalMeUsernameError} errorText={this.state.payPalMeUsernameError ? this.props.translate('addPayPalMePage.formatError') : ''} /> + + + {this.props.translate('addPayPalMePage.checkListOf')} + + Linking.openURL('https://developer.paypal.com/docs/reports/reference/paypal-supported-currencies')} + > + + + {this.props.translate('addPayPalMePage.supportedCurrencies')} + + + + + + + diff --git a/src/pages/signin/ChangeExpensifyLoginLink.js b/src/pages/signin/ChangeExpensifyLoginLink.js index 0512125e53f7..61ad7160b0fa 100755 --- a/src/pages/signin/ChangeExpensifyLoginLink.js +++ b/src/pages/signin/ChangeExpensifyLoginLink.js @@ -32,11 +32,10 @@ const defaultProps = { const ChangeExpensifyLoginLink = props => ( {!_.isEmpty(props.credentials.login) && ( - + {props.translate('loginForm.notYou', {user: Str.removeSMSDomain(props.credentials.login)})} )} -   { imageStyles={[styles.mr2]} /> - + {login} @@ -73,7 +73,7 @@ const ResendValidationForm = (props) => { {!_.isEmpty(props.account.message) && ( // DotIndicatorMessage mostly expects onyxData errors so we need to mock an object so that the messages looks similar to prop.account.errors - + )} {!_.isEmpty(props.account.errors) && ( diff --git a/src/pages/signin/SignInHeroCopy.js b/src/pages/signin/SignInHeroCopy.js new file mode 100644 index 000000000000..f8ef85081691 --- /dev/null +++ b/src/pages/signin/SignInHeroCopy.js @@ -0,0 +1,37 @@ +import {View} from 'react-native'; +import React from 'react'; +import Text from '../../components/Text'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import compose from '../../libs/compose'; +import * as StyleUtils from '../../styles/StyleUtils'; +import styles from '../../styles/styles'; +import variables from '../../styles/variables'; + +const propTypes = { + ...windowDimensionsPropTypes, + ...withLocalizePropTypes, +}; + +const SignInHeroCopy = props => ( + + + {props.translate('login.hero.header')} + + + {props.translate('login.hero.body')} + + +); + +SignInHeroCopy.displayName = 'SignInHeroCopy'; +SignInHeroCopy.propTypes = propTypes; + +export default compose( + withWindowDimensions, + withLocalize, +)(SignInHeroCopy); diff --git a/src/pages/signin/SignInHeroImage.js b/src/pages/signin/SignInHeroImage.js new file mode 100644 index 000000000000..9167bd6ddb03 --- /dev/null +++ b/src/pages/signin/SignInHeroImage.js @@ -0,0 +1,44 @@ +import {View} from 'react-native'; +import React from 'react'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; +import * as Illustrations from '../../components/Icon/Illustrations'; +import styles from '../../styles/styles'; +import variables from '../../styles/variables'; + +const propTypes = { + ...windowDimensionsPropTypes, +}; + +const SignInHeroImage = (props) => { + let imageSize; + if (props.isSmallScreenWidth) { + imageSize = { + height: variables.signInHeroImageMobileHeight, + width: variables.signInHeroImageMobileWidth, + }; + } else if (props.isMediumScreenWidth) { + imageSize = { + height: variables.signInHeroImageTabletHeight, + width: variables.signInHeroImageTabletWidth, + }; + } else { + imageSize = { + height: variables.signInHeroImageDesktopHeight, + width: variables.signInHeroImageDesktopWidth, + }; + } + + return ( + + + + ); +}; + +SignInHeroImage.displayName = 'SignInHeroImage'; +SignInHeroImage.propTypes = propTypes; + +export default withWindowDimensions(SignInHeroImage); diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 53c4d5545485..f83f467106ee 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -16,6 +16,7 @@ import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize import Performance from '../../libs/Performance'; import * as App from '../../libs/actions/App'; import Permissions from '../../libs/Permissions'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; import * as Localize from '../../libs/Localize'; const propTypes = { @@ -41,6 +42,8 @@ const propTypes = { }), ...withLocalizePropTypes, + + ...windowDimensionsPropTypes, }; const defaultProps = { @@ -89,27 +92,44 @@ class SignInPage extends Component { && (!this.props.account.validated || this.props.account.forgotPassword) && !Permissions.canUsePasswordlessLogins(this.props.betas); + let welcomeHeader = ''; let welcomeText = ''; if (showValidateCodeForm) { if (this.props.account.requiresTwoFactorAuth) { // We will only know this after a user signs in successfully, without their 2FA code + welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack'); welcomeText = this.props.translate('validateCodeForm.enterAuthenticatorCode'); } else { const userLogin = Str.removeSMSDomain(lodashGet(this.props, 'credentials.login', '')); - welcomeText = this.props.account.validated - ? this.props.translate('welcomeText.welcomeBackEnterMagicCode', {login: userLogin}) - : this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLogin}); + + if (this.props.account.validated) { + welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack'); + welcomeText = this.props.isSmallScreenWidth + ? `${this.props.translate('welcomeText.welcomeBack')} ${this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLogin})}` + : this.props.translate('welcomeText.welcomeEnterMagicCode', {login: userLogin}); + } else { + welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcome'); + welcomeText = this.props.isSmallScreenWidth + ? `${this.props.translate('welcomeText.welcome')} ${this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLogin})}` + : this.props.translate('welcomeText.newFaceEnterMagicCode', {login: userLogin}); + } } } else if (showPasswordForm) { - welcomeText = this.props.translate('welcomeText.welcomeBack'); + welcomeHeader = this.props.isSmallScreenWidth ? '' : this.props.translate('welcomeText.welcomeBack'); + welcomeText = this.props.isSmallScreenWidth + ? `${this.props.translate('welcomeText.welcomeBack')} ${this.props.translate('welcomeText.enterPassword')}` + : this.props.translate('welcomeText.enterPassword'); } else if (!showResendValidationForm) { - welcomeText = this.props.translate('welcomeText.welcome'); + welcomeHeader = this.props.isSmallScreenWidth ? this.props.translate('login.hero.header') : this.props.translate('welcomeText.getStarted'); + welcomeText = this.props.isSmallScreenWidth ? this.props.translate('welcomeText.getStarted') : ''; } return ( {/* LoginForm and PasswordForm must use the isVisible prop. This keeps them mounted, but visually hidden @@ -132,6 +152,7 @@ SignInPage.defaultProps = defaultProps; export default compose( withLocalize, + withWindowDimensions, withOnyx({ account: {key: ONYXKEYS.ACCOUNT}, betas: {key: ONYXKEYS.BETAS}, diff --git a/src/pages/signin/SignInPageHero.js b/src/pages/signin/SignInPageHero.js new file mode 100644 index 000000000000..36edb388283a --- /dev/null +++ b/src/pages/signin/SignInPageHero.js @@ -0,0 +1,34 @@ +import {View} from 'react-native'; +import React from 'react'; +import * as StyleUtils from '../../styles/StyleUtils'; +import withWindowDimensions, {windowDimensionsPropTypes} from '../../components/withWindowDimensions'; +import SignInHeroImage from './SignInHeroImage'; +import SignInHeroCopy from './SignInHeroCopy'; +import styles from '../../styles/styles'; +import variables from '../../styles/variables'; + +const propTypes = { + ...windowDimensionsPropTypes, +}; + +const SignInPageHero = props => ( + + + + + + +); + +SignInPageHero.displayName = 'SignInPageHero'; +SignInPageHero.propTypes = propTypes; + +export default withWindowDimensions(SignInPageHero); diff --git a/src/pages/signin/SignInPageLayout/Footer.js b/src/pages/signin/SignInPageLayout/Footer.js index 61b546d24be8..5fd421901f1d 100644 --- a/src/pages/signin/SignInPageLayout/Footer.js +++ b/src/pages/signin/SignInPageLayout/Footer.js @@ -4,6 +4,8 @@ import React from 'react'; import _ from 'underscore'; import Text from '../../../components/Text'; import styles from '../../../styles/styles'; +import * as StyleUtils from '../../../styles/StyleUtils'; +import themeColors from '../../../styles/themes/default'; import variables from '../../../styles/variables'; import * as Expensicons from '../../../components/Icon/Expensicons'; import TextLink from '../../../components/TextLink'; @@ -16,6 +18,7 @@ import Hoverable from '../../../components/Hoverable'; import CONST from '../../../CONST'; import Navigation, {navigationRef} from '../../../libs/Navigation/Navigation'; import * as Session from '../../../libs/actions/Session'; +import SignInGradient from '../../../../assets/images/home-fade-gradient--mobile.svg'; import screens from '../../../SCREENS'; const propTypes = { @@ -170,13 +173,18 @@ const Footer = (props) => { const imageDirection = isVertical ? styles.flexRow : styles.flexColumn; const imageStyle = isVertical ? styles.pr0 : styles.alignSelfCenter; const columnDirection = isVertical ? styles.flexColumn : styles.flexRow; - const pageFooterWrapper = [styles.footerWrapper, imageDirection, imageStyle]; + const pageFooterWrapper = [styles.footerWrapper, imageDirection, imageStyle, isVertical ? styles.pl10 : {}]; const footerColumns = [styles.footerColumnsContainer, columnDirection]; const footerColumn = isVertical ? [styles.p4] : [styles.p4, props.isMediumScreenWidth ? styles.w50 : styles.w25]; return ( - + + {props.isSmallScreenWidth ? ( + + + + ) : null} {_.map(columns, (column, i) => ( diff --git a/src/pages/signin/SignInPageLayout/SignInPageContent.js b/src/pages/signin/SignInPageLayout/SignInPageContent.js index 76f03ec87715..34bbf8531d45 100755 --- a/src/pages/signin/SignInPageLayout/SignInPageContent.js +++ b/src/pages/signin/SignInPageLayout/SignInPageContent.js @@ -1,17 +1,18 @@ import React from 'react'; -import {ScrollView, View} from 'react-native'; +import {View, ScrollView} from 'react-native'; import PropTypes from 'prop-types'; import {withSafeAreaInsets} from 'react-native-safe-area-context'; import styles from '../../../styles/styles'; -import variables from '../../../styles/variables'; -import ExpensifyCashLogo from '../../../components/ExpensifyCashLogo'; +import ExpensifyWordmark from '../../../components/ExpensifyWordmark'; import Text from '../../../components/Text'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import SignInPageForm from '../../../components/SignInPageForm'; import compose from '../../../libs/compose'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; -import KeyboardAvoidingView from '../../../components/KeyboardAvoidingView'; import OfflineIndicator from '../../../components/OfflineIndicator'; +import SignInHeroImage from '../SignInHeroImage'; +import * as StyleUtils from '../../../styles/StyleUtils'; +import variables from '../../../styles/variables'; const propTypes = { /** The children to show inside the layout */ @@ -21,9 +22,16 @@ const propTypes = { * on form type (set password, sign in, etc.) */ welcomeText: PropTypes.string.isRequired, + /** Welcome header to show in the header of the form, changes depending + * on form type (set password, sign in, etc.) and small vs large screens */ + welcomeHeader: PropTypes.string.isRequired, + /** Whether to show welcome text on a particular page */ shouldShowWelcomeText: PropTypes.bool.isRequired, + /** Whether to show welcome header on a particular page */ + shouldShowWelcomeHeader: PropTypes.bool.isRequired, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, }; @@ -34,42 +42,53 @@ const SignInPageContent = props => ( keyboardShouldPersistTaps="handled" style={[!props.isSmallScreenWidth && styles.signInPageLeftContainerWide, styles.flex1]} > - + {/* This empty view creates margin on the top of the sign in form which will shrink and grow depending on if the keyboard is open or not */} - + - - + + - {props.shouldShowWelcomeText && ( - - {props.welcomeText} - + {(props.shouldShowWelcomeHeader && props.welcomeHeader) ? ( + + {props.welcomeHeader} + + ) : null} + {(props.shouldShowWelcomeText && props.welcomeText) ? ( + + {props.welcomeText} + + ) : null} - )} {props.children} + {props.isSmallScreenWidth ? ( + <> + + + + + + + + ) : null} - - - + {!props.isSmallScreenWidth ? ( + + + + ) : null} ); diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js index cd56d0c41fe4..f3e1a926639c 100644 --- a/src/pages/signin/SignInPageLayout/index.js +++ b/src/pages/signin/SignInPageLayout/index.js @@ -7,9 +7,14 @@ import SignInPageContent from './SignInPageContent'; import Footer from './Footer'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; import styles from '../../../styles/styles'; -import SignInPageGraphics from './SignInPageGraphics'; +import SignInPageHero from '../SignInPageHero'; import * as StyleUtils from '../../../styles/StyleUtils'; import scrollViewContentContainerStyles from './signInPageStyles'; +import themeColors from '../../../styles/themes/default'; +import SignInHeroBackgroundImage from '../../../../assets/images/home-background--desktop.svg'; +import SignInHeroBackgroundImageMobile from '../../../../assets/images/home-background--mobile.svg'; +import SignInGradient from '../../../../assets/images/home-fade-gradient.svg'; +import variables from '../../../styles/variables'; const propTypes = { /** The children to show inside the layout */ @@ -19,9 +24,16 @@ const propTypes = { * on form type (set password, sign in, etc.) */ welcomeText: PropTypes.string.isRequired, + /** Welcome header to show in the header of the form, changes depending + * on form type (set password, sign in, etc.) and small vs large screens */ + welcomeHeader: PropTypes.string.isRequired, + /** Whether to show welcome text on a particular page */ shouldShowWelcomeText: PropTypes.bool.isRequired, + /** Whether to show welcome header on a particular page */ + shouldShowWelcomeHeader: PropTypes.bool.isRequired, + ...windowDimensionsPropTypes, }; @@ -44,18 +56,40 @@ const SignInPageLayout = (props) => { ? ( {props.children} - -