diff --git a/addons/mail/controllers/discuss.py b/addons/mail/controllers/discuss.py index 8cfc77bd9a1..ec7699a3dc7 100644 --- a/addons/mail/controllers/discuss.py +++ b/addons/mail/controllers/discuss.py @@ -546,8 +546,11 @@ class DiscussController(http.Controller): # -------------------------------------------------------------------------- @http.route('/mail/guest/update_name', methods=['POST'], type='json', auth='public') - def mail_guest_update_name(self, name): + def mail_guest_update_name(self, guest_id, name): guest = request.env['mail.guest']._get_guest_from_request(request) - if not guest: + guest_to_rename_sudo = guest.env['mail.guest'].browse(guest_id).sudo().exists() + if not guest_to_rename_sudo: raise NotFound() - guest.sudo()._update_name(name) + if guest_to_rename_sudo != guest and not request.env.user._is_admin(): + raise NotFound() + guest_to_rename_sudo._update_name(name) diff --git a/addons/mail/models/mail_guest.py b/addons/mail/models/mail_guest.py index c6d65c3dc39..13988603e77 100644 --- a/addons/mail/models/mail_guest.py +++ b/addons/mail/models/mail_guest.py @@ -52,19 +52,21 @@ class MailGuest(models.Model): def _update_name(self, name): self.ensure_one() name = name.strip() - if name == "": + if len(name) < 1: raise UserError(_("Guest's name cannot be empty.")) + if len(name) > 512: + raise UserError(_("Guest's name is too long.")) self.name = name - self.env['bus.bus'].sendmany([( - (self._cr.dbname, 'mail.channel', channel.id), - { - 'type': 'mail.guest_update', - 'payload': { - 'id': self.id, - 'name': self.name, - }, - } - ) for channel in self.channel_ids]) + message = { + 'type': 'mail.guest_update', + 'payload': { + 'id': self.id, + 'name': self.name, + }, + } + bus_notifs = [((self._cr.dbname, 'mail.channel', channel.id), message) for channel in self.channel_ids] + bus_notifs.append(((self._cr.dbname, 'mail.guest', self.id), message)) + self.env['bus.bus'].sendmany(bus_notifs) def _update_timezone(self, timezone): query = """ diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml index d605c11ba62..2d7f444da3d 100644 --- a/addons/mail/static/src/components/composer/composer.xml +++ b/addons/mail/static/src/components/composer/composer.xml @@ -57,7 +57,7 @@ Avatar of user - Avatar of guest + Avatar of guest diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js index 3b83795cbb4..c11b572a65d 100644 --- a/addons/mail/static/src/components/message/message.js +++ b/addons/mail/static/src/components/message/message.js @@ -96,7 +96,7 @@ export class Message extends Component { } else if (this.messageView.message.guestAuthor && (!this.messageView.message.originThread || this.messageView.message.originThread.model !== 'mail.channel')) { return this.messageView.message.guestAuthor.avatarUrl; } else if (this.messageView.message.guestAuthor && this.messageView.message.originThread && this.messageView.message.originThread.model === 'mail.channel') { - return `/mail/channel/${this.messageView.message.originThread.id}/guest/${this.messageView.message.guestAuthor.id}/avatar_128`; + return `/mail/channel/${this.messageView.message.originThread.id}/guest/${this.messageView.message.guestAuthor.id}/avatar_128?unique=${this.messageView.message.guestAuthor.name}`; } else if (this.messageView.message.message_type === 'email') { return '/mail/static/src/img/email_icon.png'; } diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js index 4930ee4df7f..6c1ca4fb543 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.js @@ -13,6 +13,7 @@ export class ThreadViewTopbar extends Component { */ setup() { super.setup(); + useRefToModel({ fieldName: 'guestNameInputRef', modelName: 'mail.thread_view_topbar', propNameAsRecordLocalId: 'localId', refName: 'guestNameInput' }); useRefToModel({ fieldName: 'threadNameInputRef', modelName: 'mail.thread_view_topbar', propNameAsRecordLocalId: 'localId', refName: 'threadNameInput' }); useRefToModel({ fieldName: 'threadDescriptionInputRef', modelName: 'mail.thread_view_topbar', propNameAsRecordLocalId: 'localId', refName: 'threadDescriptionInput' }); useUpdateToModel({ methodName: 'onComponentUpdate', modelName: 'mail.thread_view_topbar', propNameAsRecordLocalId: 'localId' }); @@ -22,6 +23,16 @@ export class ThreadViewTopbar extends Component { // Public //-------------------------------------------------------------------------- + /** + * @returns {string} + */ + get avatarUrl() { + if (this.messaging.isCurrentUserGuest) { + return `/mail/channel/${this.threadViewTopBar.thread.id}/guest/${this.messaging.currentGuest.id}/avatar_128?unique=${this.messaging.currentGuest.name}`; + } + return this.messaging.currentPartner.avatarUrl; + } + /** * @returns {mail.thread_view_topbar} */ diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss index 537f0bd50e8..cca2581b034 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.scss @@ -6,15 +6,21 @@ height: $o-statusbar-height * 1.25; } +.o_ThreadViewTopbar_avatar { + height: 26px; + width: 26px; + object-fit: cover; +} + +.o_ThreadViewTopbar_editableItem { + border: $border-width solid; +} + .o_ThreadViewTopbar_threadDescriptionSeparator { width: $border-width; height: $font-size-sm; } -.o_ThreadViewTopbar_threadName, .o_ThreadViewTopbar_threadDescription { - border: $border-width solid; -} - .o_ThreadViewTopbar_title { min-width: 0; } @@ -46,6 +52,11 @@ } } +// use selector specificity to override bootstrap style +input.o_ThreadViewTopBar_guestNameInput { + width: 150px; +} + .o_ThreadViewTopbar_invitePopover .popover-body { padding: 0; // cancel unwanted padding } @@ -87,3 +98,14 @@ color: gray('900'); } } + +.o_ThreadViewTopBar_userName { + &:not(.o-userNameEditable) { + border-color: transparent; + } + + &.o-userNameEditable { + background-color: $white; + border-color: $border-color; + } +} diff --git a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml index 5f1e9f6c137..c46eda1125c 100644 --- a/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml +++ b/addons/mail/static/src/components/thread_view_topbar/thread_view_topbar.xml @@ -10,7 +10,7 @@
-
+
@@ -27,7 +27,7 @@
-
+
@@ -42,7 +42,7 @@
-
+
@@ -79,6 +79,20 @@
+ + +
+ Avatar + + + + + + + + +
+
diff --git a/addons/mail/static/src/models/guest/guest.js b/addons/mail/static/src/models/guest/guest.js index 9142511025e..dff34933cf2 100644 --- a/addons/mail/static/src/models/guest/guest.js +++ b/addons/mail/static/src/models/guest/guest.js @@ -7,23 +7,47 @@ function factory(dependencies) { class Guest extends dependencies['mail.model'] { + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @static + * @param {Object} param0 + * @param {number} param0.id The id of the guest to rename. + * @param {string} param0.name The new name to use to rename the guest. + */ + static async performRpcGuestUpdateName({ id, name }) { + await this.env.services.rpc({ + route: '/mail/guest/update_name', + params: { + guest_id: id, + name, + }, + }); + } + + //---------------------------------------------------------------------- + // Private + //---------------------------------------------------------------------- + /** * @private * @returns {string} */ _computeAvatarUrl() { - return `/web/image/mail.guest/${this.id}/avatar_128`; + return `/web/image/mail.guest/${this.id}/avatar_128?unique=${this.name}`; } } Guest.fields = { - avatarUrl: attr({ - compute: '_computeAvatarUrl', - }), authoredMessages: one2many('mail.message', { inverse: 'guestAuthor', }), + avatarUrl: attr({ + compute: '_computeAvatarUrl', + }), id: attr({ required: true, readonly: true, diff --git a/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js b/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js index cf93b1ee72f..1fbbd8560bb 100644 --- a/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js +++ b/addons/mail/static/src/models/thread_view_topbar/thread_view_topbar.js @@ -13,15 +13,20 @@ function factory(dependencies) { */ _created() { // Bind necessary until OWL supports arrow function in handlers: https://github.com/odoo/owl/issues/876 + this.onClickUserName = this.onClickUserName.bind(this); this.onClickHideMemberList = this.onClickHideMemberList.bind(this); this.onClickInboxMarkAllAsRead = this.onClickInboxMarkAllAsRead.bind(this); this.onClickInviteButton = this.onClickInviteButton.bind(this); this.onClickShowMemberList = this.onClickShowMemberList.bind(this); this.onClickTopbarThreadName = this.onClickTopbarThreadName.bind(this); this.onClickUnstarAll = this.onClickUnstarAll.bind(this); + this.onInputGuestNameInput = this.onInputGuestNameInput.bind(this); this.onInputThreadNameInput = this.onInputThreadNameInput.bind(this); + this.onKeyDownGuestNameInput = this.onKeyDownGuestNameInput.bind(this); this.onKeyDownThreadNameInput = this.onKeyDownThreadNameInput.bind(this); + this.onMouseEnterUserName = this.onMouseEnterUserName.bind(this); this.onMouseEnterTopBarThreadName = this.onMouseEnterTopBarThreadName.bind(this); + this.onMouseLeaveUserName = this.onMouseLeaveUserName.bind(this); this.onMouseLeaveTopBarThreadName = this.onMouseLeaveTopBarThreadName.bind(this); this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); this.onClickTopbarThreadDescription = this.onClickTopbarThreadDescription.bind(this); @@ -147,10 +152,35 @@ function factory(dependencies) { this.messaging.models['mail.message'].unstarAll(); } + /** + * Handles click on the guest name. + * + * @param {MouseEvent} ev + */ + onClickUserName(ev) { + if (!this.messaging.isCurrentUserGuest) { + return; + } + const selection = window.getSelection(); + this.update({ + doFocusOnGuestNameInput: true, + doSetSelectionDirectionOnGuestNameInput: selection.anchorOffset < selection.focusOffset ? 'forward' : 'backward', + doSetSelectionEndOnGuestNameInput: Math.max(selection.focusOffset, selection.anchorOffset), + doSetSelectionStartOnGuestNameInput: Math.min(selection.focusOffset, selection.anchorOffset), + isEditingGuestName: true, + isMouseOverUserInfo: false, + pendingGuestName: this.messaging.currentGuest.name, + }); + } + /** * Handles OWL update on this top bar component. */ onComponentUpdate() { + if (this.doFocusOnGuestNameInput) { + this.guestNameInputRef.el.focus(); + this.update({ doFocusOnGuestNameInput: clear() }); + } if (this.doFocusOnThreadNameInput) { this.threadNameInputRef.el.focus(); this.update({ doFocusOnThreadNameInput: clear() }); @@ -159,6 +189,22 @@ function factory(dependencies) { this.threadDescriptionInputRef.el.focus(); this.update({ doFocusOnThreadDescriptionInput: clear() }); } + if ( + this.doSetSelectionStartOnGuestNameInput !== undefined && + this.doSetSelectionEndOnGuestNameInput !== undefined && + this.doSetSelectionDirectionOnGuestNameInput !== undefined + ) { + this.guestNameInputRef.el.setSelectionRange( + this.doSetSelectionStartOnGuestNameInput, + this.doSetSelectionEndOnGuestNameInput, + this.doSetSelectionDirectionOnGuestNameInput + ); + this.update({ + doSetSelectionDirectionOnGuestNameInput: clear(), + doSetSelectionEndOnGuestNameInput: clear(), + doSetSelectionStartOnGuestNameInput: clear(), + }); + } if ( this.doSetSelectionStartOnThreadNameInput !== undefined && this.doSetSelectionEndOnThreadNameInput !== undefined && @@ -193,6 +239,13 @@ function factory(dependencies) { } } + /** + * @param {KeyboardEvent} ev + */ + onInputGuestNameInput(ev) { + this.update({ pendingGuestName: this.guestNameInputRef.el.value }); + } + /** * Handles input on the "thread name" input of this top bar. * @@ -211,6 +264,24 @@ function factory(dependencies) { this.update({ pendingThreadDescription: ev.target.value }); } + /** + * Handles keydown on the "guest name" input of this top bar. + * + * @param {KeyboardEvent} ev + */ + onKeyDownGuestNameInput(ev) { + switch (ev.key) { + case 'Enter': + if (this.pendingGuestName.trim() !== '') { + this._applyGuestRename(); + } + break; + case 'Escape': + this._discardGuestRename(); + break; + } + } + /** * Handles keydown on the "thread name" input of this top bar. * @@ -267,6 +338,15 @@ function factory(dependencies) { this.update({ isMouseOverThreadDescription: true }); } + /** + * Handles mouseenter on the "guest name" of this top bar. + * + * @param {MouseEvent} ev + */ + onMouseEnterUserName(ev) { + this.update({ isMouseOverUserInfo: true }); + } + /** * Handles mouseleave on the "thread name" of this top bar. * @@ -285,10 +365,32 @@ function factory(dependencies) { this.update({ isMouseOverThreadDescription: false }); } + /** + * Handles mouseleave on the "guest name" of this top bar. + * + * @param {MouseEvent} ev + */ + onMouseLeaveUserName(ev) { + this.update({ isMouseOverUserInfo: false }); + } + //---------------------------------------------------------------------- // Private //---------------------------------------------------------------------- + /** + * @private + */ + _applyGuestRename() { + this.update({ isEditingGuestName: false }); + if (this.hasGuestNameChanged) { + this.messaging.models['mail.guest'].performRpcGuestUpdateName({ + id: this.messaging.currentGuest.id, + name: this.pendingGuestName.trim(), + }); + } + } + /** * @private */ @@ -323,6 +425,27 @@ function factory(dependencies) { } } + /** + * @private + * @returns {boolean} + */ + _computeHasGuestNameChanged() { + return Boolean( + this.messaging.currentGuest && + this.pendingGuestName !== this.messaging.currentGuest.name + ); + } + + /** + * @private + */ + _discardGuestRename() { + this.update({ + isEditingGuestName: false, + pendingGuestName: clear(), + }); + } + /** * @private */ @@ -348,6 +471,13 @@ function factory(dependencies) { * @param {MouseEvent} ev */ _onClickCaptureGlobal(ev) { + if (this.isEditingGuestName && this.guestNameInputRef.el && !this.guestNameInputRef.el.contains(ev.target)) { + if (this.pendingGuestName.trim() !== '') { + this._applyGuestRename(); + } else { + this._discardGuestRename(); + } + } if (this.isEditingThreadName && this.threadNameInputRef.el && !this.threadNameInputRef.el.contains(ev.target)) { this._applyThreadRename(); } @@ -359,6 +489,10 @@ function factory(dependencies) { } ThreadViewTopBar.fields = { + /** + * Determines whether the guest name input needs to be focused. + */ + doFocusOnGuestNameInput: attr(), /** * Determines whether this thread name input needs to be focused. */ @@ -367,7 +501,15 @@ function factory(dependencies) { * Determines whether this thread description input needs to be focused. */ doFocusOnThreadDescriptionInput: attr(), - /** + /** + * Determines the direction to set on the selection of this guest name + * input. This value is not a representation of current selection, but + * an instruction to set a new selection. Must be set together with + * `doSetSelectionEndOnGuestNameInput` and `doSetSelectionStartOnGuestNameInput` + * to have an effect. + */ + doSetSelectionDirectionOnGuestNameInput: attr(), + /** * Determines the direction to set on the selection of this thread name * input. This value is not a representation of current selection, but * an instruction to set a new selection. Must be set together with @@ -382,7 +524,15 @@ function factory(dependencies) { * `doSetSelectionEndOnThreadDescriptionInput` and `doSetSelectionStartOnThreadDescriptionInput` * to have an effect. */ - doSetSelectionDirectionOnThreadDescriptionInput: attr(), + doSetSelectionDirectionOnThreadDescriptionInput: attr(), + /** + * Determines the ending position where to place the selection on this + * guest name input (zero-based index). This value is not a + * representation of current selection, but an instruction to set a new + * selection. Must be set together with `doSetSelectionDirectionOnGuestNameInput` + * and `doSetSelectionStartOnGuestNameInput` to have an effect. + */ + doSetSelectionEndOnGuestNameInput: attr(), /** * Determines the ending position where to place the selection on this * thread name input (zero-based index). This value is not a @@ -399,6 +549,14 @@ function factory(dependencies) { * and `doSetSelectionStartOnThreadDescriptionInput` to have an effect. */ doSetSelectionEndOnThreadDescriptionInput: attr(), + /** + * Determines the starting position where to place the selection on this + * guest name input (zero-based index). This value is not a + * representation of current selection, but an instruction to set a new + * selection. Must be set together with `doSetSelectionDirectionOnGuestNameInput` and + * `doSetSelectionEndOnGuestNameInput` to have an effect. + */ + doSetSelectionStartOnGuestNameInput: attr(), /** * Determines the starting position where to place the selection on this * thread name input (zero-based index). This value is not a @@ -415,6 +573,27 @@ function factory(dependencies) { * `doSetSelectionEndOnThreadDescriptionInput` to have an effect. */ doSetSelectionStartOnThreadDescriptionInput: attr(), + /** + * States the OWL ref of the "guest name" input of this top bar. + * Useful to focus it, or to know when a click is done outside of it. + */ + guestNameInputRef: attr(), + /** + * Determines whether the guest's name has been updated. + * + * Useful to determine whether a RPC should be done to update the name + * server side. + */ + hasGuestNameChanged: attr({ + compute: '_computeHasGuestNameChanged', + readonly: true, + }), + /** + * Determines whether the guest is currently being renamed. + */ + isEditingGuestName: attr({ + default: false, + }), /** * Determines whether this thread is currently being renamed. */ @@ -441,6 +620,22 @@ function factory(dependencies) { isMouseOverThreadDescription: attr({ default: false, }), + /** + * States whether the cursor is currently over the guest name in this + * top bar. + */ + isMouseOverUserInfo: attr({ + default: false, + }), + /** + * Determines the pending name of the guest, which is the new name of + * the guest as the current guest is currently typing it, with the goal + * of renaming the guest. + * This value can either be applied or discarded. + */ + pendingGuestName: attr({ + default: "", + }), /** * Determines the pending name of this thread, which is the new name of * the thread as the current user is currently typing it, with the goal diff --git a/addons/mail/static/src/models/welcome_view/welcome_view.js b/addons/mail/static/src/models/welcome_view/welcome_view.js index f30b80054a4..e0a62427eda 100644 --- a/addons/mail/static/src/models/welcome_view/welcome_view.js +++ b/addons/mail/static/src/models/welcome_view/welcome_view.js @@ -28,24 +28,16 @@ function factory(dependencies) { // Public //---------------------------------------------------------------------- - /** - * @static - * @param {string} name - */ - static async performRpcGuestUpdateName(name) { - await this.env.services.rpc({ - route: '/mail/guest/update_name', - params: { name }, - }); - } - /** * Updates guest if needed then displays the thread view instead of the * welcome view. */ async joinChannel() { if (this.hasGuestNameChanged) { - await this.messaging.models['mail.welcome_view'].performRpcGuestUpdateName(this.pendingGuestName.trim()); + await this.messaging.models['mail.guest'].performRpcGuestUpdateName({ + id: this.messaging.currentGuest.id, + name: this.pendingGuestName.trim(), + }); } if (this.discussPublicView.shouldAddGuestAsMemberOnJoin) { await this.performRpcAddGuestAsMember();