diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py index 5e0872b694c..ba9966d591b 100644 --- a/addons/mail/controllers/discuss.py +++ b/addons/mail/controllers/discuss.py @@ -209,6 +209,62 @@ class DiscussController(http.Controller): raise NotFound() return attachment_sudo.unlink() + @http.route('/mail/message/add_reaction', methods=['POST'], type='json', auth='public') + def mail_message_add_reaction(self, message_id, content): + guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo() + message_sudo = guest_sudo.env['mail.message'].browse(int(message_id)).exists() + if not message_sudo: + raise NotFound() + if request.env.user.sudo()._is_public(): + if not guest_sudo or not message_sudo.model == 'mail.channel' or message_sudo.res_id not in guest_sudo.channel_ids.ids: + raise NotFound() + message_sudo._message_add_reaction(content=content) + guests = [('insert', {'id': guest_sudo.id})] + partners = [] + else: + message_sudo.sudo(False)._message_add_reaction(content=content) + guests = [] + partners = [('insert', {'id': request.env.user.partner_id.id})] + reactions = message_sudo.env['mail.message.reaction'].search([('message_id', '=', message_sudo.id), ('content', '=', content)]) + return { + 'id': message_sudo.id, + 'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', { + 'messageId': message_sudo.id, + 'content': content, + 'count': len(reactions), + 'guests': guests, + 'partners': partners, + })], + } + + @http.route('/mail/message/remove_reaction', methods=['POST'], type='json', auth='public') + def mail_message_remove_reaction(self, message_id, content): + guest_sudo = request.env['mail.guest']._get_guest_from_request(request).sudo() + message_sudo = guest_sudo.env['mail.message'].browse(int(message_id)).exists() + if not message_sudo: + raise NotFound() + if request.env.user.sudo()._is_public(): + if not guest_sudo or not message_sudo.model == 'mail.channel' or message_sudo.res_id not in guest_sudo.channel_ids.ids: + raise NotFound() + message_sudo._message_remove_reaction(content=content) + guests = [('insert-and-unlink', {'id': guest_sudo.id})] + partners = [] + else: + message_sudo.sudo(False)._message_remove_reaction(content=content) + guests = [] + partners = [('insert-and-unlink', {'id': request.env.user.partner_id.id})] + reactions = message_sudo.env['mail.message.reaction'].search([('message_id', '=', message_sudo.id), ('content', '=', content)]) + return { + 'id': message_sudo.id, + 'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', { + 'messageId': message_sudo.id, + 'content': content, + 'count': len(reactions), + 'guests': guests, + 'partners': partners, + })], + } + # -------------------------------------------------------------------------- # Channel API # -------------------------------------------------------------------------- diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 858ae8d14f3..d3e66d511d5 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -719,6 +719,55 @@ class Channel(models.Model): 'body': message.body, }, }) + return super()._message_update_content_after_hook(message=message) + + def _message_add_reaction_after_hook(self, message, content): + self.ensure_one() + if self.env.user._is_public() and 'guest' in self.env.context: + guests = [('insert', {'id': self.env.context.get('guest').id})] + partners = [] + else: + guests = [] + partners = [('insert', {'id': self.env.user.partner_id.id})] + reactions = self.env['mail.message.reaction'].sudo().search([('message_id', '=', message.id), ('content', '=', content)]) + self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', self.id), { + 'type': 'mail.message_update', + 'payload': { + 'id': message.id, + 'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', { + 'messageId': message.id, + 'content': content, + 'count': len(reactions), + 'guests': guests, + 'partners': partners, + })], + }, + }) + return super()._message_add_reaction_after_hook(message=message, content=content) + + def _message_remove_reaction_after_hook(self, message, content): + self.ensure_one() + if self.env.user._is_public() and 'guest' in self.env.context: + guests = [('insert-and-unlink', {'id': self.env.context.get('guest').id})] + partners = [] + else: + guests = [] + partners = [('insert-and-unlink', {'id': self.env.user.partner_id.id})] + reactions = self.env['mail.message.reaction'].sudo().search([('message_id', '=', message.id), ('content', '=', content)]) + self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', self.id), { + 'type': 'mail.message_update', + 'payload': { + 'id': message.id, + 'messageReactionGroups': [('insert' if len(reactions) > 0 else 'insert-and-unlink', { + 'messageId': message.id, + 'content': content, + 'count': len(reactions), + 'guests': guests, + 'partners': partners, + })], + }, + }) + return super()._message_remove_reaction_after_hook(message=message, content=content) def _message_subscribe(self, partner_ids=None, subtype_ids=None, customer_ids=None): """ Do not allow follower subscription on channels. Only members are diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 802af43d73f..0556b4061c7 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -176,6 +176,7 @@ class Message(models.Model): # Besides for new messages, and messages never sending emails, there was no mail, and it was searching for nothing. mail_ids = fields.One2many('mail.mail', 'mail_message_id', string='Mails', groups="base.group_system") canned_response_ids = fields.One2many('mail.shortcode', 'message_ids', string="Canned Responses", store=False) + reaction_ids = fields.One2many('mail.message.reaction', 'message_id', string="Reactions", groups="base.group_system") def _compute_description(self): for message in self: @@ -758,6 +759,40 @@ class Message(models.Model): notification = {'type': 'toggle_star', 'message_ids': [self.id], 'starred': starred} self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) + def _message_add_reaction(self, content): + self.ensure_one() + self.check_access_rule('write') + self.check_access_rights('write') + if self.env.user._is_public() and 'guest' in self.env.context: + guest = self.env.context.get('guest') + partner = self.env['res.partner'] + else: + guest = self.env['mail.guest'] + partner = self.env.user.partner_id + reaction = self.env['mail.message.reaction'].sudo().search([('message_id', '=', self.id), ('partner_id', '=', partner.id), ('guest_id', '=', guest.id), ('content', '=', content)]) + if not reaction: + reaction = self.env['mail.message.reaction'].sudo().create({ + 'message_id': self.id, + 'content': content, + 'partner_id': partner.id, + 'guest_id': guest.id, + }) + self.env[self.model].browse(self.res_id)._message_add_reaction_after_hook(message=self, content=reaction.content) + + def _message_remove_reaction(self, content): + self.ensure_one() + self.check_access_rule('write') + self.check_access_rights('write') + if self.env.user._is_public() and 'guest' in self.env.context: + guest = self.env.context.get('guest') + partner = self.env['res.partner'] + else: + guest = self.env['mail.guest'] + partner = self.env.user.partner_id + reaction = self.env['mail.message.reaction'].sudo().search([('message_id', '=', self.id), ('partner_id', '=', partner.id), ('guest_id', '=', guest.id), ('content', '=', content)]) + reaction.unlink() + self.env[self.model].browse(self.res_id)._message_remove_reaction_after_hook(message=self, content=content) + # ------------------------------------------------------ # MESSAGE READ / FETCH / FAILURE API # ------------------------------------------------------ @@ -818,10 +853,21 @@ class Message(models.Model): })] else: vals['author_id'] = author + reactions_per_content = defaultdict(lambda: self.env['mail.message.reaction']) + for reaction in message_sudo.reaction_ids: + reactions_per_content[reaction.content] |= reaction + reaction_groups = [('insert-and-replace', [{ + 'messageId': message_sudo.id, + 'content': content, + 'count': len(reactions), + 'partners': [('insert-and-replace', [{'id': partner.id, 'name': partner.name} for partner in reactions.partner_id])], + 'guests': [('insert-and-replace', [{'id': guest.id, 'name': guest.name} for guest in reactions.guest_id])], + } for content, reactions in reactions_per_content.items()])] vals.update({ 'notifications': message_sudo.notification_ids._filtered_for_web_client()._notification_format(), 'attachment_ids': attachments_formatted, 'tracking_value_ids': tracking_value_ids, + 'messageReactionGroups': reaction_groups, 'record_name': record_name, }) diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index c8ae28c4ad4..456b37efba1 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1886,6 +1886,12 @@ class MailThread(models.AbstractModel): def _message_update_content_after_hook(self, message): """ Hook to add custom behavior after having updated the message content. """ + def _message_add_reaction_after_hook(self, message, content): + """ Hook to add custom behavior after having added a reaction to a message. """ + + def _message_remove_reaction_after_hook(self, message, content): + """ Hook to add custom behavior after having removed a reaction from a message. """ + def _check_can_update_message_content(self, message): """" Checks that the current user can update the content of the message. """ note_id = self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note') diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js index 2195199d9ac..49333a5a209 100644 --- a/addons/mail/static/src/components/composer/composer.js +++ b/addons/mail/static/src/components/composer/composer.js @@ -281,8 +281,7 @@ export class Composer extends Component { } /** - * Called when selection an emoji from the emoji popover (from the emoji - * button). + * Handles `o-emoji-selection` event from the emoji popover. * * @private * @param {CustomEvent} ev diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 163a5c56cf9..592c9be5782 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -1174,7 +1174,7 @@ QUnit.test('open channel from active_id as channel id', async function (assert) QUnit.test('basic rendering of message', async function (assert) { // AKU TODO: should be in message-only tests - assert.expect(13); + assert.expect(14); // channel expected to be rendered, with a random unique id that will be referenced in the test this.data['mail.channel'].records.push({ id: 20 }); @@ -1253,14 +1253,19 @@ QUnit.test('basic rendering of message', async function (assert) { ); assert.strictEqual( message.querySelectorAll(`:scope .o_MessageActionList_action`).length, - 1, - "should have a single action in action list of message" + 2, + "should have 2 actions in action list of message" ); assert.strictEqual( message.querySelectorAll(`:scope .o_MessageActionList_actionStar`).length, 1, "should have action to star message" ); + assert.containsOnce( + document.body, + '.o_MessageActionList_actionReaction', + "should have action to add a reaction" + ); assert.strictEqual( message.querySelectorAll(`:scope .o_Message_content`).length, 1, @@ -2876,7 +2881,7 @@ QUnit.test('do not post message on channel with "SHIFT-Enter" keyboard shortcut' }); QUnit.test('rendering of inbox message', async function (assert) { // AKU TODO: kinda message specific test - assert.expect(7); + assert.expect(8); this.data['mail.message'].records.push({ body: "not empty", @@ -2925,8 +2930,13 @@ QUnit.test('rendering of inbox message', async function (assert) { await afterNextRender(() => message.click()); assert.strictEqual( message.querySelectorAll(`:scope .o_MessageActionList_action`).length, - 3, - "should display 3 actions" + 4, + "should display 4 actions" + ); + assert.containsOnce( + document.body, + '.o_MessageActionList_actionReaction', + "should have action to add a reaction" ); assert.strictEqual( message.querySelectorAll(`:scope .o_MessageActionList_actionStar`).length, diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js index 4a184469bf9..655e067866c 100644 --- a/addons/mail/static/src/components/message/message.js +++ b/addons/mail/static/src/components/message/message.js @@ -132,7 +132,11 @@ export class Message extends Component { * @returns {boolean} */ get isActive() { - return this.state.isHovered || this.state.isClicked; + return Boolean( + this.state.isHovered || + this.state.isClicked || + (this.message && this.message.actionList.isReactionPopoverOpened) + ); } /** @@ -470,7 +474,8 @@ export class Message extends Component { !isEventHandled(ev, 'Message.ClickAuthorAvatar') && !isEventHandled(ev, 'Message.ClickAuthorName') && !isEventHandled(ev, 'Message.ClickFailure') && - !isEventHandled(ev, 'MessageActionList.Click') + !isEventHandled(ev, 'MessageActionList.Click') && + !isEventHandled(ev, 'MessageReactionGroup.Click') ) { this.state.isClicked = !this.state.isClicked; } diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml index a0b3af38aed..ee1f09eed7f 100644 --- a/addons/mail/static/src/components/message/message.xml +++ b/addons/mail/static/src/components/message/message.xml @@ -139,6 +139,11 @@ +
Subject: