diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 3ccc9caeafa0..d04d1a2418b5 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -376,7 +376,7 @@ API.v1.addRoute( throw new Meteor.Error('error-emoji-param-not-provided', 'The required "emoji" param is missing.'); } - await executeSetReaction(this.userId, emoji, msg._id, this.bodyParams.shouldReact); + await executeSetReaction(this.userId, emoji, msg, this.bodyParams.shouldReact); return API.v1.success(); }, diff --git a/apps/meteor/app/reactions/server/setReaction.ts b/apps/meteor/app/reactions/server/setReaction.ts index 8f9c24633407..be6e5aed4a54 100644 --- a/apps/meteor/app/reactions/server/setReaction.ts +++ b/apps/meteor/app/reactions/server/setReaction.ts @@ -4,7 +4,6 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; import { Messages, EmojiCustom, Rooms, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; -import _ from 'underscore'; import { callbacks } from '../../../lib/callbacks'; import { i18n } from '../../../server/lib/i18n'; @@ -12,26 +11,39 @@ import { canAccessRoomAsync } from '../../authorization/server'; import { hasPermissionAsync } from '../../authorization/server/functions/hasPermission'; import { emoji } from '../../emoji/server'; import { isTheLastMessage } from '../../lib/server/functions/isTheLastMessage'; -import { notifyOnRoomChangedById, notifyOnMessageChange } from '../../lib/server/lib/notifyListener'; +import { notifyOnMessageChange } from '../../lib/server/lib/notifyListener'; -const removeUserReaction = (message: IMessage, reaction: string, username: string) => { +export const removeUserReaction = (message: IMessage, reaction: string, username: string) => { if (!message.reactions) { return message; } - message.reactions[reaction].usernames.splice(message.reactions[reaction].usernames.indexOf(username), 1); - if (message.reactions[reaction].usernames.length === 0) { + const idx = message.reactions[reaction].usernames.indexOf(username); + + // user not found in reaction array + if (idx === -1) { + return message; + } + + message.reactions[reaction].usernames.splice(idx, 1); + if (!message.reactions[reaction].usernames.length) { delete message.reactions[reaction]; } return message; }; -async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction: string, shouldReact?: boolean) { - reaction = `:${reaction.replace(/:/g, '')}:`; +export async function setReaction( + room: Pick, + user: IUser, + message: IMessage, + reaction: string, + userAlreadyReacted?: boolean, +) { + await Message.beforeReacted(message, room); - if (!emoji.list[reaction] && (await EmojiCustom.findByNameOrAlias(reaction, {}).count()) === 0) { - throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { - method: 'setReaction', + if (Array.isArray(room.muted) && room.muted.includes(user.username as string)) { + throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), { + rid: room._id, }); } @@ -42,50 +54,23 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction } } - if (Array.isArray(room.muted) && room.muted.indexOf(user.username as string) !== -1) { - throw new Meteor.Error('error-not-allowed', i18n.t('You_have_been_muted', { lng: user.language }), { - rid: room._id, - }); - } - - // if (!('reactions' in message)) { - // return; - // } - - await Message.beforeReacted(message, room); - - const userAlreadyReacted = - message.reactions && - Boolean(message.reactions[reaction]) && - message.reactions[reaction].usernames.indexOf(user.username as string) !== -1; - // When shouldReact was not informed, toggle the reaction. - if (shouldReact === undefined) { - shouldReact = !userAlreadyReacted; - } - - if (userAlreadyReacted === shouldReact) { - return; - } - let isReacted; - if (userAlreadyReacted) { const oldMessage = JSON.parse(JSON.stringify(message)); removeUserReaction(message, reaction, user.username as string); - if (_.isEmpty(message.reactions)) { + if (Object.keys(message.reactions || {}).length === 0) { delete message.reactions; + await Messages.unsetReactions(message._id); if (isTheLastMessage(room, message)) { await Rooms.unsetReactionsInLastMessage(room._id); - void notifyOnRoomChangedById(room._id); } - await Messages.unsetReactions(message._id); } else { await Messages.setReactions(message._id, message.reactions); if (isTheLastMessage(room, message)) { await Rooms.setReactionsInLastMessage(room._id, message.reactions); } } - await callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact, oldMessage }); + void callbacks.run('afterUnsetReaction', message, { user, reaction, shouldReact: false, oldMessage }); isReacted = false; } else { @@ -101,33 +86,61 @@ async function setReaction(room: IRoom, user: IUser, message: IMessage, reaction await Messages.setReactions(message._id, message.reactions); if (isTheLastMessage(room, message)) { await Rooms.setReactionsInLastMessage(room._id, message.reactions); - void notifyOnRoomChangedById(room._id); } - await callbacks.run('afterSetReaction', message, { user, reaction, shouldReact }); + + void callbacks.run('afterSetReaction', message, { user, reaction, shouldReact: true }); isReacted = true; } - await Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); + void Apps.self?.triggerEvent(AppEvents.IPostMessageReacted, message, user, reaction, isReacted); void notifyOnMessageChange({ id: message._id, }); } -export async function executeSetReaction(userId: string, reaction: string, messageId: IMessage['_id'], shouldReact?: boolean) { - const user = await Users.findOneById(userId); +export async function executeSetReaction( + userId: string, + reaction: string, + messageParam: IMessage['_id'] | IMessage, + shouldReact?: boolean, +) { + // Check if the emoji is valid before proceeding + const reactionWithoutColons = reaction.replace(/:/g, ''); + reaction = `:${reactionWithoutColons}:`; + + if (!emoji.list[reaction] && (await EmojiCustom.countByNameOrAlias(reactionWithoutColons)) === 0) { + throw new Meteor.Error('error-not-allowed', 'Invalid emoji provided.', { + method: 'setReaction', + }); + } + const user = await Users.findOneById(userId); if (!user) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'setReaction' }); } - const message = await Messages.findOneById(messageId); + const message = typeof messageParam === 'string' ? await Messages.findOneById(messageParam) : messageParam; if (!message) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } - const room = await Rooms.findOneById(message.rid); + const userAlreadyReacted = + message.reactions && Boolean(message.reactions[reaction]) && message.reactions[reaction].usernames.includes(user.username as string); + + // When shouldReact was not informed, toggle the reaction. + if (shouldReact === undefined) { + shouldReact = !userAlreadyReacted; + } + + if (userAlreadyReacted === shouldReact) { + return; + } + + const room = await Rooms.findOneById< + Pick + >(message.rid, { projection: { _id: 1, ro: 1, muted: 1, reactWhenReadOnly: 1, lastMessage: 1, t: 1, prid: 1, federated: 1 } }); if (!room) { throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'setReaction' }); } @@ -136,7 +149,7 @@ export async function executeSetReaction(userId: string, reaction: string, messa throw new Meteor.Error('not-authorized', 'Not Authorized', { method: 'setReaction' }); } - return setReaction(room, user, message, reaction, shouldReact); + return setReaction(room, user, message, reaction, userAlreadyReacted); } declare module '@rocket.chat/ddp-client' { diff --git a/apps/meteor/server/models/raw/EmojiCustom.ts b/apps/meteor/server/models/raw/EmojiCustom.ts index 300721f60d7b..ec3c6390f64d 100644 --- a/apps/meteor/server/models/raw/EmojiCustom.ts +++ b/apps/meteor/server/models/raw/EmojiCustom.ts @@ -72,4 +72,12 @@ export class EmojiCustomRaw extends BaseRaw implements IEmojiCusto create(data: InsertionModel): Promise>> { return this.insertOne(data); } + + countByNameOrAlias(name: string): Promise { + const query = { + $or: [{ name }, { aliases: name }], + }; + + return this.countDocuments(query); + } } diff --git a/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts b/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts new file mode 100644 index 000000000000..e267825a6a18 --- /dev/null +++ b/apps/meteor/tests/unit/app/reactions/server/setReaction.spec.ts @@ -0,0 +1,428 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const meteorMethodsMock = sinon.stub(); +const emojiList: Record = {}; +const modelsMock = { + EmojiCustom: { + countByNameOrAlias: sinon.stub(), + }, + Users: { + findOneById: sinon.stub(), + }, + Messages: { + findOneById: sinon.stub(), + setReactions: sinon.stub(), + unsetReactions: sinon.stub(), + }, + Rooms: { + findOneById: sinon.stub(), + unsetReactionsInLastMessage: sinon.stub(), + setReactionsInLastMessage: sinon.stub(), + }, +}; +const canAccessRoomAsyncMock = sinon.stub(); +const isTheLastMessageMock = sinon.stub(); +const notifyOnMessageChangeMock = sinon.stub(); +const hasPermissionAsyncMock = sinon.stub(); +const i18nMock = { t: sinon.stub() }; +const callbacksRunMock = sinon.stub(); +const meteorErrorMock = class extends Error { + constructor(message: string) { + super(message); + } +}; + +const { removeUserReaction, executeSetReaction, setReaction } = p.noCallThru().load('../../../../../app/reactions/server/setReaction.ts', { + '@rocket.chat/models': modelsMock, + '@rocket.chat/core-services': { Message: { beforeReacted: sinon.stub() } }, + 'meteor/meteor': { Meteor: { methods: meteorMethodsMock, Error: meteorErrorMock } }, + '../../../lib/callbacks': { callbacks: { run: callbacksRunMock } }, + '../../../server/lib/i18n': { i18n: i18nMock }, + '../../authorization/server': { canAccessRoomAsync: canAccessRoomAsyncMock }, + '../../authorization/server/functions/hasPermission': { hasPermissionAsync: hasPermissionAsyncMock }, + '../../emoji/server': { emoji: { list: emojiList } }, + '../../lib/server/functions/isTheLastMessage': { isTheLastMessage: isTheLastMessageMock }, + '../../lib/server/lib/notifyListener': { + notifyOnMessageChange: notifyOnMessageChangeMock, + }, +}); + +describe('Reactions', () => { + describe('removeUserReaction', () => { + it('should return the message unmodified when no reactions exist', () => { + const message = {}; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result).to.equal(message); + }); + it('should remove the reaction from a message', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test.usernames).to.not.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + }); + it('should remove the reaction from a message when the user is the last one on the array', () => { + const message = { + reactions: { + test: { + usernames: ['test'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test).to.be.undefined; + }); + it('should remove username only from the reaction thats passed in', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + other: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test'); + expect(result.reactions.test.usernames).to.not.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + expect(result.reactions.other.usernames).to.include('test'); + expect(result.reactions.other.usernames).to.include('test2'); + }); + it('should do nothing if username is not in the reaction', () => { + const message = { + reactions: { + test: { + usernames: ['test', 'test2'], + }, + }, + }; + + const result = removeUserReaction(message as any, 'test', 'test3'); + expect(result.reactions.test.usernames).to.not.include('test3'); + expect(result.reactions.test.usernames).to.include('test'); + expect(result.reactions.test.usernames).to.include('test2'); + }); + }); + describe('executeSetReaction', () => { + beforeEach(() => { + modelsMock.EmojiCustom.countByNameOrAlias.reset(); + }); + it('should throw an error if reaction is not on emojione list', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(0); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should fail if user does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-invalid-user'); + }); + it('should fail if message does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should return nothing if user already reacted and its trying to react again', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + expect(await executeSetReaction('test', 'test', 'test', true)).to.be.undefined; + }); + it('should return nothing if user hasnt reacted and its trying to unreact', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['testxxxx'] } } }); + expect(await executeSetReaction('test', 'test', 'test', false)).to.be.undefined; + }); + it('should fail if room does not exist', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves(undefined); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('error-not-allowed'); + }); + it('should fail if user doesnt have acccess to the room', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves({ t: 'd' }); + canAccessRoomAsyncMock.resolves(false); + await expect(executeSetReaction('test', 'test', 'test')).to.be.rejectedWith('not-authorized'); + }); + it('should call setReaction with correct params', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Messages.findOneById.resolves({ reactions: { ':test:': { usernames: ['test'] } } }); + modelsMock.Rooms.findOneById.resolves({ t: 'c' }); + canAccessRoomAsyncMock.resolves(true); + + const res = await executeSetReaction('test', 'test', 'test'); + expect(res).to.be.undefined; + }); + it('should use the message from param when the type is not an string', async () => { + modelsMock.EmojiCustom.countByNameOrAlias.resolves(1); + modelsMock.Users.findOneById.resolves({ username: 'test' }); + modelsMock.Rooms.findOneById.resolves({ t: 'c' }); + canAccessRoomAsyncMock.resolves(true); + + await executeSetReaction('test', 'test', { reactions: { ':test:': { usernames: ['test'] } } }); + expect(modelsMock.Messages.findOneById.calledOnce).to.be.false; + }); + }); + describe('setReaction', () => { + beforeEach(() => { + canAccessRoomAsyncMock.reset(); + hasPermissionAsyncMock.reset(); + isTheLastMessageMock.reset(); + modelsMock.Messages.setReactions.reset(); + modelsMock.Rooms.setReactionsInLastMessage.reset(); + modelsMock.Rooms.unsetReactionsInLastMessage.reset(); + modelsMock.Messages.unsetReactions.reset(); + callbacksRunMock.reset(); + }); + it('should throw an error if user is muted from the room', async () => { + const room = { + muted: ['test'], + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + await expect(setReaction(room, user, message, ':test:')).to.be.rejectedWith('error-not-allowed'); + }); + it('should throw an error if room is readonly and cannot be reacted when readonly and user trying doesnt have permissions and user is not unmuted from room', async () => { + const room = { + ro: true, + reactWhenReadOnly: false, + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + canAccessRoomAsyncMock.resolves(false); + await expect(setReaction(room, user, message, ':test:')).to.be.rejectedWith("You can't send messages because the room is readonly."); + }); + it('should remove the user reaction if userAlreadyReacted is true and call unsetReaction if reaction is the last one on message', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.unsetReactions.calledWith(message._id)).to.be.true; + }); + it('should call Rooms.unsetReactionsInLastMessage when userAlreadyReacted is true and reaction is the last one on message', async () => { + const room = { + _id: 'test', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.unsetReactions.calledWith(message._id)).to.be.true; + expect(modelsMock.Rooms.unsetReactionsInLastMessage.calledWith(room._id)).to.be.true; + }); + it('should update the reactions object when userAlreadyReacted is true and there is more reactions on message', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + ':test2:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be.true; + }); + it('should call Rooms.setReactionsInLastMessage when userAlreadyReacted is true and reaction is not the last one on message', async () => { + const room = { + _id: 'test', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + ':test2:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, true); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be.true; + expect(modelsMock.Rooms.setReactionsInLastMessage.calledWith(room._id, sinon.match({ ':test2:': { usernames: ['test'] } }))).to.be + .true; + }); + it('should call afterUnsetReaction callback when userAlreadyReacted is true', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, true); + expect( + callbacksRunMock.calledWith( + 'afterUnsetReaction', + sinon.match({ _id: 'test' }), + sinon.match({ user, reaction, shouldReact: false, oldMessage: message }), + ), + ).to.be.true; + }); + it('should set reactions when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be.true; + }); + it('should properly add username to the list of reactions when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test2', + }; + const message = { + _id: 'test', + reactions: { + ':test:': { + usernames: ['test'], + }, + }, + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test', 'test2'] } }))).to.be + .true; + }); + it('should call Rooms.setReactionInLastMessage when userAlreadyReacted is false', async () => { + const room = { + _id: 'x5', + lastMessage: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + isTheLastMessageMock.resolves(true); + + await setReaction(room, user, message, reaction, false); + expect(modelsMock.Messages.setReactions.calledWith(message._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be.true; + expect(modelsMock.Rooms.setReactionsInLastMessage.calledWith(room._id, sinon.match({ ':test:': { usernames: ['test'] } }))).to.be + .true; + }); + it('should call afterSetReaction callback when userAlreadyReacted is false', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + await setReaction(room, user, message, reaction, false); + expect( + callbacksRunMock.calledWith('afterSetReaction', sinon.match({ _id: 'test' }), sinon.match({ user, reaction, shouldReact: true })), + ).to.be.true; + }); + it('should return undefined on a successful reaction', async () => { + const room = { + _id: 'test', + }; + const user = { + username: 'test', + }; + const message = { + _id: 'test', + }; + const reaction = ':test:'; + + expect(await setReaction(room, user, message, reaction, false)).to.be.undefined; + }); + }); +}); diff --git a/packages/model-typings/src/models/IEmojiCustomModel.ts b/packages/model-typings/src/models/IEmojiCustomModel.ts index fba5f1c3ea10..30f0323c1ec7 100644 --- a/packages/model-typings/src/models/IEmojiCustomModel.ts +++ b/packages/model-typings/src/models/IEmojiCustomModel.ts @@ -10,4 +10,5 @@ export interface IEmojiCustomModel extends IBaseModel { setAliases(_id: string, aliases: string[]): Promise; setExtension(_id: string, extension: string): Promise; create(data: InsertionModel): Promise>>; + countByNameOrAlias(name: string): Promise; }