[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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user