diff --git a/src/CONST.js b/src/CONST.js index b53e2d42b5a3..a501dc100f9d 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -1011,12 +1011,17 @@ const CONST = { CODE_2FA: /^\d{6}$/, ATTACHMENT_ID: /chat-attachments\/(\d+)/, HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/, + HAS_AT_MOST_TWO_AT_SIGNS: /^@[^@]*@?[^@]*$/, // eslint-disable-next-line no-misleading-character-class NEW_LINE_OR_WHITE_SPACE_OR_EMOJI: /[\n\s\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, // 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)/, + + // Define the regular expression pattern to match a string starting with an at sign and ending with a space or newline character + MENTION_REPLACER: /^@[^\n\r]*?(?=$|\s)/, + MERGED_ACCOUNT_PREFIX: /^(MERGED_\d+@)/, }, diff --git a/src/components/MentionSuggestions.js b/src/components/MentionSuggestions.js new file mode 100644 index 000000000000..d1a6441e1d33 --- /dev/null +++ b/src/components/MentionSuggestions.js @@ -0,0 +1,111 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import _ from 'underscore'; +import styles from '../styles/styles'; +import * as StyleUtils from '../styles/StyleUtils'; +import Text from './Text'; +import CONST from '../CONST'; +import Avatar from './Avatar'; +import AutoCompleteSuggestions from './AutoCompleteSuggestions'; +import getStyledTextArray from '../libs/GetStyledTextArray'; +import avatarPropTypes from './avatarPropTypes'; + +const propTypes = { + /** The index of the highlighted mention */ + highlightedMentionIndex: PropTypes.number, + + /** Array of suggested mentions */ + mentions: PropTypes.arrayOf( + PropTypes.shape({ + /** Display name of the user */ + text: PropTypes.string, + + /** Email/phone number of the user */ + alternateText: PropTypes.string, + + /** Array of icons of the user. We use the first element of this array */ + icons: PropTypes.arrayOf(avatarPropTypes), + }), + ).isRequired, + + /** Fired when the user selects an mention */ + onSelect: PropTypes.func.isRequired, + + /** Mention prefix that follows the @ sign */ + prefix: PropTypes.string.isRequired, + + /** Show that we can use large mention picker. + * Depending on available space and whether the input is expanded, we can have a small or large mention 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. */ + isMentionPickerLarge: PropTypes.bool.isRequired, + + /** Show that we should include ReportRecipientLocalTime view height */ + shouldIncludeReportRecipientLocalTimeHeight: PropTypes.bool.isRequired, +}; + +const defaultProps = { + highlightedMentionIndex: 0, +}; + +/** + * Create unique keys for each mention item + * @param {Object} item + * @param {Number} index + * @returns {String} + */ +const keyExtractor = (item) => item.alternateText; + +const MentionSuggestions = (props) => { + /** + * Render a suggestion menu item component. + * @param {Object} item + * @returns {JSX.Element} + */ + const renderSuggestionMenuItem = (item) => { + const displayedText = _.uniq([item.text, item.alternateText]).join(' - '); + const styledTextArray = getStyledTextArray(displayedText, props.prefix); + + return ( + + + + {_.map(styledTextArray, ({text, isColored}, i) => ( + + {text} + + ))} + + + ); + }; + + return ( + + ); +}; + +MentionSuggestions.propTypes = propTypes; +MentionSuggestions.defaultProps = defaultProps; +MentionSuggestions.displayName = 'MentionSuggestions'; + +export default MentionSuggestions; diff --git a/src/libs/GetStyledTextArray.js b/src/libs/GetStyledTextArray.js index 1fa9e89a4a51..576e6f98c94b 100644 --- a/src/libs/GetStyledTextArray.js +++ b/src/libs/GetStyledTextArray.js @@ -9,7 +9,7 @@ import Str from 'expensify-common/lib/str'; const getStyledTextArray = (name, prefix) => { const texts = []; const prefixLowercase = prefix.toLowerCase(); - const prefixLocation = name.search(Str.escapeForRegExp(prefixLowercase)); + const prefixLocation = name.toLowerCase().search(Str.escapeForRegExp(prefixLowercase)); if (prefixLocation === 0 && prefix.length === name.length) { texts.push({text: prefixLowercase, isColored: true}); diff --git a/src/pages/home/report/ReportActionCompose.js b/src/pages/home/report/ReportActionCompose.js index 8547fc68a8d3..d257e9a3de53 100644 --- a/src/pages/home/report/ReportActionCompose.js +++ b/src/pages/home/report/ReportActionCompose.js @@ -44,6 +44,7 @@ import ReportDropUI from './ReportDropUI'; import DragAndDrop from '../../../components/DragAndDrop'; import reportPropTypes from '../../reportPropTypes'; import EmojiSuggestions from '../../../components/EmojiSuggestions'; +import MentionSuggestions from '../../../components/MentionSuggestions'; import withKeyboardState, {keyboardStatePropTypes} from '../../../components/withKeyboardState'; import ArrowKeyFocusManager from '../../../components/ArrowKeyFocusManager'; import OfflineWithFeedback from '../../../components/OfflineWithFeedback'; @@ -52,6 +53,7 @@ import * as ComposerUtils from '../../../libs/ComposerUtils'; import * as Welcome from '../../../libs/actions/Welcome'; import Permissions from '../../../libs/Permissions'; import * as TaskUtils from '../../../libs/actions/Task'; +import * as OptionsListUtils from '../../../libs/OptionsListUtils'; const propTypes = { /** Beta features list */ @@ -119,6 +121,9 @@ const propTypes = { /** The type of action that's pending */ pendingAction: PropTypes.oneOf(['add', 'update', 'delete']), + /** Collection of recent reports, used to calculate the mention suggestions */ + reports: PropTypes.objectOf(reportPropTypes), + ...windowDimensionsPropTypes, ...withLocalizePropTypes, ...withCurrentUserPersonalDetailsPropTypes, @@ -138,18 +143,21 @@ const defaultProps = { frequentlyUsedEmojis: [], isComposerFullSize: false, pendingAction: null, + reports: {}, ...withCurrentUserPersonalDetailsDefaultProps, }; /** * Return the max available index for arrow manager. * @param {Number} numRows - * @param {Boolean} isEmojiPickerLarge + * @param {Boolean} isAutoSuggestionPickerLarge * @returns {Number} */ -const getMaxArrowIndex = (numRows, isEmojiPickerLarge) => { +const getMaxArrowIndex = (numRows, isAutoSuggestionPickerLarge) => { // 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.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_ITEMS) : Math.max(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_ITEMS); + const emojiRowCount = isAutoSuggestionPickerLarge + ? Math.max(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_ITEMS) + : Math.max(numRows, CONST.AUTO_COMPLETE_SUGGESTER.MIN_AMOUNT_OF_ITEMS); // -1 because we start at 0 return emojiRowCount - 1; @@ -159,6 +167,7 @@ class ReportActionCompose extends React.Component { constructor(props) { super(props); this.calculateEmojiSuggestion = _.debounce(this.calculateEmojiSuggestion, 10, false); + this.calculateMentionSuggestion = _.debounce(this.calculateMentionSuggestion, 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); @@ -170,12 +179,14 @@ class ReportActionCompose extends React.Component { this.addEmojiToTextBox = this.addEmojiToTextBox.bind(this); this.onSelectionChange = this.onSelectionChange.bind(this); this.isEmojiCode = this.isEmojiCode.bind(this); + this.isMentionCode = this.isMentionCode.bind(this); this.setTextInputRef = this.setTextInputRef.bind(this); this.getInputPlaceholder = this.getInputPlaceholder.bind(this); this.getMoneyRequestOptions = this.getMoneyRequestOptions.bind(this); this.getTaskOption = this.getTaskOption.bind(this); this.addAttachment = this.addAttachment.bind(this); this.insertSelectedEmoji = this.insertSelectedEmoji.bind(this); + this.insertSelectedMention = this.insertSelectedMention.bind(this); this.setExceededMaxCommentLength = this.setExceededMaxCommentLength.bind(this); this.updateNumberOfLines = this.updateNumberOfLines.bind(this); this.showPopoverMenu = this.showPopoverMenu.bind(this); @@ -203,14 +214,10 @@ 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, isAttachmentPreviewActive: false, + ...this.getDefaultSuggestionsValues(), }; } @@ -229,7 +236,9 @@ class ReportActionCompose extends React.Component { this.unsubscribeEscapeKey = KeyboardShortcut.subscribe( shortcutConfig.shortcutKey, () => { - if (!this.state.isFocused || this.comment.length === 0) { + const suggestionsExist = this.state.suggestedEmojis.length > 0 || this.state.suggestedMentions.length > 0; + + if (!this.state.isFocused || this.comment.length === 0 || suggestionsExist) { return; } @@ -294,6 +303,22 @@ class ReportActionCompose extends React.Component { onSelectionChange(e) { this.setState({selection: e.nativeEvent.selection}); this.calculateEmojiSuggestion(); + this.calculateMentionSuggestion(); + } + + getDefaultSuggestionsValues() { + return { + suggestedEmojis: [], + suggestedMentions: [], + highlightedEmojiIndex: 0, + highlightedMentionIndex: 0, + colonIndex: -1, + atSignIndex: -1, + shouldShowEmojiSuggestionMenu: false, + shouldShowMentionSuggestionMenu: false, + mentionPrefix: '', + isAutoSuggestionPickerLarge: false, + }; } /** @@ -405,8 +430,11 @@ class ReportActionCompose extends React.Component { // eslint-disable-next-line rulesdir/prefer-early-return setShouldShowSuggestionMenuToFalse() { - if (this.state && this.state.shouldShowSuggestionMenu) { - this.setState({shouldShowSuggestionMenu: false}); + if (this.state && this.state.shouldShowEmojiSuggestionMenu) { + this.setState({shouldShowEmojiSuggestionMenu: false}); + } + if (this.state && this.state.shouldShowMentionSuggestionMenu) { + this.setState({shouldShowMentionSuggestionMenu: false}); } } @@ -441,15 +469,11 @@ class ReportActionCompose extends React.Component { } /** - * Clean data related to EmojiSuggestions + * Clean data related to EmojiSuggestions and MentionSuggestions */ - resetSuggestedEmojis() { + resetSuggestions() { this.setState({ - suggestedEmojis: [], - highlightedEmojiIndex: 0, - colonIndex: -1, - shouldShowSuggestionMenu: false, - isEmojiPickerLarge: true, + ...this.getDefaultSuggestionsValues(), }); } @@ -458,7 +482,7 @@ class ReportActionCompose extends React.Component { */ calculateEmojiSuggestion() { if (!this.state.value) { - this.resetSuggestedEmojis(); + this.resetSuggestions(); return; } if (this.state.shouldBlockEmojiCalc) { @@ -471,20 +495,20 @@ class ReportActionCompose extends React.Component { // 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 isAutoSuggestionPickerLarge = !this.props.isSmallScreenWidth || (this.props.isSmallScreenWidth && hasEnoughSpaceForLargeSuggestion); const nextState = { suggestedEmojis: [], highlightedEmojiIndex: 0, colonIndex, - shouldShowSuggestionMenu: false, - isEmojiPickerLarge, + shouldShowEmojiSuggestionMenu: false, + isAutoSuggestionPickerLarge, }; const newSuggestedEmojis = EmojiUtils.suggestEmojis(leftString); if (newSuggestedEmojis.length && isCurrentlyShowingEmojiSuggestion) { nextState.suggestedEmojis = newSuggestedEmojis; - nextState.shouldShowSuggestionMenu = !_.isEmpty(newSuggestedEmojis); + nextState.shouldShowEmojiSuggestionMenu = !_.isEmpty(newSuggestedEmojis); } LayoutAnimation.configureNext(LayoutAnimation.create(50, LayoutAnimation.Types.easeInEaseOut, LayoutAnimation.Properties.opacity)); @@ -492,6 +516,52 @@ class ReportActionCompose extends React.Component { this.setState(nextState); } + calculateMentionSuggestion() { + if (this.state.selection.end < 1) { + return; + } + + const valueAfterTheCursor = this.state.value.substring(this.state.selection.end); + const indexOfFirstWhitespaceCharOrEmojiAfterTheCursor = valueAfterTheCursor.search(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + + let indexOfLastNonWhitespaceCharAfterTheCursor; + if (indexOfFirstWhitespaceCharOrEmojiAfterTheCursor === -1) { + // we didn't find a whitespace/emoji after the cursor, so we will use the entire string + indexOfLastNonWhitespaceCharAfterTheCursor = this.state.value.length; + } else { + indexOfLastNonWhitespaceCharAfterTheCursor = indexOfFirstWhitespaceCharOrEmojiAfterTheCursor + this.state.selection.end; + } + + const leftString = this.state.value.substring(0, indexOfLastNonWhitespaceCharAfterTheCursor); + const words = leftString.split(CONST.REGEX.NEW_LINE_OR_WHITE_SPACE_OR_EMOJI); + const lastWord = _.last(words); + + let atSignIndex; + if (lastWord.startsWith('@')) { + atSignIndex = leftString.lastIndexOf(lastWord); + } + + const prefix = lastWord.substring(1); + + const nextState = { + suggestedMentions: [], + atSignIndex, + mentionPrefix: prefix, + }; + + const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(lastWord); + + if (!isCursorBeforeTheMention && this.isMentionCode(lastWord)) { + const options = OptionsListUtils.getNewChatOptions(this.props.reports, this.props.personalDetails, this.props.betas, prefix); + const suggestions = _.filter([...options.recentReports, options.userToInvite], (x) => !!x); + + nextState.suggestedMentions = suggestions; + nextState.shouldShowMentionSuggestionMenu = !_.isEmpty(suggestions); + } + + this.setState(nextState); + } + /** * Check if this piece of string looks like an emoji * @param {String} str @@ -505,6 +575,15 @@ class ReportActionCompose extends React.Component { return CONST.REGEX.HAS_COLON_ONLY_AT_THE_BEGINNING.test(leftWord) && leftWord.length > 2; } + /** + * Check if this piece of string looks like a mention + * @param {String} str + * @returns {Boolean} + */ + isMentionCode(str) { + return CONST.REGEX.HAS_AT_MOST_TWO_AT_SIGNS.test(str); + } + /** * Replace the code of emoji and update selection * @param {Number} highlightedEmojiIndex @@ -526,6 +605,26 @@ class ReportActionCompose extends React.Component { EmojiUtils.addToFrequentlyUsedEmojis(this.props.frequentlyUsedEmojis, emojiObject); } + /** + * Replace the code of mention and update selection + * @param {Number} highlightedMentionIndex + */ + insertSelectedMention(highlightedMentionIndex) { + const commentBeforeAtSign = this.state.value.slice(0, this.state.atSignIndex); + const mentionObject = this.state.suggestedMentions[highlightedMentionIndex]; + const mentionCode = `@${mentionObject.alternateText}`; + const commentAfterAtSignWithMentionRemoved = this.state.value.slice(this.state.atSignIndex).replace(CONST.REGEX.MENTION_REPLACER, ''); + + this.updateComment(`${commentBeforeAtSign}${mentionCode} ${commentAfterAtSignWithMentionRemoved}`, true); + this.setState((prevState) => ({ + selection: { + start: prevState.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + end: prevState.atSignIndex + mentionCode.length + CONST.SPACE_LENGTH, + }, + suggestedMentions: [], + })); + } + isEmptyChat() { return _.size(this.props.reportActions) === 1; } @@ -650,14 +749,21 @@ 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) { + const suggestionsExist = this.state.suggestedEmojis.length > 0 || this.state.suggestedMentions.length > 0; + + if ((e.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey || e.key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) && suggestionsExist) { e.preventDefault(); - this.insertSelectedEmoji(this.state.highlightedEmojiIndex); + if (this.state.suggestedEmojis.length > 0) { + this.insertSelectedEmoji(this.state.highlightedEmojiIndex); + } + if (this.state.suggestedMentions.length > 0) { + this.insertSelectedMention(this.state.highlightedMentionIndex); + } return; } - if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && this.state.suggestedEmojis.length) { + if (e.key === CONST.KEYBOARD_SHORTCUTS.ESCAPE.shortcutKey && suggestionsExist) { e.preventDefault(); - this.resetSuggestedEmojis(); + this.resetSuggestions(); return; } @@ -930,7 +1036,7 @@ class ReportActionCompose extends React.Component { onFocus={() => this.setIsFocused(true)} onBlur={() => { this.setIsFocused(false); - this.resetSuggestedEmojis(); + this.resetSuggestions(); }} onClick={this.setShouldBlockEmojiCalcToFalse} onPasteFile={displayFileInModal} @@ -1010,10 +1116,10 @@ class ReportActionCompose extends React.Component { {this.state.isDraggingOver && } - {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowSuggestionMenu && ( + {!_.isEmpty(this.state.suggestedEmojis) && this.state.shouldShowEmojiSuggestionMenu && ( this.setState({highlightedEmojiIndex: index})} > @@ -1028,7 +1134,30 @@ class ReportActionCompose extends React.Component { onSelect={this.insertSelectedEmoji} isComposerFullSize={this.props.isComposerFullSize} preferredSkinToneIndex={this.props.preferredSkinTone} - isEmojiPickerLarge={this.state.isEmojiPickerLarge} + isEmojiPickerLarge={this.state.isAutoSuggestionPickerLarge} + composerHeight={this.state.composerHeight} + shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} + /> + + )} + {!_.isEmpty(this.state.suggestedMentions) && this.state.shouldShowMentionSuggestionMenu && ( + this.setState({highlightedMentionIndex: index})} + > + this.setState({suggestedMentions: []})} + highlightedMentionIndex={this.state.highlightedMentionIndex} + mentions={this.state.suggestedMentions} + comment={this.state.value} + updateComment={(newComment) => this.setState({value: newComment})} + colonIndex={this.state.colonIndex} + prefix={this.state.mentionPrefix} + onSelect={this.insertSelectedMention} + isComposerFullSize={this.props.isComposerFullSize} + isMentionPickerLarge={this.state.isAutoSuggestionPickerLarge} composerHeight={this.state.composerHeight} shouldIncludeReportRecipientLocalTimeHeight={shouldShowReportRecipientLocalTime} /> @@ -1074,5 +1203,11 @@ export default compose( preferredSkinTone: { key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, }, + reports: { + key: ONYXKEYS.COLLECTION.REPORT, + }, + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS, + }, }), )(ReportActionCompose); diff --git a/src/styles/styles.js b/src/styles/styles.js index 253d0b1e9dd8..cdf4b057ad18 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -197,6 +197,12 @@ const styles = { fontSize: variables.fontSizeMedium, }, + mentionSuggestionsText: { + fontSize: variables.fontSizeMedium, + flex: 1, + ...spacing.ml2, + }, + unitCol: { margin: 0, padding: 0,