From edb25d056ee079edda0ff3efe6c809f9a6eff765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Thu, 23 Dec 2021 11:28:19 +0000 Subject: [PATCH] [IMP] mail: use popover view for emojis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-2694206 closes odoo/odoo#81877 Signed-off-by: SΓ©bastien Theys (seb) --- .../src/components/composer/composer.js | 98 +----------- .../src/components/composer/composer.xml | 27 ++-- .../composer/tests/composer_tests.js | 22 --- .../composer_text_input.js | 2 + .../src/components/emoji_list/emoji_list.js | 26 +--- .../src/components/emoji_list/emoji_list.xml | 2 +- .../static/src/components/message/message.js | 2 +- .../message_action_list.js | 2 +- .../message_action_list.xml | 7 +- .../components/popover_view/popover_view.xml | 6 +- .../channel_invitation_form.js | 24 +-- .../src/models/composer_view/composer_view.js | 93 +++++++++++ .../models/emoji_list_view/emoji_list_view.js | 34 ++++ .../message_action_list.js | 57 +++---- .../src/models/popover_view/popover_view.js | 145 +++++++++++++++++- .../src/models/thread_view/thread_view.js | 8 - .../thread_view_topbar/thread_view_topbar.js | 13 +- 17 files changed, 338 insertions(+), 230 deletions(-) create mode 100644 addons/mail/static/src/models/emoji_list_view/emoji_list_view.js diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js index 0f5276160b8..37a4bbd9b48 100644 --- a/addons/mail/static/src/components/composer/composer.js +++ b/addons/mail/static/src/components/composer/composer.js @@ -1,14 +1,12 @@ /** @odoo-module **/ +import { useComponentToModel } from '@mail/component_hooks/use_component_to_model/use_component_to_model'; import { useDragVisibleDropZone } from '@mail/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone'; +import { useRefToModel } from '@mail/component_hooks/use_ref_to_model/use_ref_to_model'; import { registerMessagingComponent } from '@mail/utils/messaging_component'; -import { - isEventHandled, - markEventHandled, -} from '@mail/utils/utils'; +import { isEventHandled } from '@mail/utils/utils'; const { Component } = owl; -const { onMounted, onWillUnmount, useRef } = owl.hooks; export class Composer extends Component { @@ -18,31 +16,11 @@ export class Composer extends Component { setup() { super.setup(); this.isDropZoneVisible = useDragVisibleDropZone(); - /** - * Reference of the emoji list. Useful to include emoji list as - * contained "inside" the composer. - */ - this._emojiListRef = useRef('emojiList'); - /** - * Reference of the text input component. Useful to save state in store - * before inserting emoji. - */ - this._textInputRef = useRef('textInput'); - this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this); - onMounted(() => this._mounted()); - onWillUnmount(() => this._willUnmount()); + useComponentToModel({ fieldName: 'component', modelName: 'ComposerView', propNameAsRecordLocalId: 'composerViewLocalId' }); + useRefToModel({ fieldName: 'buttonEmojisRef', modelName: 'ComposerView', propNameAsRecordLocalId: 'composerViewLocalId', refName: 'buttonEmojis' }); this._onDropZoneFilesDropped = this._onDropZoneFilesDropped.bind(this); this._onComposerTextInputSendShortcut = this._onComposerTextInputSendShortcut.bind(this); this._onPasteTextInput = this._onPasteTextInput.bind(this); - this._onEmojiSelection = this._onEmojiSelection.bind(this); - } - - _mounted() { - document.addEventListener('click', this._onClickCaptureGlobal, true); - } - - _willUnmount() { - document.removeEventListener('click', this._onClickCaptureGlobal, true); } //-------------------------------------------------------------------------- @@ -56,22 +34,6 @@ export class Composer extends Component { return this.messaging && this.messaging.models['ComposerView'].get(this.props.composerViewLocalId); } - /** - * Returns whether the given node is self or a children of self, including - * the emoji list. - * - * @param {Node} node - * @returns {boolean} - */ - contains(node) { - // emoji list is outside but should be considered inside - const emojiList = this._emojiListRef.comp; - if (emojiList && emojiList.contains(node)) { - return true; - } - return Boolean(this.root.el && this.root.el.contains(node)); - } - /** * Get the current partner image URL. * @@ -166,27 +128,6 @@ export class Composer extends Component { } } - /** - * 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; - } - if (!this.composerView) { - return; - } - this.composerView.discard(); - } - /** * Called when clicking on "expand" button. * @@ -235,21 +176,6 @@ export class Composer extends Component { this.isDropZoneVisible.value = false; } - /** - * Handles `onEmojiSelection` callback from the emoji list. - * - * @private - * @param {Object} detail - * @param {string} detail.unicode - */ - _onEmojiSelection(detail) { - this._textInputRef.comp.saveStateInStore(); - this.composerView.insertIntoTextInput(detail.unicode); - if (!this.messaging.device.isMobileDevice) { - this.composerView.update({ doFocus: true }); - } - } - /** * @private * @param {KeyboardEvent} ev @@ -267,20 +193,6 @@ export class Composer extends Component { } } - /** - * @private - * @param {KeyboardEvent} ev - */ - _onKeydownEmojiButton(ev) { - if (ev.key === 'Escape') { - if (this._emojiListRef.comp) { - this._emojiListRef.comp.close(); - this.composerView.update({ doFocus: true }); - markEventHandled(ev, 'Composer.closeEmojisPopover'); - } - } - } - /** * @private * @param {CustomEvent} ev diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml index ac062623cf6..3ce038005be 100644 --- a/addons/mail/static/src/components/composer/composer.xml +++ b/addons/mail/static/src/components/composer/composer.xml @@ -94,21 +94,18 @@
- - - - - - - + +
diff --git a/addons/mail/static/src/components/composer/tests/composer_tests.js b/addons/mail/static/src/components/composer/tests/composer_tests.js index ac6a106ae1c..d704a4efaf0 100644 --- a/addons/mail/static/src/components/composer/tests/composer_tests.js +++ b/addons/mail/static/src/components/composer/tests/composer_tests.js @@ -210,10 +210,6 @@ QUnit.test('add an emoji', async function (assert) { "😊", "emoji should be inserted in the composer text input" ); - // ensure popover is closed - await nextAnimationFrame(); - await nextAnimationFrame(); - await nextAnimationFrame(); }); QUnit.test('add an emoji after some text', async function (assert) { @@ -247,10 +243,6 @@ QUnit.test('add an emoji after some text', async function (assert) { "Blabla😊", "emoji should be inserted after the text" ); - // ensure popover is closed - await nextAnimationFrame(); - await nextAnimationFrame(); - await nextAnimationFrame(); }); QUnit.test('add emoji replaces (keyboard) text selection', async function (assert) { @@ -278,8 +270,6 @@ QUnit.test('add emoji replaces (keyboard) text selection', async function (asser // simulate selection of all the content by keyboard composerTextInputTextArea.setSelectionRange(0, composerTextInputTextArea.value.length); - - // select emoji await afterNextRender(() => document.querySelector('.o_Composer_buttonEmojis').click()); await afterNextRender(() => document.querySelector('.o_EmojiList_emoji[data-unicode="😊"]').click() @@ -289,10 +279,6 @@ QUnit.test('add emoji replaces (keyboard) text selection', async function (asser "😊", "whole text selection should have been replaced by emoji" ); - // ensure popover is closed - await nextAnimationFrame(); - await nextAnimationFrame(); - await nextAnimationFrame(); }); QUnit.test('display canned response suggestions on typing ":"', async function (assert) { @@ -509,8 +495,6 @@ QUnit.test('add an emoji after a canned response', async function (assert) { "Hello! How are you? 😊", "text content of composer should have previous canned response substitution and selected emoji just after" ); - // ensure popover is closed - await nextAnimationFrame(); }); QUnit.test('display channel mention suggestions on typing "#"', async function (assert) { @@ -712,8 +696,6 @@ QUnit.test('add an emoji after a channel mention', async function (assert) { "#General 😊", "text content of composer should have previous channel mention and selected emoji just after" ); - // ensure popover is closed - await nextAnimationFrame(); }); QUnit.test('display command suggestions on typing "/"', async function (assert) { @@ -948,8 +930,6 @@ QUnit.test('add an emoji after a command', async function (assert) { "/who 😊", "text content of composer should have previous command and selected emoji just after" ); - // ensure popover is closed - await nextAnimationFrame(); }); QUnit.test('display partner mention suggestions on typing "@"', async function (assert) { @@ -1204,8 +1184,6 @@ QUnit.test('add an emoji after a partner mention', async function (assert) { "@TestPartner 😊", "text content of composer should have previous mention and selected emoji just after" ); - // ensure popover is closed - await nextAnimationFrame(); }); QUnit.test('composer: add an attachment', async function (assert) { diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.js b/addons/mail/static/src/components/composer_text_input/composer_text_input.js index 5753f79245b..fdbe7b126ae 100644 --- a/addons/mail/static/src/components/composer_text_input/composer_text_input.js +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.js @@ -1,5 +1,6 @@ /** @odoo-module **/ +import { useComponentToModel } from '@mail/component_hooks/use_component_to_model/use_component_to_model'; import { registerMessagingComponent } from '@mail/utils/messaging_component'; import { useUpdate } from '@mail/component_hooks/use_update/use_update'; import { markEventHandled } from '@mail/utils/utils'; @@ -14,6 +15,7 @@ export class ComposerTextInput extends Component { */ setup() { super.setup(); + useComponentToModel({ fieldName: 'textInputComponent', modelName: 'ComposerView', propNameAsRecordLocalId: 'composerViewLocalId' }); /** * Updates the composer text input content when composer is mounted * as textarea content can't be changed from the DOM. diff --git a/addons/mail/static/src/components/emoji_list/emoji_list.js b/addons/mail/static/src/components/emoji_list/emoji_list.js index 680e96dd2f7..d611329fdbb 100644 --- a/addons/mail/static/src/components/emoji_list/emoji_list.js +++ b/addons/mail/static/src/components/emoji_list/emoji_list.js @@ -17,6 +17,10 @@ export class EmojiList extends Component { useUpdate({ func: () => this._update() }); } + get emojiListView() { + return this.messaging && this.messaging.models['EmojiListView'].get(this.props.localId); + } + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -46,31 +50,11 @@ export class EmojiList extends Component { return Boolean(this.root.el && this.root.el.contains(node)); } - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {MouseEvent} ev - */ - _onClickEmoji(ev) { - this.close(); - if (this.props.onEmojiSelection) { - this.props.onEmojiSelection({ - unicode: ev.currentTarget.dataset.unicode, - }); - } - } - } Object.assign(EmojiList, { props: { - onEmojiSelection: { - type: Function, - optional: true, - }, + localId: String, }, template: 'mail.EmojiList', }); diff --git a/addons/mail/static/src/components/emoji_list/emoji_list.xml b/addons/mail/static/src/components/emoji_list/emoji_list.xml index 2bb88e93512..cef13a0bb61 100644 --- a/addons/mail/static/src/components/emoji_list/emoji_list.xml +++ b/addons/mail/static/src/components/emoji_list/emoji_list.xml @@ -4,7 +4,7 @@
- + diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js index 84c8c4b3bce..30b7d894b8b 100644 --- a/addons/mail/static/src/components/message/message.js +++ b/addons/mail/static/src/components/message/message.js @@ -151,7 +151,7 @@ export class Message extends Component { this.messageView && this.messageView.messageActionList && ( - this.messageView.messageActionList.isReactionPopoverOpened || + this.messageView.messageActionList.reactionPopoverView || this.messageView.messageActionList.showDeleteConfirm ) ) 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 index 38c231438cf..3de13d43525 100644 --- 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 @@ -12,7 +12,7 @@ export class MessageActionList extends Component { */ setup() { super.setup(); - useRefToModel({ fieldName: 'reactionPopoverRef', modelName: 'MessageActionList', propNameAsRecordLocalId: 'messageActionListLocalId', refName: 'reactionPopover' }); + useRefToModel({ fieldName: 'actionReactionRef', modelName: 'MessageActionList', propNameAsRecordLocalId: 'messageActionListLocalId', refName: 'actionReaction' }); this.ADD_A_REACTION = this.env._t("Add a Reaction"); } 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 index 40d05e65455..23a9784800a 100644 --- 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 @@ -3,11 +3,8 @@
- - - - - + +
- +
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.