diff --git a/src/CONST.js b/src/CONST.js index b98cf6cf70ec..d1b6a5b52e37 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -171,6 +171,13 @@ const CONST = { PHONE_E164_PLUS: /^\+?[1-9]\d{1,14}$/, NON_ALPHA_NUMERIC: /[^A-Za-z0-9+]/g, }, + + GROWL: { + SUCCESS: 'success', + ERROR: 'error', + WARNING: 'warning', + DURATION: 2000, + }, }; export default CONST; diff --git a/src/Expensify.js b/src/Expensify.js index 6ac9b68a25ea..431db2e8d9a1 100644 --- a/src/Expensify.js +++ b/src/Expensify.js @@ -16,6 +16,8 @@ import styles from './styles/styles'; import PushNotification from './libs/Notification/PushNotification'; import UpdateAppModal from './components/UpdateAppModal'; import Visibility from './libs/Visibility'; +import GrowlNotification from './components/GrowlNotification'; +import {growlRef} from './libs/Growl'; // Initialize the store when the app loads for the first time Onyx.init({ @@ -156,6 +158,7 @@ class Expensify extends PureComponent { } return ( <> + {/* We include the modal for showing a new update at the top level so the option is always present. */} {this.props.updateAvailable ? : null} diff --git a/src/libs/GrowlNotification/GrowlNotificationContainer/GrowlNotificationContainerPropTypes.js b/src/components/GrowlNotification/GrowlNotificationContainer/GrowlNotificationContainerPropTypes.js similarity index 82% rename from src/libs/GrowlNotification/GrowlNotificationContainer/GrowlNotificationContainerPropTypes.js rename to src/components/GrowlNotification/GrowlNotificationContainer/GrowlNotificationContainerPropTypes.js index fb5d017e984a..1343c11821c1 100644 --- a/src/libs/GrowlNotification/GrowlNotificationContainer/GrowlNotificationContainerPropTypes.js +++ b/src/components/GrowlNotification/GrowlNotificationContainer/GrowlNotificationContainerPropTypes.js @@ -1,6 +1,6 @@ import {Animated} from 'react-native'; import PropTypes from 'prop-types'; -import {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; +import {windowDimensionsPropTypes} from '../../withWindowDimensions'; const propTypes = { /** GrowlNotification content */ diff --git a/src/libs/GrowlNotification/GrowlNotificationContainer/index.js b/src/components/GrowlNotification/GrowlNotificationContainer/index.js similarity index 89% rename from src/libs/GrowlNotification/GrowlNotificationContainer/index.js rename to src/components/GrowlNotification/GrowlNotificationContainer/index.js index 82b0b68cbf6a..b1b051107ab0 100644 --- a/src/libs/GrowlNotification/GrowlNotificationContainer/index.js +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.js @@ -1,7 +1,7 @@ import React from 'react'; import {Animated} from 'react-native'; import styles from '../../../styles/styles'; -import withWindowDimensions from '../../../components/withWindowDimensions'; +import withWindowDimensions from '../../withWindowDimensions'; import propTypes from './GrowlNotificationContainerPropTypes'; const GrowlNotificationContainer = ({children, translateY, isSmallScreenWidth}) => ( diff --git a/src/components/GrowlNotification/GrowlNotificationContainer/index.native.js b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.js new file mode 100644 index 000000000000..f718430f27b7 --- /dev/null +++ b/src/components/GrowlNotification/GrowlNotificationContainer/index.native.js @@ -0,0 +1,25 @@ +import React from 'react'; +import {Animated} from 'react-native'; +import {SafeAreaInsetsContext} from 'react-native-safe-area-context'; +import styles, {getSafeAreaPadding} from '../../../styles/styles'; +import propTypes from './GrowlNotificationContainerPropTypes'; + +const GrowlNotificationContainer = ({children, translateY}) => ( + + {insets => ( + + {children} + + )} + +); + +GrowlNotificationContainer.propTypes = propTypes; + +export default GrowlNotificationContainer; diff --git a/src/libs/GrowlNotification/index.js b/src/components/GrowlNotification/index.js similarity index 90% rename from src/libs/GrowlNotification/index.js rename to src/components/GrowlNotification/index.js index 16f232b498aa..c15ed036e5cf 100644 --- a/src/libs/GrowlNotification/index.js +++ b/src/components/GrowlNotification/index.js @@ -6,21 +6,22 @@ import { Directions, FlingGestureHandler, State, TouchableWithoutFeedback, } from 'react-native-gesture-handler'; import colors from '../../styles/colors'; -import Icon from '../../components/Icon'; -import {Checkmark, Exclamation} from '../../components/Icon/Expensicons'; +import Icon from '../Icon'; +import {Checkmark, Exclamation} from '../Icon/Expensicons'; import styles from '../../styles/styles'; import GrowlNotificationContainer from './GrowlNotificationContainer'; +import CONST from '../../CONST'; const types = { - success: { + [CONST.GROWL.SUCCESS]: { icon: Checkmark, iconColor: colors.green, }, - error: { + [CONST.GROWL.ERROR]: { icon: Exclamation, iconColor: colors.red, }, - warning: { + [CONST.GROWL.WARNING]: { icon: Exclamation, iconColor: colors.yellow, }, @@ -47,9 +48,9 @@ class GrowlNotification extends Component { * * @param {String} bodyText * @param {String} type - * @param {Number} duration - 2000 + * @param {Number} duration */ - show(bodyText, type, duration = 2000) { + show(bodyText, type, duration) { this.setState({ bodyText, type, diff --git a/src/components/TextInputFocusable/index.js b/src/components/TextInputFocusable/index.js index 4f4ca39b0c60..48329e4a5530 100755 --- a/src/components/TextInputFocusable/index.js +++ b/src/components/TextInputFocusable/index.js @@ -3,7 +3,9 @@ import {TextInput, StyleSheet} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import withLocalize, {withLocalizePropTypes} from '../withLocalize'; +import Growl from '../../libs/Growl'; import themeColors from '../../styles/themes/default'; +import CONST from '../../CONST'; const propTypes = { /** Maximum number of lines in the text input */ @@ -108,6 +110,11 @@ class TextInputFocusable extends React.Component { end: initialValue.length, }, }; + this.selection = { + start: initialValue.length, + end: initialValue.length, + }; + this.saveSelection = this.saveSelection.bind(this); } componentDidMount() { @@ -176,6 +183,17 @@ class TextInputFocusable extends React.Component { return newNumberOfLines; } + /** + * Keeps track of user cursor position on the Composer + * + * @param {{nativeEvent: {selection: any}}} event + * @memberof TextInputFocusable + */ + saveSelection(event) { + this.selection = event.nativeEvent.selection; + this.props.onSelectionChange(event); + } + /** * Check the paste event for an attachment, parse the data and * call onPasteFile from props with the selected file @@ -185,6 +203,7 @@ class TextInputFocusable extends React.Component { checkForAttachment(event) { const {files, types} = event.clipboardData; const TEXT_HTML = 'text/html'; + const TEXT_PLAIN = 'text/plain'; if (files.length > 0) { // Prevent the default so we do not post the file name into the text box event.preventDefault(); @@ -192,6 +211,7 @@ class TextInputFocusable extends React.Component { } else if (types.includes(TEXT_HTML)) { const domparser = new DOMParser(); const embededImages = domparser.parseFromString(event.clipboardData.getData(TEXT_HTML), TEXT_HTML).images; + const pastedText = event.clipboardData.getData(TEXT_PLAIN); if (embededImages.length > 0) { event.preventDefault(); fetch(embededImages[0].src) @@ -208,9 +228,23 @@ class TextInputFocusable extends React.Component { return new File([x], `pasted_image.${extension}`, {}); }) .then(this.props.onPasteFile) - .catch((error) => { + .catch(() => { const errorDesc = this.props.translate('textInputFocusable.problemGettingImageYouPasted'); - alert(`${errorDesc}. \n${error.message}`); + Growl.show(errorDesc, CONST.GROWL.ERROR); + + /* + * Since we intercepted the user-triggered paste event to check for attachments, + * we need to manually set the value and call the `onChangeText` handler. + * Synthetically-triggered paste events do not affect the document's contents. + * See https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event for more details. + */ + const beforeCursorText = this.textInput.value.substring(0, this.selection.start); + const afterCursorText = this.textInput.value.substring(this.selection.end); + this.textInput.value = beforeCursorText + pastedText + afterCursorText; + this.updateNumberOfLines(); + this.props.onChangeText(this.textInput.value); + const newCursorPosition = beforeCursorText.length + pastedText.length; + this.setState({selection: {start: newCursorPosition, end: newCursorPosition}}); }); } } @@ -247,12 +281,12 @@ class TextInputFocusable extends React.Component { onChange={() => { this.updateNumberOfLines(); }} + onSelectionChange={this.saveSelection} numberOfLines={this.state.numberOfLines} style={propStyles} /* eslint-disable-next-line react/jsx-props-no-spreading */ {...propsWithoutStyles} disabled={this.props.isDisabled} - onSelectionChange={this.props.onSelectionChange} /> ); } diff --git a/src/libs/Growl.js b/src/libs/Growl.js new file mode 100644 index 000000000000..9e20dcbcc2a6 --- /dev/null +++ b/src/libs/Growl.js @@ -0,0 +1,18 @@ +import React from 'react'; +import CONST from '../CONST'; + +export const growlRef = React.createRef(); + +/** + * Show the growl notification + * + * @param {String} bodyText + * @param {String} type + * @param {Number} [duration] +*/ +function show(bodyText, type, duration = CONST.GROWL.DURATION) { + growlRef.current.show(bodyText, type, duration); +} + + +export default {show}; diff --git a/src/libs/GrowlNotification/GrowlNotificationContainer/index.native.js b/src/libs/GrowlNotification/GrowlNotificationContainer/index.native.js deleted file mode 100644 index d6d5a0bc0e8b..000000000000 --- a/src/libs/GrowlNotification/GrowlNotificationContainer/index.native.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import {Animated} from 'react-native'; -import styles from '../../../styles/styles'; -import propTypes from './GrowlNotificationContainerPropTypes'; - -const GrowlNotificationContainer = ({children, translateY}) => ( - - {children} - -); - -GrowlNotificationContainer.propTypes = propTypes; - -export default GrowlNotificationContainer; diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 00ea71e257dd..1487e85057b8 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -29,8 +29,8 @@ import CreateMenu from '../../../components/CreateMenu'; import Picker from '../../../components/Picker'; import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import compose from '../../../libs/compose'; -import GrowlNotification from '../../../libs/GrowlNotification'; import Button from '../../../components/Button'; +import Growl from '../../../libs/Growl'; const propTypes = { /* Onyx Props */ @@ -131,7 +131,6 @@ class ProfilePage extends Component { this.setAutomaticTimezone = this.setAutomaticTimezone.bind(this); this.getLogins = this.getLogins.bind(this); this.createMenuItems = this.createMenuItems.bind(this); - this.growlNotification = undefined; } componentDidUpdate(prevProps) { @@ -211,7 +210,7 @@ class ProfilePage extends Component { }, }); - this.growlNotification.show(this.props.translate('profilePage.growlMessageOnSave'), 'success', 3000); + Growl.show(this.props.translate('profilePage.growlMessageOnSave'), CONST.GROWL.SUCCESS, 3000); } /** @@ -261,7 +260,6 @@ class ProfilePage extends Component { return ( - this.growlNotification = el} />