[IMP] mail: use popover view for emojis

Task-2694206

closes odoo/odoo#81877

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Alexandre Kühn
2021-12-29 17:55:16 +00:00
parent 07048871af
commit edb25d056e
17 changed files with 338 additions and 230 deletions
@@ -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
@@ -94,21 +94,18 @@
<div class="o_Composer_toolButtonSeparator"/>
</t>
<div class="o_Composer_primaryToolButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
<Popover position="'top'">
<!-- TODO FIXME o-open not possible to code due to https://github.com/odoo/owl/issues/693 -->
<button class="o_Composer_button o_Composer_buttonEmojis o_Composer_toolButton btn btn-light"
t-att-class="{
'o-open': false and state.displayed,
'o-mobile': messaging.device.isMobile,
}"
t-on-keydown="_onKeydownEmojiButton"
>
<i class="fa fa-smile-o"/>
</button>
<t t-set="opened">
<EmojiList t-ref="emojiList" onEmojiSelection="_onEmojiSelection"/>
</t>
</Popover>
<button class="o_Composer_button o_Composer_buttonEmojis o_Composer_toolButton btn btn-light"
t-att-class="{
'o-open': composerView.emojisPopoverView,
'o-mobile': messaging.device.isMobile,
}"
t-on-keydown="composerView.onKeydownButtonEmojis"
t-on-click="composerView.onClickButtonEmojis"
t-ref="buttonEmojis"
>
<i class="fa fa-smile-o"/>
</button>
<PopoverView t-if="composerView.emojisPopoverView" popoverViewLocalId="composerView.emojisPopoverView.localId"/>
<button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-light fa fa-paperclip" t-att-class="{ 'o-mobile': messaging.device.isMobile }" title="Add attachment" type="button" t-on-click="_onClickAddAttachment"/>
</div>
<t t-if="props.isExpandable">
@@ -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) {
@@ -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.
@@ -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',
});
@@ -4,7 +4,7 @@
<t t-name="mail.EmojiList" owl="1">
<div class="o_EmojiList" t-attf-class="{{ className }}" t-ref="root">
<t t-foreach="emojis" t-as="emoji" t-key="emoji.unicode">
<span class="o_EmojiList_emoji" t-on-click="_onClickEmoji" t-att-title="emoji.description" t-att-data-source="emoji.sources[0]" t-att-data-unicode="emoji.unicode">
<span class="o_EmojiList_emoji" t-on-click="emojiListView.onClickEmoji" t-att-title="emoji.description" t-att-data-source="emoji.sources[0]" t-att-data-unicode="emoji.unicode">
<t t-esc="emoji.unicode"/>
</span>
</t>
@@ -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
)
)
@@ -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");
}
@@ -3,11 +3,8 @@
<t t-name="mail.MessageActionList" owl="1">
<div class="o_MessageActionList d-flex" t-attf-class="{{ className }}" t-on-click="messageActionList and messageActionList.onClick" t-ref="root">
<t t-if="messageActionList">
<Popover t-if="messageActionList.message.hasReactionIcon" class="o_MessageActionList_action o_MessageActionList_actionReaction p-2 fa fa-lg fa-smile-o" position="'top'" titleAttribute="ADD_A_REACTION" onClosed="messageActionList.onReactionPopoverClosed" onOpened="messageActionList.onReactionPopoverOpened" t-ref="reactionPopover">
<t t-set="opened">
<EmojiList onEmojiSelection="messageActionList.onEmojiSelection"/>
</t>
</Popover>
<i t-if="messageActionList.message.hasReactionIcon" class="o_MessageActionList_action o_MessageActionList_actionReaction p-2 fa fa-lg fa-smile-o" t-att-title="ADD_A_REACTION" t-on-click="messageActionList.onClickActionReaction" t-ref="actionReaction"/>
<PopoverView t-if="messageActionList.reactionPopoverView" popoverViewLocalId="messageActionList.reactionPopoverView.localId"/>
<span t-if="messageActionList.message.canStarBeToggled" class="o_MessageActionList_action o_MessageActionList_actionStar p-2" t-att-class="{
'o_MessageActionList_actionStar_active': messageActionList.message.isStarred,
'fa fa-lg fa-star': messageActionList.message.isStarred,
@@ -4,7 +4,11 @@
<t t-name="mail.PopoverView" owl="1">
<div class="o_PopoverView" t-attf-class="{{ className }}" t-ref="root">
<t t-if="popoverView">
<ChannelInvitationForm className="'o_PopoverView_channelInvitationForm'" localId="popoverView.channelInvitationForm.localId"/>
<t
t-component="constructor.components[popoverView.contentComponentName]"
className="popoverView.contentClassName"
t-props="{ localId: popoverView.content.localId }"
/>
</t>
</div>
</t>
@@ -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,
}),
},
});
@@ -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).
*/
@@ -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,
}),
},
});
@@ -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.
*/
@@ -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,
}),
@@ -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.
@@ -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.