[IMP] mail: add rename guest button

This commit will display the name of the current guest in the thread
view top bar and make it editable.

closes odoo/odoo#77936

X-original-commit: 659c6a83245a396054cf26c204a7fc72fbbb971f
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Louis Wicket (wil)
2021-10-06 02:09:03 +00:00
committed by Sébastien Theys
parent 3ffad9f8a0
commit 71f6d736cc
10 changed files with 304 additions and 41 deletions
+6 -3
View File
@@ -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)
+13 -11
View File
@@ -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 = """
@@ -57,7 +57,7 @@
<img class="o_Composer_currentPartnerAvatar rounded-circle" t-att-src="currentPartnerAvatar" alt="Avatar of user"/>
</t>
<t t-if="messaging.currentGuest and composerView.composer.activeThread.model === 'mail.channel'">
<img class="o_Composer_currentPartnerAvatar rounded-circle" t-attf-src="/mail/channel/{{ composerView.composer.activeThread.id }}/guest/{{ messaging.currentGuest.id }}/avatar_128" alt="Avatar of guest"/>
<img class="o_Composer_currentPartnerAvatar rounded-circle" t-attf-src="/mail/channel/{{ composerView.composer.activeThread.id }}/guest/{{ messaging.currentGuest.id }}/avatar_128?unique={{ messaging.currentGuest.name }}" alt="Avatar of guest"/>
</t>
</div>
</t>
@@ -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';
}
@@ -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}
*/
@@ -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;
}
}
@@ -10,7 +10,7 @@
<div class="o_ThreadViewTopbar_title d-flex flex-grow-1 align-self-center align-items-center">
<t t-if="threadViewTopBar.thread">
<t t-if="!threadViewTopBar.isEditingThreadName">
<div class="o_ThreadViewTopbar_threadName flex-shrink-0 px-1 text-truncate lead font-weight-bold" t-att-title="threadViewTopBar.thread.displayName" t-on-click="threadViewTopBar.onClickTopbarThreadName" t-on-mouseenter="threadViewTopBar.onMouseEnterTopBarThreadName" t-on-mouseleave="threadViewTopBar.onMouseLeaveTopBarThreadName" t-att-class="{ 'o-threadNameEditable': threadViewTopBar.isMouseOverThreadName and !messaging.isCurrentUserGuest }">
<div class="o_ThreadViewTopbar_threadName o_ThreadViewTopbar_editableItem flex-shrink-0 px-1 text-truncate lead font-weight-bold" t-att-title="threadViewTopBar.thread.displayName" t-on-click="threadViewTopBar.onClickTopbarThreadName" t-on-mouseenter="threadViewTopBar.onMouseEnterTopBarThreadName" t-on-mouseleave="threadViewTopBar.onMouseLeaveTopBarThreadName" t-att-class="{ 'o-threadNameEditable': threadViewTopBar.isMouseOverThreadName and !messaging.isCurrentUserGuest }">
<t t-esc="threadViewTopBar.thread.displayName"/>
</div>
</t>
@@ -27,7 +27,7 @@
<div class="o_ThreadViewTopbar_threadDescriptionSeparator flex-shrink-0 mx-2"/>
<t t-if="!threadViewTopBar.isEditingThreadDescription">
<t t-if="threadViewTopBar.thread.description">
<div class="o_ThreadViewTopbar_threadDescription text-truncate px-1" t-att-title="threadViewTopBar.thread.description" t-on-click="threadViewTopBar.onClickTopbarThreadDescription" t-on-mouseenter="threadViewTopBar.onMouseEnterTopBarThreadDescription" t-on-mouseleave="threadViewTopBar.onMouseLeaveTopBarThreadDescription" t-att-class="{ 'o-threadDescriptionEditable': threadViewTopBar.isMouseOverThreadDescription and !messaging.isCurrentUserGuest }">
<div class="o_ThreadViewTopbar_threadDescription o_ThreadViewTopbar_editableItem text-truncate px-1" t-att-title="threadViewTopBar.thread.description" t-on-click="threadViewTopBar.onClickTopbarThreadDescription" t-on-mouseenter="threadViewTopBar.onMouseEnterTopBarThreadDescription" t-on-mouseleave="threadViewTopBar.onMouseLeaveTopBarThreadDescription" t-att-class="{ 'o-threadDescriptionEditable': threadViewTopBar.isMouseOverThreadDescription and !messaging.isCurrentUserGuest }">
<t t-esc="threadViewTopBar.thread.description"/>
</div>
</t>
@@ -42,7 +42,7 @@
</t>
</t>
</div>
<div class="o_ThreadViewTopbar_actions d-flex align-items-center">
<div class="o_ThreadViewTopbar_actions d-flex align-items-center ml-1">
<t t-if="threadViewTopBar.thread and threadViewTopBar.thread === messaging.inbox">
<button class="o_ThreadViewTopbar_markAllReadButton btn btn-secondary" t-att-disabled="threadViewTopBar.threadView.messages.length === 0" t-on-click="threadViewTopBar.onClickInboxMarkAllAsRead">Mark all read</button>
</t>
@@ -79,6 +79,20 @@
</button>
</t>
</div>
<!-- FIXME: handle display on mobile -->
<t t-if="threadViewTopBar.threadView.threadViewer.discussPublicView and !messaging.device.isMobile">
<div class="o_ThreadViewTopbar_userInfo d-flex align-items-center">
<img class="o_ThreadViewTopbar_avatar ml-1 mr-1 rounded-circle" t-att-src="avatarUrl" alt="Avatar"/>
<t t-if="!threadViewTopBar.isEditingGuestName">
<span class="o_ThreadViewTopBar_userName o_ThreadViewTopbar_editableItem px-1 font-weight-bold text-truncate" t-att-class="{ 'o-userNameEditable': threadViewTopBar.isMouseOverUserInfo and messaging.isCurrentUserGuest }" t-on-click="threadViewTopBar.onClickUserName" t-on-mouseenter="threadViewTopBar.onMouseEnterUserName" t-on-mouseleave="threadViewTopBar.onMouseLeaveUserName">
<t t-esc="messaging.isCurrentUserGuest ? messaging.currentGuest.name : messaging.currentUser.nameOrDisplayName"/>
</span>
</t>
<t t-if="threadViewTopBar.isEditingGuestName">
<input class="o_ThreadViewTopBar_guestNameInput" type="text" t-att-value="messaging.currentGuest.name" t-on-input="threadViewTopBar.onInputGuestNameInput" t-on-keydown="threadViewTopBar.onKeyDownGuestNameInput" t-ref="guestNameInput"/>
</t>
</div>
</t>
</t>
</div>
</t>
+28 -4
View File
@@ -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,
@@ -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
@@ -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();