diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index a5064df3d..10d6f7789 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -586,6 +586,7 @@ function loadGroupData() { const state = getState(); const actions = []; const team = getCurrentTeam(state); + const currentUserId = getCurrentUserId(state); const serverVersion = state.entities.general.serverVersion; const license = getLicense(state); const hasLicense = license?.IsLicensed === 'true' && license?.LDAPGroups === 'true'; @@ -632,10 +633,25 @@ function loadGroupData() { }); } } + break; } catch (err) { - return {error: err}; + if (i === MAX_RETRIES) { + return {error: err}; + } } } + + try { + const myGroups = await Client4.getGroupsByUserId(currentUserId); + if (myGroups.length) { + actions.push({ + type: GroupTypes.RECEIVED_MY_GROUPS, + data: myGroups, + }); + } + } catch { + // do nothing + } } if (actions.length) { diff --git a/app/components/at_mention/at_mention.js b/app/components/at_mention/at_mention.js index 0193171ac..bb79289de 100644 --- a/app/components/at_mention/at_mention.js +++ b/app/components/at_mention/at_mention.js @@ -25,6 +25,7 @@ export default class AtMention extends React.PureComponent { teammateNameDisplay: PropTypes.string, theme: PropTypes.object.isRequired, usersByUsername: PropTypes.object.isRequired, + groupsByName: PropTypes.object, }; static contextTypes = { @@ -93,6 +94,12 @@ export default class AtMention extends React.PureComponent { }; } + getGroupFromMentionName() { + const {groupsByName, mentionName} = this.props; + const mentionNameTrimmed = mentionName.toLowerCase().replace(/[._-]*$/, ''); + return groupsByName?.[mentionNameTrimmed] || {}; + } + handleLongPress = async () => { const {formatMessage} = this.context.intl; @@ -134,13 +141,28 @@ export default class AtMention extends React.PureComponent { render() { const {isSearchResult, mentionName, mentionStyle, onPostPress, teammateNameDisplay, textStyle, mentionKeys} = this.props; const {user} = this.state; + let highlighted; if (!user.username) { + const group = this.getGroupFromMentionName(); + if (group.allow_reference) { + highlighted = mentionKeys.some((item) => item.key === group.name); + return ( + + + {`@${group.name}`} + + + ); + } + return {'@' + mentionName}; } const suffix = this.props.mentionName.substring(user.username.length); - const highlighted = mentionKeys.some((item) => item.key === user.username); + highlighted = mentionKeys.some((item) => item.key === user.username); return ( diff --git a/app/components/markdown/index.js b/app/components/markdown/index.js index 2e484bf2a..c4f61fb06 100644 --- a/app/components/markdown/index.js +++ b/app/components/markdown/index.js @@ -5,16 +5,16 @@ import {connect} from 'react-redux'; import {getAutolinkedUrlSchemes, getConfig} from '@mm-redux/selectors/entities/general'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; -import {getCurrentUserMentionKeys} from '@mm-redux/selectors/entities/users'; +import {getAllUserMentionKeys} from '@mm-redux/selectors/entities/search'; import Markdown from './markdown'; -function mapStateToProps(state) { +function mapStateToProps(state, ownProps) { const {MinimumHashtagLength} = getConfig(state); return { autolinkedUrlSchemes: getAutolinkedUrlSchemes(state), - mentionKeys: getCurrentUserMentionKeys(state), + mentionKeys: ownProps.mentionKeys || getAllUserMentionKeys(state), minimumHashtagLength: MinimumHashtagLength ? parseInt(MinimumHashtagLength, 10) : 3, theme: getTheme(state), }; diff --git a/app/components/markdown/markdown.js b/app/components/markdown/markdown.js index 1cf5571a4..58187e4bf 100644 --- a/app/components/markdown/markdown.js +++ b/app/components/markdown/markdown.js @@ -92,14 +92,6 @@ export default class Markdown extends PureComponent { return !scheme || this.props.autolinkedUrlSchemes.indexOf(scheme) !== -1; }; - getMentionKeys = () => { - const mentionKeys = this.props.mentionKeys; - if (this.props.disableAtChannelMentionHighlight) { - return mentionKeys.filter((mention) => !['@all', '@channel', '@here'].includes(mention.key)); - } - return mentionKeys; - } - createRenderer = () => { return new Renderer({ renderers: { @@ -223,6 +215,7 @@ export default class Markdown extends PureComponent { isSearchResult={this.props.isSearchResult} mentionName={mentionName} onPostPress={this.props.onPostPress} + mentionKeys={this.props.mentionKeys} /> ); }; @@ -439,7 +432,7 @@ export default class Markdown extends PureComponent { ast = combineTextNodes(ast); ast = addListItemIndices(ast); ast = pullOutImages(ast); - ast = highlightMentions(ast, this.getMentionKeys()); + ast = highlightMentions(ast, this.props.mentionKeys); if (this.props.isEdited) { const editIndicatorNode = new Node('edited_indicator'); diff --git a/app/components/markdown/markdown.test.js b/app/components/markdown/markdown.test.js index 437ad2e31..e5ae99326 100644 --- a/app/components/markdown/markdown.test.js +++ b/app/components/markdown/markdown.test.js @@ -33,24 +33,4 @@ describe('Markdown', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); - - describe('getMentionKeys', () => { - let wrapper; - beforeAll(() => { - wrapper = shallow( - , - ); - }); - - it('should return base mentionKey props when disableAtChannelMentionHighlight not present', () => { - expect(wrapper.instance().getMentionKeys()).toEqual(baseProps.mentionKeys); - }); - - it('should filter channel mentions from mentionKey props when disableAtChannelMentionHighlight is true', () => { - wrapper.setProps({disableAtChannelMentionHighlight: true}); - expect(wrapper.instance().getMentionKeys()).toEqual([{key: 'user.name'}]); - }); - }); }); diff --git a/app/components/post_body/index.js b/app/components/post_body/index.js index 8d7cb1fdb..508e617ef 100644 --- a/app/components/post_body/index.js +++ b/app/components/post_body/index.js @@ -12,6 +12,7 @@ import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {getCustomEmojisByName} from '@mm-redux/selectors/entities/emojis'; import {makeGetReactionsForPost} from '@mm-redux/selectors/entities/posts'; import {memoizeResult} from '@mm-redux/utils/helpers'; +import {makeGetMentionKeysForPost} from '@mm-redux/selectors/entities/search'; import { isEdited, @@ -103,6 +104,7 @@ export function makeMapStateToProps() { isEmojiOnly, shouldRenderJumboEmoji, theme: getTheme(state), + mentionKeys: makeGetMentionKeysForPost(state, postProps?.disable_group_highlight, postProps?.mentionHighlightDisabled), canDelete, ...getDimensions(state), }; diff --git a/app/components/post_body/index.test.js b/app/components/post_body/index.test.js index ca9e4484f..b5e87ad1a 100644 --- a/app/components/post_body/index.test.js +++ b/app/components/post_body/index.test.js @@ -77,6 +77,13 @@ describe('makeMapStateToProps', () => { general: { serverVersion: '', }, + users: { + profiles: {}, + }, + groups: { + groups: {}, + myGroups: {}, + }, }, }; const defaultOwnProps = { diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index 0ad73b874..edf7dbd77 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -71,6 +71,7 @@ export default class PostBody extends PureComponent { shouldRenderJumboEmoji: PropTypes.bool.isRequired, theme: PropTypes.object, location: PropTypes.string, + mentionKeys: PropTypes.array.isRequired, }; static defaultProps = { @@ -345,6 +346,7 @@ export default class PostBody extends PureComponent { shouldRenderJumboEmoji, showLongPost, theme, + mentionKeys, } = this.props; const {isLongPost, maxHeight} = this.state; const style = getStyleSheet(theme); @@ -413,7 +415,7 @@ export default class PostBody extends PureComponent { onPostPress={onPress} textStyles={textStyles} value={message} - disableAtChannelMentionHighlight={postProps.mentionHighlightDisabled} + mentionKeys={mentionKeys} /> ); diff --git a/app/mm-redux/client/client4.ts b/app/mm-redux/client/client4.ts index df38bb185..e2925a73f 100644 --- a/app/mm-redux/client/client4.ts +++ b/app/mm-redux/client/client4.ts @@ -2861,6 +2861,13 @@ export default class Client4 { ); }; + getGroupsByUserId = async (userID: string) => { + return this.doFetch( + `${this.getUsersRoute()}/${userID}/groups`, + {method: 'get'}, + ); + } + getGroupsNotAssociatedToTeam = async (teamID: string, q = '', page = 0, perPage = PER_PAGE_DEFAULT) => { this.trackEvent('api', 'api_groups_get_not_associated_to_team', {team_id: teamID}); return this.doFetch( diff --git a/app/mm-redux/reducers/entities/groups.ts b/app/mm-redux/reducers/entities/groups.ts index f54b281ad..50d4e84c9 100644 --- a/app/mm-redux/reducers/entities/groups.ts +++ b/app/mm-redux/reducers/entities/groups.ts @@ -163,6 +163,20 @@ function syncables(state: Dictionary = {}, action: GenericAction } } +function myGroups(state: any = {}, action: GenericAction) { + switch (action.type) { + case GroupTypes.RECEIVED_MY_GROUPS: { + const nextState = {...state}; + for (const group of action.data) { + nextState[group.id] = group; + } + return nextState; + } + default: + return state; + } +} + function members(state: any = {}, action: GenericAction) { switch (action.type) { case GroupTypes.RECEIVED_GROUP_MEMBERS: { @@ -221,6 +235,7 @@ function groups(state: Dictionary = {}, action: GenericAction) { } export default combineReducers({ + myGroups, syncables, members, groups, diff --git a/app/mm-redux/selectors/entities/groups.ts b/app/mm-redux/selectors/entities/groups.ts index 0f3fb74f6..e46beb8aa 100644 --- a/app/mm-redux/selectors/entities/groups.ts +++ b/app/mm-redux/selectors/entities/groups.ts @@ -2,10 +2,12 @@ // See LICENSE.txt for license information. import * as reselect from 'reselect'; import {GlobalState} from '@mm-redux/types/store'; +import {Dictionary} from '@mm-redux/types/utilities'; import {Group} from '@mm-redux/types/groups'; import {filterGroupsMatchingTerm} from '@mm-redux/utils/group_utils'; import {getCurrentUserLocale} from '@mm-redux/selectors/entities/i18n'; import {getChannel} from '@mm-redux/selectors/entities/channels'; +import {UserMentionKey} from '@mm-redux/selectors/entities/users'; import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; import {getTeam} from '@mm-redux/selectors/entities/teams'; import {Permissions} from '@mm-redux/constants'; @@ -17,7 +19,11 @@ const emptySyncables = { }; export function getAllGroups(state: GlobalState) { - return state.entities.groups?.groups || []; + return state.entities.groups?.groups || {}; +} + +export function getMyGroups(state: GlobalState) { + return state.entities.groups?.myGroups || {}; } export function getGroup(state: GlobalState, id: string) { @@ -166,3 +172,32 @@ export const getAllAssociatedGroupsForReference = reselect.createSelector( return Object.values(allGroups).filter((group) => group.allow_reference && group.delete_at === 0); }, ); + +export const getMyAllowReferencedGroups = reselect.createSelector( + getMyGroups, + (myGroups) => { + return Object.values(myGroups).filter((group) => group.allow_reference && group.delete_at === 0); + }, +); + +export const getCurrentUserGroupMentionKeys = reselect.createSelector( + getMyAllowReferencedGroups, + (groups: Array) => { + const keys: UserMentionKey[] = []; + groups.forEach((group) => keys.push({key: `@${group.name}`})); + return keys; + }, +); + +export const getGroupsByName = reselect.createSelector( + getAllGroups, + (groups) => { + const groupsByName: Dictionary = {}; + + Object.values(groups).forEach((group) => { + groupsByName[group.name] = group; + }); + + return groupsByName; + }, +); diff --git a/app/mm-redux/selectors/entities/search.test.js b/app/mm-redux/selectors/entities/search.test.js index 1e736ff0d..5312508ed 100644 --- a/app/mm-redux/selectors/entities/search.test.js +++ b/app/mm-redux/selectors/entities/search.test.js @@ -26,4 +26,184 @@ describe('Selectors.Search', () => { it('should return current search for current team', () => { assert.deepEqual(Selectors.getCurrentSearchForCurrentTeam(testState), team1CurrentSearch); }); + + it('getAllUserMentionKeys', () => { + const userId = '1234'; + const notifyProps = { + first_name: 'true', + }; + const state = { + entities: { + users: { + currentUserId: userId, + profiles: { + [userId]: {id: userId, username: 'user', first_name: 'First', last_name: 'Last', notify_props: notifyProps}, + }, + }, + groups: { + myGroups: { + test1: { + name: 'I-AM-THE-BEST!', + delete_at: 0, + allow_reference: true, + }, + test2: { + name: 'Do-you-love-me?', + delete_at: 0, + allow_reference: true, + }, + test3: { + name: 'Maybe?-A-little-bit-I-guess....', + delete_at: 0, + allow_reference: false, + }, + }, + }, + }, + }; + + assert.deepEqual(Selectors.getAllUserMentionKeys(state), [{key: 'First', caseSensitive: true}, {key: '@user'}, {key: '@I-AM-THE-BEST!'}, {key: '@Do-you-love-me?'}]); + }); + + describe('makeGetMentionKeysForPost', () => { + it('should return all mentionKeys', () => { + const postProps = { + disable_group_highlight: false, + mentionHighlightDisabled: false, + }; + const state = { + entities: { + users: { + currentUserId: 'a123', + profiles: { + a123: { + username: 'a123', + notify_props: { + channel: 'true', + }, + }, + }, + }, + groups: { + myGroups: { + developers: { + id: 123, + name: 'developers', + allow_reference: true, + delete_at: 0, + }, + }, + }, + }, + }; + const results = Selectors.makeGetMentionKeysForPost(state, postProps.disable_group_highlight, postProps.mentionHighlightDisabled); + const expected = [{key: '@channel'}, {key: '@all'}, {key: '@here'}, {key: '@a123'}, {key: '@developers'}]; + assert.deepEqual(results, expected); + }); + + it('should return mentionKeys without groups', () => { + const postProps = { + disable_group_highlight: true, + mentionHighlightDisabled: false, + }; + const state = { + entities: { + users: { + currentUserId: 'a123', + profiles: { + a123: { + username: 'a123', + notify_props: { + channel: 'true', + }, + }, + }, + }, + groups: { + myGroups: { + developers: { + id: 123, + name: 'developers', + allow_reference: true, + delete_at: 0, + }, + }, + }, + }, + }; + const results = Selectors.makeGetMentionKeysForPost(state, postProps.disable_group_highlight, postProps.mentionHighlightDisabled); + const expected = [{key: '@channel'}, {key: '@all'}, {key: '@here'}, {key: '@a123'}]; + assert.deepEqual(results, expected); + }); + + it('should return group mentions and all mentions without channel mentions', () => { + const postProps = { + disable_group_highlight: false, + mentionHighlightDisabled: true, + }; + const state = { + entities: { + users: { + currentUserId: 'a123', + profiles: { + a123: { + username: 'a123', + notify_props: { + channel: 'true', + }, + }, + }, + }, + groups: { + myGroups: { + developers: { + id: 123, + name: 'developers', + allow_reference: true, + delete_at: 0, + }, + }, + }, + }, + }; + const results = Selectors.makeGetMentionKeysForPost(state, postProps.disable_group_highlight, postProps.mentionHighlightDisabled); + const expected = [{key: '@a123'}, {key: '@developers'}]; + assert.deepEqual(results, expected); + }); + + it('should return all mentions without group mentions and channel mentions', () => { + const postProps = { + disable_group_highlight: true, + mentionHighlightDisabled: true, + }; + const state = { + entities: { + users: { + currentUserId: 'a123', + profiles: { + a123: { + username: 'a123', + notify_props: { + channel: 'true', + }, + }, + }, + }, + groups: { + myGroups: { + developers: { + id: 123, + name: 'developers', + allow_reference: true, + delete_at: 0, + }, + }, + }, + }, + }; + const results = Selectors.makeGetMentionKeysForPost(state, postProps.disable_group_highlight, postProps.mentionHighlightDisabled); + const expected = [{key: '@a123'}]; + assert.deepEqual(results, expected); + }); + }); }); diff --git a/app/mm-redux/selectors/entities/search.ts b/app/mm-redux/selectors/entities/search.ts index b5a8481d2..92356b394 100644 --- a/app/mm-redux/selectors/entities/search.ts +++ b/app/mm-redux/selectors/entities/search.ts @@ -2,15 +2,46 @@ // See LICENSE.txt for license information. import * as reselect from 'reselect'; +import {GlobalState} from '@mm-redux/types/store'; +import {UserMentionKey} from './users'; +import {getCurrentUserMentionKeys} from '@mm-redux/selectors/entities/users'; +import {getCurrentUserGroupMentionKeys} from '@mm-redux/selectors/entities/groups'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; -import * as types from '@mm-redux/types'; - export const getCurrentSearchForCurrentTeam = reselect.createSelector( - (state: types.store.GlobalState) => state.entities.search.current, + (state: GlobalState) => state.entities.search.current, getCurrentTeamId, (current, teamId) => { return current[teamId]; }, ); + +export const getAllUserMentionKeys: (state: GlobalState) => UserMentionKey[] = reselect.createSelector( + getCurrentUserMentionKeys, + (state: GlobalState) => getCurrentUserGroupMentionKeys(state), + (userMentionKeys, groupMentionKeys) => { + return userMentionKeys.concat(groupMentionKeys); + }, +); + +export const makeGetMentionKeysForPost: (state: GlobalState, disableGroupHighlight: boolean, mentionHighlightDisabled: boolean) => UserMentionKey[] = reselect.createSelector( + getAllUserMentionKeys, + getCurrentUserMentionKeys, + (state: GlobalState, disableGroupHighlight: boolean) => disableGroupHighlight, + (state: GlobalState, disableGroupHighlight: boolean, mentionHighlightDisabled: boolean) => mentionHighlightDisabled, + (allMentionKeys, mentionKeysWithoutGroups, disableGroupHighlight = false, mentionHighlightDisabled = false) => { + let mentionKeys = allMentionKeys; + if (disableGroupHighlight) { + mentionKeys = mentionKeysWithoutGroups; + } + + if (mentionHighlightDisabled) { + const CHANNEL_MENTIONS = ['@all', '@channel', '@here']; + mentionKeys = mentionKeys.filter((value) => !CHANNEL_MENTIONS.includes(value.key)); + } + + return mentionKeys; + }, +); + diff --git a/app/mm-redux/types/groups.ts b/app/mm-redux/types/groups.ts index 15279921d..ffe25417a 100644 --- a/app/mm-redux/types/groups.ts +++ b/app/mm-redux/types/groups.ts @@ -57,6 +57,9 @@ export type GroupsState = { groups: { [x: string]: Group; }; + myGroups: { + [x: string]: Group; + }; }; export type GroupSearchOpts = { q: string; diff --git a/app/mm-redux/types/posts.ts b/app/mm-redux/types/posts.ts index 27f53afa2..36bd476f2 100644 --- a/app/mm-redux/types/posts.ts +++ b/app/mm-redux/types/posts.ts @@ -110,3 +110,8 @@ export type PostsState = { messagesHistory: MessageHistory; expandedURLs: Dictionary; }; + +export type PostProps = { + disable_group_highlight?: boolean; + mentionHighlightDisabled: boolean; +}