summaryrefslogtreecommitdiff
path: root/addons/mail/static/src
diff options
context:
space:
mode:
authorSébastien Theys <seb@odoo.com>2020-06-26 09:32:32 +0000
committerSébastien Theys <seb@odoo.com>2020-06-26 11:21:35 +0000
commit1c058b72d83da3be1068961305577b0ad2ca1a2a (patch)
treec6bae7618cbe91424c4174fca2e9da39610f4124 /addons/mail/static/src
parentdc011d12463df07ac75931a5ad332aac12c020a4 (diff)
[FIX] mail: fix (inbox reply) composer event handlers (and discard)
task-2092950 closes odoo/odoo#53731 X-original-commit: c29f5776594c4d2278ce163a78d8eb917fb672c0 Signed-off-by: Sébastien Theys (seb) <seb@odoo.com> Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
Diffstat (limited to 'addons/mail/static/src')
-rw-r--r--addons/mail/static/src/components/chat_window/chat_window.js11
-rw-r--r--addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js74
-rw-r--r--addons/mail/static/src/components/composer/composer.js72
-rw-r--r--addons/mail/static/src/components/composer/composer.xml6
-rw-r--r--addons/mail/static/src/components/composer/composer_tests.js2
-rw-r--r--addons/mail/static/src/components/composer_text_input/composer_text_input.js26
-rw-r--r--addons/mail/static/src/components/discuss/discuss.xml1
-rw-r--r--addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js300
-rw-r--r--addons/mail/static/src/components/emojis_popover/emojis_popover.js23
-rw-r--r--addons/mail/static/src/components/message/message.js16
-rw-r--r--addons/mail/static/src/components/thread_viewer/thread_viewer.js2
-rw-r--r--addons/mail/static/src/models/chat_window/chat_window.js2
-rw-r--r--addons/mail/static/src/models/composer/composer.js23
-rw-r--r--addons/mail/static/src/models/discuss/discuss.js42
-rw-r--r--addons/mail/static/src/models/message/message.js13
-rw-r--r--addons/mail/static/src/utils/utils.js32
16 files changed, 577 insertions, 68 deletions
diff --git a/addons/mail/static/src/components/chat_window/chat_window.js b/addons/mail/static/src/components/chat_window/chat_window.js
index 8d1851c78fc..24801e5f301 100644
--- a/addons/mail/static/src/components/chat_window/chat_window.js
+++ b/addons/mail/static/src/components/chat_window/chat_window.js
@@ -7,6 +7,7 @@ const components = {
ThreadViewer: require('mail/static/src/components/thread_viewer/thread_viewer.js'),
};
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const { isEventHandled } = require('mail/static/src/utils/utils.js');
const { Component } = owl;
const { useRef } = owl.hooks;
@@ -250,6 +251,10 @@ class ChatWindow extends Component {
*/
_onFocusinThread(ev) {
ev.stopPropagation();
+ if (!this.chatWindow) {
+ // prevent crash on destroy
+ return;
+ }
this.chatWindow.update({ isFocused: true });
}
@@ -293,6 +298,12 @@ class ChatWindow extends Component {
}
break;
case 'Escape':
+ if (isEventHandled(ev, 'ComposerTextInput.closeMentionSuggestions')) {
+ break;
+ }
+ if (isEventHandled(ev, 'Composer.closeEmojisPopover')) {
+ break;
+ }
ev.preventDefault();
this.chatWindow.close();
break;
diff --git a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js
index 1c51a669312..5bd516c5214 100644
--- a/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js
+++ b/addons/mail/static/src/components/chat_window_manager/chat_window_manager_tests.js
@@ -527,8 +527,13 @@ QUnit.test('chat window: open / close', async function (assert) {
});
QUnit.test('chat window: close on ESCAPE', async function (assert) {
- assert.expect(4);
+ assert.expect(12);
+ this.data['res.partner'].records = [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }];
Object.assign(this.data.initMessaging, {
channel_slots: {
channel_channel: [{
@@ -569,12 +574,73 @@ QUnit.test('chat window: close on ESCAPE', async function (assert) {
document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click()
);
assert.verifySteps(['rpc:channel_fold/open']);
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "chat window should be opened after click on preview"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Composer_buttonEmojis`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be opened after click on emojis button"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_Composer_buttonEmojis`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be closed after pressing escape on emojis button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "chat window should still be opened after pressing escape on emojis button"
+ );
await afterNextRender(() => {
- document.querySelector(`.o_ChatWindow`).focus();
- const kevt = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
- document.querySelector(`.o_ChatWindow`).dispatchEvent(kevt);
+ document.execCommand('insertText', false, "@");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
});
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestion should be opened after typing @"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestion should be closed after pressing escape on mention suggestion"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ChatWindow',
+ "chat window should still be opened after pressing escape on mention suggestion"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ChatWindow',
+ "chat window should be closed after pressing escape if there was no other priority escape handler"
+ );
assert.verifySteps(['rpc:channel_fold/closed']);
});
diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js
index 3fd82216b05..a0fe1490bc7 100644
--- a/addons/mail/static/src/components/composer/composer.js
+++ b/addons/mail/static/src/components/composer/composer.js
@@ -11,6 +11,10 @@ const components = {
};
const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js');
const useStore = require('mail/static/src/component_hooks/use_store/use_store.js');
+const {
+ isEventHandled,
+ markEventHandled,
+} = require('mail/static/src/utils/utils.js');
const mailUtils = require('mail.utils');
const { Component } = owl;
@@ -35,8 +39,8 @@ class Composer extends Component {
};
});
/**
- * Reference of the emoji button. Useful to include emoji popover as
- * click "inside" the composer for the prop `isDiscardOnClickAway`.
+ * Reference of the emoji popover. Useful to include emoji popover as
+ * contained "inside" the composer.
*/
this._emojisPopoverRef = useRef('emojisPopover');
/**
@@ -48,6 +52,7 @@ class Composer extends Component {
* Reference of the text input component.
*/
this._textInputRef = useRef('textInput');
+
this._onClickCaptureGlobal = this._onClickCaptureGlobal.bind(this);
}
@@ -71,6 +76,22 @@ class Composer extends Component {
}
/**
+ * Returns whether the given node is self or a children of self, including
+ * the emoji popover.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ contains(node) {
+ // emoji popover is outside but should be considered inside
+ const emojisPopover = this._emojisPopoverRef.comp;
+ if (emojisPopover && emojisPopover.contains(node)) {
+ return true;
+ }
+ return this.el.contains(node);
+ }
+
+ /**
* Get the current partner image URL.
*
* @returns {string}
@@ -172,23 +193,13 @@ class Composer extends Component {
}
/**
- * Discards the composer when clicking away from the Inbox reply in discuss.
- * TODO maybe move this in discuss. task-2092950
+ * Discards the composer when clicking away.
*
* @private
* @param {MouseEvent} ev
*/
_onClickCaptureGlobal(ev) {
- // in discuss app: prevents discarding the composer when clicking away
- if (!this.props.isDiscardOnClickAway) {
- return;
- }
- // prevents discarding when clicking on self (on discuss and not on discuss)
- if (this.el.contains(ev.target)) {
- return;
- }
- // emoji popover is outside but should be considered inside
- if (this._emojisPopoverRef.comp.isInsideEventTarget(ev.target)) {
+ if (this.contains(ev.target)) {
return;
}
this.composer.discard();
@@ -282,6 +293,37 @@ class Composer extends Component {
/**
* @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown(ev) {
+ if (ev.key === 'Escape') {
+ if (isEventHandled(ev, 'ComposerTextInput.closeMentionSuggestions')) {
+ return;
+ }
+ if (isEventHandled(ev, 'Composer.closeEmojisPopover')) {
+ return;
+ }
+ ev.preventDefault();
+ this.composer.discard();
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydownEmojiButton(ev) {
+ if (ev.key === 'Escape') {
+ if (this._emojisPopoverRef.comp) {
+ this._emojisPopoverRef.comp.close();
+ this.composer.focus();
+ markEventHandled(ev, 'Composer.closeEmojisPopover');
+ }
+ }
+ }
+
+ /**
+ * @private
* @param {CustomEvent} ev
*/
async _onPasteTextInput(ev) {
@@ -315,7 +357,6 @@ Object.assign(Composer, {
hasThreadName: false,
hasThreadTyping: false,
isCompact: true,
- isDiscardOnClickAway: false,
isExpandable: false,
},
props: {
@@ -357,7 +398,6 @@ Object.assign(Composer, {
optional: true,
},
isCompact: Boolean,
- isDiscardOnClickAway: Boolean,
isExpandable: Boolean,
},
template: 'mail.Composer',
diff --git a/addons/mail/static/src/components/composer/composer.xml b/addons/mail/static/src/components/composer/composer.xml
index bd14aed503a..4a1c20ca9f2 100644
--- a/addons/mail/static/src/components/composer/composer.xml
+++ b/addons/mail/static/src/components/composer/composer.xml
@@ -10,6 +10,7 @@
'o-has-header': hasHeader,
'o-is-compact': props.isCompact,
}"
+ t-on-keydown="_onKeydown"
>
<t t-if="composer">
<t t-if="isDropZoneVisible.value">
@@ -77,18 +78,19 @@
<div class="o_Composer_toolButtonSeparator"/>
</t>
<div class="o_Composer_primaryToolButtons" t-att-class="{ 'o-composer-is-compact': props.isCompact }">
- <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection" t-ref="emojisPopover">
+ <Popover position="'top'" t-on-o-emoji-selection="_onEmojiSelection">
<!-- 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': env.messaging.device.isMobile,
}"
+ t-on-keydown="_onKeydownEmojiButton"
>
<i class="fa fa-smile-o"/>
</button>
<t t-set="opened">
- <EmojisPopover/>
+ <EmojisPopover t-ref="emojisPopover"/>
</t>
</Popover>
<button class="o_Composer_button o_Composer_buttonAttachment o_Composer_toolButton btn btn-light fa fa-paperclip" t-att-class="{ 'o-mobile': env.messaging.device.isMobile }" title="Add attachment" type="button" t-on-click="_onClickAddAttachment"/>
diff --git a/addons/mail/static/src/components/composer/composer_tests.js b/addons/mail/static/src/components/composer/composer_tests.js
index 0cd79c1a9fa..0b9f67fdc51 100644
--- a/addons/mail/static/src/components/composer/composer_tests.js
+++ b/addons/mail/static/src/components/composer/composer_tests.js
@@ -266,7 +266,7 @@ QUnit.test('display partner mention suggestions on typing "@"', async function (
this.data['res.users'].records = [{
partner_id: 11,
}];
- await this.start({});
+ await this.start();
const composer = this.env.models['mail.composer'].create();
await this.createComposerComponent(composer);
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 279d5a6ae93..2ff9f9f6a89 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
@@ -6,6 +6,7 @@ const useStore = require('mail/static/src/component_hooks/use_store/use_store.js
const components = {
PartnerMentionSuggestion: require('mail/static/src/components/partner_mention_suggestion/partner_mention_suggestion.js'),
};
+const { markEventHandled } = require('mail/static/src/utils/utils.js');
const { Component } = owl;
const { useRef } = owl.hooks;
@@ -206,6 +207,13 @@ class ComposerTextInput extends Component {
*/
_onKeydownTextarea(ev) {
switch (ev.key) {
+ case 'Escape':
+ if (this.composer.hasSuggestedPartners) {
+ ev.preventDefault();
+ this.composer.closeMentionSuggestions();
+ markEventHandled(ev, 'ComposerTextInput.closeMentionSuggestions');
+ }
+ break;
// UP, DOWN, TAB: prevent moving cursor if navigation in mention suggestions
case 'ArrowUp':
case 'PageUp':
@@ -256,9 +264,8 @@ class ComposerTextInput extends Component {
*/
_onKeyupTextarea(ev) {
switch (ev.key) {
- // ESCAPE: close mention suggestions
case 'Escape':
- this._onKeyupTextareaEscape(ev);
+ // already handled in _onKeydownTextarea, break to avoid default
break;
// ENTER, HOME, END, UP, DOWN, PAGE UP, PAGE DOWN, TAB: check if navigation in mention suggestions
case 'Enter':
@@ -310,21 +317,6 @@ class ComposerTextInput extends Component {
/**
* @private
- * @param {KeyboardEvent} ev
- */
- _onKeyupTextareaEscape(ev) {
- if (this.composer.hasSuggestedPartners) {
- this.composer.closeMentionSuggestions();
- } else {
- if (!this._isEmpty()) {
- return;
- }
- this.composer.discard();
- }
- }
-
- /**
- * @private
* @param {Event} ev
*/
_onPartnerMentionSuggestionClicked(ev) {
diff --git a/addons/mail/static/src/components/discuss/discuss.xml b/addons/mail/static/src/components/discuss/discuss.xml
index f20f08b409b..f682278199c 100644
--- a/addons/mail/static/src/components/discuss/discuss.xml
+++ b/addons/mail/static/src/components/discuss/discuss.xml
@@ -96,7 +96,6 @@
hasCurrentPartnerAvatar="!env.messaging.device.isMobile"
hasDiscardButton="true"
hasThreadName="true"
- isDiscardOnClickAway="true"
t-on-o-message-posted="_onReplyingToMessageMessagePosted"
/>
</t>
diff --git a/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js
new file mode 100644
index 00000000000..49203d7fb8b
--- /dev/null
+++ b/addons/mail/static/src/components/discuss/tests/discuss_inbox_tests.js
@@ -0,0 +1,300 @@
+odoo.define('mail/static/src/components/discuss/tests/discuss_inbox_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach: utilsAfterEach,
+ afterNextRender,
+ beforeEach: utilsBeforeEach,
+ nextAnimationFrame,
+ start: utilsStart,
+} = require('mail/static/src/utils/test_utils.js');
+
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_inbox_tests.js', {
+ beforeEach() {
+ utilsBeforeEach(this);
+
+ this.start = async params => {
+ let { env, widget } = await utilsStart(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ if (this.widget) {
+ this.widget.destroy();
+ }
+ utilsAfterEach(this);
+ },
+});
+
+QUnit.test('reply: discard on pressing escape', async function (assert) {
+ assert.expect(9);
+
+ this.data['res.partner'].records = [{
+ email: "testpartnert@odoo.com",
+ id: 11,
+ name: "TestPartner",
+ }];
+ this.data['mail.message'].records = [{
+ author_id: [7, "Demo"],
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100,
+ message_type: 'comment',
+ needaction: true,
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ }];
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "should have composer after clicking on reply to message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Composer_buttonEmojis`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be opened after click on emojis button"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_Composer_buttonEmojis`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be closed after pressing escape on emojis button"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "reply composer should still be opened after pressing escape on emojis button"
+ );
+
+ await afterNextRender(() => {
+ document.execCommand('insertText', false, "@");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestion should be opened after typing @"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_ComposerTextInput_mentionDropdownPropositionList',
+ "mention suggestion should be closed after pressing escape on mention suggestion"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "reply composer should still be opened after pressing escape on mention suggestion"
+ );
+
+ await afterNextRender(() => {
+ const ev = new window.KeyboardEvent('keydown', { bubbles: true, key: "Escape" });
+ document.querySelector(`.o_ComposerTextInput_textarea`).dispatchEvent(ev);
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Composer',
+ "reply composer should be closed after pressing escape if there was no other priority escape handler"
+ );
+});
+
+QUnit.test('reply: discard on discard button click', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.message'].records = [{
+ author_id: [7, "Demo"],
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100,
+ message_type: 'comment',
+ needaction: true,
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ }];
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "should have composer after clicking on reply to message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer_buttonDiscard',
+ "composer should have a discard button"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Composer_buttonDiscard`).click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Composer',
+ "reply composer should be closed after clicking on discard"
+ );
+});
+
+QUnit.test('reply: discard on reply button toggle', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.message'].records = [{
+ author_id: [7, "Demo"],
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100,
+ message_type: 'comment',
+ needaction: true,
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ }];
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "should have composer after clicking on reply to message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Message_commandReply`).click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Composer',
+ "reply composer should be closed after clicking on reply button again"
+ );
+});
+
+QUnit.test('reply: discard on click away', async function (assert) {
+ assert.expect(7);
+
+ this.data['mail.message'].records = [{
+ author_id: [7, "Demo"],
+ body: "<p>Test</p>",
+ date: "2019-04-20 11:00:00",
+ id: 100,
+ message_type: 'comment',
+ needaction: true,
+ needaction_partner_ids: [3],
+ model: 'project.task',
+ record_name: 'Refactoring',
+ res_id: 20,
+ }];
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_Message_commandReply').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "should have composer after clicking on reply to message"
+ );
+
+ document.querySelector(`.o_ComposerTextInput_textarea`).click();
+ await nextAnimationFrame(); // wait just in case, but nothing is supposed to happen
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "reply composer should still be there after clicking inside itself"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Composer_buttonEmojis`).click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be opened after clicking on emojis button"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_EmojisPopover_emoji`).click();
+ });
+ assert.containsNone(
+ document.body,
+ '.o_EmojisPopover',
+ "emojis popover should be closed after selecting an emoji"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Composer',
+ "reply composer should still be there after selecting an emoji (even though it is technically a click away, it should be considered inside)"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector(`.o_Message`).click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Composer',
+ "reply composer should be closed after clicking away"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/mail/static/src/components/emojis_popover/emojis_popover.js b/addons/mail/static/src/components/emojis_popover/emojis_popover.js
index 2e8ee6c6ae8..6d3798d0774 100644
--- a/addons/mail/static/src/components/emojis_popover/emojis_popover.js
+++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.js
@@ -16,6 +16,27 @@ class EmojisPopover extends Component {
}
//--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ close() {
+ this.trigger('o-popover-close');
+ }
+
+ /**
+ * Returns whether the given node is self or a children of self.
+ *
+ * @param {Node} node
+ * @returns {boolean}
+ */
+ contains(node) {
+ if (!this.el) {
+ return false;
+ }
+ return this.el.contains(node);
+ }
+
+ //--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
@@ -24,7 +45,7 @@ class EmojisPopover extends Component {
* @param {MouseEvent} ev
*/
_onClickEmoji(ev) {
- this.trigger('o-popover-close');
+ this.close();
this.trigger('o-emoji-selection', {
unicode: ev.currentTarget.dataset.unicode,
});
diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js
index 091c47f0090..1881b614d2c 100644
--- a/addons/mail/static/src/components/message/message.js
+++ b/addons/mail/static/src/components/message/message.js
@@ -79,6 +79,10 @@ class Message extends Component {
},
});
/**
+ * The intent of the reply button depends on the last rendered state.
+ */
+ this._wasSelected;
+ /**
* Reference to the content of the message.
*/
this._contentRef = useRef('content');
@@ -399,6 +403,7 @@ class Message extends Component {
* @private
*/
_update() {
+ this._wasSelected = this.props.isSelected;
if (!this.state.timeElapsed) {
this.state.timeElapsed = timeFromNow(this.message.date);
}
@@ -549,8 +554,15 @@ class Message extends Component {
* @param {MouseEvent} ev
*/
_onClickReply(ev) {
- ev.stopPropagation();
- this.message.replyTo();
+ // Use this._wasSelected because this.props.isSelected might be changed
+ // by a global capture click handler (for example the one from Composer)
+ // before the current handler is executed. Indeed because it does a
+ // toggle it needs to take into account the value before the click.
+ if (this._wasSelected) {
+ this.env.messaging.discuss.clearReplyingToMessage();
+ } else {
+ this.message.replyTo();
+ }
}
/**
diff --git a/addons/mail/static/src/components/thread_viewer/thread_viewer.js b/addons/mail/static/src/components/thread_viewer/thread_viewer.js
index 26fc0d88f08..a21aa54efb9 100644
--- a/addons/mail/static/src/components/thread_viewer/thread_viewer.js
+++ b/addons/mail/static/src/components/thread_viewer/thread_viewer.js
@@ -89,7 +89,7 @@ class ThreadViewer extends Component {
*
* @private
*/
- async _update() {
+ _update() {
const messageList = this._messageListRef.comp;
this.trigger('o-rendered');
/**
diff --git a/addons/mail/static/src/models/chat_window/chat_window.js b/addons/mail/static/src/models/chat_window/chat_window.js
index e08fc9029b2..1a60a368405 100644
--- a/addons/mail/static/src/models/chat_window/chat_window.js
+++ b/addons/mail/static/src/models/chat_window/chat_window.js
@@ -294,7 +294,7 @@ function factory(dependencies) {
* chat window to put initially on mount.
*/
threadInitialScrollTop: attr(),
- thread: one2one('mail.thread', {
+ thread: many2one('mail.thread', {
related: 'threadViewer.thread',
}),
threadDisplayName: attr({
diff --git a/addons/mail/static/src/models/composer/composer.js b/addons/mail/static/src/models/composer/composer.js
index 86dfe78aa5e..da1c6e7ad10 100644
--- a/addons/mail/static/src/models/composer/composer.js
+++ b/addons/mail/static/src/models/composer/composer.js
@@ -27,17 +27,14 @@ function factory(dependencies) {
});
}
+ /**
+ * Hides the composer, which only makes sense if the composer is
+ * currently used as a Discuss Inbox reply composer.
+ */
discard() {
- const discuss = this.env.messaging.discuss;
- const thread = this.thread;
- if (
- !discuss.isOpen ||
- discuss.thread !== thread ||
- !discuss.isReplyingToMessage
- ) {
- return;
+ if (this.discussAsReplying) {
+ this.discussAsReplying.clearReplyingToMessage();
}
- discuss.clearReplyingToMessage();
}
focus() {
@@ -509,6 +506,14 @@ function factory(dependencies) {
],
default: false,
}),
+ /**
+ * Instance of discuss if this composer is used as the reply composer
+ * from Inbox. This field is computed from the inverse relation and
+ * should be considered read-only.
+ */
+ discussAsReplying: one2one('mail.discuss', {
+ inverse: 'replyingToMessageOriginThreadComposer',
+ }),
extraSuggestedPartners: many2many('mail.partner', {
compute: '_computeExtraSuggestedPartners',
dependencies: [
diff --git a/addons/mail/static/src/models/discuss/discuss.js b/addons/mail/static/src/models/discuss/discuss.js
index 77a7f0fca9a..8ed864eb82e 100644
--- a/addons/mail/static/src/models/discuss/discuss.js
+++ b/addons/mail/static/src/models/discuss/discuss.js
@@ -177,10 +177,21 @@ function factory(dependencies) {
}
/**
+ * Action to initiate reply to given message in Inbox. Assumes that
+ * Discuss and Inbox are already opened.
+ *
+ * @param {mail.message} message
+ */
+ replyToMessage(message) {
+ this.update({ replyingToMessage: [['link', message]] });
+ this.replyingToMessageOriginThreadComposer.focus();
+ }
+
+ /**
* @param {mail.thread} thread
*/
setThreadRenaming(thread) {
- this.update({ renamingThreads: [['link', thread ]] });
+ this.update({ renamingThreads: [['link', thread]] });
}
/**
@@ -262,6 +273,8 @@ function factory(dependencies) {
}
/**
+ * Ensures the reply feature is disabled if discuss is not open.
+ *
* @private
* @returns {mail.message|undefined}
*/
@@ -400,9 +413,32 @@ function factory(dependencies) {
],
}),
renamingThreads: one2many('mail.thread'),
- replyingToMessage: one2one('mail.message', {
+ /**
+ * The message that is currently selected as being replied to in Inbox.
+ * There is only one reply composer shown at a time, which depends on
+ * this selected message.
+ */
+ replyingToMessage: many2one('mail.message', {
compute: '_computeReplyingToMessage',
- dependencies: ['isOpen'],
+ dependencies: [
+ 'isOpen',
+ 'replyingToMessage',
+ ],
+ }),
+ /**
+ * The thread concerned by the reply feature in Inbox. It depends on the
+ * message set to be replied, and should be considered read-only.
+ */
+ replyingToMessageOriginThread: many2one('mail.thread', {
+ related: 'replyingToMessage.originThread',
+ }),
+ /**
+ * The composer to display for the reply feature in Inbox. It depends
+ * on the message set to be replied, and should be considered read-only.
+ */
+ replyingToMessageOriginThreadComposer: one2one('mail.composer', {
+ inverse: 'discussAsReplying',
+ related: 'replyingToMessageOriginThread.composer',
}),
/**
* Quick search input value in the discuss sidebar (desktop). Useful
diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js
index dda712dbf71..889ddef8306 100644
--- a/addons/mail/static/src/models/message/message.js
+++ b/addons/mail/static/src/models/message/message.js
@@ -277,18 +277,11 @@ function factory(dependencies) {
}
/**
- * Action to initiate reply to given message.
+ * Action to initiate reply to current message in Discuss Inbox. Assumes
+ * that Discuss and Inbox are already opened.
*/
replyTo() {
- const discuss = this.env.messaging.discuss;
- if (!discuss.isOpen) {
- return;
- }
- if (discuss.replyingToMessage === this) {
- discuss.clearReplyingToMessage();
- } else {
- discuss.update({ replyingToMessage: [['link', this]] });
- }
+ this.env.messaging.discuss.replyToMessage(this);
}
/**
diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js
index da0855437f7..8112eeca980 100644
--- a/addons/mail/static/src/utils/utils.js
+++ b/addons/mail/static/src/utils/utils.js
@@ -12,6 +12,36 @@ const {
//------------------------------------------------------------------------------
const classPatchMap = new WeakMap();
+const eventHandledWeakMap = new WeakMap();
+
+/**
+ * Returns whether the given event has been handled with the given markName.
+ *
+ * @param {Event} ev
+ * @param {string} markName
+ * @returns {boolean}
+ */
+function isEventHandled(ev, markName) {
+ if (!eventHandledWeakMap.get(ev)) {
+ return false;
+ }
+ return eventHandledWeakMap.get(ev).includes(markName);
+}
+
+/**
+ * Marks the given event as handled by the given markName. Useful to allow
+ * handlers in the propagation chain to make a decision based on what has
+ * already been done.
+ *
+ * @param {Event} ev
+ * @param {string} markName
+ */
+function markEventHandled(ev, markName) {
+ if (!eventHandledWeakMap.get(ev)) {
+ eventHandledWeakMap.set(ev, []);
+ }
+ eventHandledWeakMap.get(ev).push(markName);
+}
/**
* Wait a task tick, so that anything in micro-task queue that can be processed
@@ -117,6 +147,8 @@ function unpatchInstanceMethods(Class, patchName) {
//------------------------------------------------------------------------------
return {
+ isEventHandled,
+ markEventHandled,
nextTick,
patchClassMethods,
patchInstanceMethods,