From 776d1ee08b805ca49d5f9c8f0f581e71aee359aa Mon Sep 17 00:00:00 2001 From: Samuel Degueldre Date: Thu, 2 Sep 2021 16:39:41 +0000 Subject: [PATCH] [IMP] mail: allow user to delete their messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit allows users to "delete" their messages in discuss, as well as their notes on other records. Admins can delete anyone discuss messages or notes. Tracking messages such as a change in stage or other cannot be deleted. This "delete" is a "soft-delete" in that it only empties the message's content, so that it is no longer displayed in the UI, but remains in the database so that people who got notified by email can still reply to the message, among other things. task-2365646 closes odoo/odoo#74857 Signed-off-by: Sébastien Theys (seb) --- addons/mail/controllers/discuss.py | 19 ++- addons/mail/models/mail_channel.py | 16 ++ addons/mail/models/mail_message.py | 20 +++ addons/mail/models/mail_thread.py | 10 +- .../src/components/composer/composer.js | 7 +- .../delete_message_confirm_dialog.js | 39 +++++ .../delete_message_confirm_dialog.scss | 3 + .../delete_message_confirm_dialog.xml | 16 ++ .../discuss/tests/discuss_inbox_tests.js | 21 ++- .../components/discuss/tests/discuss_tests.js | 68 +++++---- .../static/src/components/message/message.js | 61 +++----- .../src/components/message/message.scss | 119 +-------------- .../static/src/components/message/message.xml | 138 +++++++----------- .../components/message/tests/message_tests.js | 5 +- .../message_action_list.js | 26 ++++ .../message_action_list.scss | 22 +++ .../message_action_list.xml | 16 ++ addons/mail/static/src/js/tours/mail.js | 6 +- .../mail/static/src/models/discuss/discuss.js | 5 + .../mail/static/src/models/message/message.js | 77 +++++++++- .../message_action_list.js | 108 ++++++++++++++ .../messaging_notification_handler.js | 2 + 22 files changed, 518 insertions(+), 286 deletions(-) create mode 100644 addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.js create mode 100644 addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.scss create mode 100644 addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.xml create mode 100644 addons/mail/static/src/components/message_action_list/message_action_list.js create mode 100644 addons/mail/static/src/components/message_action_list/message_action_list.scss create mode 100644 addons/mail/static/src/components/message_action_list/message_action_list.xml create mode 100644 addons/mail/static/src/models/message_action_list/message_action_list.js diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py index 8c60a406659..bd104bac47e 100644 --- a/addons/mail/controllers/discuss.py +++ b/addons/mail/controllers/discuss.py @@ -128,6 +128,18 @@ class DiscussController(http.Controller): allowed_params = {'attachment_ids', 'body', 'message_type', 'partner_ids', 'subtype_xmlid'} return thread.message_post(**{key: value for key, value in post_data.items() if key in allowed_params}).message_format()[0] + @http.route('/mail/message/update_content', methods=['POST'], type='json', auth='public') + def mail_message_update_content(self, message_id, body): + guest = request.env['mail.guest']._get_guest_from_request(request) + message_sudo = guest.env['mail.message'].browse(message_id).sudo().exists() + if not message_sudo.is_current_user_or_guest_author and not guest.env.user.has_group('base.group_system'): + raise NotFound() + message_sudo._update_content(body=body) + return { + 'id': message_sudo.id, + 'body': message_sudo.body, + } + @http.route('/mail/attachment/upload', methods=['POST'], type='http', auth='public') def mail_attachment_upload(self, ufile, thread_id, thread_model, is_pending=False, **kwargs): channel_partner = request.env['mail.channel.partner'] @@ -176,11 +188,10 @@ class DiscussController(http.Controller): # For non-internal users 2 cases are supported: # - Either the attachment is linked to a message: verify the request is made by the author of the message (portal user or guest). # - Either a valid access token is given: also verify the message is pending (because unfortunately in portal a token is also provided to guest for viewing others' attachments). - message_sudo = request.env['mail.message'].sudo().search([('attachment_ids', 'in', attachment_sudo.ids)], limit=1) + guest = request.env['mail.guest']._get_guest_from_request(request) + message_sudo = guest.env['mail.message'].sudo().search([('attachment_ids', 'in', attachment_sudo.ids)], limit=1) if message_sudo: - if request.session.uid and (not message_sudo.author_id or message_sudo.author_id != request.env.user.partner_id): - raise NotFound() - if not request.session.uid and (not message_sudo.author_guest_id or message_sudo.author_guest_id != request.env['mail.guest']._get_guest_from_request(request)): + if not message_sudo.is_current_user_or_guest_author: raise NotFound() else: if not access_token or not attachment_sudo.access_token or not consteq(access_token, attachment_sudo.access_token): diff --git a/addons/mail/models/mail_channel.py b/addons/mail/models/mail_channel.py index 0fe82b90954..825e2d3f573 100644 --- a/addons/mail/models/mail_channel.py +++ b/addons/mail/models/mail_channel.py @@ -533,6 +533,22 @@ class Channel(models.Model): self._set_last_seen_message(message) return super()._message_post_after_hook(message=message, msg_vals=msg_vals) + def _check_can_update_message_content(self, message): + """ We don't call super in this override as we want to ignore the + mail.thread behavior completely """ + if not message.message_type == 'comment': + raise UserError(_("Only messages type comment can have their content updated on model 'mail.channel'")) + + def _message_update_content_after_hook(self, message): + self.ensure_one() + self.env['bus.bus'].sendone((self._cr.dbname, 'mail.channel', self.id), { + 'type': 'mail.message_update', + 'payload': { + 'id': message.id, + 'body': message.body, + }, + }) + def _message_subscribe(self, partner_ids=None, subtype_ids=None, customer_ids=None): """ Do not allow follower subscription on channels. Only members are considered. """ diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 67e12c255bb..802af43d73f 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -125,6 +125,7 @@ class Message(models.Model): help="Author of the message. If not set, email_from may hold an email address that did not match any partner.") author_avatar = fields.Binary("Author's avatar", related='author_id.avatar_128', depends=['author_id'], readonly=False) author_guest_id = fields.Many2one(string="Guest", comodel_name='mail.guest') + is_current_user_or_guest_author = fields.Boolean(compute='_compute_is_current_user_or_guest_author') # recipients: include inactive partners (they may have been archived after # the message was sent, but they should remain visible in the relation) partner_ids = fields.Many2many('res.partner', string='Recipients', context={'active_test': False}) @@ -184,6 +185,18 @@ class Message(models.Model): plaintext_ct = '' if not message.body else tools.html2plaintext(message.body) message.description = plaintext_ct[:30] + '%s' % (' [...]' if len(plaintext_ct) >= 30 else '') + @api.depends('author_id', 'author_guest_id') + @api.depends_context('guest', 'uid') + def _compute_is_current_user_or_guest_author(self): + user = self.env.user + for message in self: + if not user._is_public() and (message.author_id and message.author_id == user.partner_id): + message.is_current_user_or_guest_author = True + elif user._is_public() and (message.author_guest_id and message.author_guest_id == self.env.context.get('guest')): + message.is_current_user_or_guest_author = True + else: + message.is_current_user_or_guest_author = False + def _compute_needaction(self): """ Need action on a mail.message = notified on my channel """ my_messages = self.env['mail.notification'].sudo().search([ @@ -660,6 +673,13 @@ class Message(models.Model): return super(Message, self).export_data(fields_to_export) + def _update_content(self, body): + self.ensure_one() + thread = self.env[self.model].browse(self.res_id) + thread._check_can_update_message_content(self) + self.body = body + thread._message_update_content_after_hook(self) + # ------------------------------------------------------ # DISCUSS API # ------------------------------------------------------ diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 3d0fef2b8cd..e6ca0547130 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1882,7 +1882,15 @@ class MailThread(models.AbstractModel): """ Hook to add custom behavior after having posted the message. Both message and computed value are given, to try to lessen query count by using already-computed values instead of having to rebrowse things. """ - pass + + def _message_update_content_after_hook(self, message): + """ Hook to add custom behavior after having updated the message content. """ + + 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') + if not message.subtype_id.id == note_id: + raise exceptions.UserError(_("Only logged notes can have their content updated on model '%s'", self._name)) # ------------------------------------------------------ # MESSAGE POST TOOLS diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js index 61765f3f210..2195199d9ac 100644 --- a/addons/mail/static/src/components/composer/composer.js +++ b/addons/mail/static/src/components/composer/composer.js @@ -208,10 +208,15 @@ export class Composer extends Component { * @private * @param {MouseEvent} ev */ - _onClickCaptureGlobal(ev) { + async _onClickCaptureGlobal(ev) { if (this.contains(ev.target)) { return; } + // Let event be handled by bubbling handlers first + await new Promise(this.env.browser.setTimeout); + if (isEventHandled(ev, 'MessageActionList.replyTo')) { + return; + } if (!this.composer) { return; } diff --git a/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.js b/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.js new file mode 100644 index 00000000000..3c1006ac038 --- /dev/null +++ b/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.js @@ -0,0 +1,39 @@ +/** @odoo-module **/ + +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +import Dialog from 'web.OwlDialog'; + +const { Component } = owl; +const { useRef } = owl.hooks; + +export class DeleteMessageConfirmDialog extends Component { + + /** + * @override + */ + setup() { + super.setup(); + this.title = this.env._t("Confirmation"); + this.dialogRef = useRef('dialog'); + } + + /** + * @returns {mail.message} + */ + get actionList() { + return this.messaging && this.messaging.models['mail.message_action_list'].get(this.props.actionListLocalId); + } +} + +Object.assign(DeleteMessageConfirmDialog, { + components: { + Dialog, + }, + props: { + actionListLocalId: String, + }, + template: 'mail.DeleteMessageConfirmDialog', +}); + +registerMessagingComponent(DeleteMessageConfirmDialog); diff --git a/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.scss b/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.scss new file mode 100644 index 00000000000..e55b10db424 --- /dev/null +++ b/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.scss @@ -0,0 +1,3 @@ +.o_DeleteMessageConfirmDialog_blockquote { + font-style: normal; +} diff --git a/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.xml b/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.xml new file mode 100644 index 00000000000..7a2727dd72a --- /dev/null +++ b/addons/mail/static/src/components/delete_message_confirm_dialog/delete_message_confirm_dialog.xml @@ -0,0 +1,16 @@ + + + + +

