diff --git a/src/CONST.js b/src/CONST.js
index 7ef61b965e42..dea1d6c76a76 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -247,6 +247,11 @@ const CONST = {
shortcutKey: 'ArrowDown',
modifiers: [],
},
+ TAB: {
+ descriptionKey: null,
+ shortcutKey: 'Tab',
+ modifiers: [],
+ },
},
KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME: {
CONTROL: 'CTRL',
@@ -564,6 +569,14 @@ const CONST = {
NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 256,
EMOJI_PICKER_ITEM_HEIGHT: 32,
EMOJI_PICKER_HEADER_HEIGHT: 32,
+ RECIPIENT_LOCAL_TIME_HEIGHT: 25,
+ EMOJI_SUGGESTER: {
+ SUGGESTER_PADDING: 6,
+ ITEM_HEIGHT: 36,
+ SMALL_CONTAINER_HEIGHT_FACTOR: 2.5,
+ MIN_AMOUNT_OF_ITEMS: 3,
+ MAX_AMOUNT_OF_ITEMS: 5,
+ },
COMPOSER_MAX_HEIGHT: 125,
CHAT_FOOTER_MIN_HEIGHT: 65,
CHAT_SKELETON_VIEW: {
@@ -829,6 +842,11 @@ const CONST = {
AFTER_FIRST_LINE_BREAK: /\n.*/g,
CODE_2FA: /^\d{6}$/,
ATTACHMENT_ID: /chat-attachments\/(\d+)/,
+ HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/,
+ NEW_LINE_OR_WHITE_SPACE: /[\n\s]/g,
+
+ // Define the regular expression pattern to match a string starting with a colon and ending with a space or newline character
+ EMOJI_REPLACER: /^:[^\n\r]+?(?=$|\s)/,
MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/,
},
@@ -1003,6 +1021,8 @@ const CONST = {
CHAT_ATTACHMENT_TOKEN_KEY: 'X-Chat-Attachment-Token',
USA_COUNTRY_NAME,
+ SPACE_LENGTH: 1,
+ SPACE: 1,
ALL_COUNTRIES: {
AC: 'Ascension Island',
AD: 'Andorra',
diff --git a/src/components/ArrowKeyFocusManager.js b/src/components/ArrowKeyFocusManager.js
index 8fd65ef95f7f..72a590547fa0 100644
--- a/src/components/ArrowKeyFocusManager.js
+++ b/src/components/ArrowKeyFocusManager.js
@@ -21,10 +21,14 @@ const propTypes = {
/** A callback executed when the focused input changes. */
onFocusedIndexChanged: PropTypes.func.isRequired,
+
+ /** If this value is true, then we exclude TextArea Node. */
+ shouldExcludeTextAreaNodes: PropTypes.bool,
};
const defaultProps = {
disabledIndexes: [],
+ shouldExcludeTextAreaNodes: true,
};
class ArrowKeyFocusManager extends Component {
@@ -48,7 +52,7 @@ class ArrowKeyFocusManager extends Component {
}
this.props.onFocusedIndexChanged(newFocusedIndex);
- }, arrowUpConfig.descriptionKey, arrowUpConfig.modifiers, true, false, 0, true, ['TEXTAREA']);
+ }, arrowUpConfig.descriptionKey, arrowUpConfig.modifiers, true, false, 0, true, [this.props.shouldExcludeTextAreaNodes && 'TEXTAREA']);
this.unsubscribeArrowDownKey = KeyboardShortcut.subscribe(arrowDownConfig.shortcutKey, () => {
if (this.props.maxIndex < 0) {
@@ -66,7 +70,7 @@ class ArrowKeyFocusManager extends Component {
}
this.props.onFocusedIndexChanged(newFocusedIndex);
- }, arrowDownConfig.descriptionKey, arrowDownConfig.modifiers, true, false, 0, true, ['TEXTAREA']);
+ }, arrowDownConfig.descriptionKey, arrowDownConfig.modifiers, true, false, 0, true, [this.props.shouldExcludeTextAreaNodes && 'TEXTAREA']);
}
componentWillUnmount() {
diff --git a/src/components/EmojiSuggestions.js b/src/components/EmojiSuggestions.js
new file mode 100644
index 000000000000..dba14fcca6f0
--- /dev/null
+++ b/src/components/EmojiSuggestions.js
@@ -0,0 +1,141 @@
+import React from 'react';
+import {View, Pressable} from 'react-native';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+
+// We take FlatList from this package to properly handle the scrolling of EmojiSuggestions in chats since one scroll is nested inside another
+import {FlatList} from 'react-native-gesture-handler';
+import styles from '../styles/styles';
+import * as StyleUtils from '../styles/StyleUtils';
+import * as EmojiUtils from '../libs/EmojiUtils';
+import Text from './Text';
+import CONST from '../CONST';
+import getStyledTextArray from '../libs/GetStyledTextArray';
+
+const propTypes = {
+ /** The index of the highlighted emoji */
+ highlightedEmojiIndex: PropTypes.number,
+
+ /** Array of suggested emoji */
+ emojis: PropTypes.arrayOf(PropTypes.shape({
+ /** The emoji code */
+ code: PropTypes.string,
+
+ /** The name of the emoji */
+ name: PropTypes.string,
+ })).isRequired,
+
+ /** Fired when the user selects an emoji */
+ onSelect: PropTypes.func.isRequired,
+
+ /** Emoji prefix that follows the colon */
+ prefix: PropTypes.string.isRequired,
+
+ /** Show that we can use large emoji picker.
+ * Depending on available space and whether the input is expanded, we can have a small or large emoji suggester.
+ * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */
+ isEmojiPickerLarge: PropTypes.bool.isRequired,
+
+ /** Show that we should include ReportRecipientLocalTime view height */
+ shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired,
+
+ /** Stores user's preferred skin tone */
+ preferredSkinToneIndex: PropTypes.number.isRequired,
+};
+
+const defaultProps = {
+ highlightedEmojiIndex: 0,
+};
+
+/**
+ * @param {Number} numRows
+ * @param {Boolean} isEmojiPickerLarge
+ * @returns {Number}
+ */
+const measureHeightOfEmojiRows = (numRows, isEmojiPickerLarge) => {
+ if (isEmojiPickerLarge) {
+ return numRows * CONST.EMOJI_SUGGESTER.ITEM_HEIGHT;
+ }
+ if (numRows > 2) {
+ // on small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible
+ return CONST.EMOJI_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.EMOJI_SUGGESTER.ITEM_HEIGHT;
+ }
+ return numRows * CONST.EMOJI_SUGGESTER.ITEM_HEIGHT;
+};
+
+/**
+ * Create unique keys for each emoji item
+ * @param {Object} item
+ * @param {Number} index
+ * @returns {String}
+ */
+const keyExtractor = (item, index) => `${item.name}+${index}}`;
+
+const EmojiSuggestions = (props) => {
+ /**
+ * Render a suggestion menu item component.
+ * @param {Object} params.item
+ * @param {Number} params.index
+ * @returns {JSX.Element}
+ */
+ const renderSuggestionMenuItem = ({item, index}) => {
+ const styledTextArray = getStyledTextArray(item.name, props.prefix);
+
+ return (
+ StyleUtils.getEmojiSuggestionItemStyle(
+ props.highlightedEmojiIndex,
+ CONST.EMOJI_SUGGESTER.ITEM_HEIGHT,
+ hovered,
+ index,
+ )}
+ onMouseDown={e => e.preventDefault()}
+ onPress={() => props.onSelect(index)}
+ >
+
+ {EmojiUtils.getEmojiCodeWithSkinColor(item, props.preferredSkinToneIndex)}
+
+ :
+ {_.map(styledTextArray, ({text, isColored}, i) => (
+
+ {text}
+
+ ))}
+ :
+
+
+
+ );
+ };
+
+ const rowHeight = measureHeightOfEmojiRows(
+ props.emojis.length,
+ props.isEmojiPickerLarge,
+ );
+
+ return (
+
+
+
+ );
+};
+
+EmojiSuggestions.propTypes = propTypes;
+EmojiSuggestions.defaultProps = defaultProps;
+EmojiSuggestions.displayName = 'EmojiSuggestions';
+
+export default EmojiSuggestions;
diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js
index 0e813076015a..9e52eec797e6 100644
--- a/src/libs/EmojiUtils.js
+++ b/src/libs/EmojiUtils.js
@@ -247,6 +247,22 @@ function suggestEmojis(text, limit = 5) {
return [];
}
+/**
+ * Given an emoji item object, return an emoji code based on its type.
+ *
+ * @param {Object} item
+ * @param {Number} preferredSkinToneIndex
+ * @returns {String}
+ */
+const getEmojiCodeWithSkinColor = (item, preferredSkinToneIndex) => {
+ const {code, types} = item;
+ if (types && types[preferredSkinToneIndex]) {
+ return types[preferredSkinToneIndex];
+ }
+
+ return code;
+};
+
export {
getHeaderEmojis,
mergeEmojisWithFrequentlyUsedEmojis,
@@ -255,4 +271,5 @@ export {
replaceEmojis,
suggestEmojis,
trimEmojiUnicode,
+ getEmojiCodeWithSkinColor,
};
diff --git a/src/libs/GetStyledTextArray.js b/src/libs/GetStyledTextArray.js
new file mode 100644
index 000000000000..cc65413ef8d2
--- /dev/null
+++ b/src/libs/GetStyledTextArray.js
@@ -0,0 +1,36 @@
+/**
+ * Render a suggestion menu item component.
+ * @param {String} name
+ * @param {String} prefix
+ * @returns {Array}
+ */
+const getStyledTextArray = (name, prefix) => {
+ const texts = [];
+ const prefixLocation = name.search(prefix);
+
+ if (prefixLocation === 0 && prefix.length === name.length) {
+ texts.push({text: prefix, isColored: true});
+ } else if (prefixLocation === 0 && prefix.length !== name.length) {
+ texts.push(
+ {text: name.slice(0, prefix.length), isColored: true},
+ {text: name.slice(prefix.length), isColored: false},
+ );
+ } else if (prefixLocation > 0 && prefix.length !== name.length) {
+ texts.push(
+ {text: name.slice(0, prefixLocation), isColored: false},
+ {
+ text: name.slice(prefixLocation, prefixLocation + prefix.length),
+ isColored: true,
+ },
+ {
+ text: name.slice(prefixLocation + prefix.length),
+ isColored: false,
+ },
+ );
+ } else {
+ texts.push({text: name, isColored: false});
+ }
+ return texts;
+};
+
+export default getStyledTextArray;
diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js
index 843c8586c3df..22fdc2f578ac 100644
--- a/src/pages/home/report/ReportActionCompose.js
+++ b/src/pages/home/report/ReportActionCompose.js
@@ -4,6 +4,7 @@ import {
View,
TouchableOpacity,
InteractionManager,
+ LayoutAnimation,
} from 'react-native';
import _ from 'underscore';
import lodashGet from 'lodash/get';
@@ -20,6 +21,9 @@ import ReportTypingIndicator from './ReportTypingIndicator';
import AttachmentModal from '../../../components/AttachmentModal';
import compose from '../../../libs/compose';
import PopoverMenu from '../../../components/PopoverMenu';
+import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
+import withDrawerState from '../../../components/withDrawerState';
+import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import willBlurTextInputOnTapOutside from '../../../libs/willBlurTextInputOnTapOutside';
import CONST from '../../../CONST';
import Navigation from '../../../libs/Navigation/Navigation';
@@ -40,13 +44,12 @@ import OfflineIndicator from '../../../components/OfflineIndicator';
import ExceededCommentLength from '../../../components/ExceededCommentLength';
import withNavigationFocus from '../../../components/withNavigationFocus';
import * as EmojiUtils from '../../../libs/EmojiUtils';
-import reportPropTypes from '../../reportPropTypes';
import ReportDropUI from './ReportDropUI';
import DragAndDrop from '../../../components/DragAndDrop';
-import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
-import withDrawerState from '../../../components/withDrawerState';
-import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
+import reportPropTypes from '../../reportPropTypes';
+import EmojiSuggestions from '../../../components/EmojiSuggestions';
import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState';
+import ArrowKeyFocusManager from '../../../components/ArrowKeyFocusManager';
import KeyboardShortcut from '../../../libs/KeyboardShortcut';
const propTypes = {
@@ -86,9 +89,6 @@ const propTypes = {
/** Is composer screen focused */
isFocused: PropTypes.bool.isRequired,
- /** Is the composer full size */
- isComposerFullSize: PropTypes.bool,
-
/** Whether user interactions should be disabled */
disabled: PropTypes.bool,
@@ -115,10 +115,26 @@ const defaultProps = {
...withCurrentUserPersonalDetailsDefaultProps,
};
+/**
+ * Return the max available index for arrow manager.
+ * @param {Number} numRows
+ * @param {Boolean} isEmojiPickerLarge
+ * @returns {Number}
+ */
+const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => {
+ // EmojiRowCount is number of emoji suggestions. For small screen we can fit 3 items and for large we show up to 5 items
+ const emojiRowCount = isEmojiPickerLarge
+ ? Math.max(numRows, CONST.EMOJI_SUGGESTER.MAX_AMOUNT_OF_ITEMS)
+ : Math.max(numRows, CONST.EMOJI_SUGGESTER.MIN_AMOUNT_OF_ITEMS);
+
+ // -1 because we start at 0
+ return emojiRowCount - 1;
+};
+
class ReportActionCompose extends React.Component {
constructor(props) {
super(props);
-
+ this.calculateEmojiSuggestion = _.debounce(this.calculateEmojiSuggestion, 10, false);
this.updateComment = this.updateComment.bind(this);
this.debouncedSaveReportComment = _.debounce(this.debouncedSaveReportComment.bind(this), 1000, false);
this.debouncedBroadcastUserIsTyping = _.debounce(this.debouncedBroadcastUserIsTyping.bind(this), 100, true);
@@ -129,10 +145,12 @@ class ReportActionCompose extends React.Component {
this.focus = this.focus.bind(this);
this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this);
this.onSelectionChange = this.onSelectionChange.bind(this);
+ this.isEmojiCode = this.isEmojiCode.bind(this);
this.setTextInputRef = this.setTextInputRef.bind(this);
this.getInputPlaceholder = this.getInputPlaceholder.bind(this);
this.getIOUOptions = this.getIOUOptions.bind(this);
this.addAttachment = this.addAttachment.bind(this);
+ this.insertSelectedEmoji = this.insertSelectedEmoji.bind(this);
this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this);
this.comment = props.comment;
@@ -155,6 +173,12 @@ class ReportActionCompose extends React.Component {
// 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)),
+ suggestedEmojis: [],
+ highlightedEmojiIndex: 0,
+ colonIndex: -1,
+ shouldShowSuggestionMenu: false,
+ isEmojiPickerLarge: false,
+ composerHeight: 0,
hasExceededMaxCommentLength: false,
};
}
@@ -224,6 +248,7 @@ class ReportActionCompose extends React.Component {
onSelectionChange(e) {
this.setState({selection: e.nativeEvent.selection});
+ this.calculateEmojiSuggestion();
}
/**
@@ -312,6 +337,16 @@ class ReportActionCompose extends React.Component {
return _.map(ReportUtils.getIOUOptions(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
*/
@@ -323,14 +358,86 @@ class ReportActionCompose extends React.Component {
this.setState({maxLines});
}
+ // eslint-disable-next-line rulesdir/prefer-early-return
+ setShouldShowSuggestionMenuToFalse() {
+ if (this.state && this.state.shouldShowSuggestionMenu) {
+ this.setState({shouldShowSuggestionMenu: false});
+ }
+ }
+
/**
- * Updates the composer when the comment length is exceeded
- * Shows red borders and prevents the comment from being sent
- *
- * @param {Boolean} hasExceededMaxCommentLength
+ * Clean data related to EmojiSuggestions
*/
- setExceededMaxCommentLength(hasExceededMaxCommentLength) {
- this.setState({hasExceededMaxCommentLength});
+ resetSuggestedEmojis() {
+ this.setState({
+ suggestedEmojis: [],
+ shouldShowSuggestionMenu: false,
+ });
+ }
+
+ /**
+ * Calculates and cares about the content of an Emoji Suggester
+ */
+ calculateEmojiSuggestion() {
+ const leftString = this.state.value.substring(0, this.state.selection.end);
+ const colonIndex = leftString.lastIndexOf(':');
+ const isCurrentlyShowingEmojiSuggestion = this.isEmojiCode(this.state.value, this.state.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;
+ const isEmojiPickerLarge = !this.props.isSmallScreenWidth || (this.props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion);
+
+ const nextState = {
+ suggestedEmojis: [],
+ highlightedEmojiIndex: 0,
+ colonIndex,
+ shouldShowSuggestionMenu: false,
+ isEmojiPickerLarge,
+ };
+ const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString);
+
+ if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) {
+ nextState.suggestedEmojis = newSuggestedEmojis;
+ nextState.shouldShowSuggestionMenu = !_.isEmpty(newSuggestedEmojis);
+ }
+
+ LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity));
+
+ this.setState(nextState);
+ }
+
+ /**
+ * Check if this piece of string looks like an emoji
+ * @param {String} str
+ * @param {Number} pos
+ * @returns {Boolean}
+ */
+ isEmojiCode(str, pos) {
+ const leftWords = str.slice(0, pos).split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE);
+ const leftWord = _.last(leftWords);
+
+ return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2;
+ }
+
+ /**
+ * Replace the code of emoji and update selection
+ * @param {Number} highlightedEmojiIndex
+ */
+ insertSelectedEmoji(highlightedEmojiIndex) {
+ const commentBeforeColon = this.state.value.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);
+
+ this.updateComment(`${commentBeforeColon}${emojiCode} ${commentAfterColonWithEmojiNameRemoved}`, true);
+ this.setState(prevState => ({
+ selection: {
+ start: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH,
+ end: prevState.colonIndex + emojiCode.length + CONST.SPACE_LENGTH,
+ },
+ suggestedEmojis: [],
+ }));
+ EmojiUtils.addToFrequentlyUsedEmojis(this.props.frequentlyUsedEmojis, emojiObject);
}
isEmptyChat() {
@@ -343,12 +450,14 @@ class ReportActionCompose extends React.Component {
* @param {String} emoji
*/
addEmojiToTextBox(emoji) {
+ const emojiWithSpace = `${emoji} `;
const newComment = this.comment.slice(0, this.state.selection.start)
- + emoji + this.comment.slice(this.state.selection.end, this.comment.length);
+ + emojiWithSpace
+ + this.comment.slice(this.state.selection.end, this.comment.length);
this.setState(prevState => ({
selection: {
- start: prevState.selection.start + emoji.length,
- end: prevState.selection.start + emoji.length,
+ start: prevState.selection.start + emojiWithSpace.length,
+ end: prevState.selection.start + emojiWithSpace.length,
},
}));
this.updateComment(newComment);
@@ -452,14 +561,27 @@ class ReportActionCompose extends React.Component {
return;
}
+ if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && this.state.suggestedEmojis.length) {
+ e.preventDefault();
+ this.insertSelectedEmoji(this.state.highlightedEmojiIndex);
+ return;
+ }
+ if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && this.state.suggestedEmojis.length) {
+ e.preventDefault();
+ this.resetSuggestedEmojis();
+ return;
+ }
+
// Submit the form when Enter is pressed
- if (e.key === 'Enter' && !e.shiftKey) {
+ if (e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && !e.shiftKey) {
e.preventDefault();
this.submitForm();
}
// Trigger the edit box for last sent message if ArrowUp is pressed and the comment is empty and Chronos is not in the participants
- if (e.key === 'ArrowUp' && this.textInput.selectionStart === 0 && this.state.isCommentEmpty && !ReportUtils.chatIncludesChronos(this.props.report)) {
+ if (
+ e.key === CONST.KEYBOARD_SHORTCUTS.ARROW_UP.shortcutKey && this.textInput.selectionStart === 0 && this.state.isCommentEmpty && !ReportUtils.chatIncludesChronos(this.props.report)
+ ) {
e.preventDefault();
const lastReportAction = _.find(
@@ -543,6 +665,7 @@ 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 shouldUseFocusedColor = !isBlockedFromConcierge && !this.props.disabled && (this.state.isFocused || this.state.isDraggingOver);
const hasExceededMaxCommentLength = this.state.hasExceededMaxCommentLength;
return (
@@ -551,10 +674,9 @@ class ReportActionCompose extends React.Component {
this.props.isComposerFullSize && styles.chatItemFullComposeRow,
]}
>
- {shouldShowReportRecipientLocalTime
- && }
+ {shouldShowReportRecipientLocalTime && }
{
e.preventDefault();
+ this.setShouldShowSuggestionMenuToFalse();
Report.setIsComposerFullSize(this.props.reportID, false);
}}
@@ -600,6 +723,7 @@ class ReportActionCompose extends React.Component {
{
e.preventDefault();
+ this.setShouldShowSuggestionMenuToFalse();
Report.setIsComposerFullSize(this.props.reportID, true);
}}
@@ -685,7 +809,10 @@ class ReportActionCompose extends React.Component {
style={[styles.textInputCompose, this.props.isComposerFullSize ? styles.textInputFullCompose : styles.flex4]}
maxLines={this.state.maxLines}
onFocus={() => this.setIsFocused(true)}
- onBlur={() => this.setIsFocused(false)}
+ onBlur={() => {
+ this.setIsFocused(false);
+ this.resetSuggestedEmojis();
+ }}
onPasteFile={displayFileInModal}
shouldClear={this.state.textInputShouldClear}
onClear={() => this.setTextInputShouldClear(false)}
@@ -696,6 +823,14 @@ class ReportActionCompose extends React.Component {
setIsFullComposerAvailable={this.setIsFullComposerAvailable}
isComposerFullSize={this.props.isComposerFullSize}
value={this.state.value}
+ onLayout={(e) => {
+ const composerHeight = e.nativeEvent.layout.height;
+ if (this.state.composerHeight === composerHeight) {
+ return;
+ }
+ this.setState({composerHeight});
+ }}
+ onScroll={this.setShouldShowSuggestionMenuToFalse}
/>
@@ -742,6 +877,30 @@ class ReportActionCompose extends React.Component {
{this.state.isDraggingOver && }
+ {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowSuggestionMenu && (
+ this.setState({highlightedEmojiIndex: index})}
+ >
+ this.setState({suggestedEmojis: []})}
+ highlightedEmojiIndex={this.state.highlightedEmojiIndex}
+ emojis={this.state.suggestedEmojis}
+ comment={this.state.value}
+ updateComment={newComment => this.setState({value: newComment})}
+ colonIndex={this.state.colonIndex}
+ prefix={this.state.value.slice(this.state.colonIndex + 1).split(' ')[0]}
+ onSelect={this.insertSelectedEmoji}
+ isComposerFullSize={this.props.isComposerFullSize}
+ preferredSkinToneIndex={this.props.preferredSkinTone}
+ isEmojiPickerLarge={this.state.isEmojiPickerLarge}
+ composerHeight={this.state.composerHeight}
+ shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime}
+ />
+
+ )}
);
}
@@ -772,5 +931,11 @@ export default compose(
blockedFromConcierge: {
key: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE,
},
+ frequentlyUsedEmojis: {
+ key: ONYXKEYS.FREQUENTLY_USED_EMOJIS,
+ },
+ preferredSkinTone: {
+ key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
+ },
}),
)(ReportActionCompose);
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index 053a768c49ee..caa49da60196 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -346,24 +346,20 @@ class ReportActionsView extends React.Component {
}
return (
<>
- {!this.props.isComposerFullSize && (
- <>
-
-
- >
- )}
+
+
>
diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js
index e6a808e180e9..516e6c8d6de4 100644
--- a/src/styles/StyleUtils.js
+++ b/src/styles/StyleUtils.js
@@ -809,6 +809,63 @@ function getReportWelcomeContainerStyle(isSmallScreenWidth) {
};
}
+/**
+ * Gets styles for Emoji Suggestion row
+ *
+ * @param {Number} highlightedEmojiIndex
+ * @param {Number} rowHeight
+ * @param {Boolean} hovered
+ * @param {Number} currentEmojiIndex
+ * @returns {Object}
+ */
+function getEmojiSuggestionItemStyle(
+ highlightedEmojiIndex,
+ rowHeight,
+ hovered,
+ currentEmojiIndex,
+) {
+ return [
+ {
+ height: rowHeight,
+ justifyContent: 'center',
+ },
+ (currentEmojiIndex === highlightedEmojiIndex && !hovered) || hovered
+ ? {
+ backgroundColor: themeColors.highlightBG,
+ }
+ : {},
+ ];
+}
+
+/**
+ * Gets the correct position for emoji suggestion container
+ *
+ * @param {Number} itemsHeight
+ * @param {Boolean} shouldIncludeReportRecipientLocalTimeHeight
+ * @returns {Object}
+ */
+function getEmojiSuggestionContainerStyle(
+ itemsHeight,
+ shouldIncludeReportRecipientLocalTimeHeight,
+) {
+ const optionalPadding = shouldIncludeReportRecipientLocalTimeHeight ? CONST.RECIPIENT_LOCAL_TIME_HEIGHT : 0;
+ const padding = CONST.EMOJI_SUGGESTER.SUGGESTER_PADDING - optionalPadding;
+
+ // The suggester is positioned absolutely within the component that includes the input and RecipientLocalTime view (for non-expanded mode only). To position it correctly,
+ // we need to shift it by the suggester's height plus its padding and, if applicable, the height of the RecipientLocalTime view.
+ return {
+ overflow: 'hidden',
+ top: -(itemsHeight + padding),
+ };
+}
+
+/**
+ * Select the correct color for text.
+ * @param {Boolean} isColored
+ * @returns {String | null}
+ */
+const getColoredBackgroundStyle = isColored => ({backgroundColor: isColored ? colors.blueLink : null});
+
function getEmojiReactionBubbleStyle(isHovered, hasUserReacted, sizeScale = 1) {
const sizeStyles = {
paddingVertical: styles.emojiReactionBubble.paddingVertical * sizeScale,
@@ -888,6 +945,9 @@ export {
getReportWelcomeBackgroundImageStyle,
getReportWelcomeTopMarginStyle,
getReportWelcomeContainerStyle,
+ getEmojiSuggestionItemStyle,
+ getEmojiSuggestionContainerStyle,
+ getColoredBackgroundStyle,
getDefaultWorspaceAvatarColor,
getAvatarBorderRadius,
getEmojiReactionBubbleStyle,
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 88a8ea989d89..2c8508af4629 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -165,6 +165,33 @@ const styles = {
flexBasis: '48%',
},
+ emojiSuggestionsContainer: {
+ backgroundColor: themeColors.appBG,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: themeColors.border,
+ justifyContent: 'center',
+ boxShadow: variables.popoverMenuShadow,
+ position: 'absolute',
+ left: 0,
+ right: 0,
+ },
+ emojiSuggestionContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+
+ emojiSuggestionsEmoji: {
+ fontFamily: fontFamily.EMOJI_TEXT_FONT,
+ fontSize: variables.fontSizeMedium,
+ width: 51,
+ textAlign: 'center',
+ },
+ emojiSuggestionsText: {
+ fontFamily: fontFamily.EMOJI_TEXT_FONT,
+ fontSize: variables.fontSizeMedium,
+ },
+
unitCol: {
margin: 0,
padding: 0,
@@ -1381,7 +1408,6 @@ const styles = {
chatItemFullComposeBox: {
...flex.flex1,
- ...spacing.mt4,
...sizing.h100,
},
@@ -1394,8 +1420,6 @@ const styles = {
chatFooterFullCompose: {
flex: 1,
- flexShrink: 1,
- flexBasis: '100%',
},
// Be extremely careful when editing the compose styles, as it is easy to introduce regressions.
diff --git a/tests/unit/getStyledArratTest.js b/tests/unit/getStyledArratTest.js
new file mode 100644
index 000000000000..6992dd41cd1b
--- /dev/null
+++ b/tests/unit/getStyledArratTest.js
@@ -0,0 +1,25 @@
+import getStyledTextArray from '../../src/libs/GetStyledTextArray';
+
+describe('getStyledTextArray', () => {
+ test('returns an array with a single object with isColored true when prefix matches entire name', () => {
+ const result = getStyledTextArray('sm', 'sm');
+ expect(result).toEqual([{text: 'sm', isColored: true}]);
+ });
+
+ test('returns an array with two objects, the first with isColored true, when prefix matches the beginning of name', () => {
+ const result = getStyledTextArray('smile', 'sm');
+ expect(result).toEqual([
+ {text: 'sm', isColored: true},
+ {text: 'ile', isColored: false},
+ ]);
+ });
+
+ test('returns an array with three objects, the second with isColored true, when prefix matches in the middle of name', () => {
+ const result = getStyledTextArray('smile', 'il');
+ expect(result).toEqual([
+ {text: 'sm', isColored: false},
+ {text: 'il', isColored: true},
+ {text: 'e', isColored: false},
+ ]);
+ });
+});