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..82596a4e46f6 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, }; @@ -581,6 +652,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/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/lib/lib/roomTypes/direct.js b/app/lib/lib/roomTypes/direct.js index a8a000b7dfd8..0f55c1388ee3 100644 --- a/app/lib/lib/roomTypes/direct.js +++ b/app/lib/lib/roomTypes/direct.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { Session } from 'meteor/session'; -import { ChatRoom, Subscriptions } from '../../../models'; +import { ChatRoom, Subscriptions, Users } from '../../../models'; import { openRoom } from '../../../ui-utils'; import { getUserPreference, RoomTypeConfig, RoomTypeRouteConfig, RoomSettingsEnum, UiTextContext } from '../../../utils'; import { hasPermission, hasAtLeastOnePermission } from '../../../authorization'; @@ -92,6 +92,14 @@ export class DirectMessageRoomType extends RoomTypeConfig { return Session.get(`user_${ subscription.name }_status`); } + getUserStatusText(roomId) { + const userId = roomId.replace(Meteor.userId(), ''); + const userData = Users.findOne({ _id: userId }); + if (userData && userData.statusText) { + return userData.statusText; + } + } + getDisplayName(room) { return room.usernames.join(' x '); } diff --git a/app/lib/server/functions/getFullUserData.js b/app/lib/server/functions/getFullUserData.js index 04a667bac1c3..9e41f6c03831 100644 --- a/app/lib/server/functions/getFullUserData.js +++ b/app/lib/server/functions/getFullUserData.js @@ -15,6 +15,7 @@ const defaultFields = { type: 1, active: 1, reason: 1, + statusText: 1, }; const fullFields = { diff --git a/app/lib/server/functions/index.js b/app/lib/server/functions/index.js index 118ca8f784e0..b5a307a4e148 100644 --- a/app/lib/server/functions/index.js +++ b/app/lib/server/functions/index.js @@ -23,6 +23,7 @@ export { saveUser } from './saveUser'; export { sendMessage } from './sendMessage'; export { setEmail } from './setEmail'; export { setRealName, _setRealName } from './setRealName'; +export { setStatusMessage, _setStatusMessage } from './setStatusMessage'; export { setUserAvatar } from './setUserAvatar'; export { _setUsername, setUsername } from './setUsername'; export { unarchiveRoom } from './unarchiveRoom'; diff --git a/app/lib/server/functions/saveUser.js b/app/lib/server/functions/saveUser.js index 320ad9cc6c8e..ea9c8772b6bc 100644 --- a/app/lib/server/functions/saveUser.js +++ b/app/lib/server/functions/saveUser.js @@ -10,7 +10,7 @@ import { settings } from '../../../settings'; import PasswordPolicy from '../lib/PasswordPolicyClass'; import { validateEmailDomain } from '../lib'; -import { checkEmailAvailability, checkUsernameAvailability, setUserAvatar, setEmail, setRealName, setUsername } from '.'; +import { checkEmailAvailability, checkUsernameAvailability, setUserAvatar, setEmail, setRealName, setUsername, setStatusMessage } from '.'; const passwordPolicy = new PasswordPolicy(); @@ -133,6 +133,13 @@ function validateUserEditing(userId, userData) { }); } + if (userData.statusText && !settings.get('Accounts_AllowUserStatusMessageChange') && (!canEditOtherUserInfo || editingMyself)) { + throw new Meteor.Error('error-action-not-allowed', 'Edit user status is not allowed', { + method: 'insertOrUpdateUser', + action: 'Update_user', + }); + } + if (userData.name && !settings.get('Accounts_AllowRealNameChange') && (!canEditOtherUserInfo || editingMyself)) { throw new Meteor.Error('error-action-not-allowed', 'Edit user real name is not allowed', { method: 'insertOrUpdateUser', @@ -248,6 +255,10 @@ export const saveUser = function(userId, userData) { setRealName(userData._id, userData.name); } + if (typeof userData.statusText === 'string') { + setStatusMessage(userData._id, userData.statusText); + } + if (userData.email) { const shouldSendVerificationEmailToUser = userData.verified !== true; setEmail(userData._id, userData.email, shouldSendVerificationEmailToUser); diff --git a/app/lib/server/functions/setStatusMessage.js b/app/lib/server/functions/setStatusMessage.js new file mode 100644 index 000000000000..ceae68be66f8 --- /dev/null +++ b/app/lib/server/functions/setStatusMessage.js @@ -0,0 +1,45 @@ +import { Meteor } from 'meteor/meteor'; +import s from 'underscore.string'; + +import { Users } from '../../../models'; +import { Notifications } from '../../../notifications'; +import { hasPermission } from '../../../authorization'; +import { RateLimiter } from '../lib'; + +export const _setStatusMessage = function(userId, statusMessage) { + statusMessage = s.trim(statusMessage); + if (statusMessage.length > 120) { + statusMessage = statusMessage.substr(0, 120); + } + + if (!userId) { + return false; + } + + const user = Users.findOneById(userId); + + // User already has desired statusMessage, return + if (user.statusText === statusMessage) { + return user; + } + + // Set new statusMessage + Users.updateStatusText(user._id, statusMessage); + user.statusText = statusMessage; + + Notifications.notifyLogged('Users:StatusMessageChanged', { + _id: user._id, + name: user.name, + username: user.username, + statusText: user.statusText, + }); + + return true; +}; + +export const setStatusMessage = RateLimiter.limitFunction(_setStatusMessage, 1, 60000, { + 0() { + // Administrators have permission to change others status, so don't limit those + return !Meteor.userId() || !hasPermission(Meteor.userId(), 'edit-other-user-info'); + }, +}); diff --git a/app/lib/server/startup/settings.js b/app/lib/server/startup/settings.js index ae78b61fce32..9d83ead14338 100644 --- a/app/lib/server/startup/settings.js +++ b/app/lib/server/startup/settings.js @@ -44,6 +44,10 @@ settings.addGroup('Accounts', function() { type: 'boolean', public: true, }); + this.add('Accounts_AllowUserStatusMessageChange', true, { + type: 'boolean', + public: true, + }); this.add('Accounts_AllowUsernameChange', true, { type: 'boolean', public: true, 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/Users.js b/app/models/server/models/Users.js index 0daea240a2ab..734370cb2da5 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 }); @@ -682,6 +683,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/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/general/base_old.css b/app/theme/client/imports/general/base_old.css index 9b4218c41c2b..bb298b0566c5 100644 --- a/app/theme/client/imports/general/base_old.css +++ b/app/theme/client/imports/general/base_old.css @@ -3742,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; + } } } @@ -5370,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/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..4fdd494bb250 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(); @@ -129,7 +138,7 @@ Template.accountProfile.helpers({ if (!avatar && user.name === realname && user.username === username && getUserEmailAddress(user) === email === email && (!password || password !== confirmationPassword)) { return ret; } - if (!validateEmail(email) || (!validateUsername(username) || usernameAvaliable !== true) || !validateName(realname)) { + if (!validateEmail(email) || (!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-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 @@

@{{username}}

{{/if}}

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 954297b789fd..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; diff --git a/app/ui-sidenav/client/sidebarHeader.js b/app/ui-sidenav/client/sidebarHeader.js index a35519ed0c62..ea81eb21761b 100644 --- a/app/ui-sidenav/client/sidebarHeader.js +++ b/app/ui-sidenav/client/sidebarHeader.js @@ -8,9 +8,10 @@ import { t, getUserPreference, handleError } from '../../utils'; import { callbacks } from '../../callbacks'; import { settings } from '../../settings'; 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(); }; @@ -315,39 +316,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-utils/client/lib/AccountBox.js b/app/ui-utils/client/lib/AccountBox.js index 1090db25561b..d82f61387816 100644 --- a/app/ui-utils/client/lib/AccountBox.js +++ b/app/ui-utils/client/lib/AccountBox.js @@ -11,8 +11,8 @@ import { SideNav } from './SideNav'; export const AccountBox = (function() { let status = 0; const items = new ReactiveVar([]); - function setStatus(status) { - return Meteor.call('UserPresence:setDefaultStatus', status); + function setStatus(status, statusText) { + return Meteor.call('setUserStatus', status, statusText); } function open() { if (SideNav.flexStatus()) { diff --git a/app/ui-utils/client/lib/popover.html b/app/ui-utils/client/lib/popover.html index 9ebb7a82c547..057787b4ce6e 100644 --- a/app/ui-utils/client/lib/popover.html +++ b/app/ui-utils/client/lib/popover.html @@ -19,7 +19,14 @@

{{group.title}}

{{> icon block="rc-popover__icon-element" icon=item.icon }} {{/if}} - {{_ item.name}} + {{#if item.name}} + {{item.name}} + {{/if}} + {{#if item.select}} + + {{> selectDropdown title=item.selectTitle name=item.selectName options=item.selectOptions}} + + {{/if}} {{/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..d8ac924a835a 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() { @@ -106,8 +111,18 @@ Template.headerRoom.helpers({ }, 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 83dfe611db0b..9f5d63bc1e32 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/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/server/functions/getDefaultUserFields.js b/app/utils/server/functions/getDefaultUserFields.js index f3332ca76177..e7aaddf1b816 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/client/importPackages.js b/client/importPackages.js index 32e492e5d21d..e26dde77f6f7 100644 --- a/client/importPackages.js +++ b/client/importPackages.js @@ -97,6 +97,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/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 64bf88bd3887..75994691a232 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", @@ -1941,6 +1952,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", @@ -2320,6 +2333,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", @@ -2698,6 +2712,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", @@ -2772,6 +2788,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", 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 40daed01b3de..35d4dbac90ea 100644 --- a/server/importPackages.js +++ b/server/importPackages.js @@ -81,6 +81,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'; @@ -105,6 +106,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/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/tests/end-to-end/api/01-users.js b/tests/end-to-end/api/01-users.js index c34069ff33cb..5b11589e7748 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"]'); }