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,