diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39c5a708e5db..4e7f01f38eb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,14 +41,18 @@ This is the most common scenario for contributors. The Expensify team posts new #### Proposing a job that Expensify hasn’t posted -It’s possible that you found a bug or enhancement that we haven’t posted to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to propose a job, and (optionally) a solution. If it's a valid job proposal that we choose to implement by deploying it to production — either internally or via an external contributor — then we will compensate you $250 for identifying and proposing the improvement. Note: If you get assigned the job you proposed **and** you complete the job, this $250 for identifying the improvement is *in addition to* the reward you will be paid for completing the job. Please follow these steps to propose a job: +It’s possible that you found a bug or enhancement that we haven’t posted to the [GitHub repository](https://github.com/Expensify/App/issues?q=is%3Aissue). This is an opportunity to propose a job, and (optionally) a solution. If it's a valid job proposal that we choose to implement by deploying it to production — either internally or via an external contributor — then we will compensate you $250 for identifying and proposing the improvement. +- Note: If you get assigned the job you proposed **and** you complete the job, this $250 for identifying the improvement is *in addition to* the reward you will be paid for completing the job. Please follow these steps to propose a job: +- Note about proposed improvements: Expensify has the right not to pay the $250 reward if the suggested improvement is already planned. Currently, Expensify plans to implement all features of the old Expensify app in New Expensify. 1. Check to ensure an issue does not already exist for this topic in the [New Expensify Issue list](https://github.com/Expensify/App/issues). Please use your best judgement by searching for similar titles and issue descriptions. 2. If your bug or enhancement matches an existing issue, please feel free to comment on that GitHub issue with your findings if you think it will help solve the issue. -3. If there is no existing GitHub issue or Upwork job, report the issue(s) in the [#expensify-open-source](https://github.com/Expensify/App/blob/main/CONTRIBUTING.md#asking-questions) Slack channel, prefixed with `BUG:` or `Feature Request:`. Include all relevant data, screenshots and examples in the post or thread. -4. After review in #expensify-open-source, if you've provided a quality proposal that we choose to implement, a GitHub issue will be created and your Slack handle will be included in the original post after `Issue reported by:` -5. If an external contributor other than yourself is hired to work on the issue, you will also be hired for the same job in Upwork. No additional work is needed. If the issue is fixed internally, a dedicated job will be created to hire and pay you after the issue is fixed. -6. Payment will be made 7 days after code is deployed to production if there are no regressions. If a regression is discovered, payment will be issued 7 days after all regressions are fixed. +4. If there is no existing GitHub issue or Upwork job, check if the issue is happening on prod (as opposed to only happening on dev) +5. If the issue is just in dev then it means it's a new issue and has not been deployed to production. In this case, you should try to find the offending PR and comment in the issue tied to the PR and ask the assigned users to add the `DeployBlockerCash` label. If you can't find it, follow the reporting instructions in the next item, but note that the issue is a regression only found in dev and not in prod. +6. If the issue happens in production then report the issue(s) in the [#expensify-open-source](https://github.com/Expensify/App/blob/main/CONTRIBUTING.md#asking-questions) Slack channel, prefixed with `BUG:` or `Feature Request:`. Include all relevant data, screenshots and examples in the post or thread. +7. After review in #expensify-open-source, if you've provided a quality proposal that we choose to implement, a GitHub issue will be created and your Slack handle will be included in the original post after `Issue reported by:` +8. If an external contributor other than yourself is hired to work on the issue, you will also be hired for the same job in Upwork. No additional work is needed. If the issue is fixed internally, a dedicated job will be created to hire and pay you after the issue is fixed. +9. Payment will be made 7 days after code is deployed to production if there are no regressions. If a regression is discovered, payment will be issued 7 days after all regressions are fixed. >**Note:** Our problem solving approach at Expensify is to focus on high value problems and avoid small optimizations with results that are difficult to measure. We also prefer to identify and solve problems at their root. Given that, please ensure all proposed jobs fix a specific problem in a measurable way with evidence so they are easy to evaluate. Here's an example of a good problem/solution: > @@ -89,7 +93,7 @@ It’s possible that you found a bug or enhancement that we haven’t posted to ``` 9. [Open a pull request](https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork), and make sure to fill in the required fields. 10. An Expensify engineer and a member from the Contributor-Plus team will be assigned to your pull request automatically to review. -11. Provide daily updates until reaching completion of your PR. +11. Daily updates on weekdays are highly recommended. If you know you won’t be able to provide updates for > 1 week, please comment on the PR or issue how long you plan to be out so that we may plan accordingly. We understand everyone needs a little vacation here and there. Any issue that doesn't receive an update for 1 full week may be considered abandoned and the original contract terminated. #### Submit your pull request for final review 12. When you are ready to submit your pull request for final review, make sure the following checks pass: diff --git a/android/app/build.gradle b/android/app/build.gradle index fbfc463b3964..adf3538e2acf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -149,8 +149,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001012408 - versionName "1.1.24-8" + versionCode 1001012419 + versionName "1.1.24-19" } splits { abi { diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index ac5a3af7fdab..8abb6b5c92dd 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -31,7 +31,7 @@ CFBundleVersion - 1.1.24.8 + 1.1.24.19 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 0e8523e64536..0f4a43926052 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.1.24.8 + 1.1.24.19 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a8781483c7cc..397b254589e5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -506,7 +506,7 @@ PODS: - React-perflogger (= 0.64.1) - rn-fetch-blob (0.12.0): - React-Core - - RNBootSplash (3.2.6): + - RNBootSplash (3.2.7): - React-Core - RNCAsyncStorage (1.15.5): - React-Core @@ -850,7 +850,7 @@ SPEC CHECKSUMS: CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: cf9b38bf0b2d048436d9a82ad2abe1404f11e7de FBLazyVector: 7b423f9e248eae65987838148c36eec1dbfe0b53 - FBReactNativeSpec: 9f813f735901d719751da82f0365b5c506c28f14 + FBReactNativeSpec: f5187ed83c38ec2fb4634140a81c0019adcd9cb3 Firebase: 54cdc8bc9c9b3de54f43dab86e62f5a76b47034f FirebaseABTesting: c3e48ebf5e7e5c674c5a131c68e941d7921d83dc FirebaseAnalytics: 4751d6a49598a2b58da678cc07df696bcd809ab9 @@ -920,7 +920,7 @@ SPEC CHECKSUMS: React-runtimeexecutor: ff951a0c241bfaefc4940a3f1f1a229e7cb32fa6 ReactCommon: bedc99ed4dae329c4fcf128d0c31b9115e5365ca rn-fetch-blob: f065bb7ab7fb48dd002629f8bdcb0336602d3cba - RNBootSplash: 00f99e3c05fb44af3613e6088406de4be0a8eca3 + RNBootSplash: b82ee16a943903ea612edb15233ea4f247155ef9 RNCAsyncStorage: 8324611026e8dc3706f829953aa6e3899f581589 RNCClipboard: 5e299c6df8e0c98f3d7416b86ae563d3a9f768a3 RNCMaskedView: 138134c4d8a9421b4f2bf39055a79aa05c2d47b1 diff --git a/package-lock.json b/package-lock.json index c12d0ec1feb4..7efaa63963cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.1.24-8", + "version": "1.1.24-19", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -20752,12 +20752,18 @@ } }, "domhandler": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", - "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", - "dev": true, + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.0.tgz", + "integrity": "sha512-fC0aXNQXqKSFTr2wDNZDhsEYjCiYsDWl3D01kwt25hm1YIPyDGHvvi3rw+PLqHAl/m71MaiF7d5zvBr0p5UB2g==", "requires": { - "domelementtype": "1" + "domelementtype": "^2.2.0" + }, + "dependencies": { + "domelementtype": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==" + } } }, "dompurify": { @@ -21578,10 +21584,9 @@ } }, "entities": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", - "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", - "dev": true + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==" }, "enumerate-devices": { "version": "1.1.1", @@ -25585,28 +25590,46 @@ } }, "htmlparser2": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", - "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", - "dev": true, + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", + "integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==", "requires": { - "domelementtype": "^1.3.1", - "domhandler": "^2.3.0", - "domutils": "^1.5.1", - "entities": "^1.1.1", - "inherits": "^2.0.1", - "readable-stream": "^3.1.1" + "domelementtype": "^2.0.1", + "domhandler": "^4.2.2", + "domutils": "^2.8.0", + "entities": "^3.0.1" }, "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, + "dom-serializer": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.2.tgz", + "integrity": "sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==", "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "dependencies": { + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" + } + } + }, + "domelementtype": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==" + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" } } } @@ -36593,9 +36616,9 @@ } }, "react-native-bootsplash": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/react-native-bootsplash/-/react-native-bootsplash-3.2.6.tgz", - "integrity": "sha512-zNEXIe2K1A06J45QOAg+OBo3wIyId9lZXOwITUcwNR2bQEg/3CO6uvcRB7MLuy2ct54R1PlbADHDZK/Ozt7mfA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/react-native-bootsplash/-/react-native-bootsplash-3.2.7.tgz", + "integrity": "sha512-jc46lBuKeZerdUndY0yJw2mIbvX0kMHpkKsayxqJfjCiMorklSPZt0dyvaM9KyebCVop54udMJAySbnZVCEvIQ==", "requires": { "chalk": "^4.1.2", "fs-extra": "^10.0.0", @@ -38116,6 +38139,46 @@ "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true }, + "domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "dev": true, + "requires": { + "domelementtype": "1" + } + }, + "entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "dev": true + }, + "htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "dev": true, + "requires": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", diff --git a/package.json b/package.json index a333d398d2f6..1dcdd3b47e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.1.24-8", + "version": "1.1.24-19", "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.", @@ -57,6 +57,8 @@ "@react-navigation/native": "6.0.5", "@react-navigation/stack": "6.0.10", "babel-plugin-transform-remove-console": "^6.9.4", + "dom-serializer": "^0.2.2", + "domhandler": "^4.3.0", "dotenv": "^8.2.0", "electron-context-menu": "^2.3.0", "electron-log": "^4.3.5", @@ -65,6 +67,7 @@ "expensify-common": "git+https://github.com/Expensify/expensify-common.git#fa190f6c844cf5646345f3e5e4862b62f1fa27bc", "file-loader": "^6.0.0", "html-entities": "^1.3.1", + "htmlparser2": "^7.2.0", "lodash": "4.17.21", "metro-config": "^0.64.0", "moment": "^2.27.0", @@ -76,7 +79,7 @@ "react-collapse": "^5.1.0", "react-dom": "^17.0.2", "react-native": "0.64.1", - "react-native-bootsplash": "^3.2.6", + "react-native-bootsplash": "^3.2.7", "react-native-collapsible": "^1.6.0", "react-native-config": "^1.4.0", "react-native-document-picker": "^5.1.0", diff --git a/src/CONST.js b/src/CONST.js index 6bcac4aad78e..04418dd9ed52 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -161,6 +161,11 @@ const CONST = { shortcutKey: 'Enter', modifiers: [], }, + COPY: { + descriptionKey: 'copy', + shortcutKey: 'C', + modifiers: ['CTRL'], + }, }, KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME: { CONTROL: 'Ctrl', diff --git a/src/components/PressableWithSecondaryInteraction/index.js b/src/components/PressableWithSecondaryInteraction/index.js index c14d0576a5aa..78434ae65476 100644 --- a/src/components/PressableWithSecondaryInteraction/index.js +++ b/src/components/PressableWithSecondaryInteraction/index.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import React, {Component} from 'react'; import {Pressable} from 'react-native'; +import SelectionScraper from '../../libs/SelectionScraper'; import * as pressableWithSecondaryInteractionPropTypes from './pressableWithSecondaryInteractionPropTypes'; import styles from '../../styles/styles'; @@ -30,7 +31,7 @@ class PressableWithSecondaryInteraction extends Component { * https://developer.mozilla.org/en-US/docs/Web/API/Element/contextmenu_event */ executeSecondaryInteractionOnContextMenu(e) { - const selection = window.getSelection().toString(); + const selection = SelectionScraper.getAsMarkdown(); e.stopPropagation(); if (this.props.preventDefaultContentMenu) { e.preventDefault(); diff --git a/src/components/WelcomeText.js b/src/components/WelcomeText.js index 1180b5df2e3a..0624f4061fbc 100755 --- a/src/components/WelcomeText.js +++ b/src/components/WelcomeText.js @@ -21,7 +21,7 @@ const WelcomeText = (props) => { return ( <> - {props.translate('welcomeText.phrase1')} + {props.translate('welcomeText.welcome')} {props.translate('welcomeText.phrase2')} diff --git a/src/languages/en.js b/src/languages/en.js index 8c0bd1eed0e6..8a5ed034202d 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -136,10 +136,10 @@ export default { hello: 'Hello', phoneCountryCode: '1', welcomeText: { - phrase1: 'Welcome to the New Expensify! Enter your phone number or email to continue.', + welcome: 'Welcome to the New Expensify! Enter your phone number or email to continue.', 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.', - phrase4: 'Welcome back to the New Expensify! Please enter your password.', + welcomeBack: 'Welcome back to the New Expensify! Please enter your password.', }, reportActionCompose: { addAction: 'Actions', @@ -275,7 +275,7 @@ export default { label: 'iOS', }, desktop: { - label: 'Desktop', + label: 'macOS', }, }, security: 'Security', @@ -290,14 +290,14 @@ export default { }, passwordPage: { changePassword: 'Change password', - changingYourPasswordPrompt: 'Changing your password will update your password for both your Expensify.com\nand New Expensify accounts.', + changingYourPasswordPrompt: 'Changing your password will update your password for both your Expensify.com and New Expensify accounts.', currentPassword: 'Current password', newPassword: 'New password', - newPasswordPrompt: 'New password must be different than your old password, have at least 8 characters,\n1 capital letter, 1 lowercase letter, and 1 number.', + newPasswordPrompt: 'New password must be different than your old password, have at least 8 characters, 1 capital letter, 1 lowercase letter, and 1 number.', errors: { currentPassword: 'Current password is required', newPasswordSameAsOld: 'New password must be different than your old password', - newPassword: 'Your password must have at least 8 characters,\n1 capital letter, 1 lowercase letter, and 1 number.', + newPassword: 'Your password must have at least 8 characters, 1 capital letter, 1 lowercase letter, and 1 number.', }, }, addPayPalMePage: { @@ -428,6 +428,7 @@ export default { linkHasBeenResent: 'Link has been re-sent', weSentYouMagicSignInLink: ({login}) => `We've sent a magic sign in link to ${login}. Check your Inbox and your Spam folder and wait 5-10 minutes before trying again.`, resendLink: 'Resend link', + validationCodeFailedMessage: 'It looks like there was an error with your validation link or it has expired.', unvalidatedAccount: 'This account exists but isn\'t validated, please check your inbox for your magic link.', newAccount: ({login, loginType}) => `Welcome ${login}, it's always great to see a new face around here! Please check your ${loginType} for a magic link to validate your account.`, }, @@ -445,7 +446,7 @@ export default { setPasswordPage: { enterPassword: 'Enter a password', setPassword: 'Set password', - newPasswordPrompt: 'Your password must have at least 8 characters,\n1 capital letter, 1 lowercase letter, and 1 number.', + newPasswordPrompt: 'Your password must have at least 8 characters, 1 capital letter, 1 lowercase letter, and 1 number.', passwordFormTitle: 'Welcome back to the New Expensify! Please set your password.', passwordNotSet: 'We were unable to set your new password correctly.', accountNotValidated: 'We were unable to validate your account. The validation code may have expired.', @@ -840,6 +841,7 @@ export default { escape: 'Escape Dialogs', search: 'Open search dialog', newGroup: 'New group screen', + copy: 'Copy comment', }, }, guides: { diff --git a/src/languages/es.js b/src/languages/es.js index 4f20b42f3330..e9ee7554db85 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -136,10 +136,10 @@ export default { hello: 'Hola', phoneCountryCode: '34', welcomeText: { - phrase1: 'Con el Nuevo Expensify, chat y pagos son lo mismo.', + welcome: 'Con el Nuevo Expensify, chat y pagos son lo mismo.', 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.', - phrase4: '¡Bienvenido de vuelta al Nuevo Expensify! Por favor, introduce tu contraseña.', + welcomeBack: '¡Bienvenido de vuelta al Nuevo Expensify! Por favor, introduce tu contraseña.', }, reportActionCompose: { addAction: 'Acción', @@ -275,7 +275,7 @@ export default { label: 'iOS', }, desktop: { - label: 'Desktop', + label: 'macOS', }, }, security: 'Seguridad', @@ -290,14 +290,14 @@ export default { }, passwordPage: { changePassword: 'Cambiar contraseña', - changingYourPasswordPrompt: 'El cambio de contraseña va a afectar tanto a la cuenta de Expensify.com\ncomo la de Nuevo Expensify.', + changingYourPasswordPrompt: 'El cambio de contraseña va a afectar tanto a la cuenta de Expensify.com como la de Nuevo Expensify.', currentPassword: 'Contraseña actual', newPassword: 'Nueva contraseña', - newPasswordPrompt: 'La nueva contraseña debe ser diferente de la antigua, tener al menos 8 caracteres,\n1 letra mayúscula, 1 letra minúscula y 1 número.', + newPasswordPrompt: 'La nueva contraseña debe ser diferente de la antigua, tener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', errors: { currentPassword: 'Contraseña actual es requerido', newPasswordSameAsOld: 'La nueva contraseña tiene que ser diferente de la antigua', - newPassword: 'Su contraseña debe tener al menos 8 caracteres, \n1 letra mayúscula, 1 letra minúscula y 1 número.', + newPassword: 'Su contraseña debe tener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', }, }, addPayPalMePage: { @@ -428,6 +428,7 @@ export default { linkHasBeenResent: 'El enlace se ha reenviado', weSentYouMagicSignInLink: ({login}) => `Hemos enviado un enlace mágico de inicio de sesión a ${login}. Verifica tu bandeja de entrada y tu carpeta de correo no deseado y espera de 5 a 10 minutos antes de intentarlo de nuevo.`, resendLink: 'Reenviar enlace', + validationCodeFailedMessage: 'Parece que hubo un error con el enlace de validación o ha caducado.', unvalidatedAccount: 'Esta cuenta existe pero no está validada, por favor busca el enlace mágico en tu bandeja de entrada', newAccount: ({login, loginType}) => `¡Bienvenido ${login}, es genial ver una cara nueva por aquí! En tu ${loginType} encontrarás un enlace para validar tu cuenta, por favor, revísalo`, }, @@ -445,7 +446,7 @@ export default { setPasswordPage: { enterPassword: 'Escribe una contraseña', setPassword: 'Configura tu contraseña', - newPasswordPrompt: 'La contraseña debe tener al menos 8 caracteres, \n1 letra mayúscula, 1 letra minúscula y 1 número.', + newPasswordPrompt: 'La contraseña debe tener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.', passwordFormTitle: '¡Bienvenido de vuelta al Nuevo Expensify! Por favor, elige una contraseña.', passwordNotSet: 'No pudimos establecer to contaseña correctamente.', accountNotValidated: 'No pudimos validar tu cuenta. Es posible que el enlace de validación haya caducado.', @@ -842,6 +843,7 @@ export default { escape: 'Diálogos de escape', search: 'Abrir diálogo de búsqueda', newGroup: 'Nueva pantalla de grupo', + copy: 'Copiar comentario', }, }, guides: { diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index e8c17a932ef9..58e57c892d9a 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -583,7 +583,7 @@ function getSearchOptions( ) { return getOptions(reports, personalDetails, 0, { betas, - searchValue, + searchValue: searchValue.trim(), includeRecentReports: true, includeMultipleParticipantReports: true, maxRecentReportsToShow: 0, // Unlimited @@ -652,7 +652,7 @@ function getNewChatOptions( ) { return getOptions(reports, personalDetails, 0, { betas, - searchValue, + searchValue: searchValue.trim(), selectedOptions, excludeDefaultRooms: true, includeRecentReports: true, diff --git a/src/libs/SelectionScraper/index.js b/src/libs/SelectionScraper/index.js new file mode 100644 index 000000000000..07fdb2ca490c --- /dev/null +++ b/src/libs/SelectionScraper/index.js @@ -0,0 +1,142 @@ +import render from 'dom-serializer'; +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import {parseDocument} from 'htmlparser2'; +import {Element} from 'domhandler'; +import _ from 'underscore'; + +const elementsWillBeSkipped = ['html', 'body']; +const tagAttribute = 'data-testid'; + +/** + * Reads html of selection. If browser doesn't support Selection API, returns empty string. + * @returns {String} HTML of selection as String + */ +const getHTMLOfSelection = () => { + if (window.getSelection) { + const selection = window.getSelection(); + + if (selection.rangeCount > 0) { + const div = document.createElement('div'); + + // HTML tag of markdown comments is in data-testid attribute (em, strong, blockquote..). Our goal here is to + // find that nodes and replace that tag with the one inside data-testid, so ExpensiMark can parse it. + // Simply, we want to replace this: + // bold + // to this: + // bold + // + // We traverse all ranges, and get closest node with data-testid and replace its contents with contents of + // range. + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + + const clonedSelection = range.cloneContents(); + + // If clonedSelection has no text content this data has no meaning to us. + if (clonedSelection.textContent) { + let node = null; + + // If selection starts and ends within same text node we use its parentNode. This is because we can't + // use closest function on a [Text](https://developer.mozilla.org/en-US/docs/Web/API/Text) node. + // We are selecting closest node because nodes with data-testid can be one of the parents of the actual node. + // Assuming we selected only "block" part of following html: + //
+ //
+ // this is block code + //
+ //
+ // commonAncestorContainer: #text "this is block code" + // commonAncestorContainer.parentNode: + //
+ // this is block code + //
+ // and finally commonAncestorContainer.parentNode.closest('data-testid') is targeted dom. + if (range.commonAncestorContainer instanceof HTMLElement) { + node = range.commonAncestorContainer.closest(`[${tagAttribute}]`); + } else { + node = range.commonAncestorContainer.parentNode.closest(`[${tagAttribute}]`); + } + + // This means "range.commonAncestorContainer" is a text node. We simply get its parent node. + if (!node) { + node = range.commonAncestorContainer.parentNode; + } + + node = node.cloneNode(); + node.appendChild(clonedSelection); + div.appendChild(node); + } + } + + return div.innerHTML; + } + + return window.getSelection().toString(); + } + + // If browser doesn't support Selection API, returns empty string. + return ''; +}; + +/** + * Clears all attributes from dom elements + * @param {Object} dom htmlparser2 dom representation + * @returns {Object} htmlparser2 dom representation + */ +const replaceNodes = (dom) => { + let domName = dom.name; + let domChildren; + const domAttribs = {}; + + // We are skipping elements which has html and body in data-testid, since ExpensiMark can't parse it. Also this data + // has no meaning for us. + if (dom.attribs && dom.attribs[tagAttribute]) { + if (!elementsWillBeSkipped.includes(dom.attribs[tagAttribute])) { + domName = dom.attribs[tagAttribute]; + } + + // Adding a new line after each comment here, because adding after each range is not working for chrome. + if (dom.attribs[tagAttribute] === 'comment') { + dom.children.push(new Element('br', {})); + } + } + + // We need to preserve href attribute in order to copy links. + if (dom.attribs && dom.attribs.href) { + domAttribs.href = dom.attribs.href; + } + + if (dom.children) { + domChildren = _.map(dom.children, c => replaceNodes(c)); + } + + return { + ...dom, + name: domName, + attribs: domAttribs, + children: domChildren, + }; +}; + +/** + * Reads html of selection, replaces with proper tags used for markdown, parses to markdown. + * @returns {String} parsed html as String + */ +const getAsMarkdown = () => { + const selectionHtml = getHTMLOfSelection(); + + const domRepresentation = parseDocument(selectionHtml); + domRepresentation.children = _.map(domRepresentation.children, c => replaceNodes(c)); + + const newHtml = render(domRepresentation); + + const parser = new ExpensiMark(); + + return parser.htmlToMarkdown(newHtml); +}; + +const SelectionScraper = { + getAsMarkdown, +}; + +export default SelectionScraper; diff --git a/src/libs/SelectionScraper/index.native.js b/src/libs/SelectionScraper/index.native.js new file mode 100644 index 000000000000..871a23988810 --- /dev/null +++ b/src/libs/SelectionScraper/index.native.js @@ -0,0 +1,9 @@ +/** + * This is a no-op component for native devices because they wouldn't be able to support Selection API like + * a website. + */ +const SelectionParser = { + getAsMarkdown: () => '', +}; + +export default SelectionParser; diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js index afdf7e66f9e9..245ba1c37395 100644 --- a/src/libs/actions/Session/index.js +++ b/src/libs/actions/Session/index.js @@ -134,6 +134,7 @@ function fetchAccountDetails(login) { validated: response.validated, closed: response.isClosed, forgotPassword: false, + validateCodeExpired: false, }); if (!response.accountExists) { @@ -281,7 +282,7 @@ function resetPassword() { Onyx.merge(ONYXKEYS.ACCOUNT, {loading: true, forgotPassword: true}); API.ResetPassword({email: credentials.login}) .finally(() => { - Onyx.merge(ONYXKEYS.ACCOUNT, {loading: false}); + Onyx.merge(ONYXKEYS.ACCOUNT, {loading: false, validateCodeExpired: false}); }); } @@ -295,7 +296,7 @@ function resetPassword() { * @param {String} accountID */ function setPassword(password, validateCode, accountID) { - Onyx.merge(ONYXKEYS.ACCOUNT, {...CONST.DEFAULT_ACCOUNT_DATA, loading: true}); + Onyx.merge(ONYXKEYS.ACCOUNT, {...CONST.DEFAULT_ACCOUNT_DATA, loading: true, validateCodeExpired: false}); API.SetPassword({ password, @@ -316,7 +317,14 @@ function setPassword(password, validateCode, accountID) { return; } - Onyx.merge(ONYXKEYS.ACCOUNT, {error: Localize.translateLocal('setPasswordPage.accountNotValidated')}); + const login = lodashGet(response, 'data.email', null); + Onyx.merge(ONYXKEYS.ACCOUNT, {accountExists: true, validateCodeExpired: true, error: null}); + + // The login might not be set if the user hits a url in a new session. We set it here to ensure calls to resendValidationLink() will succeed. + if (login) { + Onyx.merge(ONYXKEYS.CREDENTIALS, {login}); + } + Navigation.navigate(ROUTES.HOME); }) .finally(() => { Onyx.merge(ONYXKEYS.ACCOUNT, {loading: false}); diff --git a/src/libs/migrations/AddEncryptedAuthToken.js b/src/libs/migrations/AddEncryptedAuthToken.js index 540b3c9f3e10..751ae7478f79 100644 --- a/src/libs/migrations/AddEncryptedAuthToken.js +++ b/src/libs/migrations/AddEncryptedAuthToken.js @@ -1,8 +1,9 @@ +/* eslint-disable rulesdir/no-api-in-views */ import _ from 'underscore'; import Onyx from 'react-native-onyx'; import Log from '../Log'; import ONYXKEYS from '../../ONYXKEYS'; -import * as reauthenticate from '../API'; +import * as API from '../API'; /** * This migration adds an encryptedAuthToken to the SESSION key, if it is not present. @@ -28,7 +29,7 @@ export default function () { // If there is an auth token but no encrypted auth token, reauthenticate. if (session.authToken && _.isUndefined(session.encryptedAuthToken)) { - return reauthenticate('Onyx_Migration_AddEncryptedAuthToken') + return API.reauthenticate('Onyx_Migration_AddEncryptedAuthToken') .then(() => { Log.info('[Migrate Onyx] Ran migration AddEncryptedAuthToken'); return resolve(); diff --git a/src/libs/reportUtils.js b/src/libs/reportUtils.js index 1431fdac402b..f6d763136f23 100644 --- a/src/libs/reportUtils.js +++ b/src/libs/reportUtils.js @@ -200,8 +200,19 @@ function canShowReportRecipientLocalTime(personalDetails, myPersonalDetails, rep && moment().tz(currentUserTimezone.selected).utcOffset() !== moment().tz(reportRecipientTimezone.selected).utcOffset(); } +/** + * Check if the comment is deleted + * @param {Object} action + * @returns {Boolean} + */ +function isDeletedAction(action) { + // A deleted comment has either an empty array or an object with html field with empty string as value + return action.message.length === 0 || action.message[0].html === ''; +} + export { getReportParticipantsTitle, + isDeletedAction, isReportMessageAttachment, findLastAccessedReport, canEditReportAction, diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index b1359504180d..6270074ec586 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -1,6 +1,6 @@ +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import _ from 'underscore'; import lodashGet from 'lodash/get'; -import Str from 'expensify-common/lib/str'; import * as Expensicons from '../../../../components/Icon/Expensicons'; import * as Report from '../../../../libs/actions/Report'; import Clipboard from '../../../../libs/Clipboard'; @@ -49,7 +49,12 @@ export default [ onPress: (closePopover, {reportAction, selection}) => { const message = _.last(lodashGet(reportAction, 'message', null)); const html = lodashGet(message, 'html', ''); - const text = Str.htmlDecode(selection || lodashGet(message, 'text', '')); + + const parser = new ExpensiMark(); + const reportMarkdown = parser.htmlToMarkdown(html); + + const text = selection || reportMarkdown; + const isAttachment = _.has(reportAction, 'isAttachment') ? reportAction.isAttachment : ReportUtils.isReportMessageAttachment(text); diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index c26b3d2e38d0..a737a01b9109 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -23,6 +23,7 @@ import MiniReportActionContextMenu from './ContextMenu/MiniReportActionContextMe import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import * as ContextMenuActions from './ContextMenu/ContextMenuActions'; import {withReportActionsDrafts} from '../../../components/OnyxProvider'; +import * as ReportUtils from '../../../libs/reportUtils'; const propTypes = { /** The ID of the report this action is on. */ @@ -94,8 +95,8 @@ class ReportActionItem extends Component { * @param {string} [selection] - A copy text. */ showPopover(event, selection) { - // Block menu on the message being Edited - if (this.props.draftMessage) { + // Block menu on the message being Edited or is already deleted + if (this.props.draftMessage || ReportUtils.isDeletedAction(this.props.action)) { return; } ReportActionContextMenu.showContextMenu( @@ -180,7 +181,7 @@ class ReportActionItem extends Component { hovered && !this.state.isContextMenuActive && !this.props.draftMessage - + && !ReportUtils.isDeletedAction(this.props.action) } draftMessage={this.props.draftMessage} /> diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index 96c42f2c2b2d..f9559733e7c2 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -6,6 +6,11 @@ import styles from '../../../styles/styles'; import ReportActionItemFragment from './ReportActionItemFragment'; import reportActionPropTypes from './reportActionPropTypes'; import {withNetwork} from '../../../components/OnyxProvider'; +import ExpensifyText from '../../../components/ExpensifyText'; +import themeColors from '../../../styles/themes/default'; +import * as ReportUtils from '../../../libs/reportUtils'; +import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; +import compose from '../../../libs/compose'; const propTypes = { /** The report action */ @@ -16,6 +21,9 @@ const propTypes = { /** Is the network currently offline or not */ isOffline: PropTypes.bool, }), + + /** localization props */ + ...withLocalizePropTypes, }; const defaultProps = { @@ -24,16 +32,20 @@ const defaultProps = { const ReportActionItemMessage = (props) => { const isUnsent = props.network.isOffline && props.action.loading; + const isDeleted = ReportUtils.isDeletedAction(props.action); + return ( - {_.map(_.compact(props.action.message), (fragment, index) => ( - - ))} + {isDeleted + ? {`[${props.translate('common.deletedCommentMessage')}]`} + : _.map(_.compact(props.action.message), (fragment, index) => ( + + ))} ); }; @@ -42,4 +54,7 @@ ReportActionItemMessage.propTypes = propTypes; ReportActionItemMessage.defaultProps = defaultProps; ReportActionItemMessage.displayName = 'ReportActionItemMessage'; -export default withNetwork()(ReportActionItemMessage); +export default compose( + withNetwork(), + withLocalize, +)(ReportActionItemMessage); diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index 0492dd91ef4c..a7214ccc6e39 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -9,7 +9,10 @@ import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import _ from 'underscore'; import lodashGet from 'lodash/get'; +import Clipboard from '../../../libs/Clipboard'; import * as Report from '../../../libs/actions/Report'; +import KeyboardShortcut from '../../../libs/KeyboardShortcut'; +import SelectionScraper from '../../../libs/SelectionScraper'; import ReportActionItem from './ReportActionItem'; import styles from '../../../styles/styles'; import reportActionPropTypes from './reportActionPropTypes'; @@ -152,6 +155,13 @@ class ReportActionsView extends React.Component { } Report.fetchActions(this.props.reportID); + + const copyShortcutConfig = CONST.KEYBOARD_SHORTCUTS.COPY; + const copyShortcutModifiers = KeyboardShortcut.getShortcutModifiers(copyShortcutConfig.modifiers); + + this.unsubscribeCopyShortcut = KeyboardShortcut.subscribe(copyShortcutConfig.shortcutKey, () => { + this.copySelectionToClipboard(); + }, copyShortcutConfig.descriptionKey, copyShortcutModifiers, false); } shouldComponentUpdate(nextProps, nextState) { @@ -249,6 +259,10 @@ class ReportActionsView extends React.Component { } Report.unsubscribeFromReportChannel(this.props.reportID); + + if (this.unsubscribeCopyShortcut) { + this.unsubscribeCopyShortcut(); + } } /** @@ -262,6 +276,12 @@ class ReportActionsView extends React.Component { Report.updateLastReadActionID(this.props.reportID); } + copySelectionToClipboard = () => { + const selectionMarkdown = SelectionScraper.getAsMarkdown(); + + Clipboard.setString(selectionMarkdown); + } + /** * Create a unique key for Each Action in the FlatList. * We use a combination of sequenceNumber and clientID in case the clientID are the same - which @@ -320,14 +340,8 @@ class ReportActionsView extends React.Component { updateSortedReportActions(reportActions) { this.sortedReportActions = _.chain(reportActions) .sortBy('sequenceNumber') - .filter((action) => { - // Only show non-empty ADDCOMMENT actions or IOU actions - // Empty ADDCOMMENT actions typically mean they have been deleted and should not be shown - const message = _.first(lodashGet(action, 'message', null)); - const html = lodashGet(message, 'html', ''); - return action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU - || (action.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT && html !== ''); - }) + .filter(action => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU + || action.actionName === CONST.REPORT.ACTIONS.TYPE.ADDCOMMENT) .map((item, index) => ({action: item, index})) .value() .reverse(); diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js index f084e1c9caec..d31e1f9d0fd4 100644 --- a/src/pages/home/sidebar/SidebarLinks.js +++ b/src/pages/home/sidebar/SidebarLinks.js @@ -236,7 +236,7 @@ class SidebarLinks extends React.Component { source={this.props.myPersonalDetails.avatar} isActive={this.props.network && !this.props.network.isOffline} isSyncing={this.props.network && !this.props.network.isOffline && this.props.isSyncingData} - tooltipText={this.props.myPersonalDetails.displayName} + tooltipText={this.props.translate('common.settings')} /> diff --git a/src/pages/settings/NewPasswordForm.js b/src/pages/settings/NewPasswordForm.js index dbb83a315309..b8e3c1bd4858 100644 --- a/src/pages/settings/NewPasswordForm.js +++ b/src/pages/settings/NewPasswordForm.js @@ -81,7 +81,7 @@ class NewPasswordForm extends React.Component { /> diff --git a/src/pages/signin/ResendValidationForm.js b/src/pages/signin/ResendValidationForm.js index f92ffc6f84b7..465767fdc69c 100755 --- a/src/pages/signin/ResendValidationForm.js +++ b/src/pages/signin/ResendValidationForm.js @@ -99,6 +99,8 @@ class ResendValidationForm extends React.Component { login, loginType, }); + } else if (this.props.account.validateCodeExpired) { + message = this.props.translate('resendValidationForm.validationCodeFailedMessage'); } else if (isOldUnvalidatedAccount) { message = this.props.translate('resendValidationForm.unvalidatedAccount'); } else { diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 5d98a89d4ef0..9141e0ceb8e7 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -4,6 +4,7 @@ import { } from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; import ONYXKEYS from '../../ONYXKEYS'; import styles from '../../styles/styles'; import updateUnread from '../../libs/UnreadIndicatorUpdater/updateUnread/index'; @@ -59,9 +60,12 @@ class SignInPage extends Component { // - A login has not been entered yet const showLoginForm = !this.props.credentials.login; + const validateCodeExpired = lodashGet(this.props.account, 'validateCodeExpired', false); + const validAccount = this.props.account.accountExists && this.props.account.validated - && !this.props.account.forgotPassword; + && !this.props.account.forgotPassword + && !validateCodeExpired; // Show the password form if // - A login has been entered @@ -76,21 +80,23 @@ class SignInPage extends Component { // - A login has been entered // - AND a GitHub username has been entered OR they already have access to this app // - AND an account did not exist or is not validated for that login - const showResendValidationLinkForm = this.props.credentials.login && !validAccount; + const shouldShowResendValidationLinkForm = this.props.credentials.login && !validAccount; - const welcomeText = this.props.translate(`welcomeText.${showPasswordForm ? 'phrase4' : 'phrase1'}`); + const welcomeText = shouldShowResendValidationLinkForm + ? '' + : this.props.translate(`welcomeText.${showPasswordForm ? 'welcomeBack' : 'welcome'}`); return ( {/* LoginForm and PasswordForm must use the isVisible prop. This keeps them mounted, but visually hidden so that password managers can access the values. Conditionally rendering these components will break this feature. */} - {showResendValidationLinkForm && } + {shouldShowResendValidationLinkForm && } ); diff --git a/src/styles/styles.js b/src/styles/styles.js index 2e14726d70d6..49ed4d07f1b3 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -790,6 +790,13 @@ const styles = { marginBottom: 8, }, + formHelp: { + color: themeColors.textSupporting, + fontSize: variables.fontSizeLabel, + lineHeight: 18, + marginBottom: 4, + }, + formError: { color: themeColors.textError, fontSize: variables.fontSizeLabel,