diff --git a/webapp/channels/src/components/at_mention/actions.ts b/webapp/channels/src/components/at_mention/actions.ts new file mode 100644 index 0000000000000..8fb57da257321 --- /dev/null +++ b/webapp/channels/src/components/at_mention/actions.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {UserProfile} from '@mattermost/types/users'; + +import {getMissingProfilesByUsernames} from 'mattermost-redux/actions/users'; +import type {ActionFuncAsync} from 'mattermost-redux/types/actions'; + +import {getPotentialMentionsForName} from 'utils/post_utils'; + +export function getMissingMentionedUsers(text: string): ActionFuncAsync> { + return getMissingProfilesByUsernames(getPotentialMentionsForName(text)); +} diff --git a/webapp/channels/src/components/at_mention/at_mention.test.tsx b/webapp/channels/src/components/at_mention/at_mention.test.tsx index b846b82b03d12..dc3ccb653aed3 100644 --- a/webapp/channels/src/components/at_mention/at_mention.test.tsx +++ b/webapp/channels/src/components/at_mention/at_mention.test.tsx @@ -8,6 +8,7 @@ import {General} from 'mattermost-redux/constants'; import AtMention from 'components/at_mention/at_mention'; +import {render} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; /* eslint-disable global-require */ @@ -28,7 +29,7 @@ describe('components/AtMention', () => { marketing: TestHelper.getGroupMock({id: 'qwerty2', name: 'marketing', allow_reference: false}), accounting: TestHelper.getGroupMock({id: 'qwerty3', name: 'accounting', allow_reference: true}), }, - dispatch: jest.fn(), + getMissingMentionedUsers: jest.fn(), }; test('should match snapshot when mentioning user', () => { @@ -227,4 +228,34 @@ describe('components/AtMention', () => { expect(wrapper).toMatchSnapshot(); }); + + describe('fetchMissingUsers', () => { + test('when fetchMissingUsers is true, should fetch an unloaded user on mount', () => { + render( + + {'@someuser'} + , + ); + + expect(baseProps.getMissingMentionedUsers).toHaveBeenCalledWith('someuser'); + }); + + test('when fetchMissingUsers is false, should not fetch an unloaded user on mount', () => { + shallow( + + {'@someuser'} + , + ); + + expect(baseProps.getMissingMentionedUsers).not.toHaveBeenCalledWith('someuser'); + }); + }); }); diff --git a/webapp/channels/src/components/at_mention/at_mention.tsx b/webapp/channels/src/components/at_mention/at_mention.tsx index 5d461e1c19988..93deefe4977df 100644 --- a/webapp/channels/src/components/at_mention/at_mention.tsx +++ b/webapp/channels/src/components/at_mention/at_mention.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React, {useRef, useMemo, memo} from 'react'; +import React, {useRef, useMemo, memo, useEffect} from 'react'; import {Client4} from 'mattermost-redux/client'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; @@ -22,6 +22,7 @@ type OwnProps = { channelId?: string; disableHighlight?: boolean; disableGroupHighlight?: boolean; + fetchMissingUsers?: boolean; } type Props = OwnProps & PropsFromRedux; @@ -34,6 +35,12 @@ const AtMention = (props: Props) => { [props.mentionName, props.usersByUsername, props.groupsByName, props.disableGroupHighlight], ); + useEffect(() => { + if (!user && !group && props.fetchMissingUsers) { + props.getMissingMentionedUsers(props.mentionName); + } + }, [props.mentionName]); + const returnFocus = () => { document.dispatchEvent(new CustomEvent( A11yCustomEventTypes.FOCUS, { diff --git a/webapp/channels/src/components/at_mention/index.tsx b/webapp/channels/src/components/at_mention/index.tsx index f0118a87b7e4c..b8fa6e27fcae6 100644 --- a/webapp/channels/src/components/at_mention/index.tsx +++ b/webapp/channels/src/components/at_mention/index.tsx @@ -10,6 +10,7 @@ import {getCurrentUserId, getUsersByUsername} from 'mattermost-redux/selectors/e import type {GlobalState} from 'types/store'; +import {getMissingMentionedUsers} from './actions'; import AtMention from './at_mention'; function mapStateToProps(state: GlobalState) { @@ -21,7 +22,11 @@ function mapStateToProps(state: GlobalState) { }; } -const connector = connect(mapStateToProps); +const mapDispatchToProps = { + getMissingMentionedUsers, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); export type PropsFromRedux = ConnectedProps; export default connector(AtMention); diff --git a/webapp/channels/src/components/channel_invite_modal/team_warning_banner/__snapshots__/team_warning_banner.test.tsx.snap b/webapp/channels/src/components/channel_invite_modal/team_warning_banner/__snapshots__/team_warning_banner.test.tsx.snap index 4b968298f320b..112afa1d8e126 100644 --- a/webapp/channels/src/components/channel_invite_modal/team_warning_banner/__snapshots__/team_warning_banner.test.tsx.snap +++ b/webapp/channels/src/components/channel_invite_modal/team_warning_banner/__snapshots__/team_warning_banner.test.tsx.snap @@ -139,7 +139,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh > > { return async (dispatch, getState, {loaders}: any) => { - if (!loaders.missingUsernameLoader) { - loaders.missingUsernameLoader = new DelayedDataLoader({ + if (!loaders.userByUsernameLoader) { + loaders.userByUsernameLoader = new DelayedDataLoader({ fetchBatch: (usernames) => dispatch(getProfilesByUsernames(usernames)), maxBatchSize: maxUserIdsPerProfilesRequest, wait: missingProfilesWait, @@ -210,7 +210,7 @@ export function getMissingProfilesByUsernames(usernames: string[]): ActionFuncAs const missingUsernames = usernames.filter((username) => !usersByUsername[username]); if (missingUsernames.length > 0) { - await loaders.missingUsernameLoader.queueAndWait(missingUsernames); + await loaders.userByUsernameLoader.queueAndWait(missingUsernames); } return {data: missingUsernames}; diff --git a/webapp/channels/src/utils/message_html_to_component.tsx b/webapp/channels/src/utils/message_html_to_component.tsx index 6c74b5e089179..daecfd1ed3c01 100644 --- a/webapp/channels/src/utils/message_html_to_component.tsx +++ b/webapp/channels/src/utils/message_html_to_component.tsx @@ -35,6 +35,13 @@ export type Options = Partial<{ images: boolean; atPlanMentions: boolean; channelId: string; + + /** + * Whether or not the AtMention component should attempt to fetch at-mentioned users if none can be found for + * something that looks like an at-mention. This defaults to false because the web app currently loads at-mentioned + * users automatically for all posts. + */ + fetchMissingUsers: boolean; }> type ProcessingInstruction = { @@ -132,6 +139,7 @@ export function messageHtmlToComponent(html: string, options: Options = {}) { disableHighlight={!mentionHighlight} disableGroupHighlight={disableGroupHighlight} channelId={options.channelId} + fetchMissingUsers={options.fetchMissingUsers} > {children} diff --git a/webapp/channels/src/utils/post_utils.ts b/webapp/channels/src/utils/post_utils.ts index a7905bc72c072..7b05b556c563d 100644 --- a/webapp/channels/src/utils/post_utils.ts +++ b/webapp/channels/src/utils/post_utils.ts @@ -757,19 +757,32 @@ export function makeGetIsReactionAlreadyAddedToPost(): (state: GlobalState, post ); } -export function getMentionDetails(usersByUsername: Record, mentionName: string): UserProfile | Group | undefined { +/** + * Given the text of an at-mention without the @, returns an array containing that text and every substring of it that + * can be made by removing trailing punctiuation. + * + * For example, getPotentialMentionsForName('username') returns ['username'] and + * getPotentialMentionsForName('username..') return ['username..', 'username.', 'username']. + */ +export function getPotentialMentionsForName(mentionName: string): string[] { let mentionNameToLowerCase = mentionName.toLowerCase(); - while (mentionNameToLowerCase.length > 0) { - if (usersByUsername.hasOwnProperty(mentionNameToLowerCase)) { - return usersByUsername[mentionNameToLowerCase]; - } + const potentialMentions = [mentionNameToLowerCase]; - // Repeatedly trim off trailing punctuation in case this is at the end of a sentence - if ((/[._-]$/).test(mentionNameToLowerCase)) { - mentionNameToLowerCase = mentionNameToLowerCase.substring(0, mentionNameToLowerCase.length - 1); - } else { - break; + // Repeatedly trim off trailing punctuation in case this is at the end of a sentence + while (mentionNameToLowerCase.length > 0 && (/[._-]$/).test(mentionNameToLowerCase)) { + mentionNameToLowerCase = mentionNameToLowerCase.substring(0, mentionNameToLowerCase.length - 1); + + potentialMentions.push(mentionNameToLowerCase); + } + + return potentialMentions; +} + +export function getMentionDetails(entitiesByName: Record, mentionName: string): T | undefined { + for (const potentialMention of getPotentialMentionsForName(mentionName)) { + if (Object.hasOwn(entitiesByName, potentialMention)) { + return entitiesByName[potentialMention]; } } @@ -783,11 +796,11 @@ export function getUserOrGroupFromMentionName( groupsDisabled?: boolean, getMention = getMentionDetails, ): [UserProfile?, Group?] { - const user = getMention(users, mentionName) as UserProfile | undefined; + const user = getMention(users, mentionName); // prioritizes user if user exists with the same name as a group. if (!user && !groupsDisabled) { - const group = getMention(groups, mentionName) as Group | undefined; + const group = getMention(groups, mentionName); if (group && !group.allow_reference) { return [undefined, undefined]; // remove group mention if not allowed to reference }