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); }