-
-
-
-
-
+
+
-
+
diff --git a/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js b/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js
index 7bf92edaf0c..fe4e80addb1 100644
--- a/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js
+++ b/addons/mail/static/src/models/channel_invitation_form/channel_invitation_form.js
@@ -7,7 +7,7 @@ import { cleanSearchTerm } from '@mail/utils/utils';
registerModel({
name: 'ChannelInvitationForm',
- identifyingFields: [['chatWindow', 'threadView']],
+ identifyingFields: [['chatWindow', 'popoverViewOwner']],
lifecycleHooks: {
_created() {
// Bind necessary until OWL supports arrow function in handlers: https://github.com/odoo/owl/issues/876
@@ -168,8 +168,12 @@ registerModel({
* @returns {FieldCommand}
*/
_computeThread() {
- if (this.threadView && this.threadView.thread) {
- return replace(this.threadView.thread);
+ if (
+ this.popoverViewOwner &&
+ this.popoverViewOwner.threadViewTopbarOwnerAsInvite &&
+ this.popoverViewOwner.threadViewTopbarOwnerAsInvite.thread
+ ) {
+ return replace(this.popoverViewOwner.threadViewTopbarOwnerAsInvite.thread);
}
if (this.chatWindow && this.chatWindow.thread) {
return replace(this.chatWindow.thread);
@@ -210,6 +214,13 @@ registerModel({
inviteButtonText: attr({
compute: '_computeInviteButtonText',
}),
+ /**
+ * If set, this channel invitation form is content of related popover view.
+ */
+ popoverViewOwner: one2one('PopoverView', {
+ inverse: 'channelInvitationForm',
+ readonly: true,
+ }),
/**
* States the OWL ref of the "search" input of this channel invitation
* form. Useful to be able to focus it.
@@ -244,12 +255,5 @@ registerModel({
readonly: true,
required: true,
}),
- /**
- * States the thread view on which this list operates (if any).
- */
- threadView: one2one('ThreadView', {
- inverse: 'channelInvitationForm',
- readonly: true,
- }),
},
});
diff --git a/addons/mail/static/src/models/composer_view/composer_view.js b/addons/mail/static/src/models/composer_view/composer_view.js
index 4a763a7617b..b11e37ce2b8 100644
--- a/addons/mail/static/src/models/composer_view/composer_view.js
+++ b/addons/mail/static/src/models/composer_view/composer_view.js
@@ -6,6 +6,7 @@ import { attr, many2many, many2one, one2one } from '@mail/model/model_field';
import { clear, insertAndReplace, link, replace, unlink, unlinkAll } from '@mail/model/model_field_command';
import { OnChange } from '@mail/model/model_onchange';
import { addLink, escapeAndCompactTextContent, parseAndTransform } from '@mail/js/utils';
+import { isEventHandled, markEventHandled } from '@mail/utils/utils';
registerModel({
name: 'ComposerView',
@@ -24,14 +25,19 @@ registerModel({
this._nextMentionRpcFunction = undefined;
},
_created() {
+ this.onClickButtonEmojis = this.onClickButtonEmojis.bind(this);
this.onClickCancelLink = this.onClickCancelLink.bind(this);
+ this.onClickCaptureGlobal = this.onClickCaptureGlobal.bind(this);
this.onClickSaveLink = this.onClickSaveLink.bind(this);
this.onClickStopReplying = this.onClickStopReplying.bind(this);
+ this.onKeydownButtonEmojis = this.onKeydownButtonEmojis.bind(this);
+ document.addEventListener('click', this.onClickCaptureGlobal, true);
},
_willDelete() {
// Clears the mention queue on deleting the record to prevent
// unnecessary RPC.
this._nextMentionRpcFunction = undefined;
+ document.removeEventListener('click', this.onClickCaptureGlobal, true);
},
},
recordMethods: {
@@ -41,6 +47,20 @@ registerModel({
closeSuggestions() {
this.update({ suggestionDelimiterPosition: clear() });
},
+ /**
+ * Returns whether the given html element is inside this composer view,
+ * including whether it's inside the emoji popover when active.
+ *
+ * @param {Element} element
+ * @returns {boolean}
+ */
+ contains(element) {
+ // emoji popover is outside but should be considered inside
+ if (this.emojisPopoverView && this.emojisPopoverView.contains(element)) {
+ return true;
+ }
+ return Boolean(this.component && this.component.root.el && this.component.root.el.contains(element));
+ },
/**
* Hides the composer, which only makes sense if the composer is
* currently used as a Discuss Inbox reply composer or as message
@@ -151,6 +171,16 @@ registerModel({
}
this.composer.update(updateData);
},
+ /**
+ * Handles click on the emojis button.
+ */
+ onClickButtonEmojis() {
+ if (!this.emojisPopoverView) {
+ this.update({ emojisPopoverView: insertAndReplace() });
+ } else {
+ this.update({ emojisPopoverView: clear() });
+ }
+ },
/**
* Handles click on the cancel link.
*
@@ -160,6 +190,37 @@ registerModel({
ev.preventDefault();
this.discard();
},
+ /**
+ * Discards the composer when clicking away.
+ *
+ * @private
+ * @param {MouseEvent} 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;
+ }
+ this.discard();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ onClickEmoji(ev) {
+ if (this.textInputComponent) {
+ this.textInputComponent.saveStateInStore();
+ }
+ this.insertIntoTextInput(ev.currentTarget.dataset.unicode);
+ if (!this.messaging.device.isMobileDevice) {
+ this.update({ doFocus: true });
+ }
+ this.update({ emojisPopoverView: clear() });
+ },
/**
* Handles click on the save link.
*
@@ -192,6 +253,19 @@ registerModel({
replyingToMessageView: clear(),
});
},
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ onKeydownButtonEmojis(ev) {
+ if (ev.key === 'Escape' && this.emojisPopoverView) {
+ this.update({
+ doFocus: true,
+ emojisPopoverView: clear(),
+ });
+ markEventHandled(ev, 'Composer.closeEmojisPopover');
+ }
+ },
/**
* Open the full composer modal.
*/
@@ -859,6 +933,10 @@ registerModel({
isCausal: true,
readonly: true,
}),
+ /**
+ * States the ref to the html node of the emojis button.
+ */
+ buttonEmojisRef: attr(),
/**
* States the chatter which this composer allows editing (if any).
*/
@@ -866,6 +944,10 @@ registerModel({
inverse: 'composerView',
readonly: true,
}),
+ /**
+ * States the OWL component of this composer view.
+ */
+ component: attr(),
/**
* States the composer state that is displayed by this composer view.
*/
@@ -877,6 +959,13 @@ registerModel({
* Determines whether this composer should be focused at next render.
*/
doFocus: attr(),
+ /**
+ * Determines the emojis popover that is active on this composer view.
+ */
+ emojisPopoverView: one2one('PopoverView', {
+ inverse: 'composerViewOwnerAsEmoji',
+ isCausal: true,
+ }),
/**
* Determines the extra records that are currently suggested.
* Allows to have different model types of mentions through a dynamic
@@ -972,6 +1061,10 @@ registerModel({
suggestionSearchTerm: attr({
compute: '_computeSuggestionSearchTerm',
}),
+ /**
+ * States the OWL text input component of this composer view.
+ */
+ textInputComponent: attr(),
/**
* States the thread view on which this composer allows editing (if any).
*/
diff --git a/addons/mail/static/src/models/emoji_list_view/emoji_list_view.js b/addons/mail/static/src/models/emoji_list_view/emoji_list_view.js
new file mode 100644
index 00000000000..43476538a52
--- /dev/null
+++ b/addons/mail/static/src/models/emoji_list_view/emoji_list_view.js
@@ -0,0 +1,34 @@
+/** @odoo-module **/
+
+import { registerModel } from '@mail/model/model_core';
+import { one2one } from '@mail/model/model_field';
+
+registerModel({
+ name: 'EmojiListView',
+ identifyingFields: ['popoverViewOwner'],
+ lifecycleHooks: {
+ _created() {
+ this.onClickEmoji = this.onClickEmoji.bind(this);
+ },
+ },
+ recordMethods: {
+ /**
+ * @param {MouseEvent} ev
+ */
+ onClickEmoji(ev) {
+ if (this.popoverViewOwner.messageActionListOwnerAsReaction) {
+ this.popoverViewOwner.messageActionListOwnerAsReaction.onClickReaction(ev);
+ }
+ if (this.popoverViewOwner.composerViewOwnerAsEmoji) {
+ this.popoverViewOwner.composerViewOwnerAsEmoji.onClickEmoji(ev);
+ }
+ },
+ },
+ fields: {
+ popoverViewOwner: one2one('PopoverView', {
+ inverse: 'emojiListView',
+ readonly: true,
+ required: true,
+ }),
+ },
+});
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
index e38f2b86b77..084ac4a7cce 100644
--- 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
@@ -12,16 +12,14 @@ registerModel({
_created() {
// bind handlers so they can be used in templates
this.onClick = this.onClick.bind(this);
+ this.onClickActionReaction = this.onClickActionReaction.bind(this);
this.onClickConfirmDelete = this.onClickConfirmDelete.bind(this);
this.onClickDelete = this.onClickDelete.bind(this);
this.onClickEdit = this.onClickEdit.bind(this);
this.onClickMarkAsRead = this.onClickMarkAsRead.bind(this);
- this.onReactionPopoverOpened = this.onReactionPopoverOpened.bind(this);
- this.onReactionPopoverClosed = this.onReactionPopoverClosed.bind(this);
this.onClickReplyTo = this.onClickReplyTo.bind(this);
this.onClickToggleStar = this.onClickToggleStar.bind(this);
this.onDeleteConfirmDialogClosed = this.onDeleteConfirmDialogClosed.bind(this);
- this.onEmojiSelection = this.onEmojiSelection.bind(this);
},
},
recordMethods: {
@@ -64,18 +62,21 @@ registerModel({
this.message.markAsRead();
},
/**
- * @private
- * @param {Event} ev
+ * Handles click on the reaction icon.
*/
- onReactionPopoverClosed(ev) {
- this.update({ isReactionPopoverOpened: false });
+ onClickActionReaction() {
+ if (!this.reactionPopoverView) {
+ this.update({ reactionPopoverView: insertAndReplace() });
+ } else {
+ this.update({ reactionPopoverView: clear() });
+ }
},
/**
- * @private
- * @param {Event} ev
+ * @param {MouseEvent} ev
*/
- onReactionPopoverOpened(ev) {
- this.update({ isReactionPopoverOpened: true });
+ onClickReaction(ev) {
+ this.message.addReaction(ev.currentTarget.dataset.unicode);
+ this.update({ reactionPopoverView: clear() });
},
/**
* Opens the reply composer for this message (or closes it if it was
@@ -102,16 +103,6 @@ registerModel({
onDeleteConfirmDialogClosed(ev) {
this.update({ showDeleteConfirm: false });
},
- /**
- * Handles `onEmojiSelection` calback from the emoji list.
- *
- * @private
- * @param {Object} detail
- * @param {string} detail.unicode
- */
- onEmojiSelection(detail) {
- this.message.addReaction(detail.unicode);
- },
/**
* @private
* @returns {boolean}
@@ -137,13 +128,6 @@ registerModel({
)
);
},
- _computeIsReactionPopoverOpened() {
- return Boolean(
- this.reactionPopoverRef &&
- this.reactionPopoverRef.comp &&
- this.reactionPopoverRef.comp.state.displayed
- );
- },
/**
* @private
* @returns {MessageView}
@@ -155,6 +139,10 @@ registerModel({
},
},
fields: {
+ /**
+ * States the reference to the reaction action in the component.
+ */
+ actionReactionRef: attr(),
/**
* Determines whether this message action list has mark as read icon.
*/
@@ -167,12 +155,6 @@ registerModel({
hasReplyIcon: attr({
compute: '_computeHasReplyIcon',
}),
- /**
- * States whether the reaction popover is currently opened.
- */
- isReactionPopoverOpened: attr({
- compute: '_computeIsReactionPopoverOpened',
- }),
/**
* States the message on which this action message list operates.
*/
@@ -197,9 +179,12 @@ registerModel({
isCausal: true,
}),
/**
- * States the reference to the reaction popover component (if any).
+ * Determines the reaction popover that is active on this message action list.
*/
- reactionPopoverRef: attr(),
+ reactionPopoverView: one2one('PopoverView', {
+ inverse: 'messageActionListOwnerAsReaction',
+ isCausal: true,
+ }),
/**
* Determines whether to show the message delete-confirm dialog.
*/
diff --git a/addons/mail/static/src/models/popover_view/popover_view.js b/addons/mail/static/src/models/popover_view/popover_view.js
index e7ee9028698..27dda870353 100644
--- a/addons/mail/static/src/models/popover_view/popover_view.js
+++ b/addons/mail/static/src/models/popover_view/popover_view.js
@@ -2,11 +2,11 @@
import { registerModel } from '@mail/model/model_core';
import { attr, one2one } from '@mail/model/model_field';
-import { clear } from '@mail/model/model_field_command';
+import { clear, insertAndReplace, replace } from '@mail/model/model_field_command';
registerModel({
name: 'PopoverView',
- identifyingFields: ['threadViewTopbarOwner', 'channelInvitationForm'],
+ identifyingFields: [['composerViewOwnerAsEmoji', 'messageActionListOwnerAsReaction', 'threadViewTopbarOwnerAsInvite']],
lifecycleHooks: {
_created() {
this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
@@ -17,13 +17,91 @@ registerModel({
},
},
recordMethods: {
+ /**
+ * Returns whether the given html element is inside the component
+ * of this popover view.
+ *
+ * @param {Element} element
+ * @returns {boolean}
+ */
+ contains(element) {
+ return Boolean(this.component && this.component.root.el.contains(element));
+ },
/**
* @private
* @returns {owl.Ref}
*/
_computeAnchorRef() {
- if (this.threadViewTopbarOwner) {
- return this.threadViewTopbarOwner.inviteButtonRef;
+ if (this.threadViewTopbarOwnerAsInvite) {
+ return this.threadViewTopbarOwnerAsInvite.inviteButtonRef;
+ }
+ if (this.composerViewOwnerAsEmoji) {
+ return this.composerViewOwnerAsEmoji.buttonEmojisRef;
+ }
+ if (this.messageActionListOwnerAsReaction) {
+ return this.messageActionListOwnerAsReaction.actionReactionRef;
+ }
+ return clear();
+ },
+ /**
+ * @private
+ * @returns {FieldCommand}
+ */
+ _computeChannelInvitationForm() {
+ if (this.threadViewTopbarOwnerAsInvite) {
+ return insertAndReplace();
+ }
+ return clear();
+ },
+ /**
+ * @private
+ * @returns {FieldCommand}
+ */
+ _computeContent() {
+ if (this.channelInvitationForm) {
+ return replace(this.channelInvitationForm);
+ }
+ if (this.emojiListView) {
+ return replace(this.emojiListView);
+ }
+ return clear();
+ },
+ /**
+ * @private
+ * @returns {string|FieldCommand}
+ */
+ _computeContentClassName() {
+ if (this.channelInvitationForm) {
+ return 'o_PopoverView_channelInvitationForm';
+ }
+ if (this.emojiListView) {
+ return 'o_PopoverView_emojiList';
+ }
+ return clear();
+ },
+ /**
+ * @private
+ * @returns {string|FieldCommand}
+ */
+ _computeContentComponentName() {
+ if (this.channelInvitationForm) {
+ return 'ChannelInvitationForm';
+ }
+ if (this.emojiListView) {
+ return 'EmojiList';
+ }
+ return clear();
+ },
+ /**
+ * @private
+ * @returns {FieldCommand}
+ */
+ _computeEmojiListView() {
+ if (this.composerViewOwnerAsEmoji) {
+ return insertAndReplace();
+ }
+ if (this.messageActionListOwnerAsReaction) {
+ return insertAndReplace();
}
return clear();
},
@@ -32,9 +110,15 @@ registerModel({
* @returns {string}
*/
_computePosition() {
- if (this.threadViewTopbarOwner) {
+ if (this.threadViewTopbarOwnerAsInvite) {
return 'bottom';
}
+ if (this.composerViewOwnerAsEmoji) {
+ return 'top';
+ }
+ if (this.messageActionListOwnerAsReaction) {
+ return 'top';
+ }
return clear();
},
/**
@@ -68,14 +152,61 @@ registerModel({
* The record that represents the content inside the popover view.
*/
channelInvitationForm: one2one('ChannelInvitationForm', {
+ compute: '_computeChannelInvitationForm',
+ inverse: 'popoverViewOwner',
isCausal: true,
readonly: true,
- required: true,
}),
/**
* States the OWL component of this popover view.
*/
component: attr(),
+ /**
+ * If set, this popover view is owned by a composer view.
+ */
+ composerViewOwnerAsEmoji: one2one('ComposerView', {
+ inverse: 'emojisPopoverView',
+ readonly: true,
+ }),
+ /**
+ * Determines the record that is content of this popover view.
+ */
+ content: one2one('Model', {
+ compute: '_computeContent',
+ required: true,
+ }),
+ /**
+ * Determines the class name for the component
+ * that is content of this popover view.
+ */
+ contentClassName: attr({
+ compute: '_computeContentClassName',
+ default: '',
+ }),
+ /**
+ * Determines the component name of the content.
+ */
+ contentComponentName: attr({
+ compute: '_computeContentComponentName',
+ default: '',
+ required: true,
+ }),
+ /**
+ * If set, the content of this popover view is a list of emojis.
+ */
+ emojiListView: one2one('EmojiListView', {
+ compute: '_computeEmojiListView',
+ inverse: 'popoverViewOwner',
+ isCausal: true,
+ readonly: true,
+ }),
+ /**
+ * If set, this popover view is owned by a message action list.
+ */
+ messageActionListOwnerAsReaction: one2one('MessageActionList', {
+ inverse: 'reactionPopoverView',
+ readonly: true,
+ }),
/**
* Position of the popover view relative to its anchor point.
* Valid values: 'top', 'right', 'bottom', 'left'
@@ -87,7 +218,7 @@ registerModel({
/**
* If set, this popover view is owned by a thread view topbar record.
*/
- threadViewTopbarOwner: one2one('ThreadViewTopbar', {
+ threadViewTopbarOwnerAsInvite: one2one('ThreadViewTopbar', {
inverse: 'invitePopoverView',
readonly: true,
}),
diff --git a/addons/mail/static/src/models/thread_view/thread_view.js b/addons/mail/static/src/models/thread_view/thread_view.js
index 3532bcd967a..c8d7b597a21 100644
--- a/addons/mail/static/src/models/thread_view/thread_view.js
+++ b/addons/mail/static/src/models/thread_view/thread_view.js
@@ -286,14 +286,6 @@ registerModel({
compact: attr({
related: 'threadViewer.compact',
}),
- /**
- * States which channel invitation form is operating this thread view.
- * Only applies if this thread is a channel.
- */
- channelInvitationForm: one2one('ChannelInvitationForm', {
- inverse: 'threadView',
- isCausal: true,
- }),
/**
* List of component hints. Hints contain information that help
* components make UI/UX decisions based on their UI state.
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 5ec4739eee7..3c709ac859d 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
@@ -343,17 +343,12 @@ registerModel({
* Open the invite popover view in this thread view topbar.
*/
openInvitePopoverView() {
- this.threadView.update({ channelInvitationForm: insertAndReplace() });
- this.update({
- invitePopoverView: insertAndReplace({
- channelInvitationForm: replace(this.threadView.channelInvitationForm),
- }),
- });
+ this.update({ invitePopoverView: insertAndReplace() });
if (this.messaging.isCurrentUserGuest) {
return;
}
- this.threadView.channelInvitationForm.update({ doFocusOnSearchInput: true });
- this.threadView.channelInvitationForm.searchPartnersToInvite();
+ this.invitePopoverView.channelInvitationForm.update({ doFocusOnSearchInput: true });
+ this.invitePopoverView.channelInvitationForm.searchPartnersToInvite();
},
/**
* @private
@@ -593,7 +588,7 @@ registerModel({
*/
invitePopoverView: one2one('PopoverView', {
isCausal: true,
- inverse: 'threadViewTopbarOwner',
+ inverse: 'threadViewTopbarOwnerAsInvite',
}),
/**
* Determines whether this thread is currently being renamed.