Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[MentionsV2] Room mentions suggestions #39697

Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 18 additions & 16 deletions src/components/MentionSuggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ import Avatar from './Avatar';
import Text from './Text';

type Mention = {
/** Display name of the user */
/** Display name of the mention */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** Display name of the mention */
/** Display text of the mention */

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think? Actually not sure.

text: string;

/** The formatted email/phone number of the user */
/** The formatted text of the mention */
alternateText: string;

/** Email/phone number of the user */
login?: string;
/** handle of the mention */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not totally understanding from these prop descriptions what the difference between text, alternateText, and handle is. Maybe we could make these comments a bit more descriptive?

handle?: string;

/** Array of icons of the user. We use the first element of this array */
icons: Icon[];
/** Array of icons of the user. If present, we use the first element of this array. For room suggestions, the icons are not used */
icons?: Icon[];
};

type MentionSuggestionsProps = {
Expand Down Expand Up @@ -67,16 +67,18 @@ function MentionSuggestions({prefix, mentions, highlightedMentionIndex = 0, onSe

return (
<View style={[styles.autoCompleteSuggestionContainer, styles.ph2]}>
<View style={styles.mentionSuggestionsAvatarContainer}>
<Avatar
source={item.icons[0].source}
size={isIcon ? CONST.AVATAR_SIZE.MENTION_ICON : CONST.AVATAR_SIZE.SMALLER}
name={item.icons[0].name}
type={item.icons[0].type}
fill={isIcon ? theme.success : undefined}
fallbackIcon={item.icons[0].fallbackIcon}
/>
</View>
{item.icons && !!item.icons.length && (
<View style={styles.mentionSuggestionsAvatarContainer}>
<Avatar
source={item.icons[0].source}
size={isIcon ? CONST.AVATAR_SIZE.MENTION_ICON : CONST.AVATAR_SIZE.SMALLER}
name={item.icons[0].name}
type={item.icons[0].type}
fill={isIcon ? theme.success : undefined}
fallbackIcon={item.icons[0].fallbackIcon}
/>
</View>
)}
<Text
style={[styles.mentionSuggestionsText, styles.flexShrink1]}
numberOfLines={1}
Expand Down
9 changes: 9 additions & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5752,6 +5752,14 @@ function getOutstandingChildRequest(iouReport: OnyxEntry<Report> | EmptyObject):
return {};
}

function canReportBeMentionedWithinPolicy(report: OnyxEntry<Report>, policyID: string): boolean {
if (report?.policyID !== policyID) {
return false;
}

return isChatRoom(report) && !isThread(report);
}

export {
getReportParticipantsTitle,
isReportMessageAttachment,
Expand Down Expand Up @@ -5978,6 +5986,7 @@ export {
hasActionsWithErrors,
getGroupChatName,
getOutstandingChildRequest,
canReportBeMentionedWithinPolicy,
};

export type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ type ComposerWithSuggestionsProps = ComposerWithSuggestionsOnyxProps &
/** The parent report ID */
// eslint-disable-next-line react/no-unused-prop-types -- its used in the withOnyx HOC
parentReportID: string | undefined;

/** Whether report is from group policy */
isGroupPolicyReport: boolean;

/** policy ID of the report */
policyID: string;
};

const {RNTextInputReset} = NativeModules;
Expand Down Expand Up @@ -217,6 +223,8 @@ function ComposerWithSuggestions(
isEmptyChat,
lastReportAction,
parentReportActionID,
isGroupPolicyReport,
policyID,

// Focus
onFocus,
Expand Down Expand Up @@ -769,6 +777,8 @@ function ComposerWithSuggestions(
composerHeight={composerHeight}
measureParentContainer={measureParentContainer}
isAutoSuggestionPickerLarge={isAutoSuggestionPickerLarge}
isGroupPolicyReport={isGroupPolicyReport}
policyID={policyID}
// Input
value={value}
setValue={setValue}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ function ReportActionCompose({
[],
);

const isGroupPolicyReport = useMemo(() => ReportUtils.isGroupPolicy(report), [report]);
const reportRecipientAcountIDs = ReportUtils.getReportRecipientAccountIDs(report, currentUserPersonalDetails.accountID);
const reportRecipient = personalDetails[reportRecipientAcountIDs[0]];
const shouldUseFocusedColor = !isBlockedFromConcierge && !disabled && isFocused;
Expand Down Expand Up @@ -431,9 +432,11 @@ function ReportActionCompose({
isScrollLikelyLayoutTriggered={isScrollLikelyLayoutTriggered}
raiseIsScrollLikelyLayoutTriggered={raiseIsScrollLikelyLayoutTriggered}
reportID={reportID}
policyID={report?.policyID ?? ''}
parentReportID={report?.parentReportID}
parentReportActionID={report?.parentReportActionID}
includeChronos={ReportUtils.chatIncludesChronos(report)}
isGroupPolicyReport={isGroupPolicyReport}
isEmptyChat={isEmptyChat}
lastReportAction={lastReportAction}
isMenuVisible={isMenuVisible}
Expand Down
104 changes: 90 additions & 14 deletions src/pages/home/report/ReportActionCompose/SuggestionMention.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Str from 'expensify-common/lib/str';
import lodashSortBy from 'lodash/sortBy';
import type {ForwardedRef} from 'react';
import type {ForwardedRef, RefAttributes} from 'react';
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import type {OnyxCollection} from 'react-native-onyx';
import * as Expensicons from '@components/Icon/Expensicons';
import type {Mention} from '@components/MentionSuggestions';
import MentionSuggestions from '@components/MentionSuggestions';
Expand All @@ -11,10 +13,13 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'
import useLocalize from '@hooks/useLocalize';
import * as LoginUtils from '@libs/LoginUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as SuggestionsUtils from '@libs/SuggestionUtils';
import * as UserUtils from '@libs/UserUtils';
import {isValidRoomName} from '@libs/ValidationUtils';
import CONST from '@src/CONST';
import type {PersonalDetailsList} from '@src/types/onyx';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList, Report} from '@src/types/onyx';
import type {SuggestionsRef} from './ReportActionCompose';
import type {SuggestionProps} from './Suggestions';

Expand All @@ -23,6 +28,12 @@ type SuggestionValues = {
atSignIndex: number;
shouldShowSuggestionMenu: boolean;
mentionPrefix: string;
prefixType: string;
};

type RoomMentionOnyxProps = {
/** All reports shared with the user */
reports: OnyxCollection<Report>;
};

/**
Expand All @@ -35,10 +46,22 @@ const defaultSuggestionsValues: SuggestionValues = {
atSignIndex: -1,
shouldShowSuggestionMenu: false,
mentionPrefix: '',
prefixType: '',
};

function SuggestionMention(
{value, selection, setSelection, updateComment, isAutoSuggestionPickerLarge, measureParentContainer, isComposerFocused}: SuggestionProps,
{
value,
selection,
setSelection,
updateComment,
isAutoSuggestionPickerLarge,
measureParentContainer,
isComposerFocused,
reports,
isGroupPolicyReport,
policyID,
}: SuggestionProps & RoomMentionOnyxProps,
ref: ForwardedRef<SuggestionsRef>,
) {
const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT;
Expand Down Expand Up @@ -83,17 +106,25 @@ function SuggestionMention(
[currentUserPersonalDetails.login],
);

const getMentionCode = useCallback(
(mention: Mention, mentionType: string): string => {
if (mentionType === '#') {
// room mention case
return mention.handle ?? '';
}
return mention.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT ? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT : `@${formatLoginPrivateDomain(mention.handle, mention.handle)}`;
},
[formatLoginPrivateDomain],
);

/**
* Replace the code of mention and update selection
*/
const insertSelectedMention = useCallback(
(highlightedMentionIndexInner: number) => {
const commentBeforeAtSign = value.slice(0, suggestionValues.atSignIndex);
const mentionObject = suggestionValues.suggestedMentions[highlightedMentionIndexInner];
const mentionCode =
mentionObject.text === CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT
? CONST.AUTO_COMPLETE_SUGGESTER.HERE_TEXT
: `@${formatLoginPrivateDomain(mentionObject.login, mentionObject.login)}`;
const mentionCode = getMentionCode(mentionObject, suggestionValues.prefixType);
const commentAfterMention = value.slice(suggestionValues.atSignIndex + suggestionValues.mentionPrefix.length + 1);

updateComment(`${commentBeforeAtSign}${mentionCode} ${SuggestionsUtils.trimLeadingSpace(commentAfterMention)}`, true);
Expand All @@ -108,7 +139,16 @@ function SuggestionMention(
suggestedMentions: [],
}));
},
[value, suggestionValues.atSignIndex, suggestionValues.suggestedMentions, suggestionValues.mentionPrefix, updateComment, setSelection, formatLoginPrivateDomain],
[
value,
suggestionValues.atSignIndex,
suggestionValues.suggestedMentions,
suggestionValues.prefixType,
suggestionValues.mentionPrefix.length,
getMentionCode,
updateComment,
setSelection,
],
);

/**
Expand Down Expand Up @@ -146,7 +186,7 @@ function SuggestionMention(
[highlightedMentionIndex, insertSelectedMention, resetSuggestions, suggestionValues.suggestedMentions.length],
);

const getMentionOptions = useCallback(
const getUserMentionOptions = useCallback(
(personalDetailsParam: PersonalDetailsList, searchValue = ''): Mention[] => {
const suggestions = [];

Expand Down Expand Up @@ -211,6 +251,27 @@ function SuggestionMention(
[translate, formatPhoneNumber, formatLoginPrivateDomain],
);

const getRoomMentionOptions = useCallback(
(searchTerm: string, reportBatch: OnyxCollection<Report>): Mention[] => {
const filteredRoomMentions: Mention[] = [];
Object.values(reportBatch ?? {}).forEach((report) => {
if (!ReportUtils.canReportBeMentionedWithinPolicy(report, policyID ?? '')) {
return;
}
if (report?.reportName?.toLowerCase().includes(searchTerm.toLowerCase())) {
filteredRoomMentions.push({
text: report.reportName,
handle: report.reportName,
alternateText: report.reportName,
});
}
});

return lodashSortBy(filteredRoomMentions, 'handle').slice(0, CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS);
},
[policyID],
);

const calculateMentionSuggestion = useCallback(
(selectionEnd: number) => {
if (shouldBlockCalc.current || selectionEnd < 1 || !isComposerFocused) {
Expand Down Expand Up @@ -239,13 +300,15 @@ function SuggestionMention(
let atSignIndex: number | undefined;
let suggestionWord = '';
let prefix: string;
let prefixType = '';

// Detect if the last two words contain a mention (two words are needed to detect a mention with a space in it)
if (lastWord.startsWith('@')) {
if (lastWord.startsWith('@') || lastWord.startsWith('#')) {
atSignIndex = leftString.lastIndexOf(lastWord) + afterLastBreakLineIndex;
suggestionWord = lastWord;

prefix = suggestionWord.substring(1);
prefixType = suggestionWord.substring(0, 1);
} else if (secondToLastWord && secondToLastWord.startsWith('@') && secondToLastWord.length > 1) {
atSignIndex = leftString.lastIndexOf(secondToLastWord) + afterLastBreakLineIndex;
suggestionWord = `${secondToLastWord} ${lastWord}`;
Expand All @@ -259,23 +322,32 @@ function SuggestionMention(
suggestedMentions: [],
atSignIndex,
mentionPrefix: prefix,
prefixType,
};

const isCursorBeforeTheMention = valueAfterTheCursor.startsWith(suggestionWord);

if (!isCursorBeforeTheMention && isMentionCode(suggestionWord)) {
const suggestions = getMentionOptions(personalDetails, prefix);
if (!isCursorBeforeTheMention && isMentionCode(suggestionWord) && prefixType === '@') {
const suggestions = getUserMentionOptions(personalDetails, prefix);
nextState.suggestedMentions = suggestions;
nextState.shouldShowSuggestionMenu = !!suggestions.length;
}

const shouldDisplayRoomMentionsSuggestions = isGroupPolicyReport && (isValidRoomName(suggestionWord.toLowerCase()) || prefix === '');
if (!isCursorBeforeTheMention && prefixType === '#' && shouldDisplayRoomMentionsSuggestions) {
// filter reports by room name and current policy
const filteredRoomMentions = getRoomMentionOptions(prefix, reports);
nextState.suggestedMentions = filteredRoomMentions;
nextState.shouldShowSuggestionMenu = !!filteredRoomMentions.length;
}

setSuggestionValues((prevState) => ({
...prevState,
...nextState,
}));
setHighlightedMentionIndex(0);
},
[getMentionOptions, personalDetails, resetSuggestions, setHighlightedMentionIndex, value, isComposerFocused],
[isComposerFocused, value, isGroupPolicyReport, setHighlightedMentionIndex, resetSuggestions, getUserMentionOptions, personalDetails, getRoomMentionOptions, reports],
);

useEffect(() => {
Expand Down Expand Up @@ -330,4 +402,8 @@ function SuggestionMention(

SuggestionMention.displayName = 'SuggestionMention';

export default forwardRef(SuggestionMention);
export default withOnyx<SuggestionProps & RoomMentionOnyxProps & RefAttributes<SuggestionsRef>, RoomMentionOnyxProps>({
reports: {
key: ONYXKEYS.COLLECTION.REPORT,
},
})(forwardRef(SuggestionMention));
10 changes: 10 additions & 0 deletions src/pages/home/report/ReportActionCompose/Suggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ type SuggestionProps = {

/** The height of the composer */
composerHeight?: number;

/** If current composer is connected with report from group policy */
isGroupPolicyReport: boolean;

/** The policyID of the report connected to current composer */
policyID?: string;
};

/**
Expand All @@ -66,6 +72,8 @@ function Suggestions(
measureParentContainer,
isAutoSuggestionPickerLarge = true,
isComposerFocused,
isGroupPolicyReport,
policyID,
}: SuggestionProps,
ref: ForwardedRef<SuggestionsRef>,
) {
Expand Down Expand Up @@ -155,6 +163,8 @@ function Suggestions(
isAutoSuggestionPickerLarge,
measureParentContainer,
isComposerFocused,
isGroupPolicyReport,
policyID,
};

return (
Expand Down
Loading