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}, + ]); + }); +});