diff --git a/src/CONST.js b/src/CONST.js index a45c6c91240e..9067f8107c94 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -992,6 +992,7 @@ const CONST = { EMOJIS: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, TAX_ID: /^\d{9}$/, NON_NUMERIC: /\D/g, + IS_COMMENT_EMPTY: /^(\s|`)*$/, // Extract attachment's source from the data's html string ATTACHMENT_DATA: /(data-expensify-source|data-name)="([^"]+)"/g, diff --git a/src/components/Composer/index.ios.js b/src/components/Composer/index.ios.js deleted file mode 100644 index b31a5b462f50..000000000000 --- a/src/components/Composer/index.ios.js +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react'; -import {StyleSheet} from 'react-native'; -import PropTypes from 'prop-types'; -import _ from 'underscore'; -import RNTextInput from '../RNTextInput'; -import themeColors from '../../styles/themes/default'; -import CONST from '../../CONST'; -import * as ComposerUtils from '../../libs/ComposerUtils'; - -const propTypes = { - /** If the input should clear, it actually gets intercepted instead of .clear() */ - shouldClear: PropTypes.bool, - - /** A ref to forward to the text input */ - forwardedRef: PropTypes.func, - - /** When the input has cleared whoever owns this input should know about it */ - onClear: PropTypes.func, - - /** Set focus to this component the first time it renders. - * Override this in case you need to set focus on one field out of many, or when you want to disable autoFocus */ - autoFocus: PropTypes.bool, - - /** Prevent edits and interactions like focus for this input. */ - isDisabled: PropTypes.bool, - - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - - /** Whether the full composer can be opened */ - isFullComposerAvailable: PropTypes.bool, - - /** Allow the full composer to be opened */ - setIsFullComposerAvailable: PropTypes.func, - - /** Whether the composer is full size */ - isComposerFullSize: PropTypes.bool, - - /** General styles to apply to the text input */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.any, - -}; - -const defaultProps = { - shouldClear: false, - onClear: () => {}, - autoFocus: false, - isDisabled: false, - forwardedRef: null, - selection: { - start: 0, - end: 0, - }, - isFullComposerAvailable: false, - setIsFullComposerAvailable: () => {}, - isComposerFullSize: false, - style: null, -}; - -class Composer extends React.Component { - constructor(props) { - super(props); - - this.state = { - propStyles: StyleSheet.flatten(this.props.style), - }; - } - - componentDidMount() { - // This callback prop is used by the parent component using the constructor to - // get a ref to the inner textInput element e.g. if we do - // this.textInput = el} /> this will not - // return a ref to the component, but rather the HTML element by default - if (!this.props.forwardedRef || !_.isFunction(this.props.forwardedRef)) { - return; - } - - this.props.forwardedRef(this.textInput); - } - - componentDidUpdate(prevProps) { - if (prevProps.shouldClear || !this.props.shouldClear) { - return; - } - - this.textInput.clear(); - this.props.onClear(); - } - - render() { - // On native layers we like to have the Text Input not focused so the - // user can read new chats without the keyboard in the way of the view. - // On Android, the selection prop is required on the TextInput but this prop has issues on IOS - // https://github.com/facebook/react-native/issues/29063 - const propsToPass = _.omit(this.props, 'selection'); - return ( - this.textInput = el} - maxHeight={this.props.isComposerFullSize ? '100%' : CONST.COMPOSER_MAX_HEIGHT} - onContentSizeChange={e => ComposerUtils.updateNumberOfLines(this.props, e)} - rejectResponderTermination={false} - textAlignVertical="center" - style={this.state.propStyles} - /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...propsToPass} - editable={!this.props.isDisabled} - /> - ); - } -} - -Composer.propTypes = propTypes; -Composer.defaultProps = defaultProps; - -export default React.forwardRef((props, ref) => ( - /* eslint-disable-next-line react/jsx-props-no-spreading */ - -)); diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index ea2b405b716c..57fb4676d84e 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -24,9 +24,6 @@ const propTypes = { /** The default value of the comment box */ defaultValue: PropTypes.string, - /** The value of the comment box */ - value: PropTypes.string, - /** Number of lines for the comment */ numberOfLines: PropTypes.number, @@ -59,12 +56,6 @@ const propTypes = { /** Update selection position on change */ onSelectionChange: PropTypes.func, - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - /** Whether the full composer can be opened */ isFullComposerAvailable: PropTypes.bool, @@ -74,14 +65,16 @@ const propTypes = { /** Whether the composer is full size */ isComposerFullSize: PropTypes.bool, + /** Called when the user changes the text in the input */ + onChangeText: PropTypes.func, + ...withLocalizePropTypes, ...windowDimensionsPropTypes, }; const defaultProps = { - defaultValue: undefined, - value: undefined, + defaultValue: '', numberOfLines: undefined, onNumberOfLinesChange: () => {}, maxLines: -1, @@ -93,13 +86,10 @@ const defaultProps = { autoFocus: false, forwardedRef: null, onSelectionChange: () => {}, - selection: { - start: 0, - end: 0, - }, isFullComposerAvailable: false, setIsFullComposerAvailable: () => {}, isComposerFullSize: false, + onChangeText: () => {}, }; const IMAGE_EXTENSIONS = { @@ -120,16 +110,9 @@ class Composer extends React.Component { constructor(props) { super(props); - const initialValue = props.defaultValue - ? `${props.defaultValue}` - : `${props.value || ''}`; - this.state = { numberOfLines: props.numberOfLines, - selection: { - start: initialValue.length, - end: initialValue.length, - }, + value: props.defaultValue, }; this.paste = this.paste.bind(this); @@ -137,8 +120,13 @@ class Composer extends React.Component { this.handlePaste = this.handlePaste.bind(this); this.handlePastedHTML = this.handlePastedHTML.bind(this); this.handleWheel = this.handleWheel.bind(this); + this.setText = this.setText.bind(this); + this.updateNumberOfLines = this.updateNumberOfLines.bind(this); + this.focus = this.focus.bind(this); + this.focusAndSetSelection = this.focusAndSetSelection.bind(this); + this.onChangeText = this.onChangeText.bind(this); + this.setSelection = this.setSelection.bind(this); this.putSelectionInClipboard = this.putSelectionInClipboard.bind(this); - this.shouldCallUpdateNumberOfLines = this.shouldCallUpdateNumberOfLines.bind(this); } componentDidMount() { @@ -155,9 +143,18 @@ class Composer extends React.Component { // There is no onPaste or onDrag for TextInput in react-native so we will add event // listeners here and unbind when the component unmounts if (this.textInput) { + this.textInput.setText = this.setText; + this.textInput.setSelection = this.setSelection; + this.textInput.focusInput = this.textInput.focus; + this.textInput.focus = this.focus; + this.textInput.focusAndSetSelection = this.focusAndSetSelection; + this.textInput.addEventListener('paste', this.handlePaste); this.textInput.addEventListener('wheel', this.handleWheel); this.textInput.addEventListener('keydown', this.putSelectionInClipboard); + + // Selection will be at start (0,0) - so we need to update it to be at the end + this.textInput.setSelectionRange(Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER); } } @@ -165,22 +162,16 @@ class Composer extends React.Component { if (!prevProps.shouldClear && this.props.shouldClear) { this.textInput.clear(); // eslint-disable-next-line react/no-did-update-set-state - this.setState({numberOfLines: 1}); + this.setState({numberOfLines: 1, value: ''}); this.props.onClear(); } - if (prevProps.value !== this.props.value - || prevProps.defaultValue !== this.props.defaultValue + if (prevProps.defaultValue !== this.props.defaultValue || prevProps.isComposerFullSize !== this.props.isComposerFullSize || prevProps.windowWidth !== this.props.windowWidth || prevProps.numberOfLines !== this.props.numberOfLines) { this.updateNumberOfLines(); } - - if (prevProps.selection !== this.props.selection) { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({selection: this.props.selection}); - } } componentWillUnmount() { @@ -192,6 +183,42 @@ class Composer extends React.Component { this.textInput.removeEventListener('wheel', this.handleWheel); } + /** + * Handler for when the text of the text input changes. + * Will also propagate change to parent component, via + * onChangeText prop. + * + * @param {String} text + */ + onChangeText(text) { + this.setState({value: text}); + this.props.onChangeText(text); + this.updateNumberOfLines(); + } + + /** + * Updates the text of the input. + * Useful when e.g. adding emojis using an emoji picker. + * @param {String} text + * @param {Function} onDone + */ + setText(text, onDone) { + this.setState({value: text}, onDone); + + // Immediately update number of lines (otherwise we'd wait + // for "onChange" callback which gets called "too late"): + this.updateNumberOfLines(); + } + + /** + * Sets the selection of the input. + * @param {Number} start + * @param {Number} end + */ + setSelection(start, end) { + this.textInput.setSelectionRange(start, end); + } + // Prevent onKeyPress from being triggered if the Enter key is pressed while text is being composed handleKeyPress(e) { if (!this.props.onKeyPress || isEnterWhileComposition(e)) { @@ -209,6 +236,9 @@ class Composer extends React.Component { document.execCommand('insertText', false, text); this.updateNumberOfLines(); + // Keep the textinput scrolled to the bottom (prevent flashes) + this.textInput.scrollTop = this.textInput.scrollHeight; + // Pointer will go out of sight when a large paragraph is pasted on the web. Refocusing the input keeps the cursor in view. this.textInput.blur(); this.textInput.focus(); @@ -325,18 +355,6 @@ class Composer extends React.Component { Clipboard.setHtml(selectedText, selectedText); } - /** - * We want to call updateNumberOfLines only when the parent doesn't provide value in props - * as updateNumberOfLines is already being called when value changes in componentDidUpdate - */ - shouldCallUpdateNumberOfLines() { - if (!_.isEmpty(this.props.value)) { - return; - } - - this.updateNumberOfLines(); - } - /** * Check the current scrollHeight of the textarea (minus any padding) and * divide by line height to get the total number of rows for the textarea. @@ -363,10 +381,46 @@ class Composer extends React.Component { }); } + /** + * Wrapper around the text input's focus method + * with the possibility to add a delay and a callback + * that gets called once the input is focused. + * @param {Function} [onDone] Called once the input is focused + * @param {Boolean} [delay=false] Whether to delay the focus + */ + focus(onDone, delay) { + // On web we need to run any effects before the focus + if (onDone) { + onDone(); + } + setTimeout(() => { + this.textInput.focusInput(); + }, delay ? 100 : 0); + } + + /** + * Call this when you have lost focus on the text input + * and want to re-focus it, but with a specific selection. + * Usually focus will set the selection to the end of the text. + * @param {Object} selection + * @param {Number} selection.start + * @param {Number} selection.end + * @param {Function} [onDone] Called once the input is focused + * @param {Boolean} [delay=false] Whether to delay the focus + */ + focusAndSetSelection(selection, onDone, delay) { + // On web, we need to set the selection first + // and then immediately focus the input. + if (selection != null) { + this.setSelection(selection.start, selection.end); + } + this.focus(onDone, delay); + } + render() { const propStyles = StyleSheet.flatten(this.props.style); propStyles.outline = 'none'; - const propsWithoutStyles = _.omit(this.props, 'style'); + const propsWithoutStylesAndDefault = _.omit(this.props, ['style', 'defaultValue']); // We're disabling autoCorrect for iOS Safari until Safari fixes this issue. See https://github.com/Expensify/App/issues/8592 return ( @@ -375,7 +429,6 @@ class Composer extends React.Component { autoCorrect={!Browser.isMobileSafari()} placeholderTextColor={themeColors.placeholderText} ref={el => this.textInput = el} - selection={this.state.selection} onChange={this.shouldCallUpdateNumberOfLines} onSelectionChange={this.onSelectionChange} style={[ @@ -386,10 +439,12 @@ class Composer extends React.Component { this.state.numberOfLines < this.props.maxLines ? styles.overflowHidden : {}, ]} /* eslint-disable-next-line react/jsx-props-no-spreading */ - {...propsWithoutStyles} + {...propsWithoutStylesAndDefault} numberOfLines={this.state.numberOfLines} disabled={this.props.isDisabled} onKeyPress={this.handleKeyPress} + value={this.state.value} + onChangeText={this.onChangeText} /> ); } diff --git a/src/components/Composer/index.android.js b/src/components/Composer/index.native.js similarity index 50% rename from src/components/Composer/index.android.js rename to src/components/Composer/index.native.js index 603bdf829edd..4f5530b04f91 100644 --- a/src/components/Composer/index.android.js +++ b/src/components/Composer/index.native.js @@ -1,5 +1,5 @@ import React from 'react'; -import {StyleSheet} from 'react-native'; +import {InteractionManager, StyleSheet} from 'react-native'; import PropTypes from 'prop-types'; import _ from 'underscore'; import RNTextInput from '../RNTextInput'; @@ -24,12 +24,6 @@ const propTypes = { /** Prevent edits and interactions like focus for this input. */ isDisabled: PropTypes.bool, - /** Selection Object */ - selection: PropTypes.shape({ - start: PropTypes.number, - end: PropTypes.number, - }), - /** Whether the full composer can be opened */ isFullComposerAvailable: PropTypes.bool, @@ -43,6 +37,11 @@ const propTypes = { // eslint-disable-next-line react/forbid-prop-types style: PropTypes.any, + /** Called when the text gets changed by user input */ + onChangeText: PropTypes.func, + + /** A value the input should have when it first mounts. Default is empty. */ + defaultValue: PropTypes.string, }; const defaultProps = { @@ -51,22 +50,26 @@ const defaultProps = { autoFocus: false, isDisabled: false, forwardedRef: null, - selection: { - start: 0, - end: 0, - }, isFullComposerAvailable: false, setIsFullComposerAvailable: () => {}, isComposerFullSize: false, style: null, + onChangeText: () => {}, + defaultValue: '', }; class Composer extends React.Component { constructor(props) { super(props); + this.onChangeText = this.onChangeText.bind(this); + this.setText = this.setText.bind(this); + this.focus = this.focus.bind(this); + this.focusAndSetSelection = this.focusAndSetSelection.bind(this); + this.state = { propStyles: StyleSheet.flatten(this.props.style), + value: this.props.defaultValue, }; } @@ -79,6 +82,12 @@ class Composer extends React.Component { return; } + // We want this to be an available method on the ref for parent components + this.textInput.setText = this.setText; + this.textInput.focusInput = this.textInput.focus; + this.textInput.focus = this.focus; + this.textInput.focusAndSetSelection = this.focusAndSetSelection; + this.props.forwardedRef(this.textInput); } @@ -91,6 +100,78 @@ class Composer extends React.Component { this.props.onClear(); } + /** + * Handler for when the text of the text input changes. + * Will also propagate change to parent component, via + * onChangeText prop. + * + * @param {String} text + */ + onChangeText(text) { + this.setState({value: text}); + this.props.onChangeText(text); + } + + /** + * Sets the text of the input. + * @param {String} text + */ + setText(text) { + this.setState({value: text}); + } + + /** + * Wrapper around the text input's focus method + * with the possibility to add a delay and a callback. + * Note: it always uses the interaction manager to focus + * once any other interactions are done. + * + * @param {Function} [onDone] Called once the input is focused + * @param {Boolean} [delay=false] Whether to delay the focus + */ + focus(onDone, delay) { + setTimeout(() => { + // There could be other animations running while we trigger manual focus. + // This prevents focus from making those animations janky. + InteractionManager.runAfterInteractions(() => { + this.textInput.focusInput(); + if (onDone) { + onDone(); + } + }); + }, delay ? 100 : 0); + } + + /** + * Call this when you have lost focus on the text input + * and want to re-focus it, but with a specific selection. + * Usually focus will set the selection to the end of the text. + * @param {Object} selection + * @param {Number} selection.start + * @param {Number} selection.end + * @param {Function} [onDone] Called once the input is focused + * @param {Boolean} [delay=false] Whether to delay the focus + */ + focusAndSetSelection(selection, onDone, delay) { + // We first need to focus the input, and then set the selection, as otherwise + // the focus might cause the selection to be set to the end of the text input + this.focus( + () => { + requestAnimationFrame(() => { + if (selection != null) { + this.textInput.setSelection(selection.start, selection.end); + } + if (onDone) { + onDone(); + } + }); + }, + + // Run the focus with a delay. Note: Its platform dependent whether the delay will be respected or not. + delay, + ); + } + render() { return ( ); } diff --git a/src/components/RNTextInput.js b/src/components/RNTextInput.js index b3cf44c84212..70311ddc465b 100644 --- a/src/components/RNTextInput.js +++ b/src/components/RNTextInput.js @@ -20,6 +20,7 @@ const RNTextInput = props => ( if (!_.isFunction(props.forwardedRef)) { return; } + props.forwardedRef(ref); }} // eslint-disable-next-line diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index 1236e3df9ff6..fbe17ea80ac3 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -205,30 +205,47 @@ const getEmojiCodeWithSkinColor = (item, preferredSkinToneIndex) => { * Replace any emoji name in a text with the emoji icon. * If we're on mobile, we also add a space after the emoji granted there's no text after it. * @param {String} text - * @param {Boolean} isSmallScreenWidth + * @param {Boolean} addSpaceAfterEmoji * @param {Number} preferredSkinTone - * @returns {String} + * @returns {Object} results + * @returns {String} results.newText + * @returns {Object} results.lastReplacedSelection + * @returns {Number} results.lastReplacedSelection.start + * @returns {Number} results.lastReplacedSelection.end + * @returns {Number} results.lastReplacedSelection.newSelectionEnd */ -function replaceEmojis(text, isSmallScreenWidth = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE) { +function replaceEmojis(text, addSpaceAfterEmoji = false, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE) { let newText = text; const emojiData = text.match(CONST.REGEX.EMOJI_NAME); + + const lastReplacedSelection = { + start: 0, + end: 0, + newSelectionEnd: 0, + }; + if (!emojiData || emojiData.length === 0) { - return text; + return {newText, lastReplacedSelection}; } for (let i = 0; i < emojiData.length; i++) { - const checkEmoji = emojisTrie.search(emojiData[i].slice(1, -1)); + const match = emojiData[i]; + const checkEmoji = emojisTrie.search(match.slice(1, -1)); if (checkEmoji && checkEmoji.metaData.code) { let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData, preferredSkinTone); - - // If this is the last emoji in the message and it's the end of the message so far, - // add a space after it so the user can keep typing easily. - if (isSmallScreenWidth && i === emojiData.length - 1 && text.endsWith(emojiData[i])) { + if (addSpaceAfterEmoji) { emojiReplacement += ' '; } - newText = newText.replace(emojiData[i], emojiReplacement); + + lastReplacedSelection.start = newText.indexOf(match); + lastReplacedSelection.end = lastReplacedSelection.start + match.length; + lastReplacedSelection.newSelectionEnd = lastReplacedSelection.start + emojiCode.length; + + newText = newText.substr(0, lastReplacedSelection.start) + + emojiReplacement + + newText.substr(lastReplacedSelection.end); } } - return newText; + return {newText, lastReplacedSelection}; } /** diff --git a/src/libs/addEmojiToComposer/baseAddEmojiToComposer.js b/src/libs/addEmojiToComposer/baseAddEmojiToComposer.js new file mode 100644 index 000000000000..a90e3b8abdb5 --- /dev/null +++ b/src/libs/addEmojiToComposer/baseAddEmojiToComposer.js @@ -0,0 +1,41 @@ +/** + * Takes a text and adds an emoji at the place of selection. + * It will then update the text of the given TextInput using its `setTextAndSelection` method. + * `setTextAndSelection` method is usually available on TextInput refs from the composer component. + * + * Note: This is a separate method as for some platforms the update of the TextInput has to be + * handled differently, and the method is used in several places. + * + * @param {Object} params + * @param {String} params.text The text where the emoji should be added + * @param {String} params.emoji The emoji to add + * @param {Object} params.textInput + * @param {Object} params.selection + * @param {Number} params.selection.start + * @param {Number} params.selection.end + * + * @return {Object} results + */ +function baseAddEmojiToComposer({ + text, + emoji, + textInput, + selection, +}) { + const emojiWithSpace = `${emoji} `; + const newText = text.slice(0, selection.start) + emojiWithSpace + text.slice(selection.end, text.length); + const newSelectionStart = selection.start + emojiWithSpace.length; + const newSelection = { + start: newSelectionStart, + end: newSelectionStart, + }; + + textInput.setText(newText); + + return { + newText, + newSelection, + }; +} + +export default baseAddEmojiToComposer; diff --git a/src/libs/addEmojiToComposer/index.android.js b/src/libs/addEmojiToComposer/index.android.js new file mode 100644 index 000000000000..4b80b128d9cf --- /dev/null +++ b/src/libs/addEmojiToComposer/index.android.js @@ -0,0 +1,31 @@ +import baseAddEmojiToComposer from './baseAddEmojiToComposer'; + +/** + * Takes a text and adds an emoji at the place of selection. + * It will then update the text of the given TextInput using its `setTextAndSelection` method. + * `setTextAndSelection` method is usually available on TextInput refs from the composer component. + * + * @param {Object} params + * @param {String} params.text The text where the emoji should be added + * @param {String} params.emoji The emoji to add + * @param {Object} params.textInput + * @param {Object} params.selection + * @param {Number} params.selection.start + * @param {Number} params.selection.end + * + * @return {Object} results + */ +function addEmojiToComposerTextInput(params) { + const {selection, textInput} = params; + const hasRangeSelected = selection.start !== selection.end; + if (hasRangeSelected) { + // Android: when we have a range selected setSelection + // won't remove the highlight, so we manually set the cursor + // to a selection range of 0 (so there won't be any selection highlight). + textInput.setSelection(selection.start, selection.start); + } + + return baseAddEmojiToComposer(params); +} + +export default addEmojiToComposerTextInput; diff --git a/src/libs/addEmojiToComposer/index.js b/src/libs/addEmojiToComposer/index.js new file mode 100644 index 000000000000..9b93411217a8 --- /dev/null +++ b/src/libs/addEmojiToComposer/index.js @@ -0,0 +1,3 @@ +import baseAddEmojiToComposer from './baseAddEmojiToComposer'; + +export default baseAddEmojiToComposer; diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 556a0b624412..4f4549c94c37 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import { View, TouchableOpacity, - InteractionManager, LayoutAnimation, } from 'react-native'; import _ from 'underscore'; @@ -45,9 +44,9 @@ import ExceededCommentLength from '../../../components/ExceededCommentLength'; import withNavigationFocus from '../../../components/withNavigationFocus'; import withNavigation from '../../../components/withNavigation'; import * as EmojiUtils from '../../../libs/EmojiUtils'; +import addEmojiToComposer from '../../../libs/addEmojiToComposer'; import ReportDropUI from './ReportDropUI'; import DragAndDrop from '../../../components/DragAndDrop'; -import reportPropTypes from '../../reportPropTypes'; import EmojiSuggestions from '../../../components/EmojiSuggestions'; import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; import ArrowKeyFocusManager from '../../../components/ArrowKeyFocusManager'; @@ -56,6 +55,7 @@ import KeyboardShortcut from '../../../libs/KeyboardShortcut'; import * as ComposerUtils from '../../../libs/ComposerUtils'; import * as Welcome from '../../../libs/actions/Welcome'; import Permissions from '../../../libs/Permissions'; +import reportPropTypes from '../../reportPropTypes'; const propTypes = { /** Beta features list */ @@ -159,7 +159,7 @@ const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => { return emojiRowCount - 1; }; -class ReportActionCompose extends React.Component { +class ReportActionCompose extends React.PureComponent { constructor(props) { super(props); this.calculateEmojiSuggestion = _.debounce(this.calculateEmojiSuggestion, 10, false); @@ -170,7 +170,6 @@ class ReportActionCompose extends React.Component { this.submitForm = this.submitForm.bind(this); this.setIsFocused = this.setIsFocused.bind(this); this.setIsFullComposerAvailable = this.setIsFullComposerAvailable.bind(this); - this.focus = this.focus.bind(this); this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this); this.onSelectionChange = this.onSelectionChange.bind(this); this.isEmojiCode = this.isEmojiCode.bind(this); @@ -180,9 +179,10 @@ class ReportActionCompose extends React.Component { this.getTaskOption = this.getTaskOption.bind(this); this.addAttachment = this.addAttachment.bind(this); this.insertSelectedEmoji = this.insertSelectedEmoji.bind(this); - this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this); this.updateNumberOfLines = this.updateNumberOfLines.bind(this); this.showPopoverMenu = this.showPopoverMenu.bind(this); + this.focusInputAndSetSelection = this.focusInputAndSetSelection.bind(this); + this.comment = props.comment; this.setShouldBlockEmojiCalcToFalse = this.setShouldBlockEmojiCalcToFalse.bind(this); @@ -190,6 +190,16 @@ class ReportActionCompose extends React.Component { // code that will refocus the compose input after a user closes a modal or some other actions, see usage of ReportActionComposeFocusManager this.willBlurTextInputOnTapOutside = willBlurTextInputOnTapOutside(); + // This variable will be kept up to date by the text input's onSelectionChange callback + this.selection = { + start: props.comment.length, + end: props.comment.length, + }; + + // This variable will be set when we insert an emoji using the picker. It will be used + // to set the selection caret being the inserted emoji. + this.nextSelectionAfterEmojiInsertion = null; + this.state = { isFocused: this.willBlurTextInputOnTapOutside && !this.props.modal.isVisible && !this.props.modal.willAlertModalBecomeVisible, isFullComposerAvailable: props.isComposerFullSize, @@ -197,12 +207,7 @@ class ReportActionCompose extends React.Component { isCommentEmpty: props.comment.length === 0, isMenuVisible: false, isDraggingOver: false, - selection: { - start: props.comment.length, - end: props.comment.length, - }, maxLines: props.isSmallScreenWidth ? CONST.COMPOSER.MAX_LINES_SMALL_SCREEN : CONST.COMPOSER.MAX_LINES, - value: props.comment, // If we are on a small width device then don't show last 3 items from conciergePlaceholderOptions conciergePlaceholderRandomIndex: _.random(this.props.translate('reportActionCompose.conciergePlaceholderOptions').length - (this.props.isSmallScreenWidth ? 4 : 1)), @@ -212,7 +217,11 @@ class ReportActionCompose extends React.Component { shouldShowSuggestionMenu: false, isEmojiPickerLarge: false, composerHeight: 0, - hasExceededMaxCommentLength: false, + + // If this is undefined it means we haven't exceeded the max comment length. + // If it is a number it means we have exceeded the max comment length and the number is the total length. + // We only want to set this value when necessary to avoid re-renders. + exceededCommentLength: this.comment.length > CONST.MAX_COMMENT_LENGTH ? this.comment.length : undefined, }; } @@ -224,7 +233,7 @@ class ReportActionCompose extends React.Component { return; } - this.focus(false); + this.textInput.focus(); }); const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; @@ -259,7 +268,7 @@ class ReportActionCompose extends React.Component { // open creates a jarring and broken UX. if (this.willBlurTextInputOnTapOutside && this.props.isFocused && prevProps.modal.isVisible && !this.props.modal.isVisible) { - this.focus(); + this.textInput.focus(); } if (this.props.isComposerFullSize !== prevProps.isComposerFullSize) { @@ -268,7 +277,7 @@ class ReportActionCompose extends React.Component { // Value state does not have the same value as comment props when the comment gets changed from another tab. // In this case, we should synchronize the value between tabs. - const shouldSyncComment = prevProps.comment !== this.props.comment && this.state.value !== this.props.comment; + const shouldSyncComment = prevProps.comment !== this.props.comment && this.comment !== this.props.comment; // As the report IDs change, make sure to update the composer comment as we need to make sure // we do not show incorrect data in there (ie. draft of message from other report). @@ -288,7 +297,7 @@ class ReportActionCompose extends React.Component { } onSelectionChange(e) { - this.setState({selection: e.nativeEvent.selection}); + this.selection = e.nativeEvent.selection; this.calculateEmojiSuggestion(); } @@ -378,16 +387,6 @@ class ReportActionCompose extends React.Component { return _.map(ReportUtils.getMoneyRequestOptions(this.props.report, reportParticipants, this.props.betas), option => options[option]); } - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - * - * @param {Boolean} hasExceededMaxCommentLength - */ - setExceededMaxCommentLength(hasExceededMaxCommentLength) { - this.setState({hasExceededMaxCommentLength}); - } - /** * Set the maximum number of lines for the composer */ @@ -453,7 +452,7 @@ class ReportActionCompose extends React.Component { * Calculates and cares about the content of an Emoji Suggester */ calculateEmojiSuggestion() { - if (!this.state.value) { + if (!this.comment) { this.resetSuggestedEmojis(); return; } @@ -461,9 +460,9 @@ class ReportActionCompose extends React.Component { this.setState({shouldBlockEmojiCalc: false}); return; } - const leftString = this.state.value.substring(0, this.state.selection.end); + const leftString = this.comment.substring(0, this.selection.end); const colonIndex = leftString.lastIndexOf(':'); - const isCurrentlyShowingEmojiSuggestion = this.isEmojiCode(this.state.value, this.state.selection.end); + const isCurrentlyShowingEmojiSuggestion = this.isEmojiCode(this.comment, this.selection.end); // the larger composerHeight the less space for EmojiPicker, Pixel 2 has pretty small screen and this value equal 5.3 const hasEnoughSpaceForLargeSuggestion = this.props.windowHeight / this.state.composerHeight >= 6.8; @@ -506,19 +505,27 @@ class ReportActionCompose extends React.Component { * @param {Number} highlightedEmojiIndex */ insertSelectedEmoji(highlightedEmojiIndex) { - const commentBeforeColon = this.state.value.slice(0, this.state.colonIndex); + const commentBeforeColon = this.comment.slice(0, this.state.colonIndex); const emojiObject = this.state.suggestedEmojis[highlightedEmojiIndex]; const emojiCode = emojiObject.types && emojiObject.types[this.props.preferredSkinTone] ? emojiObject.types[this.props.preferredSkinTone] : emojiObject.code; - const commentAfterColonWithEmojiNameRemoved = this.state.value.slice(this.state.selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); + const commentAfterColonWithEmojiNameRemoved = this.comment.slice(this.selection.end).replace(CONST.REGEX.EMOJI_REPLACER, CONST.SPACE); - this.updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true); - this.setState(prevState => ({ - selection: { + this.setState((prevState) => { + this.selection = { start: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, end: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH, - }, - suggestedEmojis: [], - })); + }; + + return { + suggestedEmojis: [], + }; + }, () => { + const newComment = `${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`; + this.textInput.setText(newComment, () => { + this.textInput.setSelection(this.selection.end, this.selection.end); + }); + this.updateComment(newComment, true); + }); EmojiUtils.addToFrequentlyUsedEmojis(this.props.frequentlyUsedEmojis, emojiObject); } @@ -532,38 +539,32 @@ class ReportActionCompose extends React.Component { * @param {String} emoji */ addEmojiToTextBox(emoji) { - this.setState(prevState => ({ - selection: { - start: prevState.selection.start + emoji.length, - end: prevState.selection.start + emoji.length, - }, - })); - this.updateComment(ComposerUtils.insertText(this.comment, this.state.selection, emoji)); + const {newText, newSelection} = addEmojiToComposer({ + emoji, + text: this.comment, + textInput: this.textInput, + selection: this.selection, + }); + this.selection = newSelection; + this.nextSelectionAfterEmojiInsertion = newSelection; + this.updateComment(newText, true); + + // TODO: this.updateComment(ComposerUtils.insertText(this.comment, this.state.selection, emoji)); } /** - * Focus the composer text input - * @param {Boolean} [shouldelay=false] Impose delay before focusing the composer - * @memberof ReportActionCompose + * This will be called when the emoji picker modal closes. + * Once that's closed we want to focus the text input again and + * set the selection to the new position if an emoji was added. */ - focus(shouldelay = false) { - // There could be other animations running while we trigger manual focus. - // This prevents focus from making those animations janky. - InteractionManager.runAfterInteractions(() => { - if (!this.textInput) { + focusInputAndSetSelection() { + this.textInput.focusAndSetSelection(this.nextSelectionAfterEmojiInsertion, () => { + if (!this.nextSelectionAfterEmojiInsertion) { return; } - - if (!shouldelay) { - this.textInput.focus(); - } else { - // Keyboard is not opened after Emoji Picker is closed - // SetTimeout is used as a workaround - // https://github.com/react-native-modal/react-native-modal/issues/114 - // We carefully choose a delay. 100ms is found enough for keyboard to open. - setTimeout(() => this.textInput.focus(), 100); - } - }); + this.selection = this.nextSelectionAfterEmojiInsertion; + this.nextSelectionAfterEmojiInsertion = null; + }, true); } /** @@ -591,21 +592,18 @@ class ReportActionCompose extends React.Component { * @param {Boolean} shouldDebounceSaveComment */ updateComment(comment, shouldDebounceSaveComment) { - const newComment = EmojiUtils.replaceEmojis(comment, this.props.isSmallScreenWidth, this.props.preferredSkinTone); - this.setState((prevState) => { - const newState = { - isCommentEmpty: !!newComment.match(/^(\s)*$/), - value: newComment, - }; - if (comment !== newComment) { - const remainder = prevState.value.slice(prevState.selection.end).length; - newState.selection = { - start: newComment.length - remainder, - end: newComment.length - remainder, - }; - } - return newState; - }); + const emojiReplaceResults = EmojiUtils.replaceEmojis(comment, this.props.isSmallScreenWidth, this.props.preferredSkinTone); + const newComment = emojiReplaceResults.newText; + + // When the comment has changed after replacing emojis we need to update the text in the input + if (newComment !== comment) { + const newSelection = emojiReplaceResults.lastReplacedSelection; + this.textInput.setText(newComment, () => { + this.textInput.setSelection(newSelection.newSelectionEnd, newSelection.newSelectionEnd); + }); + this.selection = newSelection; + this.nextSelectionAfterEmojiInsertion = newSelection; + } // Indicate that draft has been created. if (this.comment.length === 0 && newComment.length !== 0) { @@ -626,6 +624,12 @@ class ReportActionCompose extends React.Component { if (newComment) { this.debouncedBroadcastUserIsTyping(); } + + const isCommentEmpty = Boolean(newComment.match(CONST.REGEX.IS_COMMENT_EMPTY)); + const encodedCommentLength = ReportUtils.getCommentLength(this.comment); + const hasExceededMaxCommentLength = encodedCommentLength > CONST.MAX_COMMENT_LENGTH; + const exceededCommentLength = hasExceededMaxCommentLength ? this.comment.length : undefined; + this.setState({exceededCommentLength, isCommentEmpty}); } /** @@ -681,9 +685,10 @@ class ReportActionCompose extends React.Component { } /** + * @param {Function} onDone called when all state updates completed * @returns {String} */ - prepareCommentAndResetComposer() { + prepareCommentAndResetComposer(onDone) { const trimmedComment = this.comment.trim(); // Don't submit empty comments or comments that exceed the character limit @@ -691,12 +696,13 @@ class ReportActionCompose extends React.Component { return ''; } + this.textInput.clear(); this.updateComment(''); this.setTextInputShouldClear(true); if (this.props.isComposerFullSize) { Report.setIsComposerFullSize(this.props.reportID, false); } - this.setState({isFullComposerAvailable: false}); + this.setState({isFullComposerAvailable: false}, onDone); return trimmedComment; } @@ -710,8 +716,9 @@ class ReportActionCompose extends React.Component { // We need to make sure an empty draft gets saved instead this.debouncedSaveReportComment.cancel(); const comment = this.prepareCommentAndResetComposer(); - Report.addAttachment(this.props.reportID, file, comment); - this.setTextInputShouldClear(false); + Report.addAttachment(this.props.reportID, file, comment, () => { + this.setTextInputShouldClear(false); + }); } /** @@ -758,8 +765,8 @@ class ReportActionCompose extends React.Component { const isComposeDisabled = this.props.isDrawerOpen && this.props.isSmallScreenWidth; const isBlockedFromConcierge = ReportUtils.chatIncludesConcierge(this.props.report) && User.isBlockedFromConcierge(this.props.blockedFromConcierge); const inputPlaceholder = this.getInputPlaceholder(); + const hasExceededMaxCommentLength = this.state.exceededCommentLength !== undefined; const shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && (this.state.isFocused || this.state.isDraggingOver); - const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; return ( 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} + defaultValue={this.props.comment} numberOfLines={this.props.numberOfLines} onNumberOfLinesChange={this.updateNumberOfLines} onLayout={(e) => { @@ -946,9 +952,7 @@ class ReportActionCompose extends React.Component { {DeviceCapabilities.canUseTouchScreen() && this.props.isMediumScreenWidth ? null : ( { - this.focus(true); - }} + onModalHide={this.focusInputAndSetSelection} onEmojiSelected={this.addEmojiToTextBox} /> )} @@ -982,7 +986,7 @@ class ReportActionCompose extends React.Component { > {!this.props.isSmallScreenWidth && } - + {this.state.isDraggingOver && } @@ -997,10 +1001,9 @@ class ReportActionCompose extends React.Component { onClose={() => this.setState({suggestedEmojis: []})} highlightedEmojiIndex={this.state.highlightedEmojiIndex} emojis={this.state.suggestedEmojis} - comment={this.state.value} - updateComment={newComment => this.setState({value: newComment})} + comment={this.comment} colonIndex={this.state.colonIndex} - prefix={this.state.value.slice(this.state.colonIndex + 1, this.state.selection.start)} + prefix={this.comment.slice(this.state.colonIndex + 1, this.selection.start)} onSelect={this.insertSelectedEmoji} isComposerFullSize={this.props.isComposerFullSize} preferredSkinToneIndex={this.props.preferredSkinTone} diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 7dd18f0c72b4..89a024eb7159 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -28,6 +28,7 @@ import * as ReportUtils from '../../../libs/ReportUtils'; import * as EmojiUtils from '../../../libs/EmojiUtils'; import getButtonState from '../../../libs/getButtonState'; import reportPropTypes from '../../reportPropTypes'; +import addEmojiToComposer from '../../../libs/addEmojiToComposer'; import ExceededCommentLength from '../../../components/ExceededCommentLength'; import CONST from '../../../CONST'; import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions'; @@ -83,7 +84,7 @@ class ReportActionItemMessageEdit extends React.Component { this.triggerSaveOrCancel = this.triggerSaveOrCancel.bind(this); this.onSelectionChange = this.onSelectionChange.bind(this); this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this); - this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this); + this.focusInputAndSetSelection = this.focusInputAndSetSelection.bind(this); this.saveButtonID = 'saveButton'; this.cancelButtonID = 'cancelButton'; this.emojiButtonID = 'emojiButton'; @@ -99,14 +100,22 @@ class ReportActionItemMessageEdit extends React.Component { draftMessage = Str.htmlDecode(this.props.draftMessage); } + this.selection = { + start: draftMessage.length, + end: draftMessage.length, + }; + this.draft = draftMessage; + + // This variable will be set when we insert an emoji using the picker. It will be used + // to set the selection caret being the inserted emoji. + this.nextSelectionAfterEmojiInsertion = null; + this.state = { - draft: draftMessage, - selection: { - start: draftMessage.length, - end: draftMessage.length, - }, isFocused: false, - hasExceededMaxCommentLength: false, + + // If this is undefined it means we haven't exceeded the max comment length. + // If it is a number it means we have exceeded the max comment length and the number is the total length. + exceededCommentLength: this.draft.length > CONST.MAX_COMMENT_LENGTH ? this.draft.length : undefined, }; } @@ -127,17 +136,7 @@ class ReportActionItemMessageEdit extends React.Component { * @param {Event} e */ onSelectionChange(e) { - this.setState({selection: e.nativeEvent.selection}); - } - - /** - * Updates the composer when the comment length is exceeded - * Shows red borders and prevents the comment from being sent - * - * @param {Boolean} hasExceededMaxCommentLength - */ - setExceededMaxCommentLength(hasExceededMaxCommentLength) { - this.setState({hasExceededMaxCommentLength}); + this.selection = e.nativeEvent.selection; } /** @@ -146,18 +145,16 @@ class ReportActionItemMessageEdit extends React.Component { * @param {String} draft */ updateDraft(draft) { - const newDraft = EmojiUtils.replaceEmojis(draft, this.props.isSmallScreenWidth, this.props.preferredSkinTone); - this.setState((prevState) => { - const newState = {draft: newDraft}; - if (draft !== newDraft) { - const remainder = prevState.draft.slice(prevState.selection.end).length; - newState.selection = { - start: newDraft.length - remainder, - end: newDraft.length - remainder, - }; - } - return newState; - }); + const emojiReplaceResults = EmojiUtils.replaceEmojis(draft, this.props.isSmallScreenWidth, this.props.preferredSkinTone); + const newDraft = emojiReplaceResults.newText; + + // When the draft has changed after replacing emojis we need to update the text in the input + if (newDraft !== draft) { + const cursorPosition = emojiReplaceResults.lastReplacedSelection.newSelectionEnd; + this.textInput.setTextAndSelection(newDraft, cursorPosition, cursorPosition); + } + + this.draft = newDraft; // This component is rendered only when draft is set to a non-empty string. In order to prevent component // unmount when user deletes content of textarea, we set previous message instead of empty string. @@ -167,6 +164,15 @@ class ReportActionItemMessageEdit extends React.Component { } else { this.debouncedSaveDraft(this.props.action.message[0].html); } + + const draftLength = ReportUtils.getCommentLength(this.draft); + const hasExceededMaxCommentLength = draftLength > CONST.MAX_COMMENT_LENGTH; + const exceededCommentLength = hasExceededMaxCommentLength ? draftLength : undefined; + if (this.state.exceededCommentLength !== exceededCommentLength) { + this.setState({ + exceededCommentLength, + }); + } } /** @@ -202,7 +208,7 @@ class ReportActionItemMessageEdit extends React.Component { */ publishDraft() { // Do nothing if draft exceed the character limit - if (ReportUtils.getCommentLength(this.state.draft) > CONST.MAX_COMMENT_LENGTH) { + if (ReportUtils.getCommentLength(this.draft) > CONST.MAX_COMMENT_LENGTH) { return; } @@ -210,7 +216,7 @@ class ReportActionItemMessageEdit extends React.Component { // debounce here. this.debouncedSaveDraft.cancel(); - const trimmedNewDraft = this.state.draft.trim(); + const trimmedNewDraft = this.draft.trim(); // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { @@ -231,13 +237,18 @@ class ReportActionItemMessageEdit extends React.Component { * @param {String} emoji */ addEmojiToTextBox(emoji) { - this.setState(prevState => ({ - selection: { - start: prevState.selection.start + emoji.length, - end: prevState.selection.start + emoji.length, - }, - })); - this.updateDraft(ComposerUtils.insertText(this.state.draft, this.state.selection, emoji)); + const {newText, newSelection} = addEmojiToComposer({ + emoji, + text: this.draft, + textInput: this.textInput, + selection: this.selection, + }); + + this.selection = newSelection; + this.nextSelectionAfterEmojiInsertion = newSelection; + this.updateDraft(newText); + + // TODO: this.updateDraft(ComposerUtils.insertText(this.state.draft, this.state.selection, emoji)); } /** @@ -258,8 +269,34 @@ class ReportActionItemMessageEdit extends React.Component { } } + /** + * This will be called when the emoji picker modal closes. + * Once that's closed we want to focus the text input again and + * set the selection to the new position if an emoji was added. + */ + focusInputAndSetSelection() { + // We first need to focus the input, and then set the selection, as otherwise + // the focus might cause the selection to be set to the end of the text input + this.textInput.focus( + () => { + if (!this.nextSelectionAfterEmojiInsertion) { + return; + } + + requestAnimationFrame(() => { + this.selection = this.nextSelectionAfterEmojiInsertion; + this.textInput.setSelection(this.selection.start, this.selection.end); + this.nextSelectionAfterEmojiInsertion = null; + }); + }, + + // Run the focus with a delay. Note: Its platform dependent whether the delay will be respected or not. + true, + ); + } + render() { - const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength; + const hasExceededMaxCommentLength = this.state.exceededCommentLength !== undefined; return ( <> @@ -300,7 +337,6 @@ class ReportActionItemMessageEdit extends React.Component { nativeID={this.messageEditInput} onChangeText={this.updateDraft} // Debounced saveDraftComment onKeyPress={this.triggerSaveOrCancel} - value={this.state.draft} maxLines={16} // This is the same that slack has style={[styles.textInputCompose, styles.flex1, styles.bgTransparent]} onFocus={() => { @@ -322,14 +358,14 @@ class ReportActionItemMessageEdit extends React.Component { } openReportActionComposeViewWhenClosingMessageEdit(this.props.isSmallScreenWidth); }} - selection={this.state.selection} + defaultValue={this.draft} onSelectionChange={this.onSelectionChange} /> InteractionManager.runAfterInteractions(() => this.textInput.focus())} + onModalHide={this.focusInputAndSetSelection} onEmojiSelected={this.addEmojiToTextBox} nativeID={this.emojiButtonID} /> @@ -356,7 +392,7 @@ class ReportActionItemMessageEdit extends React.Component { - + ); } diff --git a/tests/unit/EmojiTest.js b/tests/unit/EmojiTest.js index 224e3adafc05..a112ff9d56ae 100644 --- a/tests/unit/EmojiTest.js +++ b/tests/unit/EmojiTest.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import Emoji from '../../assets/emojis'; import * as EmojiUtils from '../../src/libs/EmojiUtils'; +import baseAddEmojiToComposer from '../../src/libs/addEmojiToComposer'; describe('EmojiTest', () => { it('matches all the emojis in the list', () => { @@ -94,23 +95,31 @@ describe('EmojiTest', () => { }); it('replaces an emoji code with an emoji and a space on mobile', () => { - const text = 'Hi :smile:'; - expect(EmojiUtils.replaceEmojis(text, true)).toBe('Hi 😄 '); + const text = 'Hi :smile::wave:'; + const replacedResults = EmojiUtils.replaceEmojis(text); + expect(replacedResults.newText).toBe('Hi 😄👋'); + expect(replacedResults.lastReplacedSelection.start).toEqual(5); + expect(replacedResults.lastReplacedSelection.end).toEqual(11); }); it('will not add a space after the last emoji if there is text after it', () => { const text = 'Hi :smile::wave:no space after last emoji'; - expect(EmojiUtils.replaceEmojis(text)).toBe('Hi 😄👋no space after last emoji'); + expect(EmojiUtils.replaceEmojis(text).newText).toBe('Hi 😄👋no space after last emoji'); }); - it('will not add a space after the last emoji when there is text after it on mobile', () => { + it('will not add a space after the last emoji when there is text after it', () => { const text = 'Hi :smile::wave:no space after last emoji'; - expect(EmojiUtils.replaceEmojis(text, true)).toBe('Hi 😄👋no space after last emoji'); + expect(EmojiUtils.replaceEmojis(text).newText).toBe('Hi 😄👋no space after last emoji'); }); it('will not add a space after the last emoji if we\'re not on mobile', () => { const text = 'Hi :smile:'; - expect(EmojiUtils.replaceEmojis(text)).toBe('Hi 😄'); + expect(EmojiUtils.replaceEmojis(text, false).newText).toBe('Hi 😄'); + }); + + it('will add a space after the last emoji if we\'re on mobile', () => { + const text = 'Hi :smile:'; + expect(EmojiUtils.replaceEmojis(text, true).newText).toBe('Hi 😄 '); }); it('suggests emojis when typing emojis prefix after colon', () => { @@ -147,4 +156,21 @@ describe('EmojiTest', () => { ], }]); }); + + it('should insert emoji correctly with a whitespace within a text given a selection', () => { + const res = baseAddEmojiToComposer({ + emoji: '😄', + text: 'add emoji here', + textInput: { + setText: jest.fn(), + }, + selection: { + start: 4, + end: 4, + }, + }); + + expect(res.newText).toEqual('add 😄 emoji here'); + expect(res.newSelection).toEqual({start: 7, end: 7}); + }); });