diff --git a/app/api/server/index.js b/app/api/server/index.js index be4ade59e8cf..3a33a21e3e48 100644 --- a/app/api/server/index.js +++ b/app/api/server/index.js @@ -37,5 +37,6 @@ import './v1/webdav'; import './v1/oauthapps'; import './v1/custom-sounds'; import './v1/custom-user-status'; +import './v1/mention-groups'; export { API, APIClass, defaultRateLimiterOptions } from './api'; diff --git a/app/api/server/lib/mentionGroups.js b/app/api/server/lib/mentionGroups.js new file mode 100644 index 000000000000..6d31219daa05 --- /dev/null +++ b/app/api/server/lib/mentionGroups.js @@ -0,0 +1,24 @@ +// import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { MentionGroups } from '../../../models/server/raw'; + +export async function findMentionGroupAutocomplete({ /* uid,*/ selector }) { + // if (!await hasPermissionAsync(uid, 'view-other-user-channels')) { + // return { items: [] }; + // } + const options = { + fields: { + _id: 1, + name: 1, + }, + limit: 10, + sort: { + name: 1, + }, + }; + + const groups = await MentionGroups.findByNameContaining(selector.name, options, selector.exceptions).toArray(); + + return { + items: groups, + }; +} diff --git a/app/api/server/lib/roles.js b/app/api/server/lib/roles.js new file mode 100644 index 000000000000..dddf3e819c81 --- /dev/null +++ b/app/api/server/lib/roles.js @@ -0,0 +1,24 @@ +// import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { Roles } from '../../../models/server/raw'; + +export async function findRoleAutocomplete({ /* uid,*/ selector }) { + // if (!await hasPermissionAsync(uid, 'view-other-user-channels')) { + // return { items: [] }; + // } + const options = { + fields: { + _id: 1, + name: 1, + }, + limit: 10, + sort: { + name: 1, + }, + }; + + const roles = await Roles.findByNameContaining(selector.name, options, selector.exceptions).toArray(); + + return { + items: roles, + }; +} diff --git a/app/api/server/lib/rooms.js b/app/api/server/lib/rooms.js index a0c371d1f59d..5744a2b69834 100644 --- a/app/api/server/lib/rooms.js +++ b/app/api/server/lib/rooms.js @@ -54,7 +54,7 @@ export async function findAdminRooms({ uid, filter, types = [], pagination: { of }; } -export async function findChannelAndPrivateAutocomplete({ uid, selector }) { +async function findChannelAndOrPrivateAutocomplete(withPrivate, { uid, selector }) { if (!await hasPermissionAsync(uid, 'view-other-user-channels')) { return { items: [] }; } @@ -69,9 +69,17 @@ export async function findChannelAndPrivateAutocomplete({ uid, selector }) { }, }; - const rooms = await Rooms.findChannelAndPrivateByNameStarting(selector.name, options).toArray(); + const rooms = await Rooms.findChannelAndOrPrivateByNameStarting(selector.name, options, withPrivate, selector.exceptions).toArray(); return { items: rooms, }; } + +export function findChannelAndPrivateAutocomplete(params) { + return findChannelAndOrPrivateAutocomplete(true, params); +} + +export function findChannelAutocomplete(params) { + return findChannelAndOrPrivateAutocomplete(false, params); +} diff --git a/app/api/server/v1/mention-groups.js b/app/api/server/v1/mention-groups.js new file mode 100644 index 000000000000..3275a7a6bfb7 --- /dev/null +++ b/app/api/server/v1/mention-groups.js @@ -0,0 +1,16 @@ +import { API } from '../api'; +import { findMentionGroupAutocomplete } from '../lib/mentionGroups'; + +API.v1.addRoute('mentionGroups.autocomplete', { authRequired: true }, { + get() { + const { selector } = this.queryParams; + if (!selector) { + return API.v1.failure('The \'selector\' param is required'); + } + + return API.v1.success(Promise.await(findMentionGroupAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }))); + }, +}); diff --git a/app/api/server/v1/roles.js b/app/api/server/v1/roles.js index 22a0d543ce34..07d26996f3ec 100644 --- a/app/api/server/v1/roles.js +++ b/app/api/server/v1/roles.js @@ -4,6 +4,7 @@ import { Match, check } from 'meteor/check'; import { Roles } from '../../../models'; import { API } from '../api'; import { getUsersInRole, hasPermission } from '../../../authorization/server'; +import { findRoleAutocomplete } from '../lib/roles'; API.v1.addRoute('roles.list', { authRequired: true }, { get() { @@ -86,3 +87,18 @@ API.v1.addRoute('roles.getUsersInRole', { authRequired: true }, { return API.v1.success({ users }); }, }); + + +API.v1.addRoute('roles.autocomplete', { authRequired: true }, { + get() { + const { selector } = this.queryParams; + if (!selector) { + return API.v1.failure('The \'selector\' param is required'); + } + + return API.v1.success(Promise.await(findRoleAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }))); + }, +}); diff --git a/app/api/server/v1/rooms.js b/app/api/server/v1/rooms.js index 85924d77d27f..c673d3517971 100644 --- a/app/api/server/v1/rooms.js +++ b/app/api/server/v1/rooms.js @@ -4,7 +4,7 @@ import Busboy from 'busboy'; import { FileUpload } from '../../../file-upload'; import { Rooms, Messages } from '../../../models'; import { API } from '../api'; -import { findAdminRooms, findChannelAndPrivateAutocomplete } from '../lib/rooms'; +import { findAdminRooms, findChannelAndPrivateAutocomplete, findChannelAutocomplete } from '../lib/rooms'; function findRoomByIdOrName({ params, checkedArchived = true }) { if ((!params.roomId || !params.roomId.trim()) && (!params.roomName || !params.roomName.trim())) { @@ -308,3 +308,17 @@ API.v1.addRoute('rooms.autocomplete.channelAndPrivate', { authRequired: true }, }))); }, }); + +API.v1.addRoute('rooms.autocomplete.channel', { authRequired: true }, { + get() { + const { selector } = this.queryParams; + if (!selector) { + return API.v1.failure('The \'selector\' param is required'); + } + + return API.v1.success(Promise.await(findChannelAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }))); + }, +}); diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index 6781accad030..eacc859e00a8 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -111,6 +111,7 @@ Meteor.startup(function() { { _id: 'view-livechat-webhooks', roles: ['livechat-manager', 'admin'] }, { _id: 'view-livechat-facebook', roles: ['livechat-manager', 'admin'] }, { _id: 'view-livechat-officeHours', roles: ['livechat-manager', 'admin'] }, + { _id: 'manage-mention-groups', roles: ['admin'] }, ]; for (const permission of permissions) { diff --git a/app/lib/server/lib/notifyUsersOnMessage.js b/app/lib/server/lib/notifyUsersOnMessage.js index f1d041f3e31a..e8d2c6e32186 100644 --- a/app/lib/server/lib/notifyUsersOnMessage.js +++ b/app/lib/server/lib/notifyUsersOnMessage.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import s from 'underscore.string'; import moment from 'moment'; -import { Rooms, Subscriptions } from '../../../models/server'; +import { Rooms, Subscriptions, MentionGroups } from '../../../models/server'; import { settings } from '../../../settings/server'; import { callbacks } from '../../../callbacks/server'; @@ -28,6 +28,7 @@ export function updateUsersSubscriptions(message, room, users) { if (room != null) { let toAll = false; let toHere = false; + let toGroup = false; const mentionIds = []; const highlightsIds = []; @@ -45,6 +46,9 @@ export function updateUsersSubscriptions(message, room, users) { if (!toHere && mention._id === 'here') { toHere = true; } + if (MentionGroups.findOne({ name: mention._id })) { + toGroup = true; + } if (mention._id !== message.u._id) { mentionIds.push(mention._id); } @@ -62,7 +66,7 @@ export function updateUsersSubscriptions(message, room, users) { const unreadSetting = room.t === 'd' ? 'Unread_Count_DM' : 'Unread_Count'; const unreadCount = settings.get(unreadSetting); - if (toAll || toHere) { + if (toAll || toHere || toGroup) { const incUnreadByGroup = ['all_messages', 'group_mentions_only', 'user_and_group_mentions_only'].includes(unreadCount); const incUnread = room.t === 'd' || incUnreadByGroup ? 1 : 0; diff --git a/app/lib/server/lib/sendNotificationsOnMessage.js b/app/lib/server/lib/sendNotificationsOnMessage.js index 21eab1a8535d..aeac77258c91 100644 --- a/app/lib/server/lib/sendNotificationsOnMessage.js +++ b/app/lib/server/lib/sendNotificationsOnMessage.js @@ -1,10 +1,11 @@ import { Meteor } from 'meteor/meteor'; import moment from 'moment'; +import _ from 'underscore'; import { hasPermission } from '../../../authorization'; import { settings } from '../../../settings'; import { callbacks } from '../../../callbacks'; -import { Subscriptions, Users } from '../../../models/server'; +import { Subscriptions, Users, MentionGroups } from '../../../models/server'; import { roomTypes } from '../../../utils'; import { callJoinRoom, messageContainsHighlight, parseMessageTextPerUser, replaceMentionedUsernamesWithFullNames } from '../functions/notifications'; import { sendEmail, shouldNotifyEmail } from '../functions/notifications/email'; @@ -186,16 +187,30 @@ const lookup = { }, }; -export async function sendMessageNotifications(message, room, usersInThread = []) { +export async function sendMessageNotifications(message, room, usersInThread = [], mentionGroups = []) { const sender = roomTypes.getConfig(room.t).getMsgSender(message.u._id); if (!sender) { return message; } + + const mentionGroupNames = mentionGroups.map((group) => group.name); const mentionIds = (message.mentions || []).map(({ _id }) => _id).concat(usersInThread); // add users in thread to mentions array because they follow the same rules - const mentionIdsWithoutGroups = mentionIds.filter((_id) => _id !== 'all' && _id !== 'here'); - const hasMentionToAll = mentionIds.includes('all'); - const hasMentionToHere = mentionIds.includes('here'); + const mentionIdsWithoutGroups = mentionIds.filter((_id) => _id !== 'all' && _id !== 'here' && !mentionGroupNames.includes(_id)); + const mentionIdsToGroups = mentionIds.filter((_id) => { + const group = mentionGroups.find((group) => group.name === _id); + // no channels on the list -> group works for all of them + return group && (group.channels.length === 0 || group.channels.includes(room._id)); + }); + const mentionedGroups = mentionGroups.filter((group) => mentionIdsToGroups.includes(group.name)); + const hasMentionToAll = mentionIds.includes('all') || mentionedGroups.some((group) => group.mentionsOffline); + const hasMentionToHere = mentionIds.includes('here') || mentionedGroups.length > 0; + const usersInGroups = _.flatten( // TODO: replace with flatMap on Node >= 11.0 + mentionGroups.filter((group) => + mentionIdsToGroups.includes(group.name) + || group.groups.some((name) => mentionIdsToGroups.includes(name)), + ).map((group) => group.users), + ); let notificationMessage = callbacks.run('beforeSendMessageNotifications', message.msg); if (mentionIds.length > 0 && settings.get('UI_Use_Real_Name')) { @@ -214,6 +229,7 @@ export async function sendMessageNotifications(message, room, usersInThread = [] $or: [ { 'userHighlights.0': { $exists: 1 } }, ...usersInThread.length > 0 ? [{ 'u._id': { $in: usersInThread } }] : [], + ...usersInGroups.length > 0 ? [{ 'u._id': { $in: usersInGroups } }] : [], ], }; @@ -271,7 +287,7 @@ export async function sendMessageNotifications(message, room, usersInThread = [] message, notificationMessage, room, - mentionIds, + mentionIds: [...mentionIds, ...usersInGroups], disableAllMessageNotifications, hasReplyToThread: usersInThread && usersInThread.includes(subscription.u._id), })); @@ -304,6 +320,7 @@ export async function sendAllNotifications(message, room) { return message; } + const mentionGroups = MentionGroups.find({}).fetch(); const { sender, hasMentionToAll, @@ -311,7 +328,7 @@ export async function sendAllNotifications(message, room) { notificationMessage, mentionIds, mentionIdsWithoutGroups, - } = await sendMessageNotifications(message, room); + } = await sendMessageNotifications(message, room, undefined, mentionGroups); // on public channels, if a mentioned user is not member of the channel yet, he will first join the channel and then be notified based on his preferences. if (room.t === 'c') { @@ -325,7 +342,10 @@ export async function sendAllNotifications(message, room) { }); Promise.all(mentions - .map(async (userId) => { + .filter((mention) => { + const group = mentionGroups.find((g) => g.name === mention); + return !group || group.mentionsOutside; // if group is undefined then this is a regular mention + }).map(async (userId) => { await callJoinRoom(userId, room._id); return userId; diff --git a/app/mentions/client/admin/route.js b/app/mentions/client/admin/route.js new file mode 100644 index 000000000000..dba440e2c1dd --- /dev/null +++ b/app/mentions/client/admin/route.js @@ -0,0 +1,26 @@ +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; + +import { t } from '../../../utils'; + +FlowRouter.route('/admin/mention-groups', { + name: 'admin-mention-groups', + action() { + return BlazeLayout.render('main', { + center: 'mentionGroups', + pageTitle: t('Mentions_Groups'), + }); + }, +}); + +FlowRouter.route('/admin/mention-group/:id?', { + name: 'admin-mention-group', + action(params) { + return BlazeLayout.render('main', { + center: 'pageSettingsContainer', + pageTitle: t('Mentions_GroupConfiguration'), + pageTemplate: 'mentionGroupSettings', + params, + }); + }, +}); diff --git a/app/mentions/client/admin/startup.js b/app/mentions/client/admin/startup.js new file mode 100644 index 000000000000..1684afe5b7aa --- /dev/null +++ b/app/mentions/client/admin/startup.js @@ -0,0 +1,11 @@ +import { AdminBox } from '../../../ui-utils'; +import { hasAllPermission } from '../../../authorization'; + +AdminBox.addOption({ + href: 'admin-mention-groups', + i18nLabel: 'Mentions_Groups', + icon: 'discover', + permissionGranted() { + return hasAllPermission('manage-mention-groups'); + }, +}); diff --git a/app/mentions/client/admin/views/mentionGroupSettings.css b/app/mentions/client/admin/views/mentionGroupSettings.css new file mode 100644 index 000000000000..d8cbab820b67 --- /dev/null +++ b/app/mentions/client/admin/views/mentionGroupSettings.css @@ -0,0 +1,23 @@ +.users-table { + margin-top: 8px; +} + +.users-table thead { + border-bottom: 1px solid rgba(216, 216, 216, 0.4); +} + +.users-table th { + padding-bottom: 8px; + + text-align: left; + + color: var(--rc-color-primary-light); + + font-size: 0.75rem; + font-weight: 500; + line-height: 1rem; +} + +.users-table tbody tr:first-child td { + padding-top: 8px; +} diff --git a/app/mentions/client/admin/views/mentionGroupSettings.html b/app/mentions/client/admin/views/mentionGroupSettings.html new file mode 100644 index 000000000000..e4187235303d --- /dev/null +++ b/app/mentions/client/admin/views/mentionGroupSettings.html @@ -0,0 +1,114 @@ + \ No newline at end of file diff --git a/app/mentions/client/admin/views/mentionGroupSettings.js b/app/mentions/client/admin/views/mentionGroupSettings.js new file mode 100644 index 000000000000..a4b538d174a7 --- /dev/null +++ b/app/mentions/client/admin/views/mentionGroupSettings.js @@ -0,0 +1,163 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { Tracker } from 'meteor/tracker'; +import { Session } from 'meteor/session'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; + +import { hasAllPermission } from '../../../../authorization'; +import { MentionGroups, Rooms, Roles, Users } from '../../../../models'; +import { SideNav, modal } from '../../../../ui-utils/client'; +import { t } from '../../../../utils/client'; + +import './mentionGroupSettings.css'; + +const NEW_GROUP = { + name: '', + description: '', + userCanJoin: false, + mentionsOffline: false, + mentionsOutside: false, + channels: [], + users: [], + roles: [], + groups: [], +}; + +function reactiveToObject(obj) { + console.log(obj); + return { + name: obj.name.get(), + description: obj.description.get(), + userCanJoin: obj.userCanJoin.get(), + mentionsOffline: obj.mentionsOffline.get(), + mentionsOutside: obj.mentionsOutside.get(), + users: obj.users.get().map((user) => user._id), + channels: obj.channels.get().map((channel) => channel._id), + roles: obj.roles.get().map((role) => role._id), + groups: obj.groups.get().map((group) => group._id), + }; +} + +Template.mentionGroupSettings.helpers({ + isEditing() { + return Template.instance().isEditing(); + }, + group() { + const { group } = Template.instance(); + return group; + }, + hasPermission() { + return hasAllPermission('manage-mention-groups'); + }, + modified(text = '') { + const selected = Session.get('adminMentionGroupSelected'); + const group = selected ? MentionGroups.findOne(selected.gid) : NEW_GROUP; + const { group: newGroup, selectedUsers } = Template.instance(); + const groupModified = Object.keys(group) + .filter((key) => !key.startsWith('_') && !Array.isArray(group[key])) + .some((key) => group[key] !== newGroup[key].get()); + const usersAdded = selectedUsers.get().length > 0; + return !groupModified && !usersAdded ? text : ''; + }, + onUsersChanged() { + const instance = Template.instance(); + return (users) => instance.group.users.set(users.get()); + }, + onRolesChanged() { + const instance = Template.instance(); + return (roles) => instance.group.roles.set(roles.get()); + }, + onGroupsChanged() { + const instance = Template.instance(); + return (groups) => instance.group.groups.set(groups.get()); + }, + onChannelsChanged() { + const instance = Template.instance(); + return (channels) => instance.group.channels.set(channels.get()); + }, +}); + +Template.mentionGroupSettings.onCreated(function() { + const params = this.data.params(); + const group = !params.id ? NEW_GROUP : MentionGroups.findOne({ name: params.id }); + if (!group && params.id) { + return FlowRouter.go('/admin/mention-groups'); + } + const channels = Rooms.find({ _id: { $in: group.channels } }).fetch(); + const roles = Roles.find({ _id: { $in: group.roles } }).fetch(); + const groups = MentionGroups.find({ _id: { $in: group.groups } }).fetch(); + const users = Users.find({ _id: { $in: group.users } }).fetch(); + this.group = { + _id: group._id, + name: new ReactiveVar(group.name), + description: new ReactiveVar(group.description), + userCanJoin: new ReactiveVar(group.userCanJoin), + mentionsOffline: new ReactiveVar(group.mentionsOffline), + mentionsOutside: new ReactiveVar(group.mentionsOutside), + channels: new ReactiveVar(channels), + roles: new ReactiveVar(roles), + groups: new ReactiveVar(groups), + users: new ReactiveVar(users), + }; + console.log(params); + this.isEditing = () => !!params.id; +}); + +Template.mentionGroupSettings.onDestroyed(function() { + Session.set('adminMentionGroupSelected', undefined); +}); + +Template.mentionGroupSettings.onRendered(() => { + Tracker.afterFlush(() => { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); + +Template.mentionGroupSettings.events({ + 'click .js-delete'(event, instance) { + modal.open({ + title: t('Mentions_DeleteConfirmation'), + text: t('Mentions_DeleteConfirmationDescription'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#f5455c', + confirmButtonText: t('Delete'), + cancelButtonText: t('Cancel'), + closeOnConfirm: true, + html: false, + }, (confirmed) => { + if (confirmed) { + Meteor.call('deleteMentionGroup', instance.group._id); + FlowRouter.go('/admin/mention-groups'); + } + }); + }, + 'click .js-cancel'(event, instance) { + console.log(instance); + // instance.data.tabBar.close(); + }, + 'click .js-reset'(event, instance) { + instance.group.name.set(''); + instance.group.userCanJoin.set(false); + instance.group.mentionsOffline.set(false); + }, + 'click .js-create'(event, instance) { + Meteor.call('addMentionGroup', reactiveToObject(instance.group)); + FlowRouter.go('/admin/mention-groups'); + }, + 'click .js-save'(event, instance) { + Meteor.call('updateMentionGroup', reactiveToObject(instance.group), instance.group._id); + FlowRouter.go('/admin/mention-groups'); + }, + 'input input[type="text"]'(event, instance) { + if (!instance.group[event.target.name]) { + return; + } + instance.group[event.target.name].set(event.target.value); + }, + 'change input[type="checkbox"]'(event, instance) { + instance.group[event.target.name].set(event.target.checked); + }, +}); diff --git a/app/mentions/client/admin/views/mentionGroups.html b/app/mentions/client/admin/views/mentionGroups.html new file mode 100644 index 000000000000..00d33e18cdc7 --- /dev/null +++ b/app/mentions/client/admin/views/mentionGroups.html @@ -0,0 +1,72 @@ + diff --git a/app/mentions/client/admin/views/mentionGroups.js b/app/mentions/client/admin/views/mentionGroups.js new file mode 100644 index 000000000000..45023dbb703f --- /dev/null +++ b/app/mentions/client/admin/views/mentionGroups.js @@ -0,0 +1,124 @@ +import { Tracker } from 'meteor/tracker'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Template } from 'meteor/templating'; +import s from 'underscore.string'; + +import { SideNav, RocketChatTabBar, TabBar } from '../../../../ui-utils'; +import { MentionGroups } from '../../../../models'; +import { t } from '../../../../utils'; + +Template.mentionGroups.helpers({ + fieldCount(group, field) { + return group[field] ? group[field].length || '' : ''; + }, + searchText() { + const instance = Template.instance(); + return instance.filter && instance.filter.get(); + }, + isReady() { + const instance = Template.instance(); + return instance.ready && instance.ready.get(); + }, + groups() { + return Template.instance().groups(); + }, + isLoading() { + const instance = Template.instance(); + if (!(instance.ready && instance.ready.get())) { + return 'btn-loading'; + } + }, + hasMore() { + const instance = Template.instance(); + if (instance.limit && instance.limit.get() && instance.rooms() && instance.rooms().count()) { + return instance.limit.get() === instance.rooms().count(); + } + }, + groupCount() { + const groups = Template.instance().groups(); + return groups && groups.count(); + }, + type() { + return ''; + }, + 'default'() { + if (this.default) { + return t('True'); + } + return t('False'); + }, + flexData() { + return { + tabBar: Template.instance().tabBar, + }; + }, + onTableScroll() { + const instance = Template.instance(); + return function(currentTarget) { + if ( + currentTarget.offsetHeight + currentTarget.scrollTop + >= currentTarget.scrollHeight - 100 + ) { + return instance.limit.set(instance.limit.get() + 50); + } + }; + }, +}); + +Template.mentionGroups.onCreated(function() { + const instance = this; + this.limit = new ReactiveVar(50); + this.filter = new ReactiveVar(''); + this.types = new ReactiveVar([]); + this.ready = new ReactiveVar(true); + this.tabBar = new RocketChatTabBar(); + this.tabBar.showGroup(FlowRouter.current().route.name); + TabBar.addButton({ + groups: ['admin-mention-groups'], + id: 'admin-mention-group-add', + i18nTitle: 'Mentions_SettingsAdd', + icon: 'plus', + template: 'mentionGroupSettings', + order: 1, + }); + TabBar.addButton({ + groups: ['admin-mention-groups'], + id: 'admin-mention-group', + i18nTitle: 'Mentions_Settings', + icon: 'customize', + template: 'mentionGroupSettings', + order: 1, + }); + this.autorun(function() { + const filter = instance.filter.get(); + const limit = instance.limit.get(); + const subscription = instance.subscribe('mentionGroups', filter, limit); + instance.ready.set(subscription.ready()); + }); + this.groups = function() { + let filter; + if (instance.filter && instance.filter.get()) { + filter = s.trim(instance.filter.get()); + } + let query = {}; + if (filter) { + const filterReg = new RegExp(s.escapeRegExp(filter), 'i'); + query = { name: filterReg }; + } + + const limit = instance.limit && instance.limit.get(); + return MentionGroups.find(query, { limit, sort: { default: -1, name: 1 } }); + }; +}); + +Template.mentionGroups.onRendered(function() { + Tracker.afterFlush(function() { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }); +}); + +Template.mentionGroups.events({ + +}); diff --git a/app/mentions/client/index.js b/app/mentions/client/index.js index c9353c892c3c..9de1e4cbdafe 100644 --- a/app/mentions/client/index.js +++ b/app/mentions/client/index.js @@ -1,2 +1,8 @@ import './client'; import './mentionLink.css'; +import './admin/startup'; +import './admin/route'; +import './admin/views/mentionGroupSettings.html'; +import './admin/views/mentionGroupSettings'; +import './admin/views/mentionGroups.html'; +import './admin/views/mentionGroups'; diff --git a/app/mentions/lib/MentionsParser.js b/app/mentions/lib/MentionsParser.js index 6fd1a6c6dc88..8d2c5ef9ab8d 100644 --- a/app/mentions/lib/MentionsParser.js +++ b/app/mentions/lib/MentionsParser.js @@ -42,6 +42,7 @@ export class MentionsParser { replaceUsers = (msg, { mentions, temp }, me) => msg .replace(this.userMentionRegex, (match, prefix, mention) => { const classNames = ['mention-link']; + const isMentionGroup = mentions.find((m) => m._id === mention && m._id === m.username); if (mention === 'all') { classNames.push('mention-link--all'); @@ -49,6 +50,9 @@ export class MentionsParser { } else if (mention === 'here') { classNames.push('mention-link--here'); classNames.push('mention-link--group'); + } else if (isMentionGroup) { + classNames.push(`mention-link--${ mention }`); + classNames.push('mention-link--group'); } else if (mention === me) { classNames.push('mention-link--me'); classNames.push('mention-link--user'); @@ -58,7 +62,7 @@ export class MentionsParser { const className = classNames.join(' '); - if (mention === 'all' || mention === 'here') { + if (mention === 'all' || mention === 'here' || isMentionGroup) { return `${ prefix }${ mention }`; } diff --git a/app/mentions/server/Mentions.js b/app/mentions/server/Mentions.js index e604af6fa9bb..1fabef882499 100644 --- a/app/mentions/server/Mentions.js +++ b/app/mentions/server/Mentions.js @@ -14,6 +14,7 @@ export default class MentionsServer extends MentionsParser { this.getUser = args.getUser; this.getTotalChannelMembers = args.getTotalChannelMembers; this.onMaxRoomMembersExceeded = args.onMaxRoomMembersExceeded || (() => {}); + this.getMentionGroups = args.getMentionGroups; } set getUsers(m) { @@ -50,17 +51,24 @@ export default class MentionsServer extends MentionsParser { getUsersByMentions({ msg, rid, u: sender }) { let mentions = this.getUserMentions(msg); + const mentionGroups = this.getMentionGroups(); + const mentionGroupNames = mentionGroups.map((group) => group.name); + const mentionsAll = []; const userMentions = []; mentions.forEach((m) => { const mention = m.trim().substr(1); - if (mention !== 'all' && mention !== 'here') { + const isGroupMention = mentionGroupNames.includes(mention); + if (mention !== 'all' && mention !== 'here' && !isGroupMention) { return userMentions.push(mention); } if (this.messageMaxAll > 0 && this.getTotalChannelMembers(rid) > this.messageMaxAll) { return this.onMaxRoomMembersExceeded({ sender, rid }); } + if (mention !== 'all' && mention !== 'here' && !isGroupMention) { + return; + } mentionsAll.push({ _id: mention, username: mention, diff --git a/app/mentions/server/admin/methods/addMentionGroup.js b/app/mentions/server/admin/methods/addMentionGroup.js new file mode 100644 index 000000000000..4373e515c028 --- /dev/null +++ b/app/mentions/server/admin/methods/addMentionGroup.js @@ -0,0 +1,17 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../../authorization'; +import { Users, MentionGroups } from '../../../../models'; + +Meteor.methods({ + addMentionGroup(group) { + if (!hasPermission(this.userId, 'manage-mention-groups')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'addMentionGroup' }); + } + group.name = group.name.trim(); + group._createdAt = new Date(); + group._createdBy = Users.findOne(this.userId, { fields: { username: 1 } }); + group._id = MentionGroups.insert(group); + return group; + }, +}); diff --git a/app/mentions/server/admin/methods/deleteMentionGroup.js b/app/mentions/server/admin/methods/deleteMentionGroup.js new file mode 100644 index 000000000000..1e4e4e311817 --- /dev/null +++ b/app/mentions/server/admin/methods/deleteMentionGroup.js @@ -0,0 +1,18 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../../authorization'; +import { MentionGroups } from '../../../../models'; + +Meteor.methods({ + deleteMentionGroup(groupId) { + if (!hasPermission(this.userId, 'manage-mention-groups')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'deleteMentionGroup' }); + } + const group = MentionGroups.findOne(groupId); + if (group == null) { + throw new Meteor.Error('error-not-found', 'Group not found', { method: 'deleteMentionGroup' }); + } + MentionGroups.remove({ _id: groupId }); + return true; + }, +}); diff --git a/app/mentions/server/admin/methods/updateMentionGroup.js b/app/mentions/server/admin/methods/updateMentionGroup.js new file mode 100644 index 000000000000..ae4277a230a5 --- /dev/null +++ b/app/mentions/server/admin/methods/updateMentionGroup.js @@ -0,0 +1,44 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; + +import { hasPermission } from '../../../../authorization'; +import { MentionGroups, Users } from '../../../../models'; + +Meteor.methods({ + updateMentionGroup(group, id) { + if (!hasPermission(this.userId, 'manage-mention-groups')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'updateMentionGroup' }); + } + if (!_.isString(group.name) || group.name.trim() === '') { + throw new Meteor.Error('error-invalid-name', 'Invalid name', { method: 'updateMentionGroup' }); + } + if (!_.isBoolean(group.userCanJoin) || !_.isBoolean(group.mentionsOffline)) { + throw new Meteor.Error('error-invalid-arguments', 'Invalid arguments', { method: 'updateMentionGroup' }); + } + const currentGroup = MentionGroups.findOne(id); + if (currentGroup == null) { + throw new Meteor.Error('error-not-found', 'Group not found', { method: 'updateMentionGroup' }); + } + + MentionGroups.update(id, { + $set: { + name: group.name.trim(), + userCanJoin: group.userCanJoin, + mentionsOffline: group.mentionsOffline, + mentionsOutside: group.mentionsOutside, + users: group.users, + groups: group.groups, + channels: group.channels, + roles: group.roles, + description: group.description, + _updatedAt: new Date(), + _updatedBy: Users.findOne(this.userId, { + fields: { + username: 1, + }, + }), + }, + }); + return MentionGroups.findOne(id); + }, +}); diff --git a/app/mentions/server/admin/publications/mentionGroups.js b/app/mentions/server/admin/publications/mentionGroups.js new file mode 100644 index 000000000000..4cdbe371450f --- /dev/null +++ b/app/mentions/server/admin/publications/mentionGroups.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../../authorization'; +import { MentionGroups } from '../../../../models'; + +Meteor.publish('mentionGroups', function(filter, limit) { + if (!this.userId) { + return this.ready(); + } + if (!hasPermission(this.userId, 'manage-mention-groups')) { + this.error(Meteor.Error('error-not-allowed', 'Not allowed', { publish: 'mentionGroups' })); + } + + let query = {}; + if (filter) { + const filterReg = new RegExp(s.escapeRegExp(filter), 'i'); + query = { name: filterReg }; + } + + return MentionGroups.find(query, { limit, sort: { default: -1, name: 1 } }); +}); diff --git a/app/mentions/server/admin/publications/mentionGroupsAutocomplete.js b/app/mentions/server/admin/publications/mentionGroupsAutocomplete.js new file mode 100644 index 000000000000..75bac759a70a --- /dev/null +++ b/app/mentions/server/admin/publications/mentionGroupsAutocomplete.js @@ -0,0 +1,61 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../../authorization'; +import { MentionGroups } from '../../../../models'; + +Meteor.publish('mentionGroupsAutocomplete', function(selector) { + const uid = this.userId; + if (!uid) { + return this.ready(); + } + + if (!_.isObject(selector)) { + return this.ready(); + } + + if (!hasPermission(uid, 'view-outside-room')) { + return this.ready(); + } + + const options = { + fields: { + id: 1, + name: 1, + }, + sort: { + name: 1, + }, + limit: 10, + }; + + const pub = this; + const exceptions = selector.exceptions || []; + const conditions = selector.conditions || {}; + const termRegex = new RegExp(s.escapeRegExp(selector.term), 'i'); + + const cursorHandle = MentionGroups.find({ + name: termRegex, + _id: { + $nin: exceptions, + }, + ...conditions, + }, options).observeChanges({ + added(_id, record) { + return pub.added('autocompleteRecords', _id, record); + }, + changed(_id, record) { + return pub.changed('autocompleteRecords', _id, record); + }, + removed(_id, record) { + return pub.removed('autocompleteRecords', _id, record); + }, + }); + + this.ready(); + + this.onStop(function() { + return cursorHandle.stop(); + }); +}); diff --git a/app/mentions/server/admin/publications/rolesAutocomplete.js b/app/mentions/server/admin/publications/rolesAutocomplete.js new file mode 100644 index 000000000000..8e5acb50cbe0 --- /dev/null +++ b/app/mentions/server/admin/publications/rolesAutocomplete.js @@ -0,0 +1,61 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../../authorization'; +import { Roles } from '../../../../models'; + +Meteor.publish('rolesAutocomplete', function(selector) { + const uid = this.userId; + if (!uid) { + return this.ready(); + } + + if (!_.isObject(selector)) { + return this.ready(); + } + + if (!hasPermission(uid, 'view-outside-room')) { + return this.ready(); + } + + const options = { + fields: { + id: 1, + name: 1, + }, + sort: { + name: 1, + }, + limit: 10, + }; + + const pub = this; + const exceptions = selector.exceptions || []; + const conditions = selector.conditions || {}; + const termRegex = new RegExp(s.escapeRegExp(selector.term), 'i'); + + const cursorHandle = Roles.find({ + name: termRegex, + _id: { + $nin: exceptions, + }, + ...conditions, + }, options).observeChanges({ + added(_id, record) { + return pub.added('autocompleteRecords', _id, record); + }, + changed(_id, record) { + return pub.changed('autocompleteRecords', _id, record); + }, + removed(_id, record) { + return pub.removed('autocompleteRecords', _id, record); + }, + }); + + this.ready(); + + this.onStop(function() { + return cursorHandle.stop(); + }); +}); diff --git a/app/mentions/server/admin/publications/roomsAutocomplete.js b/app/mentions/server/admin/publications/roomsAutocomplete.js new file mode 100644 index 000000000000..f345ac287059 --- /dev/null +++ b/app/mentions/server/admin/publications/roomsAutocomplete.js @@ -0,0 +1,61 @@ +import { Meteor } from 'meteor/meteor'; +import _ from 'underscore'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../../authorization'; +import { Rooms } from '../../../../models'; + +Meteor.publish('roomsAutocomplete', function(selector) { + const uid = this.userId; + if (!uid) { + return this.ready(); + } + + if (!_.isObject(selector)) { + return this.ready(); + } + + if (!hasPermission(uid, 'view-outside-room')) { + return this.ready(); + } + + const options = { + fields: { + id: 1, + name: 1, + }, + sort: { + name: 1, + }, + limit: 10, + }; + + const pub = this; + const exceptions = selector.exceptions || []; + const conditions = selector.conditions || {}; + const termRegex = new RegExp(s.escapeRegExp(selector.term), 'i'); + + const cursorHandle = Rooms.find({ + name: termRegex, + _id: { + $nin: exceptions, + }, + ...conditions, + }, options).observeChanges({ + added(_id, record) { + return pub.added('autocompleteRecords', _id, record); + }, + changed(_id, record) { + return pub.changed('autocompleteRecords', _id, record); + }, + removed(_id, record) { + return pub.removed('autocompleteRecords', _id, record); + }, + }); + + this.ready(); + + this.onStop(function() { + return cursorHandle.stop(); + }); +}); diff --git a/app/mentions/server/index.js b/app/mentions/server/index.js index 474d41a439e1..efbb88664b3c 100644 --- a/app/mentions/server/index.js +++ b/app/mentions/server/index.js @@ -1,2 +1,9 @@ import './server'; import './methods/getUserMentionsByChannel'; +import './admin/publications/mentionGroups'; +import './admin/publications/mentionGroupsAutocomplete'; +import './admin/publications/roomsAutocomplete'; +import './admin/publications/rolesAutocomplete'; +import './admin/methods/addMentionGroup'; +import './admin/methods/deleteMentionGroup'; +import './admin/methods/updateMentionGroup'; diff --git a/app/mentions/server/server.js b/app/mentions/server/server.js index 9c515e4ecdec..fda1ae835cca 100644 --- a/app/mentions/server/server.js +++ b/app/mentions/server/server.js @@ -7,9 +7,10 @@ import MentionsServer from './Mentions'; import { settings } from '../../settings'; import { callbacks } from '../../callbacks'; import { Notifications } from '../../notifications'; -import { Users, Subscriptions, Rooms } from '../../models'; +import { Users, Subscriptions, Rooms, MentionGroups } from '../../models'; const mention = new MentionsServer({ + getMentionGroups: () => MentionGroups.find({}).fetch(), pattern: () => settings.get('UTF8_Names_Validation'), messageMaxAll: () => settings.get('Message_MaxAll'), getUsers: (usernames) => Meteor.users.find({ username: { $in: _.unique(usernames) } }, { fields: { _id: true, username: true, name: 1 } }).fetch(), diff --git a/app/mentions/tests/server.tests.js b/app/mentions/tests/server.tests.js index 30bc07564984..37a61cdf0b3b 100644 --- a/app/mentions/tests/server.tests.js +++ b/app/mentions/tests/server.tests.js @@ -28,6 +28,13 @@ beforeEach(function() { }, getUser: (userId) => ({ _id: userId, language: 'en' }), getTotalChannelMembers: (/* rid*/) => 2, + getMentionGroups: () => [{ + name: 'test', + users: [1], + groups: [], + roles: [], + channels: [], + }], }); }); @@ -153,6 +160,20 @@ describe('Mention Server', () => { assert.deepEqual(expected, result); }); }); + + describe('for mention group', () => { + it('should return "test"', () => { + const message = { + msg: '@test', + }; + const expected = [{ + _id: 'test', + username: 'test', + }]; + const result = mention.getUsersByMentions(message); + assert.deepEqual(expected, result); + }); + }); }); describe('getChannelbyMentions', () => { it('should return the channel "general"', () => { diff --git a/app/models/client/index.js b/app/models/client/index.js index fbcbee481f5c..691e6cefef97 100644 --- a/app/models/client/index.js +++ b/app/models/client/index.js @@ -20,6 +20,7 @@ import { UserAndRoom } from './models/UserAndRoom'; import { UserRoles } from './models/UserRoles'; import { AuthzCachedCollection, ChatPermissions } from './models/ChatPermissions'; import { WebdavAccounts } from './models/WebdavAccounts'; +import { MentionGroups } from './models/MentionGroups'; import CustomSounds from './models/CustomSounds'; import EmojiCustom from './models/EmojiCustom'; @@ -53,4 +54,5 @@ export { CustomSounds, EmojiCustom, WebdavAccounts, + MentionGroups, }; diff --git a/app/models/client/models/MentionGroups.js b/app/models/client/models/MentionGroups.js new file mode 100644 index 000000000000..b8ad5584589a --- /dev/null +++ b/app/models/client/models/MentionGroups.js @@ -0,0 +1,3 @@ +import { Mongo } from 'meteor/mongo'; + +export const MentionGroups = new Mongo.Collection('rocketchat_mention_groups'); diff --git a/app/models/server/index.js b/app/models/server/index.js index f7bbb89c97a8..6c1d45e8992e 100644 --- a/app/models/server/index.js +++ b/app/models/server/index.js @@ -38,6 +38,7 @@ import LivechatAgentActivity from './models/LivechatAgentActivity'; import LivechatInquiry from './models/LivechatInquiry'; import ReadReceipts from './models/ReadReceipts'; import LivechatExternalMessage from './models/LivechatExternalMessages'; +import MentionGroups from './models/MentionGroups'; export { AppsLogsModel } from './models/apps-logs-model'; export { AppsPersistenceModel } from './models/apps-persistence-model'; @@ -88,4 +89,5 @@ export { ReadReceipts, LivechatExternalMessage, LivechatInquiry, + MentionGroups, }; diff --git a/app/models/server/models/MentionGroups.js b/app/models/server/models/MentionGroups.js new file mode 100644 index 000000000000..460e2588aaf0 --- /dev/null +++ b/app/models/server/models/MentionGroups.js @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +export class MentionGroups extends Base { + constructor() { + super('mention_groups'); + this.tryEnsureIndex({ name: 1 }); + } +} + +export default new MentionGroups(); diff --git a/app/models/server/raw/MentionGroups.js b/app/models/server/raw/MentionGroups.js new file mode 100644 index 000000000000..9ad7b39cdfa6 --- /dev/null +++ b/app/models/server/raw/MentionGroups.js @@ -0,0 +1,18 @@ +import s from 'underscore.string'; + +import { BaseRaw } from './BaseRaw'; + +export class MentionGroupsRaw extends BaseRaw { + findByNameContaining(name, options, exceptions = []) { + const nameRegex = new RegExp(`^${ s.escapeRegExp(name).trim() }`, 'i'); + + const query = { + name: nameRegex, + _id: { + $nin: exceptions, + }, + }; + + return this.find(query, options); + } +} diff --git a/app/models/server/raw/Roles.js b/app/models/server/raw/Roles.js index bda23eea6b55..0aea9b1cf7a3 100644 --- a/app/models/server/raw/Roles.js +++ b/app/models/server/raw/Roles.js @@ -1,3 +1,5 @@ +import s from 'underscore.string'; + import { BaseRaw } from './BaseRaw'; import * as Models from './index'; @@ -22,4 +24,17 @@ export class RolesRaw extends BaseRaw { } return false; } + + findByNameContaining(name, options, exceptions = []) { + const nameRegex = new RegExp(`^${ s.escapeRegExp(name).trim() }`, 'i'); + + const query = { + name: nameRegex, + _id: { + $nin: exceptions, + }, + }; + + return this.find(query, options); + } } diff --git a/app/models/server/raw/Rooms.js b/app/models/server/raw/Rooms.js index dc9ab5445adf..e55311d007f1 100644 --- a/app/models/server/raw/Rooms.js +++ b/app/models/server/raw/Rooms.js @@ -77,14 +77,15 @@ export class RoomsRaw extends BaseRaw { return this.find(query, options); } - findChannelAndPrivateByNameStarting(name, options) { + findChannelAndOrPrivateByNameStarting(name, options, withPrivate = true, exceptions = []) { const nameRegex = new RegExp(`^${ s.escapeRegExp(name).trim() }`, 'i'); const query = { - t: { - $in: ['c', 'p'], - }, + t: withPrivate ? { $in: ['c', 'p'] } : 'c', name: nameRegex, + _id: { + $nin: exceptions, + }, }; return this.find(query, options); diff --git a/app/models/server/raw/Users.js b/app/models/server/raw/Users.js index 1dad38cb27b1..d0353fe4aa17 100644 --- a/app/models/server/raw/Users.js +++ b/app/models/server/raw/Users.js @@ -287,6 +287,10 @@ export class UsersRaw extends BaseRaw { username: { $nin: exceptions, }, + }, { + _id: { + $nin: exceptions, + }, }], ...conditions, }; diff --git a/app/models/server/raw/index.js b/app/models/server/raw/index.js index 042900293a67..057c349bd064 100644 --- a/app/models/server/raw/index.js +++ b/app/models/server/raw/index.js @@ -46,6 +46,8 @@ import LivechatAgentActivityModel from '../models/LivechatAgentActivity'; import { LivechatAgentActivityRaw } from './LivechatAgentActivity'; import StatisticsModel from '../models/Statistics'; import { StatisticsRaw } from './Statistics'; +import MentionGroupsModel from '../models/MentionGroups'; +import { MentionGroupsRaw } from './MentionGroups'; export const Permissions = new PermissionsRaw(PermissionsModel.model.rawCollection()); export const Roles = new RolesRaw(RolesModel.model.rawCollection()); @@ -71,3 +73,4 @@ export const CustomSounds = new CustomSoundsRaw(CustomSoundsModel.model.rawColle export const CustomUserStatus = new CustomUserStatusRaw(CustomUserStatusModel.model.rawCollection()); export const LivechatAgentActivity = new LivechatAgentActivityRaw(LivechatAgentActivityModel.model.rawCollection()); export const Statistics = new StatisticsRaw(StatisticsModel.model.rawCollection()); +export const MentionGroups = new MentionGroupsRaw(MentionGroupsModel.model.rawCollection()); diff --git a/app/notifications/client/lib/Notifications.js b/app/notifications/client/lib/Notifications.js index feb3bfd46806..a3f158005e4f 100644 --- a/app/notifications/client/lib/Notifications.js +++ b/app/notifications/client/lib/Notifications.js @@ -11,7 +11,7 @@ class Notifications { } this.logged = Meteor.userId() !== null; }); - this.debug = false; + this.debug = true; this.streamAll = new Meteor.Streamer('notify-all'); this.streamLogged = new Meteor.Streamer('notify-logged'); this.streamRoom = new Meteor.Streamer('notify-room'); diff --git a/app/ui-message/client/popup/messagePopupConfig.js b/app/ui-message/client/popup/messagePopupConfig.js index a99b3e108385..1b874a72b28f 100644 --- a/app/ui-message/client/popup/messagePopupConfig.js +++ b/app/ui-message/client/popup/messagePopupConfig.js @@ -56,6 +56,32 @@ const reloadUsersFromRoomMessages = (rid, template) => { })); }; +const fetchMentionGroupsFromServer = _.throttle(async (filterText, records, rid, cb) => { + const { groups } = await call('spotlight', filterText, [], { groups: true }, rid); + + if (!groups || groups.length <= 0) { + return; + } + + groups + .slice(0, 5) + .forEach(({ name, description }) => { + if (records.length < 5) { + records.push({ + _id: name, + username: name, + name: description, + system: true, + sort: 5, + }); + } + }); + + records.sort(({ sort: sortA }, { sort: sortB }) => sortA - sortB); + + cb && cb(records); +}, 1000); + const fetchUsersFromServer = _.throttle(async (filterText, records, rid, cb) => { const usernames = records.map(({ username }) => username); @@ -343,6 +369,10 @@ Template.messagePopupConfig.helpers({ }); } + if (filterRegex) { + fetchMentionGroupsFromServer(filterText, items, rid, cb); + } + return items; }, getValue: (_id) => _id, diff --git a/app/ui/client/components/userAutocomplete.css b/app/ui/client/components/userAutocomplete.css new file mode 100644 index 000000000000..a173be8cc602 --- /dev/null +++ b/app/ui/client/components/userAutocomplete.css @@ -0,0 +1,14 @@ +.autocomplete .rc-popup-list__list { + /* top: 0 !important; */ + padding: 0; +} + +.autocomplete-list__item, +.autocomplete-list__item--empty { + margin-bottom: 0 !important; + padding: 10px; +} + +.autocomplete-list__item:hover { + background: rgba(0, 0, 0, 0.1); +} diff --git a/app/ui/client/components/userAutocomplete.html b/app/ui/client/components/userAutocomplete.html new file mode 100644 index 000000000000..a5d934e42349 --- /dev/null +++ b/app/ui/client/components/userAutocomplete.html @@ -0,0 +1,37 @@ + + + + + + + \ No newline at end of file diff --git a/app/ui/client/components/userAutocomplete.js b/app/ui/client/components/userAutocomplete.js new file mode 100644 index 000000000000..8184a9246a5d --- /dev/null +++ b/app/ui/client/components/userAutocomplete.js @@ -0,0 +1,167 @@ +import { ReactiveVar } from 'meteor/reactive-var'; +import { Blaze } from 'meteor/blaze'; +import { Template } from 'meteor/templating'; +import { Deps } from 'meteor/deps'; + +import { AutoComplete } from '../../../meteor-autocomplete/client'; +import { settings } from '../../../settings'; +import { t } from '../../../utils'; +import './userAutocomplete.html'; +import './userAutocomplete.css'; + +const acEvents = { + 'click .autocomplete-list__item'(e, t) { + t.ac.onItemClick(this, e); + if (t.data.onUsersChanged) { + t.data.onUsersChanged(t.selectedUsers); + } + }, + 'keydown [name="users"]'(e, t) { + if ([8, 46].includes(e.keyCode) && e.target.value === '') { + const users = t.selectedUsers; + const usersArr = users.get(); + usersArr.pop(); + return users.set(usersArr); + } + + t.ac.onKeyDown(e); + }, + 'keyup [name="users"]'(e, t) { + t.ac.onKeyUp(e); + }, + 'focus [name="users"]'(e, t) { + t.ac.onFocus(e); + }, + 'blur [name="users"]'(e, t) { + t.ac.onBlur(e); + }, +}; + +const filterNames = (old) => { + if (settings.get('UI_Allow_room_names_with_special_chars')) { + return old; + } + + const reg = new RegExp(`^${ settings.get('UTF8_Names_Validation') }$`); + return [...old.replace(' ', '').toLocaleLowerCase()].filter((f) => reg.test(f)).join(''); +}; + +Template.userAutocomplete.helpers({ + field(obj) { + return obj[Template.instance().data.field]; + }, + disabled() { + return Template.instance().selectedUsers.get().length === 0; + }, + tAddUsers() { + return t('Add_users'); + }, + autocomplete(key) { + const instance = Template.instance(); + const param = instance.ac[key]; + return typeof param === 'function' ? param.apply(instance.ac) : param; + }, + items() { + return Template.instance().ac.filteredList(); + }, + config() { + const instance = Template.instance(); + const filter = instance.userFilter.get(); + const { field } = instance.data; + return { + filter, + template_item: 'autocompleteItemTemplate', + noMatchTemplate: 'autocompleteEmpty', + modifier(item) { + return item[field]; + }, + // modifier(text) { + // const f = filter; + // return `@${ f.length === 0 ? text : text.replace(new RegExp(filter), function(part) { + // return `${ part }`; + // }) }`; + // }, + }; + }, + selectedUsers() { + return Template.instance().selectedUsers.get(); + }, +}); + +Template.userAutocomplete.events({ + ...acEvents, + 'click .rc-tags__tag'({ target }, t) { + const field = Blaze.getData(target).user[t.data.field]; + t.selectedUsers.set(t.selectedUsers.get().filter((user) => user[t.data.field] !== field)); + if (t.data.onUsersChanged) { + t.data.onUsersChanged(t.selectedUsers); + } + }, + 'click .rc-tags__tag-icon'(e, t) { + const field = Blaze.getData(t.find('.rc-tags__tag-text')).user[t.data.field]; + t.selectedUsers.set(t.selectedUsers.get().filter((user) => user[t.data.field] !== field)); + if (t.data.onUsersChanged) { + t.data.onUsersChanged(t.selectedUsers); + } + }, + 'input [name="users"]'(e, t) { + const input = e.target; + const position = input.selectionEnd || input.selectionStart; + const { length } = input.value; + const modified = filterNames(input.value); + input.value = modified; + document.activeElement === input && e && /input/i.test(e.type) && (input.selectionEnd = position + input.value.length - length); + + t.userFilter.set(modified); + }, +}); + +Template.userAutocomplete.onRendered(function() { + const users = this.selectedUsers; + + this.firstNode.querySelector('[name="users"]').focus(); + this.ac.element = this.firstNode.querySelector('[name="users"]'); + this.ac.$element = $(this.ac.element); + this.ac.$element.on('autocompleteselect', function(e, { item }) { + const usersArr = users.get(); + usersArr.push(item); + users.set(usersArr); + }); +}); + +Template.userAutocomplete.onCreated(function() { + const { exclude, collection, endpoint, field, selected, id, position } = this.data; + this.selectedUsers = new ReactiveVar(selected || []); + const filter = { exceptions: (Array.isArray(exclude) ? exclude : [exclude]).concat(this.selectedUsers.get().map((u) => u._id)) }; + Deps.autorun(() => { + filter.exceptions = (Array.isArray(exclude) ? exclude : [exclude]).concat(this.selectedUsers.get().map((u) => u._id)); + }); + this.userFilter = new ReactiveVar(''); + + this.ac = new AutoComplete({ + selector: { + anchor: `#${ id } .rc-input__label`, + item: `#${ id } .autocomplete-list__item`, + container: `#${ id } .rc-popup-list__list`, + }, + position: position || 'bottom', + limit: 10, + inputDelay: 300, + rules: [ + { + // @TODO maybe change this 'collection' and/or template + collection, + endpoint, + field, + matchAll: true, + filter, + doNotChangeWidth: false, + selector(match) { + return { name: match, term: match }; // @TODO: should probably be standardized on the server + }, + sort: 'username', + }, + ], + }); + this.ac.tmplInst = this; +}); diff --git a/app/ui/client/index.js b/app/ui/client/index.js index 786881dac290..d8e5464aedbb 100644 --- a/app/ui/client/index.js +++ b/app/ui/client/index.js @@ -57,6 +57,7 @@ import './components/header/headerRoom'; import './components/contextualBar.html'; import './components/contextualBar'; import './components/tooltip'; +import './components/userAutocomplete'; export { ChatMessages } from './lib/chatMessages'; export { fileUpload } from './lib/fileUpload'; diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 72312faa2831..bea09dba4cb8 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -3606,5 +3606,29 @@ "Your_question": "Your question", "Your_server_link": "Your server link", "Your_temporary_password_is_password": "Your temporary password is [password].", - "Your_workspace_is_ready": "Your workspace is ready to use 🎉" + "Your_workspace_is_ready": "Your workspace is ready to use 🎉", + "Mentions_OfflineGroups": "Groups mentioning offline users", + "Mentions_OnlineGroups": "Groups not mentioning offline users", + "Mentions_CustomGroup": "Custom mention group", + "New_Group": "New group", + "Mentions_Groups": "Mention groups", + "Mentions_UserCanJoin": "Users can join", + "Mentions_UserCanJoinDescription": "Controls if the users can join the group themselves", + "Mentions_Offline": "Mentions offline", + "Mentions_OfflineDescription": "Whether the group mentions people that are offline (like @all)", + "Mentions_Outside": "Automatic invites", + "Mentions_OutsideDescription": "Controls if mentions of this group automatically invite people to rooms (like mentioning a user directly in a room he has not joined)", + "Mentions_GroupName": "Group name", + "Mentions_GroupNameDescription": "The name of the group - this is what users will mention", + "Mentions_GroupDescription": "Group description", + "Mentions_GroupDescriptionDescription": "This will appear after the group name in autocomplete results", + "Mentions_RolesDescription": "Roles added here will be mentioned when this group is", + "Mentions_GroupsDescription": "Groups added here will be mentioned automatically when this one is", + "Mentions_ChannelsDescription": "If this list is not empty, this group will only work in the channels specified", + "Mentions_UsersDescription": "Users this group will mention", + "Mentions_GroupConfiguration": "Group configuration", + "Back_to_mention_groups": "Mention groups", + "Role_Placeholder": "Start typing a role name...", + "Group_Placeholder": "Start typing a group name...", + "Channel_Placeholder": "Start typing a channel name..." } diff --git a/server/publications/spotlight.js b/server/publications/spotlight.js index 0da321b49dfd..f4a99a067af1 100644 --- a/server/publications/spotlight.js +++ b/server/publications/spotlight.js @@ -3,7 +3,7 @@ import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import s from 'underscore.string'; import { hasPermission } from '../../app/authorization'; -import { Users, Subscriptions, Rooms } from '../../app/models'; +import { Users, Subscriptions, Rooms, MentionGroups } from '../../app/models'; import { settings } from '../../app/settings'; import { roomTypes } from '../../app/utils'; @@ -19,7 +19,7 @@ function fetchRooms(userId, rooms) { } Meteor.methods({ - spotlight(text, usernames, type = { users: true, rooms: true }, rid) { + spotlight(text, usernames, type = { users: true, rooms: true, groups: false }, rid) { const searchForChannels = text[0] === '#'; const searchForDMs = text[0] === '@'; if (searchForChannels) { @@ -34,6 +34,7 @@ Meteor.methods({ const result = { users: [], rooms: [], + groups: [], }; const roomOptions = { limit: 5, @@ -97,6 +98,10 @@ Meteor.methods({ }).fetch(); } + if (type.groups === true) { + result.groups = MentionGroups.find({ name: regex, $or: [{ channels: { $eq: [] } }, { channels: rid }] }, { fields: { name: 1, description: 1 }, limit: 5 }).fetch(); + } + return result; }, });