Are you sure you want to delete this message?

+
+ +
+ Pay attention: The followers of this document who were notified by email will still be able to read the content of this message and reply to it. + + + + +
+
+
diff --git a/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js index caaf82ff12f..c636394d329 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js @@ -74,8 +74,9 @@ QUnit.test('reply: discard on pressing escape', async function (assert) { '.o_Message', "should display a single message" ); + await afterNextRender(() => document.querySelector('.o_Message').click()); await afterNextRender(() => - document.querySelector('.o_Message_commandReply').click() + document.querySelector('.o_MessageActionList_actionReply').click() ); assert.containsOnce( document.body, @@ -192,9 +193,10 @@ QUnit.test('reply: discard on discard button click', async function (assert) { '.o_Message', "should display a single message" ); + await afterNextRender(() => document.querySelector('.o_Message').click()); await afterNextRender(() => - document.querySelector('.o_Message_commandReply').click() + document.querySelector('.o_MessageActionList_actionReply').click() ); assert.containsOnce( document.body, @@ -252,18 +254,18 @@ QUnit.test('reply: discard on reply button toggle', async function (assert) { '.o_Message', "should display a single message" ); + await afterNextRender(() => document.querySelector('.o_Message').click()); await afterNextRender(() => - document.querySelector('.o_Message_commandReply').click() + document.querySelector('.o_MessageActionList_actionReply').click() ); assert.containsOnce( document.body, '.o_Composer', "should have composer after clicking on reply to message" ); - await afterNextRender(() => - document.querySelector(`.o_Message_commandReply`).click() + document.querySelector(`.o_MessageActionList_actionReply`).click() ); assert.containsNone( document.body, @@ -307,9 +309,10 @@ QUnit.test('reply: discard on click away', async function (assert) { '.o_Message', "should display a single message" ); + await afterNextRender(() => document.querySelector('.o_Message').click()); await afterNextRender(() => - document.querySelector('.o_Message_commandReply').click() + document.querySelector('.o_MessageActionList_actionReply').click() ); assert.containsOnce( document.body, @@ -410,9 +413,10 @@ QUnit.test('"reply to" composer should log note if message replied to is a note' '.o_Message', "should display a single message" ); + await afterNextRender(() => document.querySelector('.o_Message').click()); await afterNextRender(() => - document.querySelector('.o_Message_commandReply').click() + document.querySelector('.o_MessageActionList_actionReply').click() ); assert.strictEqual( document.querySelector('.o_Composer_buttonSend').textContent.trim(), @@ -481,9 +485,10 @@ QUnit.test('"reply to" composer should send message if message replied to is not '.o_Message', "should display a single message" ); + await afterNextRender(() => document.querySelector('.o_Message').click()); await afterNextRender(() => - document.querySelector('.o_Message_commandReply').click() + document.querySelector('.o_MessageActionList_actionReply').click() ); assert.strictEqual( document.querySelector('.o_Composer_buttonSend').textContent.trim(), 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 5cb2a717795..01630fa65ef 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -1243,20 +1243,23 @@ QUnit.test('basic rendering of message', async function (assert) { 1, "should have date in header of message" ); - assert.strictEqual( - message.querySelectorAll(`:scope .o_Message_header .o_Message_commands`).length, - 1, - "should have commands in header of message" + await afterNextRender(() => + document.querySelector('.o_Message').click() ); assert.strictEqual( - message.querySelectorAll(`:scope .o_Message_header .o_Message_command`).length, + message.querySelectorAll(`:scope .o_MessageActionList`).length, 1, - "should have a single command in header of message" + "should action list in message" ); assert.strictEqual( - message.querySelectorAll(`:scope .o_Message_commandStar`).length, + message.querySelectorAll(`:scope .o_MessageActionList_action`).length, 1, - "should have command to star message" + "should have a single action in action list of message" + ); + assert.strictEqual( + message.querySelectorAll(`:scope .o_MessageActionList_actionStar`).length, + 1, + "should have action to star message" ); assert.strictEqual( message.querySelectorAll(`:scope .o_Message_content`).length, @@ -1345,20 +1348,23 @@ QUnit.test('basic rendering of squashed message', async function (assert) { message2.querySelector(`:scope .o_Message_sidebar`).classList.contains('o-message-squashed'), "message 2 should have squashed sidebar" ); + await afterNextRender(() => + document.querySelector('.o_Message.o-squashed').click() + ); assert.strictEqual( message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_date`).length, 1, "message 2 should have date in sidebar" ); assert.strictEqual( - message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commands`).length, + message2.querySelectorAll(`:scope .o_MessageActionList`).length, 1, - "message 2 should have some commands in sidebar" + "message 2 should have some actions" ); assert.strictEqual( - message2.querySelectorAll(`:scope .o_Message_sidebar .o_Message_commandStar`).length, + message2.querySelectorAll(`:scope .o_MessageActionList_actionStar`).length, 1, - "message 2 should have star command in sidebar" + "message 2 should have star action in action list" ); assert.strictEqual( message2.querySelectorAll(`:scope .o_Message_core`).length, @@ -2489,13 +2495,14 @@ QUnit.test('toggle_star message', async function (assert) { message.classList.contains('o-starred'), "message should not be starred" ); + await afterNextRender(() => message.click()); assert.strictEqual( - message.querySelectorAll(`:scope .o_Message_commandStar`).length, + message.querySelectorAll(`:scope .o_MessageActionList_actionStar`).length, 1, - "message should have star command" + "message should have star action" ); - await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click()); + await afterNextRender(() => message.querySelector(`:scope .o_MessageActionList_actionStar`).click()); assert.verifySteps(['rpc:toggle_message_starred']); assert.strictEqual( document.querySelector(` @@ -2518,7 +2525,7 @@ QUnit.test('toggle_star message', async function (assert) { "message should be starred" ); - await afterNextRender(() => message.querySelector(`:scope .o_Message_commandStar`).click()); + await afterNextRender(() => message.querySelector(`:scope .o_MessageActionList_actionStar`).click()); assert.verifySteps(['rpc:toggle_message_starred']); assert.strictEqual( document.querySelectorAll(` @@ -2915,25 +2922,26 @@ QUnit.test('rendering of inbox message', async function (assert) { " on Refactoring", "should display origin thread name" ); + await afterNextRender(() => message.click()); assert.strictEqual( - message.querySelectorAll(`:scope .o_Message_command`).length, + message.querySelectorAll(`:scope .o_MessageActionList_action`).length, 3, - "should display 3 commands" + "should display 3 actions" ); assert.strictEqual( - message.querySelectorAll(`:scope .o_Message_commandStar`).length, + message.querySelectorAll(`:scope .o_MessageActionList_actionStar`).length, 1, - "should display star command" + "should display star action" ); assert.strictEqual( - message.querySelectorAll(`:scope .o_Message_commandReply`).length, + message.querySelectorAll(`:scope .o_MessageActionList_actionReply`).length, 1, - "should display reply command" + "should display reply action" ); assert.strictEqual( - message.querySelectorAll(`:scope .o_Message_commandMarkAsRead`).length, + message.querySelectorAll(`:scope .o_MessageActionList_actionMarkRead`).length, 1, - "should display mark as read command" + "should display mark as read action" ); }); @@ -3209,9 +3217,10 @@ QUnit.test('reply to message from inbox (message linked to document)', async fun " on Refactoring", "should display message originates from record 'Refactoring'" ); + await afterNextRender(() => document.querySelector('.o_Message').click()); await afterNextRender(() => - document.querySelector('.o_Message_commandReply').click() + document.querySelector('.o_MessageActionList_actionReply').click() ); assert.ok( document.querySelector('.o_Message').classList.contains('o-selected'), @@ -3456,12 +3465,19 @@ QUnit.test('mark a single message as read should only move this message to "Hist 2, "inbox mailbox should have 2 messages" ); + await afterNextRender(() => + document.querySelector(` + .o_Message[data-message-local-id="${ + this.messaging.models['mail.message'].findFromIdentifyingData({ id: 1 }).localId + }"] + `).click() + ); await afterNextRender(() => document.querySelector(` .o_Message[data-message-local-id="${ this.messaging.models['mail.message'].findFromIdentifyingData({ id: 1 }).localId - }"] .o_Message_commandMarkAsRead + }"] .o_MessageActionList_actionMarkRead `).click() ); assert.containsOnce( diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js index 01c6ef4a290..4a184469bf9 100644 --- a/addons/mail/static/src/components/message/message.js +++ b/addons/mail/static/src/components/message/message.js @@ -22,17 +22,18 @@ export class Message extends Component { constructor(...args) { super(...args); this.state = useState({ + /** + * Determine whether the message is hovered. When message is hovered + * it displays message actions. + */ + isHovered: false, /** * Determine whether the message is clicked. When message is in - * clicked state, it keeps displaying the commands. + * clicked state, it keeps displaying actions even if not hovered. */ isClicked: false, }); useUpdate({ func: () => this._update() }); - /** - * The intent of the reply button depends on the last rendered state. - */ - this._wasSelected; /** * Value of the last rendered prettyBody. Useful to compare to new value * to decide if it has to be updated. @@ -124,6 +125,16 @@ export class Message extends Component { return true; } + /** + * Whether the message is "active", ie: hovered or clicked, and should + * display additional things (date in sidebar, message actions, etc.) + * + * @returns {boolean} + */ + get isActive() { + return this.state.isHovered || this.state.isClicked; + } + /** * Tell whether the bottom of this message is visible or not. * @@ -419,7 +430,6 @@ export class Message extends Component { message: this.message, }); } - this._wasSelected = this.isSelected; this.message.refreshDateFromNow(); clearInterval(this._intervalId); this._intervalId = setInterval(() => { @@ -459,7 +469,8 @@ export class Message extends Component { if ( !isEventHandled(ev, 'Message.ClickAuthorAvatar') && !isEventHandled(ev, 'Message.ClickAuthorName') && - !isEventHandled(ev, 'Message.ClickFailure') + !isEventHandled(ev, 'Message.ClickFailure') && + !isEventHandled(ev, 'MessageActionList.Click') ) { this.state.isClicked = !this.state.isClicked; } @@ -507,40 +518,6 @@ export class Message extends Component { ev.preventDefault(); this.message.originThread.open(); } - - /** - * @private - * @param {MouseEvent} ev - */ - _onClickStar(ev) { - ev.stopPropagation(); - this.message.toggleStar(); - } - - /** - * @private - * @param {MouseEvent} ev - */ - _onClickMarkAsRead(ev) { - ev.stopPropagation(); - this.message.markAsRead(); - } - - /** - * @private - * @param {MouseEvent} ev - */ - _onClickReply(ev) { - // Use this._wasSelected because this.props.isSelected might be changed - // by a global capture click handler (for example the one from Composer) - // before the current handler is executed. Indeed because it does a - // toggle it needs to take into account the value before the click. - if (this._wasSelected) { - this.messaging.discuss.clearReplyingToMessage(); - } else { - this.message.replyTo(); - } - } } Object.assign(Message, { @@ -548,6 +525,7 @@ Object.assign(Message, { hasMarkAsReadIcon: false, hasReplyIcon: false, isSquashed: false, + showActions: true, }, props: { attachmentsDetailsMode: { @@ -563,6 +541,7 @@ Object.assign(Message, { type: String, optional: true, }, + showActions: Boolean, }, template: 'mail.Message', }); diff --git a/addons/mail/static/src/components/message/message.scss b/addons/mail/static/src/components/message/message.scss index 895767e4de6..6b42fe90aa0 100644 --- a/addons/mail/static/src/components/message/message.scss +++ b/addons/mail/static/src/components/message/message.scss @@ -27,19 +27,10 @@ margin-inline-end: map-get($spacers, 2); } -.o_Message_commandStar { - font-size: 1.3em; -} - .o_Message_Composer { flex: 1 1 auto; } -.o_Message_commands { - display: flex; - align-items: center; -} - .o_Message_content { word-wrap: break-word; word-break: break-word; @@ -79,29 +70,6 @@ align-items: baseline; } -.o_Message_headerCommands { - margin-inline-end: map-get($spacers, 2); - align-self: center; - - .o_Message_headerCommand { - padding-left: map-get($spacers, 2); - padding-right: map-get($spacers, 2); - - &.o-mobile { - padding-left: map-get($spacers, 3); - padding-right: map-get($spacers, 3); - - &:first-child { - padding-left: map-get($spacers, 2); - } - - &:last-child { - padding-right: map-get($spacers, 2); - } - } - } -} - .o_Message_headerDate { margin-inline-end: map-get($spacers, 2); font-size: 0.8em; @@ -143,21 +111,12 @@ max-width: $o-mail-message-sidebar-width; display: flex; margin-top: map-get($spacers, 2); - margin-inline-end: map-get($spacers, 2); justify-content: center; - - &.o-message-squashed { - align-items: flex-start; - } } .o_Message_sidebarItem { margin-left: map-get($spacers, 1); margin-right: map-get($spacers, 1); - - &.o-message-squashed { - display: flex; - } } .o_Message_trackingValues { @@ -181,21 +140,6 @@ .o_Message { background-color: $white; - &:hover, &.o-clicked { - - .o_Message_commands { - opacity: 1; - } - - .o_Message_sidebarItem.o-message-squashed { - display: flex; - } - - .o_Message_seenIndicator.o-message-squashed { - display: none; - } - } - .o_Message_partnerImStatusIcon { color: $white; } @@ -220,17 +164,6 @@ } } - &.o-starred { - - .o_Message_commandStar { - display: flex; - } - - .o_Message_commands { - display: flex; - } - } - &.o-has-message-selection:not(.o-selected) { opacity: 0.5; } @@ -248,40 +181,6 @@ cursor: pointer; } -.o_Message_command { - cursor: pointer; - color: gray('400'); - - &:not(.o-mobile) { - &:hover { - filter: brightness(0.8); - } - } - - &.o-mobile { - filter: brightness(0.8); - - &:hover { - filter: brightness(0.75); - } - } - - &.o-message-selected { - color: gray('500'); - } -} - -.o_Message_commandStar { - - &.o-message-starred { - color: gold; - - &:hover { - filter: brightness(0.9); - } - } -} - .o_Message_content .o_mention { color: $o-brand-primary; cursor: pointer; @@ -299,8 +198,14 @@ } } -.o_Message_headerCommands:not(.o-mobile) { - opacity: 0; +.o_Message_contentWrapper { + // transparent border to avoid layout moving around when it becomes active + border: $border-width solid transparent; + border-radius: $o-mail-rounded-rectangle-border-radius-sm; + + &.o_Message_contentWrapper_active { + border: $border-width solid $border-color; + } } .o_Message_highlightIndicator.o-active { @@ -333,14 +238,6 @@ } } -.o_Message_sidebarCommands { - display: none; -} - -.o_Message_sidebarItem.o-message-squashed { - display: none; -} - .o_Message_subject { font-style: italic; } diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml index c72d9e0b487..a0b3af38aed 100644 --- a/addons/mail/static/src/components/message/message.xml +++ b/addons/mail/static/src/components/message/message.xml @@ -14,11 +14,11 @@ 'o-selected': isSelected, 'o-squashed': props.isSquashed, 'o-starred': message and message.isStarred, - }" t-on-click="_onClick" t-att-data-message-local-id="message and message.localId" + }" t-on-click="_onClick" t-on-mouseenter="state.isHovered = true" t-on-mouseleave="state.isHovered = false" t-att-data-message-local-id="message and message.localId" >
-
+
Avatar @@ -36,32 +36,19 @@
- -
+ +
-
- -
- -
- - + +
-
+
-
+
@@ -87,7 +74,7 @@ -
- + @@ -119,75 +106,54 @@ -
- - - - - - - - - -
-
-
- -

+

+ +
+
+ +

+ + +

    + +
  • +
    +
    + +
    + +
  • +
    +
+
+
+ +

Subject:

- -
    - -
  • -
    -
    - -
    - -
  • -
    -
+ +
- -

Subject:

-
- - -
diff --git a/addons/mail/static/src/components/message/tests/message_tests.js b/addons/mail/static/src/components/message/tests/message_tests.js index b0d01146165..7c4d70d55f7 100644 --- a/addons/mail/static/src/components/message/tests/message_tests.js +++ b/addons/mail/static/src/components/message/tests/message_tests.js @@ -103,10 +103,11 @@ QUnit.test('basic rendering', async function (assert) { 1, "message should display date" ); + await afterNextRender(() => messageEl.click()); assert.strictEqual( - messageEl.querySelectorAll(`:scope .o_Message_commands`).length, + messageEl.querySelectorAll(`:scope .o_MessageActionList`).length, 1, - "message should display list of commands" + "message should display list of actions" ); assert.strictEqual( messageEl.querySelectorAll(`:scope .o_Message_content`).length, diff --git a/addons/mail/static/src/components/message_action_list/message_action_list.js b/addons/mail/static/src/components/message_action_list/message_action_list.js new file mode 100644 index 00000000000..7679c6b15f8 --- /dev/null +++ b/addons/mail/static/src/components/message_action_list/message_action_list.js @@ -0,0 +1,26 @@ +/** @odoo-module */ +import { registerMessagingComponent } from '@mail/utils/messaging_component'; + +const { Component } = owl; + +export class MessageActionList extends Component { + + /** + * @returns {mail.message} + */ + get actionList() { + return this.messaging && this.messaging.models['mail.message_action_list'].get(this.props.actionListLocalId); + } + +} + +Object.assign(MessageActionList, { + props: { + actionListLocalId: String, + hasMarkAsReadIcon: Boolean, + hasReplyIcon: Boolean, + }, + template: "mail.MessageActionList", +}); + +registerMessagingComponent(MessageActionList); diff --git a/addons/mail/static/src/components/message_action_list/message_action_list.scss b/addons/mail/static/src/components/message_action_list/message_action_list.scss new file mode 100644 index 00000000000..002fe0aa4e9 --- /dev/null +++ b/addons/mail/static/src/components/message_action_list/message_action_list.scss @@ -0,0 +1,22 @@ +.o_MessageActionList { + @include o-position-absolute($top: - map-get($spacers, 3), $right: map-get($spacers, 2)); + background-color: $white; + border: $border-width solid $border-color; + border-radius: $o-mail-rounded-rectangle-border-radius-sm; + + &:hover { + box-shadow: 0 4px .5rem -.5rem black; + } +} + +.o_MessageActionList_action { + cursor: pointer; + + &:hover { + background-color: mix($border-color, $white); + } +} + +.o_MessageActionList_actionStar.o_MessageActionList_actionStar_active { + color: gold; +} diff --git a/addons/mail/static/src/components/message_action_list/message_action_list.xml b/addons/mail/static/src/components/message_action_list/message_action_list.xml new file mode 100644 index 00000000000..a18fc877ed0 --- /dev/null +++ b/addons/mail/static/src/components/message_action_list/message_action_list.xml @@ -0,0 +1,16 @@ + + + +
+ + + + + +
+
+
diff --git a/addons/mail/static/src/js/tours/mail.js b/addons/mail/static/src/js/tours/mail.js index f2eb7168350..b2f007f930a 100644 --- a/addons/mail/static/src/js/tours/mail.js +++ b/addons/mail/static/src/js/tours/mail.js @@ -41,7 +41,11 @@ tour.register('mail_tour', { content: _t("Post your message on the thread"), position: "top", }, { - trigger: '.o_Discuss_thread .o_Message_commandStar', + trigger: '.o_Discuss_thread .o_Message', + content: _t("Click on your message"), + position: "top", +}, { + trigger: '.o_Discuss_thread .o_MessageActionList_actionStar', content: Markup(_t("Messages can be starred to remind you to check back later.")), position: "bottom", }, { diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js index ab657e45ff6..a6e010a3f60 100644 --- a/addons/mail/static/src/models/discuss/discuss.js +++ b/addons/mail/static/src/models/discuss/discuss.js @@ -205,6 +205,11 @@ function factory(dependencies) { * @param {mail.message} message */ replyToMessage(message) { + // When already replying to this message, just stop replying to it. + if (this.replyingToMessage === message) { + this.clearReplyingToMessage(); + return; + } this.update({ replyingToMessage: link(message) }); // avoid to reply to a note by a message and vice-versa. // subject to change later by allowing subtype choice. diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js index 47d5c518a09..ef1793aa204 100644 --- a/addons/mail/static/src/models/message/message.js +++ b/addons/mail/static/src/models/message/message.js @@ -1,11 +1,13 @@ /** @odoo-module **/ import { registerNewModel } from '@mail/model/model_core'; -import { attr, many2many, many2one, one2many } from '@mail/model/model_field'; -import { clear, insert, insertAndReplace, replace, unlinkAll } from '@mail/model/model_field_command'; +import { attr, many2many, many2one, one2many, one2one } from '@mail/model/model_field'; +import { clear, create, insert, insertAndReplace, replace, unlinkAll } from '@mail/model/model_field_command'; import emojis from '@mail/js/emojis'; import { addLink, htmlToTextContentInline, parseAndTransform, timeFromNow } from '@mail/js/utils'; +import { session } from '@web/session'; + import { str_to_datetime } from 'web.time'; function factory(dependencies) { @@ -250,6 +252,26 @@ function factory(dependencies) { })); } + /** + * Updates the message's content. + * + * @param {Object} param0 + * @param {string} param0.body the new body of the message + */ + async updateContent({ body }) { + const messageData = await this.env.services.rpc({ + route: '/mail/message/update_content', + params: { + body, + message_id: this.id, + }, + }); + if (!this.messaging) { + return; + } + this.messaging.models['mail.message'].insert(messageData); + } + //---------------------------------------------------------------------- // Private //---------------------------------------------------------------------- @@ -261,6 +283,29 @@ function factory(dependencies) { return `${this.modelName}_${data.id}`; } + /** + * @returns {boolean} + */ + _computeCanBeDeleted() { + if (!session.is_admin && !this.isCurrentUserOrGuestAuthor) { + return false; + } + if (!this.originThread) { + return false; + } + if (this.originThread.model === 'mail.channel') { + return this.message_type === 'comment'; + } + return this.is_note; + } + + /** + * @returns {boolean} + */ + _computeCanStarBeToggled() { + return !this.isTemporary && !this.isTransient; + } + /** * @returns {string} */ @@ -284,11 +329,15 @@ function factory(dependencies) { * @private * @returns {boolean} */ - _computeIsCurrentPartnerAuthor() { + _computeIsCurrentUserOrGuestAuthor() { return !!( this.author && this.messaging.currentPartner && this.messaging.currentPartner === this.author + ) || !!( + this.guestAuthor && + this.messaging.currentGuest && + this.messaging.currentGuest === this.guestAuthor ); } @@ -442,6 +491,12 @@ function factory(dependencies) { } Message.fields = { + actionList: one2one('mail.message_action_list', { + default: create(), + inverse: 'message', + isCausal: true, + readonly: true, + }), attachments: many2many('mail.attachment', { inverse: 'messages', }), @@ -457,6 +512,18 @@ function factory(dependencies) { body: attr({ default: "", }), + /** + * Whether this message can be deleted. + */ + canBeDeleted: attr({ + compute: '_computeCanBeDeleted', + }), + /** + * Whether this message can be starred/unstarred. + */ + canStarBeToggled: attr({ + compute: '_computeCanStarBeToggled', + }), /** * Determines the date of the message as a moment object. */ @@ -477,8 +544,8 @@ function factory(dependencies) { id: attr({ required: true, }), - isCurrentPartnerAuthor: attr({ - compute: '_computeIsCurrentPartnerAuthor', + isCurrentUserOrGuestAuthor: attr({ + compute: '_computeIsCurrentUserOrGuestAuthor', default: false, }), /** diff --git a/addons/mail/static/src/models/message_action_list/message_action_list.js b/addons/mail/static/src/models/message_action_list/message_action_list.js new file mode 100644 index 00000000000..37ff7c82297 --- /dev/null +++ b/addons/mail/static/src/models/message_action_list/message_action_list.js @@ -0,0 +1,108 @@ +/** @odoo-module **/ + +import { registerNewModel } from '@mail/model/model_core'; +import { attr, one2one } from '@mail/model/model_field'; +import { markEventHandled } from '@mail/utils/utils'; + +function factory(dependencies) { + + class MessageActionList extends dependencies['mail.model'] { + + /** + * @override + */ + _created() { + // bind handlers so they can be used in templates + this.onClick = this.onClick.bind(this); + this.onClickReplyTo = this.onClickReplyTo.bind(this); + this.onClickConfirmDelete = this.onClickConfirmDelete.bind(this); + this.onClickDelete = this.onClickDelete.bind(this); + this.onClickMarkAsRead = this.onClickMarkAsRead.bind(this); + this.onClickToggleStar = this.onClickToggleStar.bind(this); + this.onDeleteConfirmDialogClosed = this.onDeleteConfirmDialogClosed.bind(this); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + onClick(ev) { + markEventHandled(ev, 'MessageActionList.Click'); + } + + /** + * @private + * @param {MouseEvent} ev + */ + onClickConfirmDelete(ev) { + this.message.updateContent({ body: '' }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + onClickDelete(ev) { + this.update({ showDeleteConfirm: true }); + } + + /** + * @private + * @param {MouseEvent} ev + */ + onClickMarkAsRead(ev) { + this.message.markAsRead(); + } + + /** + * Opens the reply composer for this message (or closes it if it was + * already opened). + * + * @private + * @param {MouseEvent} ev + */ + onClickReplyTo(ev) { + markEventHandled(ev, 'MessageActionList.replyTo'); + this.message.replyTo(); + } + + /** + * @private + * @param {MouseEvent} ev + */ + onClickToggleStar(ev) { + this.message.toggleStar(); + } + + /** + * @private + * @param {CustomEvent} ev + */ + onDeleteConfirmDialogClosed(ev) { + this.update({ showDeleteConfirm: false }) + } + + } + + MessageActionList.fields = { + message: one2one('mail.message', { + inverse: 'actionList' + }), + /** + * Whether to show the message delete-confirm dialog + */ + showDeleteConfirm: attr({ + default: false, + }), + }; + + MessageActionList.modelName = 'mail.message_action_list'; + + return MessageActionList; +} + +registerNewModel('mail.message_action_list', factory); diff --git a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js index eaccf4f03da..d39601cac1c 100644 --- a/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js +++ b/addons/mail/static/src/models/messaging_notification_handler/messaging_notification_handler.js @@ -89,6 +89,8 @@ function factory(dependencies) { return this._handleNotificationChannelRenamed(message.payload); case 'mail.channel_update': return this._handleNotificationChannelUpdate(message.payload); + case 'mail.message_update': + return this.messaging.models['mail.message'].insert(message.payload); case 'res.users_settings_changed': return this._handleNotificationResUsersSettings(message.payload); }