diff --git a/addons/mail/static/src/component_hooks/use_update/use_update.js b/addons/mail/static/src/component_hooks/use_update/use_update.js new file mode 100644 index 00000000000..4d7893022cc --- /dev/null +++ b/addons/mail/static/src/component_hooks/use_update/use_update.js @@ -0,0 +1,64 @@ +odoo.define('mail/static/src/component_hooks/use_update/use_update.js', function (require) { +'use strict'; + +const { Component } = owl; +const { onMounted, onPatched } = owl.hooks; + +const executionQueue = []; + +function executeNextInQueue() { + if (executionQueue.length === 0) { + return; + } + const { component, func } = executionQueue.shift(); + if (!component.__owl__.isDestroyed) { + func(); + } + executeNextInQueue(); +} + +/** + * @param {Object} param0 + * @param {Component} param0.component + * @param {function} param0.func + * @param {integer} param0.priority + */ +async function addFunctionToQueue({ component, func, priority }) { + const index = executionQueue.findIndex(item => item.priority > priority); + const item = { component, func, priority }; + if (index === -1) { + executionQueue.push(item); + } else { + executionQueue.splice(index, 0, item); + } + // Timeout to allow all components to register their function before + // executing any of them, to respect all priorities. + await new Promise(resolve => setTimeout(resolve)); + executeNextInQueue(); +} + +/** + * This hook provides support for executing code after update (render or patch). + * + * @param {Object} param0 + * @param {function} param0.func the function to execute after the update. + * @param {integer} [param0.priority] determines the execution order of the function + * among the update function of other components. Lower priority is executed + * first. If no priority is given, the function is executed immediately. + */ +function useUpdate({ func, priority }) { + const component = Component.current; + onMounted(onUpdate); + onPatched(onUpdate); + function onUpdate() { + if (priority === undefined) { + func(); + return; + } + addFunctionToQueue({ component, func, priority }); + } +} + +return useUpdate; + +}); 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 28e7f08a1fb..27d6bb68b68 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 = { ThreadView: require('mail/static/src/components/thread_view/thread_view.js'), }; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const { isEventHandled } = require('mail/static/src/utils/utils.js'); const { Component } = owl; @@ -29,6 +30,7 @@ class ChatWindow extends Component { thread: thread ? thread.__state : undefined, }; }); + useUpdate({ func: () => this._update() }); /** * Reference of the header of the chat window. * Useful to prevent click on header from wrongly focusing the window. @@ -55,11 +57,6 @@ class ChatWindow extends Component { mounted() { this.env.messagingBus.on('will_hide_home_menu', this, this._onWillHideHomeMenu.bind(this)); this.env.messagingBus.on('will_show_home_menu', this, this._onWillShowHomeMenu.bind(this)); - this._update(); - } - - patched() { - this._update(); } willUnmount() { @@ -132,7 +129,16 @@ class ChatWindow extends Component { * @private */ _saveThreadScrollTop() { - if (!this._threadRef.comp || !this.chatWindow.threadViewer) { + if ( + !this._threadRef.comp || + !this.chatWindow.threadViewer || + !this.chatWindow.threadViewer.threadView + ) { + return; + } + if (this.chatWindow.threadViewer.threadView.componentHintList.length > 0) { + // the current scroll position is likely incorrect due to the + // presence of hints to adjust it return; } this.chatWindow.threadViewer.saveThreadCacheScrollHeightAsInitial( 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 1a8ded7815a..d8c62e31a87 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 @@ -797,11 +797,8 @@ QUnit.test('[technical] chat window: composer state conservation on toggle home QUnit.test('[technical] chat window: scroll conservation on toggle home menu', async function (assert) { // technical as show/hide home menu simulation are involved and home menu implementation // have side-effects on DOM that may make chat window components not work - assert.expect(3); + assert.expect(2); - - // channel that is expected to be found in the messaging menu - // with random unique id that is needed to link messages this.data['mail.channel'].records.push({ id: 20 }); for (let i = 0; i < 10; i++) { this.data['mail.message'].records.push({ @@ -811,9 +808,19 @@ QUnit.test('[technical] chat window: scroll conservation on toggle home menu', a } await this.start(); await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); - await afterNextRender(() => - document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() - ); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_NotificationList_preview').click(), + message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); // Set a scroll position to chat window await this.afterEvent({ eventName: 'o-component-message-list-scrolled', @@ -829,12 +836,6 @@ QUnit.test('[technical] chat window: scroll conservation on toggle home menu', a ); }, }); - assert.strictEqual( - document.querySelector(`.o_ThreadView_messageList`).scrollTop, - 142, - "chat window initial scrollTop should be 142px" - ); - await afterNextRender(() => this.hideHomeMenu()); assert.strictEqual( document.querySelector(`.o_ThreadView_messageList`).scrollTop, @@ -845,7 +846,7 @@ QUnit.test('[technical] chat window: scroll conservation on toggle home menu', a await this.afterEvent({ eventName: 'o-component-message-list-scrolled', func: () => this.showHomeMenu(), - message: "should wait until channel 20 restored its scroll to 142 after hiding the home menu", + message: "should wait until channel 20 restored its scroll to 142 after showing the home menu", predicate: ({ scrollTop, threadViewer }) => { return ( threadViewer.thread.model === 'mail.channel' && @@ -1575,9 +1576,19 @@ QUnit.test('chat window with a thread: keep scroll position in message list on f } await this.start(); await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); - await afterNextRender(() => - document.querySelector(`.o_NotificationList_preview`).click() - ); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_NotificationList_preview').click(), + message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); // Set a scroll position to chat window await this.afterEvent({ eventName: 'o-component-message-list-scrolled', @@ -1752,7 +1763,7 @@ QUnit.test('[technical] chat window: composer state conservation on toggle home QUnit.test('[technical] chat window with a thread: keep scroll position in message list on toggle home menu when folded', async function (assert) { // technical as show/hide home menu simulation are involved and home menu implementation // have side-effects on DOM that may make chat window components not work - assert.expect(3); + assert.expect(2); // channel that is expected to be found in the messaging menu // with random unique id, needed to link messages @@ -1765,22 +1776,48 @@ QUnit.test('[technical] chat window with a thread: keep scroll position in messa } await this.start(); await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click()); - await afterNextRender(() => - document.querySelector(`.o_MessagingMenu_dropdownMenu .o_NotificationList_preview`).click() - ); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_NotificationList_preview').click(), + message: "should wait until channel 20 scrolled to its last message after opening it from the messaging menu", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); // Set a scroll position to chat window - document.querySelector(`.o_ThreadView_messageList`).scrollTop = 142; - assert.strictEqual( - document.querySelector(`.o_ThreadView_messageList`).scrollTop, - 142, - "should have scrolled to 142px" - ); - + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector(`.o_ThreadView_messageList`).scrollTop = 142, + message: "should wait until channel 20 scrolled to 142 after setting this value manually", + predicate: ({ scrollTop, threadViewer }) => { + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === 142 + ); + }, + }); // fold chat window await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); await this.hideHomeMenu(); // unfold chat window - await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_ChatWindow_header').click(), + message: "should wait until channel 20 restored its scroll to 142 after unfolding it", + predicate: ({ scrollTop, threadViewer }) => { + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === 142 + ); + }, + }); assert.strictEqual( document.querySelector(`.o_ThreadView_messageList`).scrollTop, 142, @@ -1792,7 +1829,18 @@ QUnit.test('[technical] chat window with a thread: keep scroll position in messa // Show home menu await this.showHomeMenu(); // unfold chat window - await afterNextRender(() => document.querySelector('.o_ChatWindow_header').click()); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => document.querySelector('.o_ChatWindow_header').click(), + message: "should wait until channel 20 restored its scroll position to the last saved value (142)", + predicate: ({ scrollTop, threadViewer }) => { + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === 142 + ); + }, + }); assert.strictEqual( document.querySelector(`.o_ThreadView_messageList`).scrollTop, 142, @@ -1927,8 +1975,8 @@ QUnit.test('new message separator is shown in a chat window of a chat on receivi }); this.data['mail.channel'].records = [ { - id: 10, channel_type: "chat", + id: 10, is_minimized: true, is_pinned: false, members: [this.data.currentPartnerId, 10], @@ -1953,8 +2001,8 @@ QUnit.test('new message separator is shown in a chat window of a chat on receivi context: { mockedUserId: 42, }, - uuid: 'channel-10-uuid', message_content: "hu", + uuid: 'channel-10-uuid', }, })); assert.containsOnce( @@ -1987,28 +2035,28 @@ QUnit.test('focusing a chat window of a chat should make new message separator d name: "Foreigner user", partner_id: 10, }); - this.data['mail.channel'].records = [ + this.data['mail.channel'].records.push( { - id: 10, channel_type: "chat", + id: 10, is_minimized: true, is_pinned: false, members: [this.data.currentPartnerId, 10], message_unread_counter: 0, uuid: 'channel-10-uuid', }, - ]; + ); await this.start(); // simulate receiving a message - await afterNextRender(async () => this.env.services.rpc({ + await afterNextRender(() => this.env.services.rpc({ route: '/mail/chat_post', params: { context: { mockedUserId: 42, }, - uuid: 'channel-10-uuid', message_content: "hu", + uuid: 'channel-10-uuid', }, })); assert.containsOnce( @@ -2017,7 +2065,17 @@ QUnit.test('focusing a chat window of a chat should make new message separator d "should display 'new messages' separator in the conversation, from reception of new messages" ); - await afterNextRender(() => document.querySelector('.o_ComposerTextInput_textarea').focus()); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => document.querySelector('.o_ComposerTextInput_textarea').focus(), + message: "should wait until last seen by current partner message id changed", + predicate: ({ thread }) => { + return ( + thread.id === 10 && + thread.model === 'mail.channel' + ); + }, + })); assert.containsNone( document.body, '.o_MessageList_separatorNewMessages', diff --git a/addons/mail/static/src/components/chatter/chatter.js b/addons/mail/static/src/components/chatter/chatter.js index d4314108b87..8c19c3ace07 100644 --- a/addons/mail/static/src/components/chatter/chatter.js +++ b/addons/mail/static/src/components/chatter/chatter.js @@ -9,6 +9,7 @@ const components = { ThreadView: require('mail/static/src/components/thread_view/thread_view.js'), }; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const { Component } = owl; const { useRef } = owl.hooks; @@ -37,6 +38,7 @@ class Chatter extends Component { attachments: 1, }, }); + useUpdate({ func: () => this._update() }); /** * Reference of the composer. Useful to focus it. */ @@ -47,14 +49,6 @@ class Chatter extends Component { this._threadRef = useRef('thread'); } - mounted() { - this._update(); - } - - patched() { - this._update(); - } - //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- @@ -84,6 +78,9 @@ class Chatter extends Component { * @private */ _update() { + if (!this.chatter) { + return; + } if (this.chatter.thread) { this._notifyRendered(); } diff --git a/addons/mail/static/src/components/chatter_container/chatter_container.js b/addons/mail/static/src/components/chatter_container/chatter_container.js index 39331939da8..19c847d01d9 100644 --- a/addons/mail/static/src/components/chatter_container/chatter_container.js +++ b/addons/mail/static/src/components/chatter_container/chatter_container.js @@ -5,6 +5,7 @@ const components = { Chatter: require('mail/static/src/components/chatter/chatter.js'), }; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const { clear } = require('mail/static/src/model/model_field_command.js'); const { Component } = owl; @@ -37,10 +38,7 @@ class ChatterContainer extends Component { } return { chatter: this.chatter }; }); - } - - mounted() { - this._update(); + useUpdate({ func: () => this._update() }); } /** @@ -53,10 +51,6 @@ class ChatterContainer extends Component { return super.willUpdateProps(...arguments); } - patched() { - this._update(); - } - /** * @override */ diff --git a/addons/mail/static/src/components/composer/composer.js b/addons/mail/static/src/components/composer/composer.js index 6c9965a57d1..4434697f8fb 100644 --- a/addons/mail/static/src/components/composer/composer.js +++ b/addons/mail/static/src/components/composer/composer.js @@ -11,6 +11,7 @@ const components = { ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'), }; const useDragVisibleDropZone = require('mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); const { isEventHandled, @@ -38,6 +39,7 @@ class Composer extends Component { : undefined, }; }); + useUpdate({ func: () => this._update() }); /** * Reference of the emoji popover. Useful to include emoji popover as * contained "inside" the composer. @@ -61,11 +63,6 @@ class Composer extends Component { mounted() { document.addEventListener('click', this._onClickCaptureGlobal, true); - this._update(); - } - - patched() { - this._update(); } willUnmount() { @@ -201,6 +198,9 @@ class Composer extends Component { * @private */ _update() { + if (!this.composer) { + return; + } if (this._subjectRef.el) { this._subjectRef.el.value = this.composer.subjectContent; } diff --git a/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js index cbc215a3b7e..f9b3944454b 100644 --- a/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js +++ b/addons/mail/static/src/components/composer_suggested_recipient/composer_suggested_recipient.js @@ -2,6 +2,7 @@ odoo.define('mail/static/src/components/composer_suggested_recipient/composer_su 'use strict'; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const { FormViewDialog } = require('web.view_dialogs'); const { ComponentAdapter } = require('web.OwlCompatibility'); @@ -38,6 +39,7 @@ class ComposerSuggestedRecipient extends Component { suggestedRecipientInfo: suggestedRecipientInfo && suggestedRecipientInfo.__state, }; }); + useUpdate({ func: () => this._update() }); /** * Form view dialog class. Useful to reference it in the template. */ @@ -62,14 +64,6 @@ class ComposerSuggestedRecipient extends Component { this._onDialogSaved = this._onDialogSaved.bind(this); } - mounted() { - this._update(); - } - - patched() { - this._update(); - } - //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- diff --git a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js index 80043eaf057..308dee76821 100644 --- a/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js +++ b/addons/mail/static/src/components/composer_suggestion/composer_suggestion.js @@ -2,6 +2,7 @@ odoo.define('mail/static/src/components/composer_suggestion/composer_suggestion. 'use strict'; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const components = { PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), @@ -24,14 +25,7 @@ class ComposerSuggestion extends Component { record: record ? record.__state : undefined, }; }); - } - - mounted() { - this._update(); - } - - patched() { - this._update(); + useUpdate({ func: () => this._update() }); } //-------------------------------------------------------------------------- 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 c2cc133417a..5afb99c4990 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 @@ -2,6 +2,7 @@ odoo.define('mail/static/src/components/composer_text_input/composer_text_input. 'use strict'; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const components = { ComposerSuggestionList: require('mail/static/src/components/composer_suggestion_list/composer_suggestion_list.js'), @@ -28,6 +29,11 @@ class ComposerTextInput extends Component { isDeviceMobile: this.env.messaging.device.isMobile, }; }); + /** + * Updates the composer text input content when composer is mounted + * as textarea content can't be changed from the DOM. + */ + useUpdate({ func: () => this._update() }); /** * Last content of textarea from input event. Useful to determine * whether the current partner is typing something. @@ -39,22 +45,6 @@ class ComposerTextInput extends Component { this._textareaRef = useRef('textarea'); } - /** - * Updates the composer text input content when composer is mounted - * as textarea content can't be changed from the DOM. - */ - mounted() { - this._update(); - } - - /** - * Updates the composer text input content when composer has changed - * as textarea content can't be changed from the DOM. - */ - patched() { - this._update(); - } - //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- @@ -153,6 +143,9 @@ class ComposerTextInput extends Component { * @private */ _update() { + if (!this.composer) { + return; + } this._textareaRef.el.value = this.composer.textInputContent; this._textareaRef.el.setSelectionRange( this.composer.textInputCursorStart, diff --git a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml index dfa7bbd1182..493acbb1b4f 100644 --- a/addons/mail/static/src/components/composer_text_input/composer_text_input.xml +++ b/addons/mail/static/src/components/composer_text_input/composer_text_input.xml @@ -10,7 +10,7 @@ isBelow="props.hasMentionSuggestionsBelowPosition" /> - + diff --git a/addons/mail/static/src/components/discuss/discuss.js b/addons/mail/static/src/components/discuss/discuss.js index 377fde0dd72..75904de8dfd 100644 --- a/addons/mail/static/src/components/discuss/discuss.js +++ b/addons/mail/static/src/components/discuss/discuss.js @@ -13,6 +13,7 @@ const components = { ThreadView: require('mail/static/src/components/thread_view/thread_view.js'), }; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const { Component } = owl; const { useRef } = owl.hooks; @@ -44,6 +45,7 @@ class Discuss extends Component { uncheckedMessages: 1, }, }); + useUpdate({ func: () => this._update() }); this._updateLocalStoreProps(); /** * Reference of the composer. Useful to focus it. @@ -66,7 +68,6 @@ class Discuss extends Component { this.discuss.openInitThread(); } this._updateLocalStoreProps(); - this._update(); } patched() { @@ -85,7 +86,6 @@ class Discuss extends Component { } this._activeThreadCache = this.discuss.threadView && this.discuss.threadView.threadCache; this._updateLocalStoreProps(); - this._update(); } willUnmount() { @@ -146,6 +146,9 @@ class Discuss extends Component { * @private */ _update() { + if (!this.discuss) { + return; + } if (this.discuss.isDoFocus) { this.discuss.update({ isDoFocus: false }); const composer = this._composerRef.comp; diff --git a/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js index 21146652d28..26431207467 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_domain_tests.js @@ -63,6 +63,7 @@ QUnit.test('discuss should filter messages based on given domain', async functio predicate: ({ hint, threadViewer }) => { return ( hint.type === 'messages-loaded' && + hint.data.fetchedMessages.length === 1 && threadViewer.thread.model === 'mail.box' && threadViewer.thread.id === 'inbox' ); @@ -197,8 +198,8 @@ QUnit.test('discuss should refresh filtered thread on receiving new message', as func: () => this.env.services.rpc({ route: '/mail/chat_post', params: { - uuid: channel.uuid, message_content: "test", + uuid: channel.uuid, }, }), message: "should wait until channel 20 refreshed its filtered message list", @@ -294,8 +295,8 @@ QUnit.test('discuss should refresh filtered thread on changing thread', async fu await this.env.services.rpc({ route: '/mail/chat_post', params: { - uuid: channel20.uuid, message_content: "test", + uuid: channel20.uuid, }, }); assert.containsNone( diff --git a/addons/mail/static/src/components/discuss/tests/discuss_tests.js b/addons/mail/static/src/components/discuss/tests/discuss_tests.js index 481e78681bc..ff7fcd47f8e 100644 --- a/addons/mail/static/src/components/discuss/tests/discuss_tests.js +++ b/addons/mail/static/src/components/discuss/tests/discuss_tests.js @@ -1706,6 +1706,18 @@ QUnit.test('auto-scroll to bottom of thread', async function (assert) { default_active_id: 'mail.channel_20', }, }, + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until channel 20 scrolled to its last message initially", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }, }); assert.strictEqual( document.querySelectorAll(` @@ -1716,8 +1728,8 @@ QUnit.test('auto-scroll to bottom of thread', async function (assert) { ); const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); assert.strictEqual( - messageList.scrollTop + messageList.clientHeight, - messageList.scrollHeight, + messageList.scrollTop, + messageList.scrollHeight - messageList.clientHeight, "should have scrolled to bottom of thread" ); }); @@ -1726,7 +1738,6 @@ QUnit.test('load more messages from channel (auto-load on scroll)', async functi // AKU TODO: thread specific test assert.expect(3); - // channel expected to be rendered, with a random unique id that will be referenced in the test this.data['mail.channel'].records.push({ id: 20 }); for (let i = 0; i < 40; i++) { this.data['mail.message'].records.push({ @@ -1742,6 +1753,18 @@ QUnit.test('load more messages from channel (auto-load on scroll)', async functi default_active_id: 'mail.channel_20', }, }, + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until channel 20 scrolled to its last message initially", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }, }); assert.strictEqual( document.querySelectorAll(` @@ -1751,8 +1774,17 @@ QUnit.test('load more messages from channel (auto-load on scroll)', async functi "should have 30 messages" ); - await afterNextRender(() => { - document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop = 0; + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => document.querySelector('.o_ThreadView_messageList').scrollTop = 0, + message: "should wait until channel 20 loaded more messages after scrolling to top", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'more-messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, }); assert.strictEqual( document.querySelectorAll(` @@ -1809,6 +1841,18 @@ QUnit.test('new messages separator [REQUIRE FOCUS]', async function (assert) { default_active_id: 'mail.channel_20', }, }, + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until channel 20 scrolled to its last message initially", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }, }); assert.containsN( document.body, @@ -1845,8 +1889,8 @@ QUnit.test('new messages separator [REQUIRE FOCUS]', async function (assert) { context: { mockedUserId: 42, }, - uuid: 'randomuuid', message_content: "hu", + uuid: 'randomuuid', }, })); @@ -1899,11 +1943,9 @@ QUnit.test('restore thread scroll position', async function (assert) { this.data['mail.channel'].records.push( { id: 11, - seen_message_id: 50, // prevent race condition with mark as seen (task-2372339) }, { id: 12, - seen_message_id: 50, // prevent race condition with mark as seen (task-2372339) }, ); for (let i = 1; i <= 25; i++) { @@ -1948,8 +1990,8 @@ QUnit.test('restore thread scroll position', async function (assert) { .o_ThreadView_messageList `); assert.strictEqual( - initialMessageList.scrollTop + initialMessageList.clientHeight, - initialMessageList.scrollHeight, + initialMessageList.scrollTop, + initialMessageList.scrollHeight - initialMessageList.clientHeight, "should have scrolled to bottom of channel 11 initially" ); @@ -1986,8 +2028,13 @@ QUnit.test('restore thread scroll position', async function (assert) { `).click(); }, message: "should wait until channel 12 scrolled to its last message", - predicate: ({ threadViewer }) => { - return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 12; + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 12 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); }, }); assert.strictEqual( @@ -2013,8 +2060,12 @@ QUnit.test('restore thread scroll position', async function (assert) { `).click(); }, message: "should wait until channel 11 restored its scroll position", - predicate: ({ threadViewer }) => { - return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 11; + predicate: ({ scrollTop, threadViewer }) => { + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 && + scrollTop === 0 + ); }, }); assert.strictEqual( @@ -2037,18 +2088,20 @@ QUnit.test('restore thread scroll position', async function (assert) { }"] `).click(); }, - message: "should wait until channel 12 recovered its scroll position", - predicate: ({ threadViewer }) => { - return threadViewer.thread.model === 'mail.channel' && threadViewer.thread.id === 12; + message: "should wait until channel 12 recovered its scroll position (to bottom)", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 12 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); }, }); - const messageList = document.querySelector(` - .o_Discuss_thread - .o_ThreadView_messageList - `); + const messageList = document.querySelector('.o_ThreadView_messageList'); assert.strictEqual( - messageList.scrollTop + messageList.clientHeight, - messageList.scrollHeight, + messageList.scrollTop, + messageList.scrollHeight - messageList.clientHeight, "should have recovered scroll position of channel 12 (scroll to bottom)" ); }); @@ -3259,6 +3312,7 @@ QUnit.test('mark channel as seen on last message visible [REQUIRE FOCUS]', async // and a random unique id that will be referenced in the test this.data['mail.channel'].records.push({ id: 10, message_unread_counter: 1 }); this.data['mail.message'].records.push({ + id: 12, body: "not empty", channel_ids: [10], model: 'mail.channel', @@ -3288,16 +3342,27 @@ QUnit.test('mark channel as seen on last message visible [REQUIRE FOCUS]', async "sidebar item of channel ID 10 should be unread" ); - await afterNextRender(() => - document.querySelector(` - .o_DiscussSidebar_item[data-thread-local-id="${ - this.env.models['mail.thread'].findFromIdentifyingData({ - id: 10, - model: 'mail.channel', - }).localId - }"] - `).click() - ); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).click(); + }, + message: "should wait until last seen by current partner message id changed", + predicate: ({ thread }) => { + return ( + thread.id === 10 && + thread.model === 'mail.channel' && + thread.lastSeenByCurrentPartnerMessageId === 12 + ); + }, + })); assert.doesNotHaveClass( document.querySelector(` .o_DiscussSidebar_item[data-thread-local-id="${ @@ -3591,16 +3656,28 @@ QUnit.test('load recent messages from thread (already loaded some old messages)' "the only message initially should be the one marked as 'needaction'" ); - await afterNextRender(() => - document.querySelector(` - .o_DiscussSidebar_item[data-thread-local-id="${ - this.env.models['mail.thread'].find(thread => - thread.id === 20 && - thread.model === 'mail.channel' - ).localId - }"] - `).click() - ); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].find(thread => + thread.id === 20 && + thread.model === 'mail.channel' + ).localId + }"] + `).click(); + }, + message: "should wait until channel scrolled to bottom after opening it from the discuss sidebar", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector('.o_ThreadView_messageList'); + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }); assert.strictEqual( document.querySelectorAll('.o_Message').length, 31, @@ -3617,8 +3694,17 @@ QUnit.test('load recent messages from thread (already loaded some old messages)' "should display the message from Inbox inside the channel too" ); - await afterNextRender(() => { - document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`).scrollTop = 0; + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => document.querySelector('.o_Discuss_thread .o_ThreadView_messageList').scrollTop = 0, + message: "should wait until channel 20 loaded more messages after scrolling to top", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'more-messages-loaded' && + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 20 + ); + }, }); assert.strictEqual( document.querySelectorAll('.o_Message').length, @@ -3908,7 +3994,20 @@ QUnit.test('all messages in "Inbox" in "History" after marked all as read', asyn }); } - await this.start(); + await this.start({ + waitUntilEvent: { + eventName: 'o-component-message-list-scrolled', + message: "should wait until inbox scrolled to its last message initially", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector(`.o_Discuss_thread .o_ThreadView_messageList`); + return ( + threadViewer.thread.model === 'mail.box' && + threadViewer.thread.id === 'inbox' && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, + }, + }); assert.containsN( document.body, '.o_Message', @@ -3926,12 +4025,24 @@ QUnit.test('all messages in "Inbox" in "History" after marked all as read', asyn "there should no message in Inbox anymore" ); - await afterNextRender(async () => { - document.querySelector(` - .o_DiscussSidebarItem[data-thread-local-id="${ - this.env.messaging.history.localId - }"] - `).click(); + await this.afterEvent({ + eventName: 'o-component-message-list-scrolled', + func: () => { + document.querySelector(` + .o_DiscussSidebarItem[data-thread-local-id="${ + this.env.messaging.history.localId + }"] + `).click(); + }, + message: "should wait until history scrolled to its last message after opening it from the discuss sidebar", + predicate: ({ scrollTop, threadViewer }) => { + const messageList = document.querySelector('.o_MessageList'); + return ( + threadViewer.thread.model === 'mail.box' && + threadViewer.thread.id === 'history' && + scrollTop === messageList.scrollHeight - messageList.clientHeight + ); + }, }); assert.containsN( document.body, @@ -3941,8 +4052,17 @@ QUnit.test('all messages in "Inbox" in "History" after marked all as read', asyn ); // simulate a scroll to top to load more messages - await afterNextRender(async () => { - document.querySelector('.o_MessageList').scrollTop = 0; + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => document.querySelector('.o_MessageList').scrollTop = 0, + message: "should wait until mailbox history loaded more messages after scrolling to top", + predicate: ({ hint, threadViewer }) => { + return ( + hint.type === 'more-messages-loaded' && + threadViewer.thread.model === 'mail.box' && + threadViewer.thread.id === 'history' + ); + }, }); assert.containsN( document.body, @@ -4240,20 +4360,42 @@ QUnit.test('mark channel as seen if last message is visible when switching chann await this.start({ discuss: { context: { - active_id: 'mail.channel_11', + active_id: 'mail.channel_11', + }, + }, + waitUntilEvent: { + eventName: 'o-thread-view-hint-processed', + message: "should wait until channel 11 loaded its messages initially", + predicate: ({ hint, threadViewer }) => { + return ( + threadViewer.thread.model === 'mail.channel' && + threadViewer.thread.id === 11 && + hint.type === 'messages-loaded' + ); }, }, }); - await afterNextRender(() => { - document.querySelector(` - .o_DiscussSidebar_item[data-thread-local-id="${ - this.env.models['mail.thread'].findFromIdentifyingData({ - id: 10, - model: 'mail.channel', - }).localId - }"] - `).click(); - }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => { + document.querySelector(` + .o_DiscussSidebar_item[data-thread-local-id="${ + this.env.models['mail.thread'].findFromIdentifyingData({ + id: 10, + model: 'mail.channel', + }).localId + }"] + `).click(); + }, + message: "should wait until last seen by current partner message id changed", + predicate: ({ thread }) => { + return ( + thread.id === 10 && + thread.model === 'mail.channel' && + thread.lastSeenByCurrentPartnerMessageId === 10 + ); + }, + })); assert.doesNotHaveClass( document.querySelector(` .o_DiscussSidebar_item[data-thread-local-id="${ diff --git a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js index d5eaf72618d..0d567df080b 100644 --- a/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js +++ b/addons/mail/static/src/components/discuss_sidebar/discuss_sidebar.js @@ -6,6 +6,7 @@ const components = { DiscussSidebarItem: require('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js'), }; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const { Component } = owl; const { useRef } = owl.hooks; @@ -21,7 +22,7 @@ class DiscussSidebar extends Component { (...args) => this._useStoreSelector(...args), { compareDepth: () => this._useStoreCompareDepth() } ); - + useUpdate({ func: () => this._update() }); /** * Reference of the quick search input. Useful to filter channels and * chats based on this input content. @@ -35,14 +36,6 @@ class DiscussSidebar extends Component { this._onAddChatAutocompleteSource = this._onAddChatAutocompleteSource.bind(this); } - mounted() { - this._update(); - } - - patched() { - this._update(); - } - //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- @@ -140,6 +133,9 @@ class DiscussSidebar extends Component { * @private */ _update() { + if (!this.discuss) { + return; + } if (this._quickSearchInputRef.el) { this._quickSearchInputRef.el.value = this.discuss.sidebarQuickSearchValue; } 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 9259b24bee5..9bda844a030 100644 --- a/addons/mail/static/src/components/emojis_popover/emojis_popover.js +++ b/addons/mail/static/src/components/emojis_popover/emojis_popover.js @@ -2,6 +2,7 @@ odoo.define('mail/static/src/components/emojis_popover/emojis_popover.js', funct 'use strict'; const emojis = require('mail.emojis'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const { Component } = owl; @@ -13,15 +14,7 @@ class EmojisPopover extends Component { constructor(...args) { super(...args); this.emojis = emojis; - } - - - mounted() { - this._update(); - } - - patched() { - this._update(); + useUpdate({ func: () => this._update() }); } //-------------------------------------------------------------------------- diff --git a/addons/mail/static/src/components/message/message.js b/addons/mail/static/src/components/message/message.js index 641c6cc9563..c0f6e1dea40 100644 --- a/addons/mail/static/src/components/message/message.js +++ b/addons/mail/static/src/components/message/message.js @@ -11,7 +11,7 @@ const components = { PartnerImStatusIcon: require('mail/static/src/components/partner_im_status_icon/partner_im_status_icon.js'), }; const useStore = require('mail/static/src/component_hooks/use_store/use_store.js'); -const { timeFromNow } = require('mail.utils'); +const useUpdate = require('mail/static/src/component_hooks/use_update/use_update.js'); const { _lt } = require('web.core'); const { getLangDatetimeFormat } = require('web.time'); @@ -41,10 +41,6 @@ class Message extends Component { * clicked state, it keeps displaying the commands. */ isClicked: false, - /** - * Time elapsed from message datetime to current datetime. - */ - timeElapsed: null, }); useStore(props => { const message = this.env.models['mail.message'].get(props.messageLocalId); @@ -78,6 +74,7 @@ class Message extends Component { notifications: 1, }, }); + useUpdate({ func: () => this._update() }); /** * The intent of the reply button depends on the last rendered state. */ @@ -103,14 +100,6 @@ class Message extends Component { */ _constructor() {} - mounted() { - this._update(); - } - - patched() { - this._update(); - } - willUnmount() { clearInterval(this._intervalId); } @@ -388,6 +377,9 @@ class Message extends Component { * @private */ _update() { + if (!this.message) { + return; + } // Remove all readmore before if any before reinsert them with _insertReadMoreLess. // This is needed because _insertReadMoreLess is working with direct DOM mutations // which are not sync with Owl. @@ -396,14 +388,15 @@ class Message extends Component { el.remove(); } this._insertReadMoreLess($(this._contentRef.el)); + this.env.messagingBus.trigger('o-component-message-read-more-less-inserted', { + message: this.message, + }); } this._wasSelected = this.props.isSelected; - if (!this.state.timeElapsed) { - this.state.timeElapsed = timeFromNow(this.message.date); - } + this.message.refreshDateFromNow(); clearInterval(this._intervalId); this._intervalId = setInterval(() => { - this.state.timeElapsed = timeFromNow(this.message.date); + this.message.refreshDateFromNow(); }, 60 * 1000); } diff --git a/addons/mail/static/src/components/message/message.xml b/addons/mail/static/src/components/message/message.xml index 29cb10207d7..300130f6518 100644 --- a/addons/mail/static/src/components/message/message.xml +++ b/addons/mail/static/src/components/message/message.xml @@ -74,7 +74,7 @@
fdsfsd
", - author_id: [10, "Fred"], - model: "mail.channel", - channel_ids: [100], - }] - ]; - await afterNextRender(() => - this.widget.call('bus_service', 'trigger', 'notification', notifications) - ); + // simulate receiving a message + await this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 10, + }, + message_content: "fdsfsd
", + uuid: thread.uuid, + }, + }), + message: "should wait until last seen by current partner message id changed after receiving a message while thread is focused", + predicate: ({ thread }) => { + return ( + thread.id === 100 && + thread.model === 'mail.channel' + ); + }, + }); assert.verifySteps( ['rpc:channel_seen'], "Channel should have been mark as seen directly" @@ -489,11 +544,11 @@ QUnit.test('[technical] new messages separator on posting message', async functi ); }); -QUnit.test('new messages separator on receiving new message', async function (assert) { +QUnit.test('new messages separator on receiving new message [REQUIRE FOCUS]', async function (assert) { assert.expect(6); this.data['res.partner'].records.push({ - id:11, + id: 11, name: "Foreigner partner", }); this.data['res.users'].records.push({ @@ -539,16 +594,27 @@ QUnit.test('new messages separator on receiving new message', async function (as document.querySelector('.o_ComposerTextInput_textarea').blur(); // simulate receiving a message - await afterNextRender(async () => this.env.services.rpc({ - route: '/mail/chat_post', - params: { - context: { - mockedUserId: 42, + await this.afterEvent({ + eventName: 'o-thread-view-hint-processed', + func: () => this.env.services.rpc({ + route: '/mail/chat_post', + params: { + context: { + mockedUserId: 42, + }, + message_content: "hu", + uuid: thread.uuid, }, - uuid: thread.uuid, - message_content: "hu", + }), + message: "should wait until new message is received", + predicate: ({ hint, threadViewer }) => { + return ( + threadViewer.thread.id === 20 && + threadViewer.thread.model === 'mail.channel' && + hint.type === 'message-received' + ); }, - })); + }); assert.containsN( document.body, '.o_Message', @@ -564,12 +630,22 @@ QUnit.test('new messages separator on receiving new message', async function (as assert.containsOnce( document.body, `.o_MessageList_separatorNewMessages ~ .o_Message[data-message-local-id="${ - this.env.models['mail.message'].findFromIdentifyingData({id: 2}).localId + this.env.models['mail.message'].findFromIdentifyingData({ id: 2 }).localId }"]`, "'new messages' separator should be shown above new message received" ); - await afterNextRender(() => document.querySelector('.o_ComposerTextInput_textarea').focus()); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-thread-last-seen-by-current-partner-message-id-changed', + func: () => document.querySelector('.o_ComposerTextInput_textarea').focus(), + message: "should wait until last seen by current partner message id changed after focusing the thread", + predicate: ({ thread }) => { + return ( + thread.id === 20 && + thread.model === 'mail.channel' + ); + }, + })); assert.containsNone( document.body, '.o_MessageList_separatorNewMessages', @@ -626,94 +702,6 @@ QUnit.test('new messages separator on posting message', async function (assert) ); }); -QUnit.test('new messages separator on receiving new message', async function (assert) { - assert.expect(6); - - this.data['res.partner'].records.push({ - id:11, - name: "Foreigner partner", - }); - this.data['res.users'].records.push({ - id: 42, - name: "Foreigner user", - partner_id: 11, - }); - this.data['mail.channel'].records.push({ - channel_type: 'channel', - id: 20, - is_pinned: true, - message_unread_counter: 0, - name: "General", - seen_message_id: 1, - uuid: 'randomuuid', - }); - this.data['mail.message'].records.push({ - body: "blah", - channel_ids: [20], - id: 1, - }); - await this.start(); - const thread = this.env.models['mail.thread'].findFromIdentifyingData({ - id: 20, - model: 'mail.channel' - }); - const threadViewer = this.env.models['mail.thread_viewer'].create({ - hasThreadView: true, - thread: [['link', thread]], - }); - await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true }); - - assert.containsOnce( - document.body, - '.o_MessageList_message', - "should have an initial message" - ); - assert.containsNone( - document.body, - '.o_MessageList_separatorNewMessages', - "should not display 'new messages' separator" - ); - - document.querySelector('.o_ComposerTextInput_textarea').blur(); - // simulate receiving a message - await afterNextRender(async () => this.env.services.rpc({ - route: '/mail/chat_post', - params: { - context: { - mockedUserId: 42, - }, - uuid: thread.uuid, - message_content: "hu", - }, - })); - assert.containsN( - document.body, - '.o_Message', - 2, - "should now have 2 messages after receiving a new message" - ); - assert.containsOnce( - document.body, - '.o_MessageList_separatorNewMessages', - "'new messages' separator should be shown" - ); - - assert.containsOnce( - document.body, - `.o_MessageList_separatorNewMessages ~ .o_Message[data-message-local-id="${ - this.env.models['mail.message'].findFromIdentifyingData({id: 2}).localId - }"]`, - "'new messages' separator should be shown above new message received" - ); - - await afterNextRender(() => document.querySelector('.o_ComposerTextInput_textarea').focus()); - assert.containsNone( - document.body, - '.o_MessageList_separatorNewMessages', - "'new messages' separator should no longer be shown as last message has been seen" - ); -}); - QUnit.test('basic rendering of canceled notification', async function (assert) { assert.expect(8); @@ -861,8 +849,8 @@ QUnit.test('should scroll to bottom on receiving new message if the list is init context: { mockedUserId: 42, }, - uuid: thread.uuid, message_content: "hello", + uuid: thread.uuid, }, }), message: "should wait until channel 20 scrolled after receiving a message", @@ -944,8 +932,8 @@ QUnit.test('should not scroll on receiving new message if the list is initially context: { mockedUserId: 42, }, - uuid: thread.uuid, message_content: "hello", + uuid: thread.uuid, }, }), message: "should wait until channel 20 processed new message hint", @@ -1720,8 +1708,8 @@ QUnit.test('first unseen message should be directly preceded by the new message context: { mockedUserId: 42, }, - uuid: 'channel20uuid', message_content: "test", + uuid: 'channel20uuid', }, })); assert.containsN( diff --git a/addons/mail/static/src/models/message/message.js b/addons/mail/static/src/models/message/message.js index 6ca7cf4a5bd..c3318aa8906 100644 --- a/addons/mail/static/src/models/message/message.js +++ b/addons/mail/static/src/models/message/message.js @@ -4,7 +4,8 @@ odoo.define('mail/static/src/models/message/message.js', function (require) { const emojis = require('mail.emojis'); const { registerNewModel } = require('mail/static/src/model/model_core.js'); const { attr, many2many, many2one, one2many } = require('mail/static/src/model/model_field.js'); -const { addLink, htmlToTextContentInline, parseAndTransform } = require('mail.utils'); +const { clear } = require('mail/static/src/model/model_field_command.js'); +const { addLink, htmlToTextContentInline, parseAndTransform, timeFromNow } = require('mail.utils'); const { str_to_datetime } = require('web.time'); @@ -314,6 +315,13 @@ function factory(dependencies) { }); } + /** + * Refreshes the value of `dateFromNow` field to the "current now". + */ + refreshDateFromNow() { + this.update({ dateFromNow: this._computeDateFromNow() }); + } + /** * Action to initiate reply to current message in Discuss Inbox. Assumes * that Discuss and Inbox are already opened. @@ -360,6 +368,16 @@ function factory(dependencies) { return `${this.modelName}_${data.id}`; } + /** + * @returns {string} + */ + _computeDateFromNow() { + if (!this.date) { + return clear(); + } + return timeFromNow(this.date); + } + /** * @returns {boolean} */ @@ -516,6 +534,15 @@ function factory(dependencies) { date: attr({ default: moment(), }), + /** + * States the time elapsed since date up to now. + */ + dateFromNow: attr({ + compute: '_computeDateFromNow', + dependencies: [ + 'date', + ], + }), email_from: attr(), failureNotifications: one2many('mail.notification', { compute: '_computeFailureNotifications', diff --git a/addons/mail/static/src/models/thread/thread.js b/addons/mail/static/src/models/thread/thread.js index ca44630013e..176a8afb501 100644 --- a/addons/mail/static/src/models/thread/thread.js +++ b/addons/mail/static/src/models/thread/thread.js @@ -1425,6 +1425,15 @@ function factory(dependencies) { this._currentPartnerLastNotifiedIsTyping = isTyping; } + /** + * @private + */ + _onChangeLastSeenByCurrentPartnerMessageId() { + this.env.messagingBus.trigger('o-thread-last-seen-by-current-partner-message-id-changed', { + thread: this, + }); + } + /** * Handles change of pinned state coming from the server. Useful to * clear pending state once server acknowledged the change. @@ -1842,6 +1851,16 @@ function factory(dependencies) { compute: '_computeNeedactionMessages', dependencies: ['messages'], }), + /** + * Not a real field, used to trigger `_onChangeLastSeenByCurrentPartnerMessageId` when one of + * the dependencies changes. + */ + onChangeLastSeenByCurrentPartnerMessageId: attr({ + compute: '_onChangeLastSeenByCurrentPartnerMessageId', + dependencies: [ + 'lastSeenByCurrentPartnerMessageId', + ], + }), /** * Not a real field, used to trigger `_onIsServerPinnedChanged` when one of * the dependencies changes. diff --git a/addons/mail/static/src/models/thread_cache/thread_cache.js b/addons/mail/static/src/models/thread_cache/thread_cache.js index c684861e2be..5ca4443df5f 100644 --- a/addons/mail/static/src/models/thread_cache/thread_cache.js +++ b/addons/mail/static/src/models/thread_cache/thread_cache.js @@ -30,13 +30,13 @@ function factory(dependencies) { extraDomain: [['id', '<', Math.min(...messageIds)]], limit, })); - for (const threadView of this.threadViews) { - threadView.addComponentHint('more-messages-loaded', { fetchedMessages }); - } this.update({ isLoadingMore: false }); if (fetchedMessages.length < limit) { this.update({ isAllHistoryLoaded: true }); } + for (const threadView of this.threadViews) { + threadView.addComponentHint('more-messages-loaded', { fetchedMessages }); + } return fetchedMessages; } @@ -165,7 +165,7 @@ function factory(dependencies) { * @returns {mail.message[]} */ _computeNonEmptyMessages() { - return [['replace', this.messages.filter(message => !message.isEmpty)]] + return [['replace', this.messages.filter(message => !message.isEmpty)]]; } /** diff --git a/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js index 0984885ddb4..19f5381be37 100644 --- a/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js +++ b/addons/mail/static/src/widgets/form_renderer/form_renderer_tests.js @@ -40,7 +40,8 @@ QUnit.module('form_renderer_tests.js', { }, viewParams, ); - const { env, widget } = await start(viewArgs, ...args); + const { afterEvent, env, widget } = await start(viewArgs, ...args); + this.afterEvent = afterEvent; this.env = env; this.widget = widget; }); @@ -639,6 +640,11 @@ QUnit.test('read more/less links are not duplicated when switching from read to `, + waitUntilEvent: { + eventName: 'o-component-message-read-more-less-inserted', + message: "should wait until read more/less is inserted initially", + predicate: ({ message }) => message.id === 1000, + }, }); assert.containsOnce( document.body, @@ -655,19 +661,24 @@ QUnit.test('read more/less links are not duplicated when switching from read to '.o_Message_readMoreLess', "there should be only one read more" ); - - await afterNextRender(() => { - document.querySelector('.o_form_button_edit').click(); - }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-component-message-read-more-less-inserted', + func: () => document.querySelector('.o_form_button_edit').click(), + message: "should wait until read more/less is inserted after clicking on edit", + predicate: ({ message }) => message.id === 1000, + })); assert.containsOnce( document.body, '.o_Message_readMoreLess', "there should still be only one read more after switching to edit mode" ); - await afterNextRender(() => { - document.querySelector('.o_form_button_cancel').click(); - }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-component-message-read-more-less-inserted', + func: () => document.querySelector('.o_form_button_cancel').click(), + message: "should wait until read more/less is inserted after canceling edit", + predicate: ({ message }) => message.id === 1000, + })); assert.containsOnce( document.body, '.o_Message_readMoreLess', @@ -718,6 +729,11 @@ QUnit.test('read more links becomes read less after being clicked', async functi `, + waitUntilEvent: { + eventName: 'o-component-message-read-more-less-inserted', + message: "should wait until read more/less is inserted initially", + predicate: ({ message }) => message.id === 1000, + }, }); assert.containsOnce( document.body, @@ -740,9 +756,12 @@ QUnit.test('read more links becomes read less after being clicked', async functi "read more/less link should contain 'read more' as text" ); - await afterNextRender(() => { - document.querySelector('.o_form_button_edit').click(); - }); + await afterNextRender(() => this.afterEvent({ + eventName: 'o-component-message-read-more-less-inserted', + func: () => document.querySelector('.o_form_button_edit').click(), + message: "should wait until read more/less is inserted after clicking on edit", + predicate: ({ message }) => message.id === 1000, + })); assert.strictEqual( document.querySelector('.o_Message_readMoreLess').textContent, 'read more', diff --git a/addons/mail/static/tests/helpers/mock_models.js b/addons/mail/static/tests/helpers/mock_models.js index d9101cf1f71..c2617b6108e 100644 --- a/addons/mail/static/tests/helpers/mock_models.js +++ b/addons/mail/static/tests/helpers/mock_models.js @@ -236,6 +236,7 @@ class MockModels { }, 'res.fake': { fields: { + activity_ids: { string: "Activities", type: 'one2many', relation: 'mail.activity' }, email_cc: { type: 'char' }, partner_ids: { string: "Related partners", diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js index 9f39f0eb9f1..e78ce39fd31 100644 --- a/addons/mail/static/tests/helpers/mock_server.js +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -872,7 +872,7 @@ MockServer.include({ const members = channel.members.map(memberId => this._getRecords('res.partner', [['id', '=', memberId]])[0].name); let message = "You are alone in this channel."; if (members.length > 0) { - message = `Users in this channel: ${members.join(', ')} and you` + message = `Users in this channel: ${members.join(', ')} and you`; } const notification = [ ["dbName", 'res.partner', this.currentPartnerId], @@ -987,7 +987,7 @@ MockServer.include({ this._mockWrite('mail.channel', [ [channel.id], { message_unread_counter: (channel.message_unread_counter || 0) + 1 }, - ]) + ]); } return messageId; }, diff --git a/addons/mail/views/assets.xml b/addons/mail/views/assets.xml index acf1f74928b..69a46cc7a9a 100644 --- a/addons/mail/views/assets.xml +++ b/addons/mail/views/assets.xml @@ -59,6 +59,7 @@ +