diff --git a/.snapcraft/resources/preparemongo b/.snapcraft/resources/preparemongo index 332dd7d46847..9cfe098409ec 100755 --- a/.snapcraft/resources/preparemongo +++ b/.snapcraft/resources/preparemongo @@ -2,8 +2,8 @@ if [[ $(uname -m) == "x86_64" ]] then - wget --backups=0 "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-ubuntu1604-3.2.7.tgz" - tar -zxf ./mongodb-linux-x86_64-ubuntu1604-3.2.7.tgz --strip-components=1 + wget --backups=0 "https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-3.4.20.tgz" + tar -zxf ./mongodb-linux-x86_64-3.4.20.tgz --strip-components=1 else IFS=" " read -a links <<< $(apt-get -y --print-uris install mongodb | egrep -o "https?://[^']+") for link in ${links[@]} diff --git a/app/api/server/v1/emoji-custom.js b/app/api/server/v1/emoji-custom.js index f638f025b693..243386792058 100644 --- a/app/api/server/v1/emoji-custom.js +++ b/app/api/server/v1/emoji-custom.js @@ -122,7 +122,7 @@ API.v1.addRoute('emoji-custom.update', { authRequired: true }, { if (!fields._id) { return callback(new Meteor.Error('The required "_id" query param is missing.')); } - const emojiToUpdate = EmojiCustom.findOneByID(fields._id); + const emojiToUpdate = EmojiCustom.findOneById(fields._id); if (!emojiToUpdate) { return callback(new Meteor.Error('Emoji not found.')); } diff --git a/app/api/server/v1/users.js b/app/api/server/v1/users.js index 1cf3926f035d..4458cca5fc60 100644 --- a/app/api/server/v1/users.js +++ b/app/api/server/v1/users.js @@ -18,6 +18,7 @@ import { } from '../../../lib'; import { getFullUserData } from '../../../lib/server/functions/getFullUserData'; import { API } from '../api'; +import { setStatusMessage } from '../../../lib/server'; API.v1.addRoute('users.create', { authRequired: true }, { post() { @@ -325,6 +326,73 @@ API.v1.addRoute('users.setAvatar', { authRequired: true }, { }, }); +API.v1.addRoute('users.getStatus', { authRequired: true }, { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + message: user.statusText, + connectionStatus: user.statusConnection, + status: user.status, + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + message: user.statusText, + status: user.status, + }); + }, +}); + +API.v1.addRoute('users.setStatus', { authRequired: true }, { + post() { + check(this.bodyParams, Match.ObjectIncluding({ + status: Match.Maybe(String), + message: Match.Maybe(String), + })); + + if (!settings.get('Accounts_AllowUserStatusMessageChange')) { + throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { + method: 'users.setStatus', + }); + } + + let user; + if (this.isUserFromParams()) { + user = Meteor.users.findOne(this.userId); + } else if (hasPermission(this.userId, 'edit-other-user-info')) { + user = this.getUserFromParams(); + } else { + return API.v1.unauthorized(); + } + + Meteor.runAsUser(user._id, () => { + if (this.bodyParams.message) { + setStatusMessage(user._id, this.bodyParams.message); + } + if (this.bodyParams.status) { + const validStatus = ['online', 'away', 'offline', 'busy']; + if (validStatus.includes(this.bodyParams.status)) { + Meteor.users.update(this.userId, { + $set: { + status: this.bodyParams.status, + statusDefault: this.bodyParams.status, + }, + }); + } else { + throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { + method: 'users.setStatus', + }); + } + } + }); + + return API.v1.success(); + }, +}); + API.v1.addRoute('users.update', { authRequired: true }, { post() { check(this.bodyParams, { @@ -334,6 +402,7 @@ API.v1.addRoute('users.update', { authRequired: true }, { name: Match.Maybe(String), password: Match.Maybe(String), username: Match.Maybe(String), + statusText: Match.Maybe(String), active: Match.Maybe(Boolean), roles: Match.Maybe(Array), joinDefaultChannels: Match.Maybe(Boolean), @@ -369,6 +438,7 @@ API.v1.addRoute('users.updateOwnBasicInfo', { authRequired: true }, { email: Match.Maybe(String), name: Match.Maybe(String), username: Match.Maybe(String), + statusText: Match.Maybe(String), currentPassword: Match.Maybe(String), newPassword: Match.Maybe(String), }), @@ -379,6 +449,7 @@ API.v1.addRoute('users.updateOwnBasicInfo', { authRequired: true }, { email: this.bodyParams.data.email, realname: this.bodyParams.data.name, username: this.bodyParams.data.username, + statusText: this.bodyParams.data.statusText, newPassword: this.bodyParams.data.newPassword, typedPassword: this.bodyParams.data.currentPassword, }; @@ -451,6 +522,7 @@ API.v1.addRoute('users.setPreferences', { authRequired: true }, { sidebarHideAvatar: Match.Optional(Boolean), sidebarGroupByType: Match.Optional(Boolean), sidebarShowDiscussion: Match.Optional(Boolean), + sidebarShowServiceAccounts: Match.Optional(Boolean), muteFocusedConversations: Match.Optional(Boolean), }), }); @@ -581,6 +653,7 @@ API.v1.addRoute('users.presence', { authRequired: true }, { name: 1, status: 1, utcOffset: 1, + statusText: 1, }, }; diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index 8d57052b66db..72f155c8a013 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -46,6 +46,7 @@ Meteor.startup(function() { { _id: 'leave-p', roles: ['admin', 'user', 'bot', 'anonymous'] }, { _id: 'manage-assets', roles: ['admin'] }, { _id: 'manage-emoji', roles: ['admin'] }, + { _id: 'manage-user-status', roles: ['admin'] }, { _id: 'manage-integrations', roles: ['admin'] }, { _id: 'manage-own-integrations', roles: ['admin'] }, { _id: 'manage-oauth-apps', roles: ['admin'] }, diff --git a/app/custom-sounds/server/methods/deleteCustomSound.js b/app/custom-sounds/server/methods/deleteCustomSound.js index ba26d32673b2..b72c852bacfc 100644 --- a/app/custom-sounds/server/methods/deleteCustomSound.js +++ b/app/custom-sounds/server/methods/deleteCustomSound.js @@ -10,7 +10,7 @@ Meteor.methods({ let sound = null; if (hasPermission(this.userId, 'manage-sounds')) { - sound = CustomSounds.findOneByID(_id); + sound = CustomSounds.findOneById(_id); } else { throw new Meteor.Error('not_authorized'); } @@ -20,7 +20,7 @@ Meteor.methods({ } RocketChatFileCustomSoundsInstance.deleteFile(`${ sound._id }.${ sound.extension }`); - CustomSounds.removeByID(_id); + CustomSounds.removeById(_id); Notifications.notifyAll('deleteCustomSound', { soundData: sound }); return true; diff --git a/app/custom-sounds/server/methods/insertOrUpdateSound.js b/app/custom-sounds/server/methods/insertOrUpdateSound.js index 36ea5839736b..03a2ff69a4d8 100644 --- a/app/custom-sounds/server/methods/insertOrUpdateSound.js +++ b/app/custom-sounds/server/methods/insertOrUpdateSound.js @@ -32,7 +32,7 @@ Meteor.methods({ let matchingResults = []; if (soundData._id) { - matchingResults = CustomSounds.findByNameExceptID(soundData.name, soundData._id).fetch(); + matchingResults = CustomSounds.findByNameExceptId(soundData.name, soundData._id).fetch(); } else { matchingResults = CustomSounds.findByName(soundData.name).fetch(); } diff --git a/app/e2e/client/rocketchat.e2e.js b/app/e2e/client/rocketchat.e2e.js index ba48d15969ac..243713b9fa91 100644 --- a/app/e2e/client/rocketchat.e2e.js +++ b/app/e2e/client/rocketchat.e2e.js @@ -500,6 +500,7 @@ Meteor.startup(function() { e2e.enabled.set(true); } else { e2e.enabled.set(false); + e2e.closeAlert(); } } }); diff --git a/app/e2e/client/tabbar.js b/app/e2e/client/tabbar.js index 00cae6e57821..656285620096 100644 --- a/app/e2e/client/tabbar.js +++ b/app/e2e/client/tabbar.js @@ -1,22 +1,30 @@ import { Meteor } from 'meteor/meteor'; import { Session } from 'meteor/session'; +import { Tracker } from 'meteor/tracker'; import { hasAllPermission } from '../../authorization'; import { call, TabBar } from '../../ui-utils'; import { ChatRoom } from '../../models'; +import { settings } from '../../settings'; Meteor.startup(() => { - TabBar.addButton({ - groups: ['direct', 'group'], - id: 'e2e', - i18nTitle: 'E2E', - icon: 'key', - class: () => (ChatRoom.findOne(Session.get('openedRoom')) || {}).encrypted && 'enabled', - action: () => { - const room = ChatRoom.findOne(Session.get('openedRoom')); - call('saveRoomSettings', room._id, 'encrypted', !room.encrypted); - }, - order: 10, - condition: () => hasAllPermission('edit-room', Session.get('openedRoom')), + Tracker.autorun(() => { + if (settings.get('E2E_Enable')) { + TabBar.addButton({ + groups: ['direct', 'group'], + id: 'e2e', + i18nTitle: 'E2E', + icon: 'key', + class: () => (ChatRoom.findOne(Session.get('openedRoom')) || {}).encrypted && 'enabled', + action: () => { + const room = ChatRoom.findOne(Session.get('openedRoom')); + call('saveRoomSettings', room._id, 'encrypted', !room.encrypted); + }, + order: 10, + condition: () => hasAllPermission('edit-room', Session.get('openedRoom')), + }); + } else { + TabBar.removeButton('e2e'); + } }); }); diff --git a/app/emoji-custom/server/methods/deleteEmojiCustom.js b/app/emoji-custom/server/methods/deleteEmojiCustom.js index b9623960ea2b..0e5b383f7188 100644 --- a/app/emoji-custom/server/methods/deleteEmojiCustom.js +++ b/app/emoji-custom/server/methods/deleteEmojiCustom.js @@ -10,7 +10,7 @@ Meteor.methods({ let emoji = null; if (hasPermission(this.userId, 'manage-emoji')) { - emoji = EmojiCustom.findOneByID(emojiID); + emoji = EmojiCustom.findOneById(emojiID); } else { throw new Meteor.Error('not_authorized'); } @@ -20,7 +20,7 @@ Meteor.methods({ } RocketChatFileEmojiCustomInstance.deleteFile(encodeURIComponent(`${ emoji.name }.${ emoji.extension }`)); - EmojiCustom.removeByID(emojiID); + EmojiCustom.removeById(emojiID); Notifications.notifyLogged('deleteEmojiCustom', { emojiData: emoji }); return true; diff --git a/app/emoji/client/emojiPicker.js b/app/emoji/client/emojiPicker.js index 92227e84e1b1..9bc0b89ff7b0 100644 --- a/app/emoji/client/emojiPicker.js +++ b/app/emoji/client/emojiPicker.js @@ -13,7 +13,7 @@ const getEmojiElement = (emoji, image) => image && `
  • - -
    - +
    +
    - -
    - +
    +
    @@ -44,10 +53,10 @@

    {{_ "Webhooks"}}

    {{_ "Send_request_on_agent_message"}}
    -
    - - - +
    + + +
    diff --git a/app/livechat/client/views/app/livechatAppearance.html b/app/livechat/client/views/app/livechatAppearance.html index 74bb2604a46d..a6885d18a392 100644 --- a/app/livechat/client/views/app/livechatAppearance.html +++ b/app/livechat/client/views/app/livechatAppearance.html @@ -1,209 +1,212 @@ diff --git a/app/livechat/client/views/app/livechatCurrentChats.js b/app/livechat/client/views/app/livechatCurrentChats.js index 4190b37b7548..ed712a059238 100644 --- a/app/livechat/client/views/app/livechatCurrentChats.js +++ b/app/livechat/client/views/app/livechatCurrentChats.js @@ -1,18 +1,23 @@ import _ from 'underscore'; import moment from 'moment'; -import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; import { ReactiveVar } from 'meteor/reactive-var'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Template } from 'meteor/templating'; -import { modal } from '../../../../ui-utils/client'; -import { t, handleError } from '../../../../utils/client'; +import { modal, call } from '../../../../ui-utils'; +import { t } from '../../../../utils/client'; import './livechatCurrentChats.html'; const LivechatRoom = new Mongo.Collection('livechatRoom'); Template.livechatCurrentChats.helpers({ + hasMore() { + return Template.instance().ready.get() && LivechatRoom.find({ t: 'l' }, { sort: { ts: -1 } }).count() === Template.instance().limit.get(); + }, + isReady() { + return Template.instance().ready.get(); + }, livechatRoom() { return LivechatRoom.find({ t: 'l' }, { sort: { ts: -1 } }); }, @@ -31,31 +36,28 @@ Template.livechatCurrentChats.helpers({ isClosed() { return !this.open; }, - agentAutocompleteSettings() { - return { - limit: 10, - inputDelay: 300, - rules: [{ - collection: 'UserAndRoom', - subscription: 'userAutocomplete', - field: 'username', - template: Template.userSearch, - noMatchTemplate: Template.userSearchEmpty, - matchAll: true, - selector(match) { - return { term: match }; - }, - sort: 'username', - }], + onSelectAgents() { + return Template.instance().onSelectAgents; + }, + agentModifier() { + return (filter, text = '') => { + const f = filter.get(); + return `@${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), (part) => `${ part }`) }`; }; }, + selectedAgents() { + return Template.instance().selectedAgents.get(); + }, + onClickTagAgent() { + return Template.instance().onClickTagAgent; + }, }); Template.livechatCurrentChats.events({ 'click .row-link'() { FlowRouter.go('live', { id: this._id }); }, - 'click .load-more'(event, instance) { + 'click .js-load-more'(event, instance) { instance.limit.set(instance.limit.get() + 20); }, 'submit form'(event, instance) { @@ -80,8 +82,9 @@ Template.livechatCurrentChats.events({ delete filter.to; } - if (!_.isEmpty(instance.selectedAgent.get())) { - filter.agent = instance.selectedAgent.get(); + const agents = instance.selectedAgents.get(); + if (agents && agents.length > 0) { + filter.agent = agents[0]._id; } instance.filter.set(filter); @@ -100,39 +103,45 @@ Template.livechatCurrentChats.events({ cancelButtonText: t('Cancel'), closeOnConfirm: false, html: false, - }, () => { - Meteor.call('livechat:removeRoom', this._id, function(error/* , result*/) { - if (error) { - return handleError(error); - } - modal.open({ - title: t('Deleted'), - text: t('Room_has_been_deleted'), - type: 'success', - timer: 1000, - showConfirmButton: false, - }); + }, async (confirmed) => { + if (!confirmed) { + return; + } + await call('livechat:removeRoom', this._id); + modal.open({ + title: t('Deleted'), + text: t('Room_has_been_deleted'), + type: 'success', + timer: 1000, + showConfirmButton: false, }); }); }, - 'autocompleteselect input[id=agent]'(event, template, agent) { - template.selectedAgent.set(agent._id); - }, 'input [id=agent]'(event, template) { const input = event.currentTarget; if (input.value === '') { - template.selectedAgent.set(); + template.selectedAgents.set([]); } }, }); Template.livechatCurrentChats.onCreated(function() { + this.ready = new ReactiveVar(false); this.limit = new ReactiveVar(20); this.filter = new ReactiveVar({}); - this.selectedAgent = new ReactiveVar(); + this.selectedAgents = new ReactiveVar([]); + + this.onSelectAgents = ({ item: agent }) => { + this.selectedAgents.set([agent]); + }; + + this.onClickTagAgent = ({ username }) => { + this.selectedAgents.set(this.selectedAgents.get().filter((user) => user.username !== username)); + }; + this.autorun(() => { - this.subscribe('livechat:rooms', this.filter.get(), 0, this.limit.get()); + this.ready.set(this.subscribe('livechat:rooms', this.filter.get(), 0, this.limit.get()).ready()); }); }); diff --git a/app/livechat/client/views/app/livechatCustomFieldForm.html b/app/livechat/client/views/app/livechatCustomFieldForm.html index f796928f15a4..31fc6e58f250 100644 --- a/app/livechat/client/views/app/livechatCustomFieldForm.html +++ b/app/livechat/client/views/app/livechatCustomFieldForm.html @@ -35,9 +35,9 @@
    -
    - - +
    + +
    {{else}} {{> loading}} diff --git a/app/livechat/client/views/app/livechatCustomFields.html b/app/livechat/client/views/app/livechatCustomFields.html index b03734f64485..9d0b8b65b5ca 100644 --- a/app/livechat/client/views/app/livechatCustomFields.html +++ b/app/livechat/client/views/app/livechatCustomFields.html @@ -1,20 +1,26 @@ diff --git a/app/livechat/client/views/app/livechatDepartmentForm.html b/app/livechat/client/views/app/livechatDepartmentForm.html index 897a4bc8a1ce..fbb0c137f410 100644 --- a/app/livechat/client/views/app/livechatDepartmentForm.html +++ b/app/livechat/client/views/app/livechatDepartmentForm.html @@ -59,41 +59,45 @@

    {{_ "Agents"}}

    {{_ "Selected_agents"}} -
    - +
    + {{#table fixed='true'}}
    - - - - + + + + - {{#if selectedAgents}} - {{#each selectedAgents}} - - - - - - - {{/each}} + {{#each selectedAgents}} + + + + + + {{else}} - {{/if}} + {{/each}} -
    {{_ "Username"}}{{_ "Count"}}{{_ "Order"}} 
    {{_ "Username"}}
    {{_ "Count"}}
    {{_ "Order"}}
     
    {{username}}
    +
    +
    + {{username}} +
    +
    +
    {{_ "There_are_no_agents_added_to_this_department_yet"}}
    + {{/table}}
    -
    - - +
    + +
    {{else}} {{> loading}} diff --git a/app/livechat/client/views/app/livechatDepartments.html b/app/livechat/client/views/app/livechatDepartments.html index 49a85d77eefc..39c20cff524f 100644 --- a/app/livechat/client/views/app/livechatDepartments.html +++ b/app/livechat/client/views/app/livechatDepartments.html @@ -1,21 +1,22 @@ diff --git a/app/livechat/client/views/app/livechatInstallation.html b/app/livechat/client/views/app/livechatInstallation.html index 3aced66ab596..b8dbd5faac2a 100644 --- a/app/livechat/client/views/app/livechatInstallation.html +++ b/app/livechat/client/views/app/livechatInstallation.html @@ -4,14 +4,14 @@
    - +

    {{{_ "To_install_RocketChat_Livechat_in_your_website_copy_paste_this_code_above_the_last_body_tag_on_your_site"}}}

    - +
    {{/requiresPermission}} diff --git a/app/livechat/client/views/app/livechatOfficeHours.html b/app/livechat/client/views/app/livechatOfficeHours.html index bde4d243a773..4c949f4f42c9 100644 --- a/app/livechat/client/views/app/livechatOfficeHours.html +++ b/app/livechat/client/views/app/livechatOfficeHours.html @@ -52,8 +52,8 @@

    {{name day}}

    -
    - +
    +
    diff --git a/app/livechat/client/views/app/livechatTriggers.html b/app/livechat/client/views/app/livechatTriggers.html index ea15e3b22c14..75c5c4142e2e 100644 --- a/app/livechat/client/views/app/livechatTriggers.html +++ b/app/livechat/client/views/app/livechatTriggers.html @@ -1,28 +1,33 @@ diff --git a/app/livechat/client/views/app/livechatTriggersForm.html b/app/livechat/client/views/app/livechatTriggersForm.html index b5d25f55a329..0a5b1e4ea317 100644 --- a/app/livechat/client/views/app/livechatTriggersForm.html +++ b/app/livechat/client/views/app/livechatTriggersForm.html @@ -52,9 +52,9 @@ {{/unless}}
    -
    - - +
    + +
    diff --git a/app/livechat/client/views/app/livechatUsers.html b/app/livechat/client/views/app/livechatUsers.html index 33c7e7544708..3c79a66c344b 100644 --- a/app/livechat/client/views/app/livechatUsers.html +++ b/app/livechat/client/views/app/livechatUsers.html @@ -1,72 +1,146 @@ diff --git a/app/livechat/client/views/app/livechatUsers.js b/app/livechat/client/views/app/livechatUsers.js index 2843ecd6d807..e2c014e7c508 100644 --- a/app/livechat/client/views/app/livechatUsers.js +++ b/app/livechat/client/views/app/livechatUsers.js @@ -1,12 +1,13 @@ import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; import { Template } from 'meteor/templating'; -import _ from 'underscore'; -import toastr from 'toastr'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { ReactiveDict } from 'meteor/reactive-dict'; -import { modal } from '../../../../ui-utils'; +import { modal, call } from '../../../../ui-utils'; import { t, handleError } from '../../../../utils'; import { AgentUsers } from '../../collections/AgentUsers'; + import './livechatUsers.html'; let ManagerUsers; @@ -14,8 +15,41 @@ let ManagerUsers; Meteor.startup(function() { ManagerUsers = new Mongo.Collection('managerUsers'); }); - +const getUsername = (user) => user.username; Template.livechatUsers.helpers({ + exceptionsManagers() { + const { selectedManagers } = Template.instance(); + return ManagerUsers.find({}, { fields: { username: 1 } }) + .fetch() + .map(getUsername) + .concat(selectedManagers.get().map(getUsername)); + }, + exceptionsAgents() { + const { selectedAgents } = Template.instance(); + return AgentUsers.find({}, { fields: { username: 1 } }) + .fetch() + .map(getUsername) + .concat(selectedAgents.get().map(getUsername)); + }, + deleteLastAgent() { + const i = Template.instance(); + return () => { + const arr = i.selectedAgents.curValue; + arr.pop(); + i.selectedAgents.set(arr); + }; + }, + deleteLastManager() { + const i = Template.instance(); + return () => { + const arr = i.selectedManagers.curValue; + arr.pop(); + i.selectedManagers.set(arr); + }; + }, + isLoading() { + return Template.instance().state.get('loading'); + }, managers() { return ManagerUsers.find({}, { sort: { name: 1 } }); }, @@ -27,152 +61,174 @@ Template.livechatUsers.helpers({ return this.emails[0].address; } }, - agentAutocompleteSettings() { - return { - limit: 10, - // inputDelay: 300 - rules: [{ - // @TODO maybe change this 'collection' and/or template - collection: 'UserAndRoom', - subscription: 'userAutocomplete', - field: 'username', - template: Template.userSearch, - noMatchTemplate: Template.userSearchEmpty, - matchAll: true, - filter: { - exceptions: _.map(AgentUsers.find({}, { fields: { username: 1 } }).fetch(), (user) => user.username), - }, - selector(match) { - return { term: match }; - }, - sort: 'username', - }], + agentModifier() { + return (filter, text = '') => { + const f = filter.get(); + return `@${ + f.length === 0 + ? text + : text.replace( + new RegExp(filter.get()), + (part) => `${ part }` + ) + }`; }; }, - managerAutocompleteSettings() { - return { - limit: 10, - // inputDelay: 300 - rules: [{ - // @TODO maybe change this 'collection' and/or template - collection: 'UserAndRoom', - subscription: 'userAutocomplete', - field: 'username', - template: Template.userSearch, - noMatchTemplate: Template.userSearchEmpty, - matchAll: true, - filter: { - exceptions: _.map(ManagerUsers.find({}, { fields: { username: 1 } }).fetch(), (user) => user.username), - }, - selector(match) { - return { term: match }; - }, - sort: 'username', - }], - }; + onSelectManagers() { + return Template.instance().onSelectManagers; + }, + onSelectAgents() { + return Template.instance().onSelectAgents; + }, + selectedAgents() { + return Template.instance().selectedAgents.get(); + }, + selectedManagers() { + return Template.instance().selectedManagers.get(); + }, + onClickTagAgents() { + return Template.instance().onClickTagAgents; + }, + onClickTagManagers() { + return Template.instance().onClickTagManagers; }, }); Template.livechatUsers.events({ - 'click .remove-manager'(e/* , instance*/) { + 'click .remove-manager'(e /* , instance*/) { e.preventDefault(); - modal.open({ - title: t('Are_you_sure'), - type: 'warning', - showCancelButton: true, - confirmButtonColor: '#DD6B55', - confirmButtonText: t('Yes'), - cancelButtonText: t('Cancel'), - closeOnConfirm: false, - html: false, - }, () => { - Meteor.call('livechat:removeManager', this.username, function(error/* , result*/) { - if (error) { - return handleError(error); - } - modal.open({ - title: t('Removed'), - text: t('Manager_removed'), - type: 'success', - timer: 1000, - showConfirmButton: false, + modal.open( + { + title: t('Are_you_sure'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('Cancel'), + closeOnConfirm: false, + html: false, + }, + () => { + Meteor.call('livechat:removeManager', this.username, function( + error /* , result*/ + ) { + if (error) { + return handleError(error); + } + modal.open({ + title: t('Removed'), + text: t('Manager_removed'), + type: 'success', + timer: 1000, + showConfirmButton: false, + }); }); - }); - }); + } + ); }, - 'click .remove-agent'(e/* , instance*/) { + 'click .remove-agent'(e /* , instance*/) { e.preventDefault(); - modal.open({ - title: t('Are_you_sure'), - type: 'warning', - showCancelButton: true, - confirmButtonColor: '#DD6B55', - confirmButtonText: t('Yes'), - cancelButtonText: t('Cancel'), - closeOnConfirm: false, - html: false, - }, () => { - Meteor.call('livechat:removeAgent', this.username, function(error/* , result*/) { - if (error) { - return handleError(error); - } - modal.open({ - title: t('Removed'), - text: t('Agent_removed'), - type: 'success', - timer: 1000, - showConfirmButton: false, + modal.open( + { + title: t('Are_you_sure'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes'), + cancelButtonText: t('Cancel'), + closeOnConfirm: false, + html: false, + }, + () => { + Meteor.call('livechat:removeAgent', this.username, function( + error /* , result*/ + ) { + if (error) { + return handleError(error); + } + modal.open({ + title: t('Removed'), + text: t('Agent_removed'), + type: 'success', + timer: 1000, + showConfirmButton: false, + }); }); - }); - }); + } + ); }, - 'submit #form-manager'(e/* , instance*/) { + + async 'submit #form-manager'(e, instance) { e.preventDefault(); + const { selectedManagers, state } = instance; - if (e.currentTarget.elements.username.value.trim() === '') { - return toastr.error(t('Please_fill_a_username')); + const users = selectedManagers.get(); + + if (!users.length) { + return; } - const oldBtnValue = e.currentTarget.elements.add.value; + state.set('loading', true); + try { + await Promise.all( + users.map(({ username }) => call('livechat:addManager', username)) + ); + selectedManagers.set([]); + } finally { + state.set('loading', false); + } + }, - e.currentTarget.elements.add.value = t('Saving'); + async 'submit #form-agent'(e, instance) { + e.preventDefault(); + const { selectedAgents, state } = instance; - Meteor.call('livechat:addManager', e.currentTarget.elements.username.value, function(error/* , result*/) { - e.currentTarget.elements.add.value = oldBtnValue; - if (error) { - return handleError(error); - } + const users = selectedAgents.get(); - toastr.success(t('Manager_added')); - e.currentTarget.reset(); - }); - }, - 'submit #form-agent'(e/* , instance*/) { - e.preventDefault(); + if (!users.length) { + return; + } - if (e.currentTarget.elements.username.value.trim() === '') { - return toastr.error(t('Please_fill_a_username')); + state.set('loading', true); + try { + await Promise.all( + users.map(({ username }) => call('livechat:addAgent', username)) + ); + selectedAgents.set([]); + } finally { + state.set('loading', false); } + }, +}); - const oldBtnValue = e.currentTarget.elements.add.value; +Template.livechatUsers.onCreated(function() { + this.state = new ReactiveDict({ + loading: false, + }); - e.currentTarget.elements.add.value = t('Saving'); + this.selectedAgents = new ReactiveVar([]); + this.selectedManagers = new ReactiveVar([]); - Meteor.call('livechat:addAgent', e.currentTarget.elements.username.value, function(error/* , result*/) { - e.currentTarget.elements.add.value = oldBtnValue; - if (error) { - return handleError(error); - } + this.onSelectAgents = ({ item: agent }) => { + this.selectedAgents.set([...this.selectedAgents.curValue, agent]); + }; - toastr.success(t('Agent_added')); - e.currentTarget.reset(); - }); - }, -}); + this.onClickTagAgents = ({ username }) => { + this.selectedAgents.set(this.selectedAgents.curValue.filter((user) => user.username !== username)); + }; + + this.onSelectManagers = ({ item: manager }) => { + this.selectedManagers.set([...this.selectedManagers.curValue, manager]); + }; + + this.onClickTagManagers = ({ username }) => { + this.selectedManagers.set( + this.selectedManagers.curValue.filter((user) => user.username !== username) + ); + }; -Template.livechatUsers.onCreated(function() { this.subscribe('livechat:agents'); this.subscribe('livechat:managers'); }); diff --git a/app/models/client/index.js b/app/models/client/index.js index fbcbee481f5c..0c10bf534d30 100644 --- a/app/models/client/index.js +++ b/app/models/client/index.js @@ -21,6 +21,7 @@ import { UserRoles } from './models/UserRoles'; import { AuthzCachedCollection, ChatPermissions } from './models/ChatPermissions'; import { WebdavAccounts } from './models/WebdavAccounts'; import CustomSounds from './models/CustomSounds'; +import CustomUserStatus from './models/CustomUserStatus'; import EmojiCustom from './models/EmojiCustom'; const Users = _.extend({}, users, Meteor.users); @@ -51,6 +52,7 @@ export { ChatSubscription, Rooms, CustomSounds, + CustomUserStatus, EmojiCustom, WebdavAccounts, }; diff --git a/app/models/client/models/CustomUserStatus.js b/app/models/client/models/CustomUserStatus.js new file mode 100644 index 000000000000..2cdfa29e3627 --- /dev/null +++ b/app/models/client/models/CustomUserStatus.js @@ -0,0 +1,10 @@ +import { Base } from './_Base'; + +class CustomUserStatus extends Base { + constructor() { + super(); + this._initModel('custom_user_status'); + } +} + +export default new CustomUserStatus(); diff --git a/app/models/server/index.js b/app/models/server/index.js index 74b882789e00..9aa14dea719e 100644 --- a/app/models/server/index.js +++ b/app/models/server/index.js @@ -15,6 +15,7 @@ import Statistics from './models/Statistics'; import Permissions from './models/Permissions'; import Roles from './models/Roles'; import CustomSounds from './models/CustomSounds'; +import CustomUserStatus from './models/CustomUserStatus'; import Integrations from './models/Integrations'; import IntegrationHistory from './models/IntegrationHistory'; import CredentialTokens from './models/CredentialTokens'; @@ -58,6 +59,7 @@ export { Permissions, Roles, CustomSounds, + CustomUserStatus, Integrations, IntegrationHistory, CredentialTokens, diff --git a/app/models/server/models/CustomSounds.js b/app/models/server/models/CustomSounds.js index 40b25d5dc80a..b9971b954229 100644 --- a/app/models/server/models/CustomSounds.js +++ b/app/models/server/models/CustomSounds.js @@ -8,7 +8,7 @@ class CustomSounds extends Base { } // find one - findOneByID(_id, options) { + findOneById(_id, options) { return this.findOne(_id, options); } @@ -21,7 +21,7 @@ class CustomSounds extends Base { return this.find(query, options); } - findByNameExceptID(name, except, options) { + findByNameExceptId(name, except, options) { const query = { _id: { $nin: [except] }, name, @@ -48,7 +48,7 @@ class CustomSounds extends Base { // REMOVE - removeByID(_id) { + removeById(_id) { return this.remove(_id); } } diff --git a/app/models/server/models/CustomUserStatus.js b/app/models/server/models/CustomUserStatus.js new file mode 100644 index 000000000000..9e0818dc6354 --- /dev/null +++ b/app/models/server/models/CustomUserStatus.js @@ -0,0 +1,66 @@ +import { Base } from './_Base'; + +class CustomUserStatus extends Base { + constructor() { + super('custom_user_status'); + + this.tryEnsureIndex({ name: 1 }); + } + + // find one + findOneById(_id, options) { + return this.findOne(_id, options); + } + + // find + findByName(name, options) { + const query = { + name, + }; + + return this.find(query, options); + } + + findByNameExceptId(name, except, options) { + const query = { + _id: { $nin: [except] }, + name, + }; + + return this.find(query, options); + } + + // update + setName(_id, name) { + const update = { + $set: { + name, + }, + }; + + return this.update({ _id }, update); + } + + setStatusType(_id, statusType) { + const update = { + $set: { + statusType, + }, + }; + + return this.update({ _id }, update); + } + + // INSERT + create(data) { + return this.insert(data); + } + + + // REMOVE + removeById(_id) { + return this.remove(_id); + } +} + +export default new CustomUserStatus(); diff --git a/app/models/server/models/EmojiCustom.js b/app/models/server/models/EmojiCustom.js index 8f9f676072f5..d0cd7d7bc4cb 100644 --- a/app/models/server/models/EmojiCustom.js +++ b/app/models/server/models/EmojiCustom.js @@ -10,7 +10,7 @@ class EmojiCustom extends Base { } // find one - findOneByID(_id, options) { + findOneById(_id, options) { return this.findOne(_id, options); } @@ -83,7 +83,7 @@ class EmojiCustom extends Base { // REMOVE - removeByID(_id) { + removeById(_id) { return this.remove(_id); } } diff --git a/app/models/server/models/Rooms.js b/app/models/server/models/Rooms.js index 2d7f38770932..24b205dcf97e 100644 --- a/app/models/server/models/Rooms.js +++ b/app/models/server/models/Rooms.js @@ -1072,6 +1072,18 @@ export class Rooms extends Base { return this.update(query, update, { multi: true }); } + replaceServiceAccountBroadcastRoomName(previousUsername, username) { + const query = { name: `broadcast_${ previousUsername }` }; + + const update = { + $set: { + name: `broadcast_${ username }`, + }, + }; + + return this.update(query, update); + } + setJoinCodeById(_id, joinCode) { let update; const query = { _id }; diff --git a/app/models/server/models/Subscriptions.js b/app/models/server/models/Subscriptions.js index 93813847b88a..03fb3e9c6a01 100644 --- a/app/models/server/models/Subscriptions.js +++ b/app/models/server/models/Subscriptions.js @@ -1206,6 +1206,18 @@ export class Subscriptions extends Base { return this.update(query, update, { multi: true }); } + replaceServiceAccountBroadcastRoomName(previousUsername, username) { + const query = { name: `broadcast_${ previousUsername }` }; + + const update = { + $set: { + name: `broadcast_${ username }`, + }, + }; + + return this.update(query, update); + } + // INSERT createWithRoomAndUser(room, user, extraData) { const subscription = { diff --git a/app/models/server/models/Users.js b/app/models/server/models/Users.js index d7a8fe9df736..c28def2a928a 100644 --- a/app/models/server/models/Users.js +++ b/app/models/server/models/Users.js @@ -15,6 +15,7 @@ export class Users extends Base { this.tryEnsureIndex({ name: 1 }); this.tryEnsureIndex({ lastLogin: 1 }); this.tryEnsureIndex({ status: 1 }); + this.tryEnsureIndex({ statusText: 1 }); this.tryEnsureIndex({ active: 1 }, { sparse: 1 }); this.tryEnsureIndex({ statusConnection: 1 }, { sparse: 1 }); this.tryEnsureIndex({ type: 1 }); @@ -518,6 +519,9 @@ export class Users extends Base { { username: { $exists: true, $nin: exceptions }, }, + { + u: { $exists: false }, + }, ...extraQuery, ], }; @@ -534,6 +538,9 @@ export class Users extends Base { { 'federation.peer': localPeer }, ], }, + { + u: { $exists: false }, + }, ]; return this.findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); } @@ -542,10 +549,72 @@ export class Users extends Base { const extraQuery = [ { federation: { $exists: true } }, { 'federation.peer': { $ne: localPeer } }, + { + u: { $exists: false }, + }, ]; return this.findByActiveUsersExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); } + findByActiveServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery = []) { + if (exceptions == null) { exceptions = []; } + if (options == null) { options = {}; } + if (!_.isArray(exceptions)) { + exceptions = [exceptions]; + } + + const termRegex = new RegExp(s.escapeRegExp(searchTerm), 'i'); + const searchFields = forcedSearchFields || settings.get('Service_Accounts_SearchFields').trim().split(','); + const orStmt = _.reduce(searchFields, function(acc, el) { + acc.push({ [el.trim()]: termRegex }); + return acc; + }, []); + + const query = { + $and: [ + { + active: true, + $or: orStmt, + }, + { + username: { $exists: true, $nin: exceptions }, + }, + { + u: { $exists: true }, + }, + ...extraQuery, + ], + }; + + return this._db.find(query, options); + } + + findByActiveExternalServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, localPeer) { + const extraQuery = [ + { federation: { $exists: true } }, + { 'federation.peer': { $ne: localPeer } }, + { + u: { $exists: true }, + }, + ]; + return this.findByActiveServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); + } + + findByActiveLocalServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, localPeer) { + const extraQuery = [ + { + $or: [ + { federation: { $exists: false } }, + { 'federation.peer': localPeer }, + ], + }, + { + u: { $exists: true }, + }, + ]; + return this.findByActiveServiceAccountsExcept(searchTerm, exceptions, options, forcedSearchFields, extraQuery); + } + findUsersByNameOrUsername(nameOrUsername, options) { const query = { username: { @@ -689,6 +758,16 @@ export class Users extends Base { return this.update(query, update); } + updateStatusText(_id, statusText) { + const update = { + $set: { + statusText, + }, + }; + + return this.update(_id, update); + } + updateLastLoginById(_id) { const update = { $set: { diff --git a/app/service-accounts/client/index.js b/app/service-accounts/client/index.js index ba096451948b..35078e74e4c0 100644 --- a/app/service-accounts/client/index.js +++ b/app/service-accounts/client/index.js @@ -4,4 +4,7 @@ import './route'; // views import './views/serviceAccountDashboard'; import './views/creationDialog/createServiceAccount'; +import './views/serviceAccountsList'; + +import '../lib/serviceAccountRoomType'; import './views/serviceAccountSidebarLogin'; diff --git a/app/service-accounts/client/views/serviceAccountSidebarLogin.js b/app/service-accounts/client/views/serviceAccountSidebarLogin.js index 9a34f73f7008..ee652351b48b 100644 --- a/app/service-accounts/client/views/serviceAccountSidebarLogin.js +++ b/app/service-accounts/client/views/serviceAccountSidebarLogin.js @@ -33,7 +33,6 @@ Template.serviceAccountSidebarLogin.events({ if (Meteor.user().u) { username = Meteor.user().u.username; } - console.log(username); Meteor.call('getLoginToken', username, function(error, token) { if (error) { return handleError(error); @@ -41,7 +40,7 @@ Template.serviceAccountSidebarLogin.events({ FlowRouter.go('/home'); Meteor.loginWithToken(token.token, (err) => { if (err) { - console.log(err); + return handleError(err); } document.location.reload(true); if (Meteor.user().u) { diff --git a/app/service-accounts/client/views/serviceAccountsList.html b/app/service-accounts/client/views/serviceAccountsList.html new file mode 100644 index 000000000000..98819ed29d0c --- /dev/null +++ b/app/service-accounts/client/views/serviceAccountsList.html @@ -0,0 +1,10 @@ + diff --git a/app/service-accounts/client/views/serviceAccountsList.js b/app/service-accounts/client/views/serviceAccountsList.js new file mode 100644 index 000000000000..9ccc500b69b4 --- /dev/null +++ b/app/service-accounts/client/views/serviceAccountsList.js @@ -0,0 +1,29 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; + +import { ChatSubscription } from '../../../models/client'; +import { getUserPreference } from '../../../utils/client'; +import { settings } from '../../../settings/client'; + +import './serviceAccountsList.html'; + +Template.serviceAccountsList.helpers({ + rooms() { + const user = Meteor.userId(); + const sortBy = getUserPreference(user, 'sidebarSortby') || 'alphabetical'; + const query = { + open: true, + }; + + const sort = {}; + + if (sortBy === 'activity') { + sort.lm = -1; + } else { // alphabetical + sort[this.identifier === 'd' && settings.get('UI_Use_Real_Name') ? 'lowerCaseFName' : 'lowerCaseName'] = /descending/.test(sortBy) ? -1 : 1; + } + + query.sa = { $exists: true }; + return ChatSubscription.find(query, { sort }); + }, +}); diff --git a/app/service-accounts/lib/serviceAccountRoomType.js b/app/service-accounts/lib/serviceAccountRoomType.js new file mode 100644 index 000000000000..f3f18c3e6f25 --- /dev/null +++ b/app/service-accounts/lib/serviceAccountRoomType.js @@ -0,0 +1,22 @@ +import { Meteor } from 'meteor/meteor'; + +import { RoomTypeConfig, roomTypes, getUserPreference } from '../../utils'; + +export class ServiceAccountRoomType extends RoomTypeConfig { + constructor() { + super({ + identifier: 'sa', + order: 60, + label: 'Subscriptions', + }); + + // we need a custom template in order to have a custom query showing the subscriptions to serviceAccounts + this.customTemplate = 'serviceAccountsList'; + } + + condition() { + return getUserPreference(Meteor.userId(), 'sidebarShowServiceAccounts'); + } +} + +roomTypes.add(new ServiceAccountRoomType()); diff --git a/app/service-accounts/server/config.js b/app/service-accounts/server/config.js index 3f7c7b16d360..45f2ab361af5 100644 --- a/app/service-accounts/server/config.js +++ b/app/service-accounts/server/config.js @@ -14,5 +14,16 @@ Meteor.startup(() => { type: 'int', public: true, }); + this.add('Service_Accounts_SearchFields', 'username, name, description', { + type: 'string', + public: true, + }); + }); + settings.add('Accounts_Default_User_Preferences_sidebarShowServiceAccounts', true, { + group: 'Accounts', + section: 'Accounts_Default_User_Preferences', + type: 'boolean', + public: true, + i18nLabel: 'Group_subscriptions', }); }); diff --git a/app/service-accounts/server/index.js b/app/service-accounts/server/index.js index f6a6fb8418af..ec817f2af218 100644 --- a/app/service-accounts/server/index.js +++ b/app/service-accounts/server/index.js @@ -11,3 +11,5 @@ import './hooks/serviceAccountCallback'; import './publications/fullServiceAccountData'; import './publications/userServiceAccounts'; + +import '../lib/serviceAccountRoomType'; diff --git a/app/service-accounts/server/methods/getLoginToken.js b/app/service-accounts/server/methods/getLoginToken.js index c3bb7025cadd..ad8a0bf6919b 100644 --- a/app/service-accounts/server/methods/getLoginToken.js +++ b/app/service-accounts/server/methods/getLoginToken.js @@ -9,21 +9,17 @@ Meteor.methods({ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getLoginToken' }); } + let stampedToken = {}; const user = Users.findOneByUsername(username, {}); - if (user.u) { - if (user.u._id !== Meteor.userId()) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getLoginToken' }); - } - } + const isOwnerAccount = user.u && user.u._id === Meteor.userId(); // check if the requested account is owned by the user + const isServiceAccount = Meteor.user().u && user._id === Meteor.user().u._id; // check if the service account is requesting owner account login token - if (Meteor.user().u) { - if (user._id !== Meteor.user().u._id) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getLoginToken' }); - } + if (isOwnerAccount || isServiceAccount) { + stampedToken = Accounts._generateStampedLoginToken(); + Accounts._insertLoginToken(user._id, stampedToken); + } else { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'getLoginToken' }); } - - const stampedToken = Accounts._generateStampedLoginToken(); - Accounts._insertLoginToken(user._id, stampedToken); return stampedToken; }, }); diff --git a/app/service-accounts/server/methods/usernameExists.js b/app/service-accounts/server/methods/usernameExists.js index 82dfc7f58445..50733b56574c 100644 --- a/app/service-accounts/server/methods/usernameExists.js +++ b/app/service-accounts/server/methods/usernameExists.js @@ -1,18 +1,17 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; -import { Users } from '../../../models'; +import { checkUsernameAvailability } from '../../../lib/server'; Meteor.methods({ - usernameExists(name) { - check(name, String); + usernameExists(username) { + check(username, String); if (!Meteor.userId()) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'roomExists', }); } - const user = Users.findOneByUsername(name, {}); - return !!user; + return !checkUsernameAvailability(username); }, }); diff --git a/app/slashcommands-join/server/server.js b/app/slashcommands-join/server/server.js index ef6bb96377cd..5d8cc8ac2e04 100644 --- a/app/slashcommands-join/server/server.js +++ b/app/slashcommands-join/server/server.js @@ -1,8 +1,3 @@ - -/* -* Join is a named function that will replace /join commands -* @param {Object} message - The message object -*/ import { Meteor } from 'meteor/meteor'; import { Match } from 'meteor/check'; import { Random } from 'meteor/random'; @@ -12,7 +7,7 @@ import { Rooms, Subscriptions } from '../../models'; import { Notifications } from '../../notifications'; import { slashCommands } from '../../utils'; -slashCommands.add('join', function Join(command, params, item) { +function Join(command, params, item) { if (command !== 'join' || !Match.test(params, String)) { return; } @@ -42,7 +37,9 @@ slashCommands.add('join', function Join(command, params, item) { }); } Meteor.call('joinRoom', room._id); -}, { +} + +slashCommands.add('join', Join, { description: 'Join_the_given_channel', params: '#channel', }); diff --git a/app/slashcommands-status/client/index.js b/app/slashcommands-status/client/index.js new file mode 100644 index 000000000000..11e5ad1b8640 --- /dev/null +++ b/app/slashcommands-status/client/index.js @@ -0,0 +1 @@ +import '../lib/status'; diff --git a/app/slashcommands-status/index.js b/app/slashcommands-status/index.js new file mode 100644 index 000000000000..a67eca871efb --- /dev/null +++ b/app/slashcommands-status/index.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; + +if (Meteor.isClient) { + module.exports = require('./client/index.js'); +} +if (Meteor.isServer) { + module.exports = require('./server/index.js'); +} diff --git a/app/slashcommands-status/lib/status.js b/app/slashcommands-status/lib/status.js new file mode 100644 index 000000000000..8188ed41a598 --- /dev/null +++ b/app/slashcommands-status/lib/status.js @@ -0,0 +1,46 @@ +import { Meteor } from 'meteor/meteor'; +import { TAPi18n } from 'meteor/tap:i18n'; +import { Random } from 'meteor/random'; + +import { handleError, slashCommands } from '../../utils'; +import { hasPermission } from '../../authorization'; +import { Notifications } from '../../notifications'; + +function Status(command, params, item) { + if (command === 'status') { + if ((Meteor.isClient && hasPermission('edit-other-user-info')) || (Meteor.isServer && hasPermission(Meteor.userId(), 'edit-other-user-info'))) { + const user = Meteor.users.findOne(Meteor.userId()); + + Meteor.call('setUserStatus', null, params, (err) => { + if (err) { + if (Meteor.isClient) { + return handleError(err); + } + + if (err.error === 'error-not-allowed') { + Notifications.notifyUser(Meteor.userId(), 'message', { + _id: Random.id(), + rid: item.rid, + ts: new Date(), + msg: TAPi18n.__('StatusMessage_Change_Disabled', null, user.language), + }); + } + + throw err; + } else { + Notifications.notifyUser(Meteor.userId(), 'message', { + _id: Random.id(), + rid: item.rid, + ts: new Date(), + msg: TAPi18n.__('StatusMessage_Changed_Successfully', null, user.language), + }); + } + }); + } + } +} + +slashCommands.add('status', Status, { + description: 'Slash_Status_Description', + params: 'Slash_Status_Params', +}); diff --git a/app/slashcommands-status/server/index.js b/app/slashcommands-status/server/index.js new file mode 100644 index 000000000000..11e5ad1b8640 --- /dev/null +++ b/app/slashcommands-status/server/index.js @@ -0,0 +1 @@ +import '../lib/status'; diff --git a/app/slashcommands-topic/lib/topic.js b/app/slashcommands-topic/lib/topic.js index 1bc495e8f277..1e490046336a 100644 --- a/app/slashcommands-topic/lib/topic.js +++ b/app/slashcommands-topic/lib/topic.js @@ -4,10 +4,6 @@ import { handleError, slashCommands } from '../../utils'; import { ChatRoom } from '../../models'; import { callbacks } from '../../callbacks'; import { hasPermission } from '../../authorization'; -/* - * Join is a named function that will replace /topic commands - * @param {Object} message - The message object - */ function Topic(command, params, item) { if (command === 'topic') { diff --git a/app/theme/client/imports/components/header.css b/app/theme/client/imports/components/header.css index 21b33b1ea3c2..063647aca0bb 100644 --- a/app/theme/client/imports/components/header.css +++ b/app/theme/client/imports/components/header.css @@ -201,8 +201,13 @@ text-overflow: ellipsis; } - &-visual-status { - text-transform: capitalize; + &__visual-status { + overflow: hidden; + + width: 100%; + max-width: fit-content; + + text-overflow: ellipsis; } &__status { diff --git a/app/theme/client/imports/components/popover.css b/app/theme/client/imports/components/popover.css index ef22fd3a1efd..15c2b539286d 100644 --- a/app/theme/client/imports/components/popover.css +++ b/app/theme/client/imports/components/popover.css @@ -118,6 +118,30 @@ &--star-filled .rc-icon { fill: currentColor; } + + &--online { + & .rc-popover__icon { + color: var(--status-online); + } + } + + &--away { + & .rc-popover__icon { + color: var(--status-away); + } + } + + &--busy { + & .rc-popover__icon { + color: var(--status-busy); + } + } + + &--offline { + & .rc-popover__icon { + color: var(--status-invisible); + } + } } &__label { diff --git a/app/theme/client/imports/components/table.css b/app/theme/client/imports/components/table.css index 19d96082a231..40d06314fee0 100644 --- a/app/theme/client/imports/components/table.css +++ b/app/theme/client/imports/components/table.css @@ -7,6 +7,16 @@ font-weight: 500; + &-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + + height: 100%; + min-height: 200px; + justify-content: stretch; + } + &--fixed { table-layout: fixed; } diff --git a/app/theme/client/imports/forms/input.css b/app/theme/client/imports/forms/input.css index 0be7e3f66a48..ad82c59e6263 100644 --- a/app/theme/client/imports/forms/input.css +++ b/app/theme/client/imports/forms/input.css @@ -57,6 +57,8 @@ textarea.rc-input__element { height: 2.5rem; padding: 0 1rem; + text-align: start; + text-overflow: ellipsis; color: var(--input-text-color); @@ -74,6 +76,8 @@ textarea.rc-input__element { } &::placeholder { + + text-align: start; text-overflow: ellipsis; color: var(--input-placeholder-color); diff --git a/app/theme/client/imports/forms/tags.css b/app/theme/client/imports/forms/tags.css index 15705e275887..5d1dce60c5a8 100644 --- a/app/theme/client/imports/forms/tags.css +++ b/app/theme/client/imports/forms/tags.css @@ -2,7 +2,7 @@ display: flex; width: 100%; - min-height: 43px; + min-height: 40px; padding: 0 1rem 0 2.25rem; @@ -20,7 +20,7 @@ &__tag { display: flex; - margin: 0.25rem; + margin: 0.15rem; padding: 0.35rem 0.5rem; color: var(--tags-text-color); diff --git a/app/theme/client/imports/general/base.css b/app/theme/client/imports/general/base.css index eaea675afe08..4c5467746743 100644 --- a/app/theme/client/imports/general/base.css +++ b/app/theme/client/imports/general/base.css @@ -257,6 +257,10 @@ button { animation-delay: -0.16s; } +.file-picker-loading .loading-animation > .bounce { + background-color: #444444; +} + @keyframes loading-bouncedelay { 0%, 80%, diff --git a/app/theme/client/imports/general/base_old.css b/app/theme/client/imports/general/base_old.css index 3edd8420674b..bb298b0566c5 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -145,7 +145,17 @@ } .rc-old .text-center { + display: flex; + text-align: center; + justify-content: center; +} + +.rc-old .text-left { + display: flex; + + text-align: left; + justify-content: start; } .connection-status > .alert { @@ -3732,58 +3742,58 @@ rc-old select, } } - & .edit-form { - padding: 20px 20px 0; + & .room-info-content > div { + margin: 0 0 20px; + } +} - white-space: normal; +.rc-old .edit-form { + padding: 20px 20px 0; - & h3 { - margin-bottom: 8px; + white-space: normal; - font-size: 24px; - line-height: 22px; - } + & h3 { + margin-bottom: 8px; - & p { - font-size: 12px; - font-weight: 300; - line-height: 18px; - } + font-size: 24px; + line-height: 22px; + } - & > .input-line { - margin-top: 20px; + & p { + font-size: 12px; + font-weight: 300; + line-height: 18px; + } - & #password { - width: 70%; - } + & > .input-line { + margin-top: 20px; - & #roleSelect { - width: 70%; - } + & #password { + width: 70%; } - & nav { - padding: 0; - - &.buttons { - margin-top: 2em; - } + & #roleSelect { + width: 70%; } + } - & .form-divisor { - height: 9px; - margin: 2em 0; - - text-align: center; + & nav { + padding: 0; - & > span { - padding: 0 1em; - } + &.buttons { + margin-top: 2em; } } - & .room-info-content > div { - margin: 0 0 20px; + & .form-divisor { + height: 9px; + margin: 2em 0; + + text-align: center; + + & > span { + padding: 0 1em; + } } } @@ -4941,13 +4951,6 @@ rc-old select, } } -.rc-old .load-more { - position: relative; - - height: 10px; - padding: 1rem 0; -} - .rc-old .flex-tab { &__content { display: flex; @@ -5181,6 +5184,8 @@ rc-old select, & .form-group { display: inline-block; + + vertical-align: middle; } } @@ -5365,9 +5370,8 @@ rc-old select, position: absolute; right: 25px; - width: 80px; height: 30px; - padding-top: 4px; + padding: 4px; cursor: pointer; text-align: center; diff --git a/app/ui-account/client/accountPreferences.html b/app/ui-account/client/accountPreferences.html index 76e95a26c038..8f73514889ad 100644 --- a/app/ui-account/client/accountPreferences.html +++ b/app/ui-account/client/accountPreferences.html @@ -278,6 +278,13 @@

    {{_ "Sidebar"}}

    +
    + +
    + + +
    +
    diff --git a/app/ui-account/client/accountPreferences.js b/app/ui-account/client/accountPreferences.js index 247fc0785179..253fd600b9fb 100644 --- a/app/ui-account/client/accountPreferences.js +++ b/app/ui-account/client/accountPreferences.js @@ -169,6 +169,7 @@ Template.accountPreferences.onCreated(function() { data.mobileNotifications = $('#mobileNotifications').find('select').val(); data.unreadAlert = JSON.parse($('#unreadAlert').find('input:checked').val()); data.sidebarShowDiscussion = JSON.parse($('#sidebarShowDiscussion').find('input:checked').val()); + data.sidebarShowServiceAccounts = JSON.parse($('#sidebarShowServiceAccounts').find('input:checked').val()); data.notificationsSoundVolume = parseInt($('#notificationsSoundVolume').val()); data.roomCounterSidebar = JSON.parse($('#roomCounterSidebar').find('input:checked').val()); data.highlights = _.compact(_.map($('[name=highlights]').val().split(/,|\n/), function(e) { @@ -244,10 +245,15 @@ Template.accountPreferences.onCreated(function() { if (results.exportOperation) { if (results.exportOperation.status === 'completed') { + const text = results.url + ? TAPi18n.__('UserDataDownload_CompletedRequestExistedWithLink_Text', { download_link: results.url }) + : t('UserDataDownload_CompletedRequestExisted_Text'); + modal.open({ title: t('UserDataDownload_Requested'), - text: t('UserDataDownload_CompletedRequestExisted_Text'), + text, type: 'success', + html: true, }); return true; diff --git a/app/ui-account/client/accountProfile.html b/app/ui-account/client/accountProfile.html index c3e3e5a40a57..59435c30f2ff 100644 --- a/app/ui-account/client/accountProfile.html +++ b/app/ui-account/client/accountProfile.html @@ -70,6 +70,26 @@ {{/if}}
    +
    + {{# with canChange=allowStatusMessageChange}} + +
    + + {{# unless canChange}} +
    {{_ 'StatusMessage_Change_Disabled'}}
    + {{/unless}} +
    + {{/with}} +
    {{# with canChange=allowRealNameChange}}
    diff --git a/app/ui-account/client/accountProfile.js b/app/ui-account/client/accountProfile.js index 653167237042..73f65a96e351 100644 --- a/app/ui-account/client/accountProfile.js +++ b/app/ui-account/client/accountProfile.js @@ -20,6 +20,11 @@ const validateUsername = (username) => { return reg.test(username); }; const validateName = (name) => name && name.length; +const validateStatusMessage = (statusMessage) => { + if (!statusMessage || statusMessage.length <= 120 || statusMessage.length === 0) { + return true; + } +}; const validatePassword = (password, confirmationPassword) => { if (!confirmationPassword) { return true; @@ -72,6 +77,9 @@ Template.accountProfile.helpers({ nameInvalid() { return !validateName(Template.instance().realname.get()); }, + statusMessageInvalid() { + return !validateStatusMessage(Template.instance().statusText.get()); + }, confirmationPasswordInvalid() { const { password, confirmationPassword } = Template.instance(); return !validatePassword(password.get(), confirmationPassword.get()); @@ -109,6 +117,7 @@ Template.accountProfile.helpers({ const instance = Template.instance(); instance.dep.depend(); const realname = instance.realname.get(); + const statusText = instance.statusText.get(); const username = instance.username.get(); const password = instance.password.get(); const confirmationPassword = instance.confirmationPassword.get(); @@ -126,10 +135,10 @@ Template.accountProfile.helpers({ return; } } - if (!avatar && user.name === realname && user.username === username && getUserEmailAddress(user) === email === email && (!password || password !== confirmationPassword)) { + if (!avatar && user.name === realname && user.username === username && (!!user.u || getUserEmailAddress(user) === email === email) && (!password || password !== confirmationPassword)) { return ret; } - if (!validateEmail(email) || (!validateUsername(username) || usernameAvaliable !== true) || !validateName(realname)) { + if ((!validateEmail(email) && !user.u) || (!validateUsername(username) || usernameAvaliable !== true) || !validateName(realname) || !validateStatusMessage(statusText)) { return ret; } }, @@ -142,6 +151,9 @@ Template.accountProfile.helpers({ username() { return Meteor.user().username; }, + statusText() { + return Meteor.user().statusText; + }, email() { const user = Meteor.user(); return getUserEmailAddress(user); @@ -153,6 +165,9 @@ Template.accountProfile.helpers({ allowRealNameChange() { return settings.get('Accounts_AllowRealNameChange'); }, + allowStatusMessageChange() { + return settings.get('Accounts_AllowUserStatusMessageChange'); + }, allowUsernameChange() { return settings.get('Accounts_AllowUsernameChange') && settings.get('LDAP_Enable') !== true; }, @@ -187,6 +202,7 @@ Template.accountProfile.onCreated(function() { self.avatar = new ReactiveVar(); self.url = new ReactiveVar(''); self.usernameAvaliable = new ReactiveVar(true); + self.statusText = new ReactiveVar(user.statusText); Notifications.onLogged('updateAvatar', () => self.avatar.set()); self.getSuggestions = function() { @@ -251,6 +267,16 @@ Template.accountProfile.onCreated(function() { } data.realname = s.trim(self.realname.get()); } + if (s.trim(self.statusText.get()) !== user.statusText) { + if (!settings.get('Accounts_AllowUserStatusMessageChange')) { + toastr.remove(); + toastr.error(t('StatusMessage_Change_Disabled')); + instance.clearForm(); + return cb && cb(); + } + + data.statusText = s.trim(self.statusText.get()); + } if (s.trim(self.username.get()) !== user.username) { if (!settings.get('Accounts_AllowUsernameChange')) { toastr.remove(); @@ -370,6 +396,9 @@ Template.accountProfile.events({ 'input [name=realname]'(e, instance) { instance.realname.set(e.target.value); }, + 'input [name=statusText]'(e, instance) { + instance.statusText.set(e.target.value); + }, 'input [name=password]'(e, instance) { instance.password.set(e.target.value); diff --git a/app/ui-cached-collection/client/models/CachedCollection.js b/app/ui-cached-collection/client/models/CachedCollection.js index a8c3276d2711..9a0aa0305ce8 100644 --- a/app/ui-cached-collection/client/models/CachedCollection.js +++ b/app/ui-cached-collection/client/models/CachedCollection.js @@ -9,6 +9,7 @@ import _ from 'underscore'; import { callbacks } from '../../../callbacks'; import Notifications from '../../../notifications/client/lib/Notifications'; +import { getConfig } from '../../../ui-utils/client/config'; class CachedCollectionManagerClass { constructor() { @@ -99,7 +100,7 @@ class CachedCollectionManagerClass { export const CachedCollectionManager = new CachedCollectionManagerClass(); -const debug = false; +const debug = (name) => [getConfig(`debugCachedCollection-${ name }`), getConfig('debugCachedCollection'), getConfig('debug')].includes('true'); const nullLog = function() {}; @@ -134,13 +135,12 @@ export class CachedCollection { this.useSync = useSync; this.useCache = useCache; this.listenChangesForLoggedUsersOnly = listenChangesForLoggedUsersOnly; - this.debug = debug; this.version = version; this.userRelated = userRelated; this.updatedAt = new Date(0); this.maxCacheTime = maxCacheTime; this.onSyncData = onSyncData; - this.log = debug ? log : nullLog; + this.log = debug(name) ? log : nullLog; CachedCollectionManager.register(this); if (userRelated === true) { diff --git a/app/ui-flextab/client/tabs/userEdit.html b/app/ui-flextab/client/tabs/userEdit.html index dbe58ba74b3b..bfb908eb57d0 100644 --- a/app/ui-flextab/client/tabs/userEdit.html +++ b/app/ui-flextab/client/tabs/userEdit.html @@ -98,6 +98,20 @@
    +
    +
    + +
    +
    + {{#if hasPermission 'edit-other-user-password'}}
    diff --git a/app/ui-flextab/client/tabs/userEdit.js b/app/ui-flextab/client/tabs/userEdit.js index 499422177e88..d1a9771ab21e 100644 --- a/app/ui-flextab/client/tabs/userEdit.js +++ b/app/ui-flextab/client/tabs/userEdit.js @@ -182,6 +182,7 @@ Template.userEdit.onCreated(function() { const userData = { _id: this.user != null ? this.user._id : undefined }; userData.name = s.trim(this.$('#name').val()); userData.username = s.trim(this.$('#username').val()); + userData.statusText = s.trim(this.$('#status').val()); userData.email = s.trim(this.$('#email').val()); userData.verified = this.$('#verified:checked').length > 0; userData.password = s.trim(this.$('#password').val()); diff --git a/app/ui-flextab/client/tabs/userInfo.html b/app/ui-flextab/client/tabs/userInfo.html index 3b1aff5fc614..bc8cec480100 100644 --- a/app/ui-flextab/client/tabs/userInfo.html +++ b/app/ui-flextab/client/tabs/userInfo.html @@ -33,7 +33,7 @@
    diff --git a/app/ui-flextab/client/tabs/userInfo.js b/app/ui-flextab/client/tabs/userInfo.js index 8921477ec0c7..68a68e265cf4 100644 --- a/app/ui-flextab/client/tabs/userInfo.js +++ b/app/ui-flextab/client/tabs/userInfo.js @@ -8,11 +8,12 @@ import moment from 'moment'; import { DateFormat } from '../../../lib'; import { popover } from '../../../ui-utils'; -import { templateVarHandler } from '../../../utils'; +import { t, templateVarHandler } from '../../../utils'; import { RoomRoles, UserRoles, Roles } from '../../../models'; import { settings } from '../../../settings'; import FullUser from '../../../models/client/models/FullUser'; import { getActions } from './userActions'; + import './userInfo.html'; const shownActionsCount = 2; @@ -85,6 +86,15 @@ Template.userInfo.helpers({ return userStatus || 'offline'; }, + userStatusText() { + if (s.trim(this.statusText)) { + return this.statusText; + } + + const user = Template.instance().user.get(); + return t(Session.get(`user_${ user.username }_status`)); + }, + email() { const user = Template.instance().user.get(); return user && user.emails && user.emails[0] && user.emails[0].address; diff --git a/app/ui-master/client/main.js b/app/ui-master/client/main.js index 1b2be0ce0b5d..45685d58d9e5 100644 --- a/app/ui-master/client/main.js +++ b/app/ui-master/client/main.js @@ -86,14 +86,15 @@ Template.body.onRendered(function() { return; } - popover.close(); - if (/input|textarea|select/i.test(target.tagName)) { return; } if (target.id === 'pswp') { return; } + + popover.close(); + const inputMessage = chatMessages[RoomManager.openedRoom] && chatMessages[RoomManager.openedRoom].input; if (!inputMessage) { return; @@ -184,7 +185,7 @@ Template.main.helpers({ hasUsername() { const uid = Meteor.userId(); const user = uid && Users.findOne({ _id: uid }, { fields: { username: 1 } }); - return (user && user.username) || settings.get('Accounts_AllowAnonymousRead'); + return (user && user.username) || (!uid && settings.get('Accounts_AllowAnonymousRead')); }, requirePasswordChange() { const user = Meteor.user(); @@ -194,7 +195,7 @@ Template.main.helpers({ const user = Meteor.user(); // User is already using 2fa - if (user.services.totp !== undefined && user.services.totp.enabled) { + if (!user || (user.services.totp !== undefined && user.services.totp.enabled)) { return false; } diff --git a/app/ui-message/client/message.js b/app/ui-message/client/message.js index f92fa28ac2a1..b0fcd419455a 100644 --- a/app/ui-message/client/message.js +++ b/app/ui-message/client/message.js @@ -429,33 +429,34 @@ Template.message.helpers({ const findParentMessage = (() => { const waiting = []; - + const uid = Tracker.nonreactive(() => Meteor.userId()); const getMessages = _.debounce(async function() { - const uid = Tracker.nonreactive(() => Meteor.userId()); const _tmp = [...waiting]; waiting.length = 0; - (await call('getMessages', _tmp)).map((msg) => upsertMessage({ msg, uid })); + (await call('getMessages', _tmp)).map((msg) => Messages.findOne({ _id: msg._id }) || upsertMessage({ msg: { ...msg, _hidden: true }, uid })); }, 500); + return (tmid) => { if (waiting.indexOf(tmid) > -1) { return; } - const message = Messages.findOne({ _id: tmid }); - if (message) { - const uid = Tracker.nonreactive(() => Meteor.userId()); - return Messages.update({ tmid, repliesCount: { $exists: 0 } }, { + if (!message) { + waiting.push(tmid); + return getMessages(); + } + return Messages.update( + { tmid, repliesCount: { $exists: 0 } }, + { $set: { following: message.replies && message.replies.indexOf(uid) > -1, threadMsg: normalizeThreadMessage(message), repliesCount: message.tcount, }, - }, { multi: true }); - } - - waiting.push(tmid); - getMessages(); + }, + { multi: true } + ); }; })(); diff --git a/app/ui-sidenav/client/roomList.js b/app/ui-sidenav/client/roomList.js index 50ac594740e9..e1cdc8b40b1d 100644 --- a/app/ui-sidenav/client/roomList.js +++ b/app/ui-sidenav/client/roomList.js @@ -25,6 +25,7 @@ Template.roomList.helpers({ 'settings.preferences.sidebarShowFavorites': 1, 'settings.preferences.sidebarShowUnread': 1, 'settings.preferences.sidebarShowDiscussion': 1, + 'settings.preferences.sidebarShowServiceAccounts': 1, 'services.tokenpass': 1, messageViewMode: 1, }, @@ -84,6 +85,10 @@ Template.roomList.helpers({ query.prid = { $exists: false }; } + if (getUserPreference(user, 'sidebarShowServiceAccounts')) { + query.sa = { $exists: false }; + } + if (getUserPreference(user, 'sidebarShowUnread')) { query.$or = [ { alert: { $ne: true } }, @@ -125,6 +130,9 @@ Template.roomList.helpers({ noSubscriptionText() { const instance = Template.instance(); + if (instance.data.anonymous) { + return 'No_channels_yet'; + } return roomTypes.roomTypes[instance.data.identifier].getUiText(UiTextContext.NO_ROOMS_SUBSCRIBED) || 'No_channels_yet'; }, diff --git a/app/ui-sidenav/client/sidebarHeader.js b/app/ui-sidenav/client/sidebarHeader.js index e3da42f88748..f7517262a731 100644 --- a/app/ui-sidenav/client/sidebarHeader.js +++ b/app/ui-sidenav/client/sidebarHeader.js @@ -8,10 +8,11 @@ import { popover, AccountBox, menu, SideNav, modal } from '../../ui-utils'; import { t, getUserPreference, handleError } from '../../utils'; import { callbacks } from '../../callbacks'; import { settings } from '../../settings'; -import { hasAtLeastOnePermission, hasPermission } from '../../authorization'; +import { hasAtLeastOnePermission } from '../../authorization'; +import { userStatus } from '../../user-status'; -const setStatus = (status) => { - AccountBox.setStatus(status); +const setStatus = (status, statusText) => { + AccountBox.setStatus(status, statusText); callbacks.run('userStatusManuallySet', status); popover.close(); }; @@ -219,7 +220,7 @@ const toolbarButtons = (user) => [{ }); } - if (serviceAccountEnabled && hasPermission('create-service-account')) { + if (serviceAccountEnabled && hasAtLeastOnePermission(['create-service-account'])) { items.push({ icon: 'user', name: t('Service_account'), @@ -373,39 +374,61 @@ Template.sidebarHeader.events({ 'click .sidebar__header .avatar'(e) { if (!(Meteor.userId() == null && settings.get('Accounts_AllowAnonymousRead'))) { const user = Meteor.user(); + + const userStatusList = Object.keys(userStatus.list).map((key) => { + const status = userStatus.list[key]; + const customName = status.localizeName ? null : status.name; + const name = status.localizeName ? t(status.name) : status.name; + const modifier = status.statusType || user.status; + + return { + icon: 'circle', + name, + modifier, + action: () => setStatus(status.statusType, customName), + }; + }); + + const statusText = user.statusText || t(user.status); + + userStatusList.push({ + icon: 'edit', + name: t('Edit_Status'), + type: 'open', + action: (e) => { + e.preventDefault(); + modal.open({ + title: t('Edit_Status'), + content: 'editStatus', + data: { + onSave() { + modal.close(); + }, + }, + modalClass: 'modal', + showConfirmButton: false, + showCancelButton: false, + confirmOnEnter: false, + }); + }, + }); + const config = { popoverClass: 'sidebar-header', columns: [ { groups: [ + { + title: user.name, + items: [{ + icon: 'circle', + name: statusText, + modifier: user.status, + }], + }, { title: t('User'), - items: [ - { - icon: 'circle', - name: t('online'), - modifier: 'online', - action: () => setStatus('online'), - }, - { - icon: 'circle', - name: t('away'), - modifier: 'away', - action: () => setStatus('away'), - }, - { - icon: 'circle', - name: t('busy'), - modifier: 'busy', - action: () => setStatus('busy'), - }, - { - icon: 'circle', - name: t('invisible'), - modifier: 'offline', - action: () => setStatus('offline'), - }, - ], + items: userStatusList, }, { items: [ diff --git a/app/ui-sidenav/client/sidebarItem.js b/app/ui-sidenav/client/sidebarItem.js index c3f9d260fb3d..a22add9c6b54 100644 --- a/app/ui-sidenav/client/sidebarItem.js +++ b/app/ui-sidenav/client/sidebarItem.js @@ -78,8 +78,6 @@ Template.sidebarItem.onCreated(function() { this.lastMessageTs = new ReactiveVar(); this.timeAgoInterval; - // console.log('sidebarItem.onCreated'); - this.autorun(() => { const currentData = Template.currentData(); @@ -101,7 +99,7 @@ Template.sidebarItem.onCreated(function() { const otherUser = settings.get('UI_Use_Real_Name') ? currentData.lastMessage.u.name || currentData.lastMessage.u.username : currentData.lastMessage.u.username; const renderedMessage = renderMessageBody(currentData.lastMessage).replace(//g, ' '); - const sender = this.user._id === currentData.lastMessage.u._id ? t('You') : otherUser; + const sender = this.user && this.user._id === currentData.lastMessage.u._id ? t('You') : otherUser; if (currentData.t === 'd' && Meteor.userId() !== currentData.lastMessage.u._id) { this.renderedMessage = currentData.lastMessage.msg === '' ? t('Sent_an_attachment') : renderedMessage; diff --git a/app/ui-sidenav/client/sortlist.html b/app/ui-sidenav/client/sortlist.html index fa8efb05de23..40b58017368d 100644 --- a/app/ui-sidenav/client/sortlist.html +++ b/app/ui-sidenav/client/sortlist.html @@ -31,6 +31,15 @@ {{_ "Group_discussions"}}
  • +
  • + +
  • {{/with}} {{/each}} diff --git a/app/ui/client/components/header/headerRoom.html b/app/ui/client/components/header/headerRoom.html index 725aaeef2ec9..5d232cb86566 100644 --- a/app/ui/client/components/header/headerRoom.html +++ b/app/ui/client/components/header/headerRoom.html @@ -42,7 +42,7 @@ {{#if isDirect}}
    -
    {{_ userStatus}}
    +
    {{userStatusText}}
    {{else}} {{#if roomTopic}}{{{roomTopic}}}{{/if}} diff --git a/app/ui/client/components/header/headerRoom.js b/app/ui/client/components/header/headerRoom.js index ce6f0ef4d685..7e78dae45b8b 100644 --- a/app/ui/client/components/header/headerRoom.js +++ b/app/ui/client/components/header/headerRoom.js @@ -4,6 +4,7 @@ import { ReactiveVar } from 'meteor/reactive-var'; import { Session } from 'meteor/session'; import { Template } from 'meteor/templating'; import { FlowRouter } from 'meteor/kadira:flow-router'; +import s from 'underscore.string'; import { t, roomTypes, handleError } from '../../../../utils'; import { TabBar, fireGlobalEvent, call } from '../../../../ui-utils'; @@ -22,6 +23,10 @@ const isDiscussion = ({ _id }) => { return !!(room && room.prid); }; +const getUserStatus = (id) => { + const roomData = Session.get(`roomData${ id }`); + return roomTypes.getUserStatus(roomData.t, id) || 'offline'; +}; Template.headerRoom.helpers({ back() { @@ -102,12 +107,22 @@ Template.headerRoom.helpers({ }, encryptionState() { const room = ChatRoom.findOne(this._id); - return (room && room.encrypted) && 'encrypted'; + return settings.get('E2E_Enable') && room && room.encrypted && 'encrypted'; }, userStatus() { + return getUserStatus(this._id); + }, + + userStatusText() { const roomData = Session.get(`roomData${ this._id }`); - return roomTypes.getUserStatus(roomData.t, this._id) || t('offline'); + const statusText = roomTypes.getUserStatusText(roomData.t, this._id); + + if (s.trim(statusText)) { + return statusText; + } + + return t(getUserStatus(this._id)); }, showToggleFavorite() { diff --git a/app/ui/client/components/selectDropdown.html b/app/ui/client/components/selectDropdown.html index 54015b97f5d9..39b7ab710dc0 100644 --- a/app/ui/client/components/selectDropdown.html +++ b/app/ui/client/components/selectDropdown.html @@ -1,19 +1,18 @@ diff --git a/app/ui/client/components/selectDropdown.js b/app/ui/client/components/selectDropdown.js deleted file mode 100644 index 45a8df186451..000000000000 --- a/app/ui/client/components/selectDropdown.js +++ /dev/null @@ -1,23 +0,0 @@ -import { ReactiveVar } from 'meteor/reactive-var'; -import { Template } from 'meteor/templating'; - -Template.selectDropdown.events({ - 'focus input'(e, i) { - i.open.set(true); - console.log('asdasd'); - }, - 'blur input'(e, i) { - setTimeout(() => { - i.open.set(false); - }, 100); - console.log('asdasd'); - }, -}); -Template.selectDropdown.helpers({ - open() { - return Template.instance().open.get(); - }, -}); -Template.selectDropdown.onCreated(function() { - this.open = new ReactiveVar(false); -}); diff --git a/app/ui/client/index.js b/app/ui/client/index.js index 9c9c1b40be47..aafc4dc9cbfd 100644 --- a/app/ui/client/index.js +++ b/app/ui/client/index.js @@ -15,6 +15,8 @@ import './views/404/invalidSecretURL.html'; import './views/app/audioNotification.html'; import './views/app/burger.html'; import './views/app/createChannel.html'; +import './views/app/editStatus.html'; +import './views/app/editStatus.css'; import './views/app/fullModal.html'; import './views/app/home.html'; import './views/app/notAuthorized.html'; @@ -33,6 +35,7 @@ import './views/modal'; import './views/404/roomNotFound'; import './views/app/burger'; import './views/app/createChannel'; +import './views/app/editStatus'; import './views/app/fullModal'; import './views/app/home'; import './views/app/directory'; @@ -50,7 +53,7 @@ import './components/tabs'; import './components/popupList.html'; import './components/popupList'; import './components/selectDropdown.html'; -import './components/selectDropdown'; + import './components/header/header.html'; import './components/header/header'; import './components/header/headerRoom.html'; diff --git a/app/ui/client/lib/iframeCommands.js b/app/ui/client/lib/iframeCommands.js index b2e8b3d39c41..a8948edb6938 100644 --- a/app/ui/client/lib/iframeCommands.js +++ b/app/ui/client/lib/iframeCommands.js @@ -16,7 +16,6 @@ const commands = { FlowRouter.go(data.path, null, FlowRouter.current().queryParams); }, - 'set-user-status'(data) { AccountBox.setStatus(data.status); }, diff --git a/app/ui/client/views/app/directory.html b/app/ui/client/views/app/directory.html index a209293b1fc4..34c3362a3719 100644 --- a/app/ui/client/views/app/directory.html +++ b/app/ui/client/views/app/directory.html @@ -18,7 +18,7 @@ class="rc-input__element js-search" name="message-search" id="message-search" - placeholder="{{#if $eq searchType 'channels'}}{{_ "Search_Channels"}}{{/if}}{{#if $eq searchType 'users'}}{{_ "Search_Users"}}{{/if}}" + placeholder="{{#if $eq searchType 'channels'}}{{_ "Search_Channels"}}{{/if}}{{#if $eq searchType 'users'}}{{_ "Search_Users"}}{{/if}}{{#if $eq searchType 'serviceAccounts'}}{{_ "Search_ServiceAccounts"}}{{/if}}" autocomplete="off"> {{#if $eq searchType 'users'}} @@ -164,6 +164,65 @@ {{/table}} {{/if}} + {{#if $eq searchType 'serviceAccounts'}} + {{#table onItemClick=onTableItemClick onScroll=onTableScroll onResize=onTableResize onSort=onTableSort}} + + + +
    {{_ "Name"}} {{> icon icon=(sortIcon 'name') }}
    + + +
    {{_ "Username"}} {{> icon icon=(sortIcon 'username') }}
    + + +
    {{_ "Subscribers"}} {{> icon icon=(sortIcon 'subscribers') }}
    + + +
    {{_ "Created_at"}} {{> icon icon=(sortIcon 'createdAt') }}
    + + +
    {{_ "Description"}}
    + + + + + {{#each searchResults}} + + +
    +
    {{> avatar username=username}}
    +
    + + {{name}} + +
    +
    + + {{username}} + {{subscribers}} + {{createdAt}} + {{description}} + + {{else}} + {{# with searchText}} + + + {{_ "No_results_found_for"}} {{.}} + + + {{/with}} + {{/each}} + {{#if isLoading}} + + + {{> loading}} + + + {{/if}} + + {{/table}} + {{/if}} diff --git a/app/ui/client/views/app/directory.js b/app/ui/client/views/app/directory.js index ba4af117e7f0..19215393efe1 100644 --- a/app/ui/client/views/app/directory.js +++ b/app/ui/client/views/app/directory.js @@ -36,6 +36,17 @@ function directorySearch(config, cb) { domain: result.federation && result.federation.peer, }; } + + if (config.type === 'serviceAccounts') { + return { + name: result.name, + username: result.username, + createdAt: timeAgo(result.createdAt, t), + description: result.description, + subscribers: result.subscribers || 0, + domain: result.federation && result.federation.peer, + }; + } return null; })); }); @@ -96,13 +107,20 @@ Template.directory.helpers({ return true; }, }; + const serviceAccountsTab = { + label: t('Service_accounts'), + value: 'serviceAccounts', + condition() { + return true; + }, + }; if (searchType.get() === 'channels') { channelsTab.active = true; } else { usersTab.active = true; } return { - tabs: [channelsTab, usersTab], + tabs: [channelsTab, usersTab, serviceAccountsTab], onChange(value) { results.set([]); end.set(false); @@ -130,6 +148,9 @@ Template.directory.helpers({ if (searchType.get() === 'channels') { type = 'c'; routeConfig = { name: item.name }; + } else if (searchType.get() === 'users') { + type = 'd'; + routeConfig = { name: item.username }; } else { type = 'd'; routeConfig = { name: item.username }; diff --git a/app/ui/client/views/app/editStatus.css b/app/ui/client/views/app/editStatus.css new file mode 100644 index 000000000000..ab2170f62c88 --- /dev/null +++ b/app/ui/client/views/app/editStatus.css @@ -0,0 +1,45 @@ +.edit-status-type.rc-popover { + &__item { + &--online { + color: var(--status-online); + } + + &--away { + color: var(--status-away); + } + + &--busy { + color: var(--status-busy); + } + + &--offline { + color: var(--status-invisible); + } + } +} + +.edit-status-type-icon { + &--online { + & .rc-icon { + color: var(--status-online); + } + } + + &--away { + & .rc-icon { + color: var(--status-away); + } + } + + &--busy { + & .rc-icon { + color: var(--status-busy); + } + } + + &--offline { + & .rc-icon { + color: var(--status-invisible); + } + } +} diff --git a/app/ui/client/views/app/editStatus.html b/app/ui/client/views/app/editStatus.html new file mode 100644 index 000000000000..7228679a3983 --- /dev/null +++ b/app/ui/client/views/app/editStatus.html @@ -0,0 +1,30 @@ + diff --git a/app/ui/client/views/app/editStatus.js b/app/ui/client/views/app/editStatus.js new file mode 100644 index 000000000000..2c35e80b5fb4 --- /dev/null +++ b/app/ui/client/views/app/editStatus.js @@ -0,0 +1,113 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import toastr from 'toastr'; +import s from 'underscore.string'; + +import { settings } from '../../../../settings'; +import { t } from '../../../../utils'; +import { popover } from '../../../../ui-utils'; + +Template.editStatus.helpers({ + canChange() { + return settings.get('Accounts_AllowUserStatusMessageChange'); + }, + statusType() { + return Meteor.user().status; + }, + statusText() { + return Meteor.user().statusText; + }, +}); + +Template.editStatus.events({ + 'click .edit-status .rc-input__icon'(e) { + const options = [ + { + icon: 'circle', + name: t('Online'), + modifier: 'online', + action: () => { + $('input[name=statusType]').val('online'); + $('.edit-status .rc-input__icon').prop('class', 'rc-input__icon edit-status-type-icon--online'); + }, + }, + { + icon: 'circle', + name: t('Away'), + modifier: 'away', + action: () => { + $('input[name=statusType]').val('away'); + $('.edit-status .rc-input__icon').prop('class', 'rc-input__icon edit-status-type-icon--away'); + }, + }, + { + icon: 'circle', + name: t('Busy'), + modifier: 'busy', + action: () => { + $('input[name=statusType]').val('busy'); + $('.edit-status .rc-input__icon').prop('class', 'rc-input__icon edit-status-type-icon--busy'); + }, + }, + { + icon: 'circle', + name: t('Invisible'), + modifier: 'offline', + action: () => { + $('input[name=statusType]').val('offline'); + $('.edit-status .rc-input__icon').prop('class', 'rc-input__icon edit-status-type-icon--offline'); + }, + }, + ]; + + const config = { + popoverClass: 'edit-status-type', + columns: [ + { + groups: [ + { + items: options, + }, + ], + }, + ], + currentTarget: e.currentTarget, + offsetVertical: e.currentTarget.clientHeight, + }; + popover.open(config); + }, + + 'submit .edit-status__content'(e, instance) { + e.preventDefault(); + e.stopPropagation(); + const statusText = s.trim(e.target.status.value); + const statusType = e.target.statusType.value; + + if (statusText !== this.statusText) { + if (statusText.length > 120) { + toastr.remove(); + toastr.error(t('StatusMessage_Too_Long')); + return false; + } + if (!settings.get('Accounts_AllowUserStatusMessageChange')) { + toastr.remove(); + toastr.error(t('StatusMessage_Change_Disabled')); + return false; + } + + if (statusText || statusText.length === 0) { + Meteor.call('setUserStatus', statusType, statusText); + if (instance.data.onSave) { + instance.data.onSave(true); + } + return; + } + } + return false; + }, +}); + + +Template.editStatus.onRendered(function() { + this.firstNode.querySelector('[name="status"]').focus(); +}); diff --git a/app/ui/client/views/app/room.js b/app/ui/client/views/app/room.js index 95113da33759..b72edd3c07d4 100644 --- a/app/ui/client/views/app/room.js +++ b/app/ui/client/views/app/room.js @@ -315,12 +315,13 @@ Template.room.helpers({ roomLeader() { const roles = RoomRoles.findOne({ rid: this._id, roles: 'leader', 'u._id': { $ne: Meteor.userId() } }); if (roles) { - const leader = Users.findOne({ _id: roles.u._id }, { fields: { status: 1 } }) || {}; + const leader = Users.findOne({ _id: roles.u._id }, { fields: { status: 1, statusText: 1 } }) || {}; + return { ...roles.u, name: settings.get('UI_Use_Real_Name') ? roles.u.name || roles.u.username : roles.u.username, status: leader.status || 'offline', - statusDisplay: ((status) => status.charAt(0).toUpperCase() + status.slice(1))(leader.status || 'offline'), + statusDisplay: leader.statusText || leader.status || 'offline', }; } }, @@ -386,11 +387,6 @@ Template.room.helpers({ return roomIcon; }, - userStatus() { - const { room } = Template.instance(); - return roomTypes.getUserStatus(room.t, this._id) || 'offline'; - }, - maxMessageLength() { return settings.get('Message_MaxAllowedSize'); }, diff --git a/app/user-status/client/admin/adminUserStatus.html b/app/user-status/client/admin/adminUserStatus.html new file mode 100644 index 000000000000..d1e7bdf52d67 --- /dev/null +++ b/app/user-status/client/admin/adminUserStatus.html @@ -0,0 +1,72 @@ + diff --git a/app/user-status/client/admin/adminUserStatus.js b/app/user-status/client/admin/adminUserStatus.js new file mode 100644 index 000000000000..4a2ae3da3f29 --- /dev/null +++ b/app/user-status/client/admin/adminUserStatus.js @@ -0,0 +1,137 @@ +import s from 'underscore.string'; +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { Tracker } from 'meteor/tracker'; + +import { CustomUserStatus } from '../../../models'; +import { TabBar, SideNav, RocketChatTabBar } from '../../../ui-utils'; +import { t } from '../../../utils'; + +Template.adminUserStatus.helpers({ + isReady() { + if (Template.instance().ready != null) { + return Template.instance().ready.get(); + } + return undefined; + }, + customUserStatus() { + return Template.instance().customUserStatus().map((userStatus) => { + const { _id, name, statusType } = userStatus; + const localizedStatusType = statusType ? t(statusType) : ''; + + return { + _id, + name, + statusType, + localizedStatusType, + }; + }); + }, + isLoading() { + if (Template.instance().ready != null) { + if (!Template.instance().ready.get()) { + return 'btn-loading'; + } + } + }, + hasMore() { + if (Template.instance().limit != null) { + if (typeof Template.instance().customUserStatus === 'function') { + return Template.instance().limit.get() === Template.instance().customUserStatus().length; + } + } + return false; + }, + flexData() { + return { + tabBar: Template.instance().tabBar, + data: Template.instance().tabBarData.get(), + }; + }, +}); + +Template.adminUserStatus.onCreated(function() { + const instance = this; + this.limit = new ReactiveVar(50); + this.filter = new ReactiveVar(''); + this.ready = new ReactiveVar(false); + + this.tabBar = new RocketChatTabBar(); + this.tabBar.showGroup(FlowRouter.current().route.name); + this.tabBarData = new ReactiveVar(); + + TabBar.addButton({ + groups: ['user-status-custom'], + id: 'add-user-status', + i18nTitle: 'Custom_User_Status_Add', + icon: 'plus', + template: 'adminUserStatusEdit', + order: 1, + }); + + TabBar.addButton({ + groups: ['user-status-custom'], + id: 'admin-user-status-info', + i18nTitle: 'Custom_User_Status_Info', + icon: 'customize', + template: 'adminUserStatusInfo', + order: 2, + }); + + this.autorun(function() { + const limit = instance.limit !== null ? instance.limit.get() : 0; + const subscription = instance.subscribe('fullUserStatusData', '', limit); + instance.ready.set(subscription.ready()); + }); + + this.customUserStatus = function() { + const filter = instance.filter != null ? s.trim(instance.filter.get()) : ''; + + let query = {}; + + if (filter) { + const filterReg = new RegExp(s.escapeRegExp(filter), 'i'); + query = { $or: [{ name: filterReg }] }; + } + + const limit = instance.limit != null ? instance.limit.get() : 0; + + return CustomUserStatus.find(query, { limit, sort: { name: 1 } }).fetch(); + }; +}); + +Template.adminUserStatus.onRendered(() => + Tracker.afterFlush(function() { + SideNav.setFlex('adminFlex'); + SideNav.openFlex(); + }) +); + +Template.adminUserStatus.events({ + 'keydown #user-status-filter'(e) { + // stop enter key + if (e.which === 13) { + e.stopPropagation(); + e.preventDefault(); + } + }, + + 'keyup #user-status-filter'(e, t) { + e.stopPropagation(); + e.preventDefault(); + t.filter.set(e.currentTarget.value); + }, + + 'click .user-status-info'(e, instance) { + e.preventDefault(); + instance.tabBarData.set(CustomUserStatus.findOne({ _id: this._id })); + instance.tabBar.open('admin-user-status-info'); + }, + + 'click .load-more'(e, t) { + e.preventDefault(); + e.stopPropagation(); + t.limit.set(t.limit.get() + 50); + }, +}); diff --git a/app/user-status/client/admin/adminUserStatusEdit.html b/app/user-status/client/admin/adminUserStatusEdit.html new file mode 100644 index 000000000000..afe5682b0bae --- /dev/null +++ b/app/user-status/client/admin/adminUserStatusEdit.html @@ -0,0 +1,7 @@ + diff --git a/app/user-status/client/admin/adminUserStatusInfo.html b/app/user-status/client/admin/adminUserStatusInfo.html new file mode 100644 index 000000000000..e6f68e9c66e1 --- /dev/null +++ b/app/user-status/client/admin/adminUserStatusInfo.html @@ -0,0 +1,7 @@ + diff --git a/app/user-status/client/admin/route.js b/app/user-status/client/admin/route.js new file mode 100644 index 000000000000..d5c8c6be1e21 --- /dev/null +++ b/app/user-status/client/admin/route.js @@ -0,0 +1,9 @@ +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; + +FlowRouter.route('/admin/user-status-custom', { + name: 'user-status-custom', + action(/* params */) { + BlazeLayout.render('main', { center: 'adminUserStatus' }); + }, +}); diff --git a/app/user-status/client/admin/startup.js b/app/user-status/client/admin/startup.js new file mode 100644 index 000000000000..d9e33bbf5beb --- /dev/null +++ b/app/user-status/client/admin/startup.js @@ -0,0 +1,11 @@ +import { AdminBox } from '../../../ui-utils'; +import { hasAtLeastOnePermission } from '../../../authorization'; + +AdminBox.addOption({ + href: 'user-status-custom', + i18nLabel: 'Custom_User_Status', + icon: 'user', + permissionGranted() { + return hasAtLeastOnePermission(['manage-user-status']); + }, +}); diff --git a/app/user-status/client/admin/userStatusEdit.html b/app/user-status/client/admin/userStatusEdit.html new file mode 100644 index 000000000000..d0ac4103511b --- /dev/null +++ b/app/user-status/client/admin/userStatusEdit.html @@ -0,0 +1,42 @@ + diff --git a/app/user-status/client/admin/userStatusEdit.js b/app/user-status/client/admin/userStatusEdit.js new file mode 100644 index 000000000000..7aaa668cdf35 --- /dev/null +++ b/app/user-status/client/admin/userStatusEdit.js @@ -0,0 +1,115 @@ +import toastr from 'toastr'; +import s from 'underscore.string'; +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { TAPi18n } from 'meteor/tap:i18n'; + +import { t, handleError } from '../../../utils'; + +Template.userStatusEdit.helpers({ + userStatus() { + return Template.instance().userStatus; + }, + + options() { + const userStatusType = this.userStatus ? this.userStatus.statusType : ''; + + return [{ + value: 'online', + name: t('Online'), + selected: userStatusType === 'online' ? 'selected' : '', + }, { + value: 'away', + name: t('Away'), + selected: userStatusType === 'away' ? 'selected' : '', + }, { + value: 'busy', + name: t('Busy'), + selected: userStatusType === 'busy' ? 'selected' : '', + }, { + value: 'offline', + name: t('Invisible'), + selected: userStatusType === 'offline' ? 'selected' : '', + }]; + }, +}); + +Template.userStatusEdit.events({ + 'click .cancel'(e, t) { + e.stopPropagation(); + e.preventDefault(); + t.cancel(t.find('form')); + }, + + 'submit form'(e, t) { + e.stopPropagation(); + e.preventDefault(); + t.save(e.currentTarget); + }, +}); + +Template.userStatusEdit.onCreated(function() { + if (this.data != null) { + this.userStatus = this.data.userStatus; + } else { + this.userStatus = undefined; + } + + this.tabBar = Template.currentData().tabBar; + + this.cancel = (form, name) => { + form.reset(); + this.tabBar.close(); + if (this.userStatus) { + this.data.back(name); + } + }; + + this.getUserStatusData = () => { + const userStatusData = {}; + if (this.userStatus != null) { + userStatusData._id = this.userStatus._id; + userStatusData.previousName = this.userStatus.name; + } + userStatusData.name = s.trim(this.$('#name').val()); + userStatusData.statusType = s.trim(this.$('#statusType').val()); + return userStatusData; + }; + + this.validate = () => { + const userStatusData = this.getUserStatusData(); + + const errors = []; + if (!userStatusData.name) { + errors.push('Name'); + } + + for (const error of errors) { + toastr.error(TAPi18n.__('error-the-field-is-required', { field: TAPi18n.__(error) })); + } + + return errors.length === 0; + }; + + this.save = (form) => { + if (this.validate()) { + const userStatusData = this.getUserStatusData(); + + Meteor.call('insertOrUpdateUserStatus', userStatusData, (error, result) => { + if (result) { + if (userStatusData._id) { + toastr.success(t('Custom_User_Status_Updated_Successfully')); + } else { + toastr.success(t('Custom_User_Status_Added_Successfully')); + } + + this.cancel(form, userStatusData.name); + } + + if (error) { + handleError(error); + } + }); + } + }; +}); diff --git a/app/user-status/client/admin/userStatusInfo.html b/app/user-status/client/admin/userStatusInfo.html new file mode 100644 index 000000000000..e3cd7948b144 --- /dev/null +++ b/app/user-status/client/admin/userStatusInfo.html @@ -0,0 +1,22 @@ + diff --git a/app/user-status/client/admin/userStatusInfo.js b/app/user-status/client/admin/userStatusInfo.js new file mode 100644 index 000000000000..e3fe8f75c41f --- /dev/null +++ b/app/user-status/client/admin/userStatusInfo.js @@ -0,0 +1,117 @@ +import { Meteor } from 'meteor/meteor'; +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import { t, handleError } from '../../../utils'; +import { modal } from '../../../ui-utils'; + +Template.userStatusInfo.helpers({ + name() { + const userStatus = Template.instance().userStatus.get(); + return userStatus.name; + }, + + userStatus() { + return Template.instance().userStatus.get(); + }, + + editingUserStatus() { + return Template.instance().editingUserStatus.get(); + }, + + userStatusToEdit() { + const instance = Template.instance(); + return { + tabBar: this.tabBar, + userStatus: instance.userStatus.get(), + back(name) { + instance.editingUserStatus.set(); + + if (name != null) { + const userStatus = instance.userStatus.get(); + if (userStatus != null && userStatus.name != null && userStatus.name !== name) { + return instance.loadedName.set(name); + } + } + }, + }; + }, +}); + +Template.userStatusInfo.events({ + 'click .thumb'(e) { + $(e.currentTarget).toggleClass('bigger'); + }, + + 'click .delete'(e, instance) { + e.stopPropagation(); + e.preventDefault(); + const userStatus = instance.userStatus.get(); + if (userStatus != null) { + const { _id } = userStatus; + modal.open({ + title: t('Are_you_sure'), + text: t('Custom_User_Status_Delete_Warning'), + type: 'warning', + showCancelButton: true, + confirmButtonColor: '#DD6B55', + confirmButtonText: t('Yes_delete_it'), + cancelButtonText: t('Cancel'), + closeOnConfirm: false, + html: false, + }, function() { + Meteor.call('deleteCustomUserStatus', _id, (error/* , result */) => { + if (error) { + return handleError(error); + } + + modal.open({ + title: t('Deleted'), + text: t('Custom_User_Status_Has_Been_Deleted'), + type: 'success', + timer: 2000, + showConfirmButton: false, + }); + + instance.tabBar.close(); + }); + }); + } + }, + + 'click .edit-user-satus'(e, instance) { + e.stopPropagation(); + e.preventDefault(); + + instance.editingUserStatus.set(instance.userStatus.get()._id); + }, +}); + +Template.userStatusInfo.onCreated(function() { + this.userStatus = new ReactiveVar(); + this.editingUserStatus = new ReactiveVar(); + this.loadedName = new ReactiveVar(); + this.tabBar = Template.currentData().tabBar; + + this.autorun(() => { + const data = Template.currentData(); + if (data != null && data.clear != null) { + this.clear = data.clear; + } + }); + + this.autorun(() => { + const data = Template.currentData(); + const userStatus = this.userStatus.get(); + if (userStatus != null && userStatus.name != null) { + this.loadedName.set(userStatus.name); + } else if (data != null && data.name != null) { + this.loadedName.set(data.name); + } + }); + + this.autorun(() => { + const data = Template.currentData(); + this.userStatus.set(data); + }); +}); diff --git a/app/user-status/client/admin/userStatusPreview.html b/app/user-status/client/admin/userStatusPreview.html new file mode 100644 index 000000000000..5279a07cfdc1 --- /dev/null +++ b/app/user-status/client/admin/userStatusPreview.html @@ -0,0 +1,5 @@ + diff --git a/app/user-status/client/index.js b/app/user-status/client/index.js new file mode 100644 index 000000000000..cd0736a72021 --- /dev/null +++ b/app/user-status/client/index.js @@ -0,0 +1,17 @@ +import './admin/adminUserStatus.html'; +import './admin/adminUserStatus'; +import './admin/adminUserStatusEdit.html'; +import './admin/adminUserStatusInfo.html'; +import './admin/userStatusEdit.html'; +import './admin/userStatusEdit'; +import './admin/userStatusInfo.html'; +import './admin/userStatusInfo'; +import './admin/userStatusPreview.html'; +import './admin/route'; +import './admin/startup'; + +import './notifications/deleteCustomUserStatus'; +import './notifications/updateCustomUserStatus'; + +export { userStatus } from './lib/userStatus'; +export { deleteCustomUserStatus, updateCustomUserStatus } from './lib/customUserStatus'; diff --git a/app/user-status/client/lib/customUserStatus.js b/app/user-status/client/lib/customUserStatus.js new file mode 100644 index 000000000000..2168febe96dd --- /dev/null +++ b/app/user-status/client/lib/customUserStatus.js @@ -0,0 +1,54 @@ +import { Meteor } from 'meteor/meteor'; + +import { userStatus } from './userStatus'; + +userStatus.packages.customUserStatus = { + list: [], +}; + +export const deleteCustomUserStatus = function(customUserStatusData) { + delete userStatus.list[customUserStatusData._id]; + + const arrayIndex = userStatus.packages.customUserStatus.list.indexOf(customUserStatusData._id); + if (arrayIndex !== -1) { + userStatus.packages.customUserStatusData.list.splice(arrayIndex, 1); + } +}; + +export const updateCustomUserStatus = function(customUserStatusData) { + const newUserStatus = { + name: customUserStatusData.name, + id: customUserStatusData._id, + statusType: customUserStatusData.statusType, + localizeName: false, + }; + + const arrayIndex = userStatus.packages.customUserStatus.list.indexOf(newUserStatus.id); + if (arrayIndex === -1) { + userStatus.packages.customUserStatus.list.push(newUserStatus); + } else { + userStatus.packages.customUserStatus.list[arrayIndex] = newUserStatus; + } + + userStatus.list[newUserStatus.id] = newUserStatus; +}; + +Meteor.startup(() => + Meteor.call('listCustomUserStatus', (error, result) => { + if (!result) { + return; + } + + for (const customStatus of result) { + const newUserStatus = { + name: customStatus.name, + id: customStatus._id, + statusType: customStatus.statusType, + localizeName: false, + }; + + userStatus.packages.customUserStatus.list.push(newUserStatus); + userStatus.list[newUserStatus.id] = newUserStatus; + } + }) +); diff --git a/app/user-status/client/lib/userStatus.js b/app/user-status/client/lib/userStatus.js new file mode 100644 index 000000000000..71fded4e86d5 --- /dev/null +++ b/app/user-status/client/lib/userStatus.js @@ -0,0 +1,36 @@ +export const userStatus = { + packages: { + base: { + render(html) { + return html; + }, + }, + }, + + list: { + online: { + name: 'online', + localizeName: true, + id: 'online', + statusType: 'online', + }, + away: { + name: 'away', + localizeName: true, + id: 'away', + statusType: 'away', + }, + busy: { + name: 'busy', + localizeName: true, + id: 'busy', + statusType: 'busy', + }, + invisible: { + name: 'invisible', + localizeName: true, + id: 'offline', + statusType: 'offline', + }, + }, +}; diff --git a/app/user-status/client/notifications/deleteCustomUserStatus.js b/app/user-status/client/notifications/deleteCustomUserStatus.js new file mode 100644 index 000000000000..8bc89decab30 --- /dev/null +++ b/app/user-status/client/notifications/deleteCustomUserStatus.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; + +import { deleteCustomUserStatus } from '../lib/customUserStatus'; +import { Notifications } from '../../../notifications'; + +Meteor.startup(() => + Notifications.onLogged('deleteCustomUserStatus', (data) => deleteCustomUserStatus(data.userStatusData)) +); diff --git a/app/user-status/client/notifications/updateCustomUserStatus.js b/app/user-status/client/notifications/updateCustomUserStatus.js new file mode 100644 index 000000000000..28f4ed07e706 --- /dev/null +++ b/app/user-status/client/notifications/updateCustomUserStatus.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; + +import { updateCustomUserStatus } from '../lib/customUserStatus'; +import { Notifications } from '../../../notifications'; + +Meteor.startup(() => + Notifications.onLogged('updateCustomUserStatus', (data) => updateCustomUserStatus(data.userStatusData)) +); diff --git a/app/user-status/index.js b/app/user-status/index.js new file mode 100644 index 000000000000..a67eca871efb --- /dev/null +++ b/app/user-status/index.js @@ -0,0 +1,8 @@ +import { Meteor } from 'meteor/meteor'; + +if (Meteor.isClient) { + module.exports = require('./client/index.js'); +} +if (Meteor.isServer) { + module.exports = require('./server/index.js'); +} diff --git a/app/user-status/server/index.js b/app/user-status/server/index.js new file mode 100644 index 000000000000..1875567ed31f --- /dev/null +++ b/app/user-status/server/index.js @@ -0,0 +1,6 @@ +import './methods/deleteCustomUserStatus'; +import './methods/insertOrUpdateUserStatus'; +import './methods/listCustomUserStatus'; +import './methods/setUserStatus'; + +import './publications/fullUserStatusData'; diff --git a/app/user-status/server/methods/deleteCustomUserStatus.js b/app/user-status/server/methods/deleteCustomUserStatus.js new file mode 100644 index 000000000000..4d947ff5732c --- /dev/null +++ b/app/user-status/server/methods/deleteCustomUserStatus.js @@ -0,0 +1,26 @@ +import { Meteor } from 'meteor/meteor'; + +import { hasPermission } from '../../../authorization'; +import { Notifications } from '../../../notifications'; +import { CustomUserStatus } from '../../../models'; + +Meteor.methods({ + deleteCustomUserStatus(userStatusID) { + let userStatus = null; + + if (hasPermission(this.userId, 'manage-user-status')) { + userStatus = CustomUserStatus.findOneById(userStatusID); + } else { + throw new Meteor.Error('not_authorized'); + } + + if (userStatus == null) { + throw new Meteor.Error('Custom_User_Status_Error_Invalid_User_Status', 'Invalid user status', { method: 'deleteCustomUserStatus' }); + } + + CustomUserStatus.removeById(userStatusID); + Notifications.notifyLogged('deleteCustomUserStatus', { userStatusData: userStatus }); + + return true; + }, +}); diff --git a/app/user-status/server/methods/insertOrUpdateUserStatus.js b/app/user-status/server/methods/insertOrUpdateUserStatus.js new file mode 100644 index 000000000000..07b4631173be --- /dev/null +++ b/app/user-status/server/methods/insertOrUpdateUserStatus.js @@ -0,0 +1,70 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { hasPermission } from '../../../authorization'; +import { Notifications } from '../../../notifications'; +import { CustomUserStatus } from '../../../models'; + +Meteor.methods({ + insertOrUpdateUserStatus(userStatusData) { + if (!hasPermission(this.userId, 'manage-user-status')) { + throw new Meteor.Error('not_authorized'); + } + + if (!s.trim(userStatusData.name)) { + throw new Meteor.Error('error-the-field-is-required', 'The field Name is required', { method: 'insertOrUpdateUserStatus', field: 'Name' }); + } + + // allow all characters except >, <, &, ", ' + // more practical than allowing specific sets of characters; also allows foreign languages + const nameValidation = /[><&"']/; + + if (nameValidation.test(userStatusData.name)) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${ userStatusData.name } is not a valid name`, { method: 'insertOrUpdateUserStatus', input: userStatusData.name, field: 'Name' }); + } + + let matchingResults = []; + + if (userStatusData._id) { + matchingResults = CustomUserStatus.findByNameExceptId(userStatusData.name, userStatusData._id).fetch(); + } else { + matchingResults = CustomUserStatus.findByName(userStatusData.name).fetch(); + } + + if (matchingResults.length > 0) { + throw new Meteor.Error('Custom_User_Status_Error_Name_Already_In_Use', 'The custom user status name is already in use', { method: 'insertOrUpdateUserStatus' }); + } + + const validStatusTypes = ['online', 'away', 'busy', 'offline']; + if (userStatusData.statusType && validStatusTypes.indexOf(userStatusData.statusType) < 0) { + throw new Meteor.Error('error-input-is-not-a-valid-field', `${ userStatusData.statusType } is not a valid status type`, { method: 'insertOrUpdateUserStatus', input: userStatusData.statusType, field: 'StatusType' }); + } + + if (!userStatusData._id) { + // insert user status + const createUserStatus = { + name: userStatusData.name, + statusType: userStatusData.statusType || null, + }; + + const _id = CustomUserStatus.create(createUserStatus); + + Notifications.notifyLogged('updateCustomUserStatus', { userStatusData: createUserStatus }); + + return _id; + } + + // update User status + if (userStatusData.name !== userStatusData.previousName) { + CustomUserStatus.setName(userStatusData._id, userStatusData.name); + } + + if (userStatusData.statusType !== userStatusData.previousStatusType) { + CustomUserStatus.setStatusType(userStatusData._id, userStatusData.statusType); + } + + Notifications.notifyLogged('updateCustomUserStatus', { userStatusData }); + + return true; + }, +}); diff --git a/app/user-status/server/methods/listCustomUserStatus.js b/app/user-status/server/methods/listCustomUserStatus.js new file mode 100644 index 000000000000..912f8531a405 --- /dev/null +++ b/app/user-status/server/methods/listCustomUserStatus.js @@ -0,0 +1,9 @@ +import { Meteor } from 'meteor/meteor'; + +import { CustomUserStatus } from '../../../models'; + +Meteor.methods({ + listCustomUserStatus() { + return CustomUserStatus.find({}).fetch(); + }, +}); diff --git a/app/user-status/server/methods/setUserStatus.js b/app/user-status/server/methods/setUserStatus.js new file mode 100644 index 000000000000..3ea7d4e07ff9 --- /dev/null +++ b/app/user-status/server/methods/setUserStatus.js @@ -0,0 +1,30 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; + +import { settings } from '../../../settings'; +import { RateLimiter, setStatusMessage } from '../../../lib'; + +Meteor.methods({ + setUserStatus(statusType, statusText) { + if (statusType) { + Meteor.call('UserPresence:setDefaultStatus', statusType); + } + + if (statusText || statusText === '') { + check(statusText, String); + + if (!settings.get('Accounts_AllowUserStatusMessageChange')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { + method: 'setUserStatus', + }); + } + + const userId = Meteor.userId(); + setStatusMessage(userId, statusText); + } + }, +}); + +RateLimiter.limitMethod('setUserStatus', 1, 1000, { + userId: () => true, +}); diff --git a/app/user-status/server/publications/fullUserStatusData.js b/app/user-status/server/publications/fullUserStatusData.js new file mode 100644 index 000000000000..f877cb942824 --- /dev/null +++ b/app/user-status/server/publications/fullUserStatusData.js @@ -0,0 +1,30 @@ +import s from 'underscore.string'; +import { Meteor } from 'meteor/meteor'; + +import { CustomUserStatus } from '../../../models'; + +Meteor.publish('fullUserStatusData', function(filter, limit) { + if (!this.userId) { + return this.ready(); + } + + const fields = { + name: 1, + statusType: 1, + }; + + filter = s.trim(filter); + + const options = { + fields, + limit, + sort: { name: 1 }, + }; + + if (filter) { + const filterReg = new RegExp(s.escapeRegExp(filter), 'i'); + return CustomUserStatus.findByName(filterReg, options); + } + + return CustomUserStatus.find({}, options); +}); diff --git a/app/utils/client/lib/roomTypes.js b/app/utils/client/lib/roomTypes.js index 887d82c791b6..709d8ee63950 100644 --- a/app/utils/client/lib/roomTypes.js +++ b/app/utils/client/lib/roomTypes.js @@ -51,6 +51,10 @@ export const roomTypes = new class RocketChatRoomTypes extends RoomTypesCommon { return room && room.t; } + getUserStatusText(roomType, rid) { + return this.roomTypes[roomType] && typeof this.roomTypes[roomType].getUserStatusText === 'function' && this.roomTypes[roomType].getUserStatusText(rid); + } + findRoom(roomType, identifier, user) { return this.roomTypes[roomType] && this.roomTypes[roomType].findRoom(identifier, user); } diff --git a/app/utils/lib/RoomTypeConfig.js b/app/utils/lib/RoomTypeConfig.js index 02638f39e1ce..098ffff938e9 100644 --- a/app/utils/lib/RoomTypeConfig.js +++ b/app/utils/lib/RoomTypeConfig.js @@ -1,11 +1,13 @@ import { Meteor } from 'meteor/meteor'; import { Random } from 'meteor/random'; -import { settings } from '../../settings'; - let Users; +let settings; if (Meteor.isServer) { + ({ settings } = require('../../settings/server')); Users = require('../../models/server/models/Users').default; +} else { + ({ settings } = require('../../settings/client')); } export const RoomSettingsEnum = { diff --git a/app/utils/server/functions/getDefaultUserFields.js b/app/utils/server/functions/getDefaultUserFields.js index 124315039d70..efba29e14643 100644 --- a/app/utils/server/functions/getDefaultUserFields.js +++ b/app/utils/server/functions/getDefaultUserFields.js @@ -4,6 +4,7 @@ export const getDefaultUserFields = () => ({ emails: 1, status: 1, statusDefault: 1, + statusText: 1, statusConnection: 1, avatarOrigin: 1, utcOffset: 1, diff --git a/app/webdav/client/webdavFilePicker.html b/app/webdav/client/webdavFilePicker.html index 257b66774a81..79ebe7556be5 100644 --- a/app/webdav/client/webdavFilePicker.html +++ b/app/webdav/client/webdavFilePicker.html @@ -8,22 +8,28 @@ {{/if}} - {{#each webdavNodes}} -
  • - {{#with iconType}} -
    - {{>icon icon=icon}} -
    {{extension}}
    -
    - {{/with}} - -
    -
    {{this.basename}}
    -
    {{this.lastmod}}
    -
    -
    -
  • - {{/each}} + {{#if isLoading}} +
    + {{> loading}} +
    + {{else}} + {{#each webdavNodes}} +
  • + {{#with iconType}} +
    + {{>icon icon=icon}} +
    {{extension}}
    +
    + {{/with}} + +
    +
    {{this.basename}}
    +
    {{this.lastmod}}
    +
    +
    +
  • + {{/each}} + {{/if}} diff --git a/app/webdav/client/webdavFilePicker.js b/app/webdav/client/webdavFilePicker.js index fc6edd3edfde..ccbd3919177c 100644 --- a/app/webdav/client/webdavFilePicker.js +++ b/app/webdav/client/webdavFilePicker.js @@ -4,6 +4,7 @@ import _ from 'underscore'; import toastr from 'toastr'; import { Session } from 'meteor/session'; import { Handlebars } from 'meteor/ui'; +import { ReactiveVar } from 'meteor/reactive-var'; import { modal, call } from '../../ui-utils'; import { t } from '../../utils'; @@ -18,10 +19,13 @@ Template.webdavFilePicker.rendered = async function() { return toastr.error(t(response.message)); } Session.set('webdavNodes', response.data); + this.isLoading.set(false); }; + Template.webdavFilePicker.destroyed = function() { Session.set('webdavNodes', []); }; + Template.webdavFilePicker.helpers({ iconType() { // add icon for different types @@ -52,6 +56,9 @@ Template.webdavFilePicker.helpers({ } return { icon, type, extension }; }, + isLoading() { + return Template.instance().isLoading.get(); + }, webdavNodes() { return Session.get('webdavNodes'); }, @@ -59,9 +66,12 @@ Template.webdavFilePicker.helpers({ return Session.get('webdavCurrentFolder'); }, }); + Template.webdavFilePicker.events({ async 'click #webdav-go-back'() { - const { accountId } = Template.instance().data; + const instance = Template.instance(); + const { accountId } = instance.data; + instance.isLoading.set(true); let currentFolder = Session.get('webdavCurrentFolder'); // determine parent directory to go back @@ -75,16 +85,20 @@ Template.webdavFilePicker.events({ Session.set('webdavCurrentFolder', parentFolder); Session.set('webdavNodes', []); const response = await call('getWebdavFileList', accountId, parentFolder); + instance.isLoading.set(false); if (!response.success) { return toastr.error(t(response.message)); } Session.set('webdavNodes', response.data); }, async 'click .webdav_directory'() { - const { accountId } = Template.instance().data; + const instance = Template.instance(); + const { accountId } = instance.data; + instance.isLoading.set(true); Session.set('webdavCurrentFolder', this.filename); Session.set('webdavNodes', []); const response = await call('getWebdavFileList', accountId, this.filename); + instance.isLoading.set(false); if (!response.success) { modal.close(); return toastr.error(t(response.message)); @@ -93,10 +107,12 @@ Template.webdavFilePicker.events({ }, async 'click .webdav_file'() { const roomId = Session.get('openedRoom'); - const { accountId } = Template.instance().data; + const instance = Template.instance(); + const { accountId } = instance.data; + instance.isLoading.set(true); const file = this; const response = await call('getFileFromWebdav', accountId, file); - + instance.isLoading.set(false); if (!response.success) { modal.close(); return toastr.error(t('Failed_to_get_webdav_file')); @@ -106,15 +122,14 @@ Template.webdavFilePicker.events({ blob.lastModified = file.lastmod; blob.name = file.basename; const text = ` -
    -
    - -
    -
    - -
    -
    `; - +
    +
    + +
    +
    + +
    +
    `; return modal.open({ title: t('Upload_file_question'), text, @@ -200,3 +215,7 @@ Template.webdavFilePicker.events({ }); }, }); + +Template.webdavFilePicker.onCreated(function() { + this.isLoading = new ReactiveVar(true); +}); diff --git a/client/importPackages.js b/client/importPackages.js index 8277767b6a28..8ff3715321b9 100644 --- a/client/importPackages.js +++ b/client/importPackages.js @@ -98,6 +98,7 @@ import '../app/lazy-load'; import '../app/discussion/client'; import '../app/threads/client'; import '../app/mail-messages/client'; +import '../app/user-status'; import '../app/utils'; import '../app/settings'; import '../app/models'; diff --git a/client/startup/usersObserve.js b/client/startup/usersObserve.js index ef705bc2e3d5..d9456fd2eb62 100644 --- a/client/startup/usersObserve.js +++ b/client/startup/usersObserve.js @@ -4,13 +4,15 @@ import { Session } from 'meteor/session'; import { RoomManager } from '../../app/ui-utils'; Meteor.startup(function() { - Meteor.users.find({}, { fields: { name: 1, username: 1, status: 1, utcOffset: 1 } }).observe({ + Meteor.users.find({}, { fields: { name: 1, username: 1, status: 1, utcOffset: 1, statusText: 1 } }).observe({ added(user) { Session.set(`user_${ user.username }_status`, user.status); + Session.set(`user_${ user.username }_status_text`, user.statusText); RoomManager.updateUserStatus(user, user.status, user.utcOffset); }, changed(user) { Session.set(`user_${ user.username }_status`, user.status); + Session.set(`user_${ user.username }_status_text`, user.statusText); RoomManager.updateUserStatus(user, user.status, user.utcOffset); }, removed(user) { diff --git a/imports/startup/client/listenActiveUsers.js b/imports/startup/client/listenActiveUsers.js index bddc19c97133..19a33ff66632 100644 --- a/imports/startup/client/listenActiveUsers.js +++ b/imports/startup/client/listenActiveUsers.js @@ -25,6 +25,7 @@ const saveUser = (user, force = false) => { // name: user.name, // utcOffset: user.utcOffset, status: user.status, + statusText: user.statusText, }, }); } diff --git a/package-lock.json b/package-lock.json index 672f1a1c6ad8..d39f8545b291 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "Rocket.Chat", - "version": "1.1.0-develop", + "version": "1.2.0-develop", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4749,7 +4749,7 @@ }, "colors": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "resolved": "http://registry.npmjs.org/colors/-/colors-1.1.2.tgz", "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=", "dev": true }, @@ -7974,7 +7974,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=" }, "get-value": { @@ -11975,7 +11975,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "requires": { "core-util-is": "~1.0.0", @@ -11997,7 +11997,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "requires": { "safe-buffer": "~5.1.0" @@ -12146,7 +12146,7 @@ "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=", "requires": { "brace-expansion": "^1.1.7" } @@ -12922,7 +12922,7 @@ "npmlog": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", - "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "integrity": "sha1-CKfyqL9zRgR3mp76StXMcXq7lUs=", "requires": { "are-we-there-yet": "~1.1.2", "console-control-strings": "~1.1.0", @@ -13589,7 +13589,7 @@ }, "tough-cookie": { "version": "2.3.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "resolved": "http://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", "dev": true, "requires": { @@ -14742,7 +14742,7 @@ }, "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index a77af632509b..910541eefa67 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -38,6 +38,7 @@ "Accounts_AllowUserAvatarChange": "Allow User Avatar Change", "Accounts_AllowUsernameChange": "Allow Username Change", "Accounts_AllowUserProfileChange": "Allow User Profile Change", + "Accounts_AllowUserStatusMessageChange": "Allow Custom Status Message", "Accounts_AvatarBlockUnauthenticatedAccess": "Block Unauthenticated Access to Avatars", "Accounts_AvatarCacheTime": "Avatar cache time", "Accounts_AvatarCacheTime_description": "Number of seconds the http protocol is told to cache the avatar images.", @@ -973,6 +974,15 @@ "Custom_Sounds": "Custom Sounds", "Custom_Translations": "Custom Translations", "Custom_Translations_Description": "Should be a valid JSON where keys are languages containing a dictionary of key and translations. Example:
    {\n \"en\": {\n  \"Channels\": \"Rooms\"\n },\n \"pt\": {\n  \"Channels\": \"Salas\"\n }\n} ", + "Custom_User_Status": "Custom User Status", + "Custom_User_Status_Add": "Add Custom User Status", + "Custom_User_Status_Added_Successfully" : "Custom User Status Added Successfully", + "Custom_User_Status_Delete_Warning": "Deleting a Custom User Status cannot be undone.", + "Custom_User_Status_Error_Invalid_User_Status": "Invalid User Status", + "Custom_User_Status_Error_Name_Already_In_Use": "The Custom User Status Name is already in use.", + "Custom_User_Status_Has_Been_Deleted": "Custom User Status Has Been Deleted", + "Custom_User_Status_Info": "Custom User Status Info", + "Custom_User_Status_Updated_Successfully": "Custom User Status Updated Successfully", "Customize": "Customize", "CustomSoundsFilesystem": "Custom Sounds Filesystem", "Dashboard": "Dashboard", @@ -1112,6 +1122,7 @@ "E2E_password_reveal_text": "You can now create encrypted private groups and direct messages. You may also change existing private groups or DMs to encrypted.

    This is end to end encryption so the key to encode/decode your messages will not be saved on the server. For that reason you need to store this password somewhere safe. You will be required to enter it on other devices you wish to use e2e encryption on. Learn more here!

    Your password is: %s

    This is an auto generated password, you can setup a new password for your encryption key any time from any browser you have entered the existing password.
    This password is only stored on this browser until you store the password and dismiss this message.", "E2E_password_request_text": "To access your encrypted private groups and direct messages, enter your encryption password.
    You need to enter this password to encode/decode your messages on every client you use, since the key is not stored on the server.", "Edit": "Edit", + "Edit_Status": "Edit Status", "edit-message": "Edit Message", "edit-message_description": "Permission to edit a message within a room", "edit-other-user-active-status": "Edit Other User Active Status", @@ -1480,6 +1491,7 @@ "Group_favorites": "Group favorites", "Group_mentions_disabled_x_members": "Group mentions `@all` and `@here` have been disabled for rooms with more than __total__ members.", "Group_mentions_only": "Group mentions only", + "Group_subscriptions": "Group subscriptions", "Guest_Pool": "Guest Pool", "Hash": "Hash", "Header": "Header", @@ -1941,6 +1953,8 @@ "manage-own-integrations_description": "Permition to allow users to create and edit their own integration or webhooks", "manage-sounds": "Manage Sounds", "manage-sounds_description": "Permission to manage the server sounds", + "manage-user-status": "Manage User Status", + "manage-user-status_description": "Permission to manage the server custom user statuses", "Manage_Apps": "Manage Apps", "Manage_the_App": "Manage the App", "Manager_added": "Manager added", @@ -2321,6 +2335,7 @@ "Preparing_list_of_channels": "Preparing list of channels", "Preparing_list_of_messages": "Preparing list of messages", "Preparing_list_of_users": "Preparing list of users", + "Presence": "Presence", "preview-c-room": "Preview Public Channel", "preview-c-room_description": "Permission to view the contents of a public channel before joining", "Previous_month": "Previous Month", @@ -2590,6 +2605,7 @@ "Search_Page_Size": "Page Size", "Search_Private_Groups": "Search Private Groups", "Search_Provider": "Search Provider", + "Search_ServiceAccounts": "Search Service Accounts", "Search_Users": "Search Users", "seconds": "seconds", "Secret_token": "Secret Token", @@ -2634,6 +2650,7 @@ "Server_Type": "Server Type", "Service": "Service", "Service_account": "Service Account", + "Service_accounts": "Service Accounts", "Service_account_key": "Service account key", "Service_account_applied": "Service Accounts approval applications", "Service_account_created_successfully": "Service Account created successfully", @@ -2641,6 +2658,7 @@ "Service_account_description": "Service Accounts are an upgrade to existing user accounts. You can connect to a large number of users using service account with exclusive features such as broadcast message to all your subscribers at once", "Service_account_limit": "Service Accounts per user", "Service_account_login": "Service Account login", + "Service_Accounts_SearchFields": "Fields to consider for service account search", "Service_account_name_placeholder": "Service Account name", "Service_account_username_placeholder": "Service Account username", "Service_account_title": "Create a new Service Account", @@ -2709,6 +2727,8 @@ "Slash_Shrug_Description": "Displays ¯\\_(ツ)_/¯ after your message", "Slash_Tableflip_Description": "Displays (╯°□°)╯︵ ┻━┻", "Slash_TableUnflip_Description": "Displays ┬─┬ ノ( ゜-゜ノ)", + "Slash_Status_Description": "Set your status message", + "Slash_Status_Params": "Status message", "Slash_Topic_Description": "Set topic", "Slash_Topic_Params": "Topic message", "Smarsh_Email": "Smarsh Email", @@ -2783,6 +2803,11 @@ "Stats_Total_Uploads": "Total Uploads", "Stats_Total_Uploads_Size": "Total Uploads Size", "Status": "Status", + "StatusMessage": "Status Message", + "StatusMessage_Change_Disabled": "Your Rocket.Chat administrator has disabled the changing of status messages", + "StatusMessage_Changed_Successfully": "Status message changed successfully.", + "StatusMessage_Placeholder": "What are you doing right now?", + "StatusMessage_Too_Long": "Status message must be shorter than 120 characters.", "Step": "Step", "Stop_Recording": "Stop Recording", "Store_Last_Message": "Store Last Message", @@ -3098,6 +3123,7 @@ "UserData_ProcessingFrequency": "Processing Frequency (Minutes)", "UserDataDownload": "User Data Download", "UserDataDownload_CompletedRequestExisted_Text": "Your data file was already generated. Check your email account for the download link.", + "UserDataDownload_CompletedRequestExistedWithLink_Text": "Your data file was already generated. Click here to download it.", "UserDataDownload_EmailBody": "Your data file is now ready to download. Click here to download it.", "UserDataDownload_EmailSubject": "Your Data File is Ready to Download", "UserDataDownload_Requested": "Download File Requested", diff --git a/packages/rocketchat-i18n/i18n/es.i18n.json b/packages/rocketchat-i18n/i18n/es.i18n.json index af4322965026..454970fdead0 100644 --- a/packages/rocketchat-i18n/i18n/es.i18n.json +++ b/packages/rocketchat-i18n/i18n/es.i18n.json @@ -38,6 +38,7 @@ "Accounts_AllowUserAvatarChange": "Permitir al Usuario modificar su Avatar", "Accounts_AllowUsernameChange": "Permitir el Cambio de Nombre de Usuario", "Accounts_AllowUserProfileChange": "Permitir al Usuario modificar su Perfil", + "Accounts_AllowUserStatusMessageChange": "Permitir cambio de mensaje de estado", "Accounts_AvatarCacheTime": "Tiempo de caché de Avatar", "Accounts_AvatarCacheTime_description": "Número de segundos que se le dice al protocolo http para almacenar en caché las imágenes de avatar.", "Accounts_AvatarResize": "Cambiar el Tamaño de los Avatars", @@ -2377,6 +2378,8 @@ "Slash_Shrug_Description": "Muestra ¯ \\ _ (ツ) _ / ¯ después de su mensaje", "Slash_Tableflip_Description": "Muestra ° (╯ ° □ °) ╯( ┻━┻", "Slash_TableUnflip_Description": "Muestra ┬─┬ ノ (゜ - ゜ ノ)", + "Slash_Status_Description": "Configura tu mensaje de estado", + "Slash_Status_Params": "Mensaje de estado", "Slash_Topic_Description": "Establecer tema", "Slash_Topic_Params": "Mensaje del tema", "Smarsh_Email": "Smarsh Email", @@ -2444,6 +2447,11 @@ "Stats_Total_Rooms": "Total de Salas", "Stats_Total_Users": "Total de Usuarios", "Status": "Estado", + "StatusMessage": "Mensaje de estado", + "StatusMessage_Change_Disabled": "Tu administrador de Rocket.Chat ha desactivado el cambio de mensajes de estado", + "StatusMessage_Changed_Successfully": "Mensaje de estado cambiado correctamente.", + "StatusMessage_Placeholder": "¿Qué estás haciendo ahora?", + "StatusMessage_Too_Long": "El mensaje de estado debe tener menos de 120 caracteres.", "Step": "Paso", "Stop_Recording": "Detener Grabacion", "Store_Last_Message": "Almacenar el último mensaje", diff --git a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json index 6b293a90110d..ea5ec5fb0564 100644 --- a/packages/rocketchat-i18n/i18n/pt-BR.i18n.json +++ b/packages/rocketchat-i18n/i18n/pt-BR.i18n.json @@ -2280,6 +2280,7 @@ "Preparing_list_of_channels": "Preparando lista de canais", "Preparing_list_of_messages": "Preparando lista de mensagens", "Preparing_list_of_users": "Preparando lista de usuários", + "Presence": "Presença", "preview-c-room": "Pré-visualizar Canal público", "preview-c-room_description": "Permissão para visualizar o conteúdo de um canal público antes de se juntar", "Previous_month": "Mês anterior", diff --git a/server/importPackages.js b/server/importPackages.js index 95dd7e535d7d..41f928f7a50c 100644 --- a/server/importPackages.js +++ b/server/importPackages.js @@ -82,6 +82,7 @@ import '../app/slashcommands-leave'; import '../app/slashcommands-me'; import '../app/slashcommands-msg'; import '../app/slashcommands-mute'; +import '../app/slashcommands-status'; import '../app/slashcommands-topic/server'; import '../app/slashcommands-unarchiveroom/server'; import '../app/smarsh-connector'; @@ -106,6 +107,7 @@ import '../app/chatpal-search/server'; import '../app/discussion/server'; import '../app/bigbluebutton'; import '../app/mail-messages/server'; +import '../app/user-status'; import '../app/utils'; import '../app/settings'; import '../app/models'; diff --git a/server/methods/browseChannels.js b/server/methods/browseChannels.js index 2d8d9ffb3c7f..53b09e32755a 100644 --- a/server/methods/browseChannels.js +++ b/server/methods/browseChannels.js @@ -5,6 +5,7 @@ import s from 'underscore.string'; import { hasPermission } from '../../app/authorization'; import { Rooms, Users } from '../../app/models'; import { Federation } from '../../app/federation/server'; +import { settings } from '../../app/settings/server'; const sortChannels = function(field, direction) { switch (field) { @@ -28,11 +29,20 @@ const sortUsers = function(field, direction) { } }; +const sortServiceAccounts = function(field, direction) { + switch (field) { + default: + return { + [field]: direction === 'asc' ? 1 : -1, + }; + } +}; + Meteor.methods({ browseChannels({ text = '', workspace = '', type = 'channels', sortBy = 'name', sortDirection = 'asc', page, offset, limit = 10 }) { const regex = new RegExp(s.trim(s.escapeRegExp(text)), 'i'); - if (!['channels', 'users'].includes(type)) { + if (!['channels', 'users', 'serviceAccounts'].includes(type)) { return; } @@ -57,11 +67,13 @@ Meteor.methods({ limit, }; + const canViewAnonymous = settings.get('Accounts_AllowAnonymousRead') === true; + const user = Meteor.user(); if (type === 'channels') { const sort = sortChannels(sortBy, sortDirection); - if (!hasPermission(user._id, 'view-c-room')) { + if ((!user && !canViewAnonymous) || (user && !hasPermission(user._id, 'view-c-room'))) { return; } @@ -85,6 +97,45 @@ Meteor.methods({ }; } + if (type === 'serviceAccounts') { + const options = { + ...pagination, + sort: sortServiceAccounts(sortBy, sortDirection), + fields: { + username: 1, + name: 1, + createdAt: 1, + description: 1, + federation: 1, + }, + }; + + const exceptions = [user.username]; + const forcedSearchFields = workspace === 'all' && ['username', 'name', 'description']; + + let result; + if (workspace === 'all') { + result = Users.findByActiveServiceAccountsExcept(text, exceptions, forcedSearchFields, options); + } else if (workspace === 'external') { + result = Users.findByActiveExternalServiceAccountsExcept(text, exceptions, options, forcedSearchFields, Federation.localIdentifier); + } else { + result = Users.findByActiveLocalServiceAccountsExcept(text, exceptions, options, forcedSearchFields, Federation.localIdentifier); + } + const total = result.count(); + const results = result.fetch(); + results.forEach((account) => { + account.subscribers = Rooms.findDirectRoomContainingUsername(account.username).count(); + }); + return { + total, + results, + }; + } + // non-logged id user + if (!user) { + return; + } + // type === users if (!hasPermission(user._id, 'view-outside-room') || !hasPermission(user._id, 'view-d-room')) { return; diff --git a/server/methods/createDirectMessage.js b/server/methods/createDirectMessage.js index 2fb895435984..e4f022033686 100644 --- a/server/methods/createDirectMessage.js +++ b/server/methods/createDirectMessage.js @@ -109,6 +109,10 @@ Meteor.methods({ upsertSubscription.$set.archived = true; } + if (to.u !== undefined) { + upsertSubscription.$set.sa = true; + } + Subscriptions.upsert({ rid, $and: [{ 'u._id': me._id }], // work around to solve problems with upsert and dot diff --git a/server/methods/requestDataDownload.js b/server/methods/requestDataDownload.js index 604d68c72ec9..255def67976a 100644 --- a/server/methods/requestDataDownload.js +++ b/server/methods/requestDataDownload.js @@ -1,9 +1,10 @@ import fs from 'fs'; import path from 'path'; +import mkdirp from 'mkdirp'; import { Meteor } from 'meteor/meteor'; -import { ExportOperations } from '../../app/models'; +import { ExportOperations, UserDataFiles } from '../../app/models'; import { settings } from '../../app/settings'; let tempFolder = '/tmp/userData'; @@ -25,30 +26,42 @@ Meteor.methods({ yesterday.setUTCDate(yesterday.getUTCDate() - 1); if (lastOperation.createdAt > yesterday) { + if (lastOperation.status === 'completed') { + const lastFile = UserDataFiles.findLastFileByUser(userId); + if (lastFile) { + return { + requested: false, + exportOperation: lastOperation, + url: lastFile.url, + }; + } + } + return { requested: false, exportOperation: lastOperation, + url: null, }; } } if (!fs.existsSync(tempFolder)) { - fs.mkdirSync(tempFolder); + mkdirp.sync(tempFolder); } const subFolderName = fullExport ? 'full' : 'partial'; const baseFolder = path.join(tempFolder, userId); if (!fs.existsSync(baseFolder)) { - fs.mkdirSync(baseFolder); + mkdirp.sync(baseFolder); } const folderName = path.join(baseFolder, subFolderName); if (!fs.existsSync(folderName)) { - fs.mkdirSync(folderName); + mkdirp.sync(folderName); } const assetsFolder = path.join(folderName, 'assets'); if (!fs.existsSync(assetsFolder)) { - fs.mkdirSync(assetsFolder); + mkdirp.sync(assetsFolder); } const exportOperation = { @@ -67,6 +80,7 @@ Meteor.methods({ return { requested: true, exportOperation, + url: null, }; }, }); diff --git a/server/methods/saveUserPreferences.js b/server/methods/saveUserPreferences.js index f9e0d6c97683..cc3fc86a8f08 100644 --- a/server/methods/saveUserPreferences.js +++ b/server/methods/saveUserPreferences.js @@ -38,6 +38,7 @@ Meteor.methods({ sidebarHideAvatar: Match.Optional(Boolean), sidebarGroupByType: Match.Optional(Boolean), sidebarShowDiscussion: Match.Optional(Boolean), + sidebarShowServiceAccounts: Match.Optional(Boolean), muteFocusedConversations: Match.Optional(Boolean), }; check(settings, Match.ObjectIncluding(keys)); diff --git a/server/methods/saveUserProfile.js b/server/methods/saveUserProfile.js index 788ee9281d04..2fdf5e5fafdb 100644 --- a/server/methods/saveUserProfile.js +++ b/server/methods/saveUserProfile.js @@ -49,6 +49,10 @@ Meteor.methods({ Meteor.call('setUsername', settings.username); } + if (settings.statusText || settings.statusText === '') { + Meteor.call('setUserStatus', null, settings.statusText); + } + if (settings.email) { if (!checkPassword(user, settings.typedPassword)) { throw new Meteor.Error('error-invalid-password', 'Invalid password', { diff --git a/server/publications/activeUsers.js b/server/publications/activeUsers.js index c6e33e283517..0256c5a37215 100644 --- a/server/publications/activeUsers.js +++ b/server/publications/activeUsers.js @@ -13,6 +13,7 @@ Meteor.publish('activeUsers', function() { name: 1, status: 1, utcOffset: 1, + statusText: 1, }, }); }); diff --git a/server/publications/subscription.js b/server/publications/subscription.js index 182de0eaf38b..aead3afcbea9 100644 --- a/server/publications/subscription.js +++ b/server/publications/subscription.js @@ -18,6 +18,7 @@ const fields = { roles: 1, unread: 1, prid: 1, + sa: 1, userMentions: 1, groupMentions: 1, archived: 1, diff --git a/tests/end-to-end/api/00-miscellaneous.js b/tests/end-to-end/api/00-miscellaneous.js index 3a6773fa2bac..91e09e3ca510 100644 --- a/tests/end-to-end/api/00-miscellaneous.js +++ b/tests/end-to-end/api/00-miscellaneous.js @@ -141,6 +141,7 @@ describe('miscellaneous', function() { 'sidebarGroupByType', 'muteFocusedConversations', 'sidebarShowDiscussion', + 'sidebarShowServiceAccounts', ]; expect(res.body).to.have.property('success', true); diff --git a/tests/end-to-end/api/01-users.js b/tests/end-to-end/api/01-users.js index b63ad391ff47..b16f0a15ca5d 100644 --- a/tests/end-to-end/api/01-users.js +++ b/tests/end-to-end/api/01-users.js @@ -495,6 +495,7 @@ describe('[Users]', function() { updateSetting('Accounts_AllowUserProfileChange', true) .then(() => updateSetting('Accounts_AllowUsernameChange', true)) .then(() => updateSetting('Accounts_AllowRealNameChange', true)) + .then(() => updateSetting('Accounts_AllowUserStatusMessageChange', true)) .then(() => updateSetting('Accounts_AllowEmailChange', true)) .then(() => updateSetting('Accounts_AllowPasswordChange', true)) .then(done); @@ -503,6 +504,7 @@ describe('[Users]', function() { updateSetting('Accounts_AllowUserProfileChange', true) .then(() => updateSetting('Accounts_AllowUsernameChange', true)) .then(() => updateSetting('Accounts_AllowRealNameChange', true)) + .then(() => updateSetting('Accounts_AllowUserStatusMessageChange', true)) .then(() => updateSetting('Accounts_AllowEmailChange', true)) .then(() => updateSetting('Accounts_AllowPasswordChange', true)) .then(done); @@ -662,6 +664,50 @@ describe('[Users]', function() { }); }); + it('should return an error when trying update user status message and it is not allowed', (done) => { + updatePermission('edit-other-user-info', ['user']).then(() => { + updateSetting('Accounts_AllowUserStatusMessageChange', false) + .then(() => { + request.post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + statusMessage: 'a new status', + }, + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); + }); + }); + + it('should update user status message when the required permission is applied', (done) => { + updatePermission('edit-other-user-info', ['admin']).then(() => { + updateSetting('Accounts_AllowUserStatusMessageChange', false) + .then(() => { + request.post(api('users.update')) + .set(credentials) + .send({ + userId: targetUser._id, + data: { + name: 'a new status', + }, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }) + .end(done); + }); + }); + }); + it('should return an error when trying update user email and it is not allowed', (done) => { updatePermission('edit-other-user-info', ['user']).then(() => { updateSetting('Accounts_AllowEmailChange', false) diff --git a/tests/pageobjects/administration.page.js b/tests/pageobjects/administration.page.js index 15a3a6746010..1629e980a9bd 100644 --- a/tests/pageobjects/administration.page.js +++ b/tests/pageobjects/administration.page.js @@ -364,6 +364,10 @@ class Administration extends Page { get accountsRealNameChangeFalse() { return browser.element('label:nth-of-type(2) [name="Accounts_AllowRealNameChange"]'); } + get accountsUserStatusMessageChangeTrue() { return browser.element('label:nth-of-type(1) [name="Accounts_AllowUserStatusMessageChange"]'); } + + get accountsUserStatusMessageChangeFalse() { return browser.element('label:nth-of-type(2) [name="Accounts_AllowUserStatusMessageChange"]'); } + get accountsUsernameChangeTrue() { return browser.element('label:nth-of-type(1) [name="Accounts_AllowUsernameChange"]'); } get accountsUsernameChangeFalse() { return browser.element('label:nth-of-type(2) [name="Accounts_AllowUsernameChange"]'); }