[FIX] mail: fix race conditions, especially with scroll

The `_update` of some components must take place before the `_update`
of other (usually sibling) components that might directly influence their
layout. This is in particular the case for `_update` that are relying on height.

Prevent force save of scroll position if there are pending hints to adjust it,
and prevent pending hints from overriding new scroll value.

Prevent flicker with message date.

This should fix all known issues related to incorrectly saving or restoring
scroll position.

task-2344226
task-2358066
task-2369332
task-2372339
task-2373741

closes odoo/odoo#61791

X-original-commit: 2002c6a599a685f4a18fc670a9f7992c8d595c0f
Related: odoo/enterprise#14759
Signed-off-by: Alexandre Kühn (aku) <aku@odoo.com>
This commit is contained in:
Sébastien Theys
2020-11-16 15:40:10 +00:00
parent c65e284dcc
commit 91bbdf41f4
27 changed files with 675 additions and 379 deletions
@@ -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;
});
@@ -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(
@@ -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',
@@ -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();
}
@@ -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
*/
@@ -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;
}
@@ -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
//--------------------------------------------------------------------------
@@ -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() });
}
//--------------------------------------------------------------------------
@@ -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,
@@ -10,7 +10,7 @@
isBelow="props.hasMentionSuggestionsBelowPosition"
/>
</t>
<textarea class="o_ComposerTextInput_textarea" t-att-class="{ 'o-composer-is-compact': props.isCompact }" t-esc="composer.textInputContent" t-att-placeholder="textareaPlaceholder" t-on-focusin="_onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
<textarea class="o_ComposerTextInput_textarea" t-att-class="{ 'o-composer-is-compact': props.isCompact }" style="height: 0;" t-esc="composer.textInputContent" t-att-placeholder="textareaPlaceholder" t-on-focusin="_onFocusinTextarea" t-on-focusout="_onFocusoutTextarea" t-on-keydown="_onKeydownTextarea" t-on-keyup="_onKeyupTextarea" t-on-input="_onInputTextarea" t-ref="textarea"/>
</t>
</div>
</t>
@@ -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;
@@ -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(
@@ -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="${
@@ -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;
}
@@ -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() });
}
//--------------------------------------------------------------------------
@@ -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);
}
@@ -74,7 +74,7 @@
</div>
</t>
<div class="o_Message_date o_Message_headerDate" t-att-class="{ 'o-message-selected': props.isSelected }" t-att-title="datetime">
- <t t-esc="state.timeElapsed"/>
- <t t-esc="message.dateFromNow"/>
</div>
<t t-if="message.isCurrentPartnerAuthor and threadView and threadView.thread">
<MessageSeenIndicator class="o_Message_seenIndicator" messageLocalId="message.localId" threadLocalId="threadView.thread.localId"/>
@@ -6,6 +6,7 @@ const components = {
};
const useRefs = require('mail/static/src/component_hooks/use_refs/use_refs.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;
@@ -36,6 +37,10 @@ class MessageList extends Component {
},
});
this._getRefs = useRefs();
useUpdate({
func: () => this._update(),
priority: 150, // must be executed after adjust (from thread view)
});
/**
* Determine whether the auto-scroll on load is active or not. This
* is useful to disable some times, such as when mounting message list
@@ -55,11 +60,7 @@ class MessageList extends Component {
* Snapshot computed during willPatch, which is used by patched.
*/
this._willPatchSnapshot = undefined;
this.onScroll = _.throttle(this.onScroll.bind(this), 100);
}
mounted() {
this._update();
this._onScrollThrottled = _.throttle(this._onScrollThrottled.bind(this), 100);
}
willPatch() {
@@ -73,10 +74,6 @@ class MessageList extends Component {
};
}
patched() {
this._update();
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -113,6 +110,9 @@ class MessageList extends Component {
if (!this.threadView) {
return;
}
if (!this.el) {
return;
}
for (const hint of this.threadView.componentHintList) {
switch (hint.type) {
case 'change-of-thread-cache':
@@ -561,6 +561,22 @@ class MessageList extends Component {
* @param {ScrollEvent} ev
*/
onScroll(ev) {
if (!this.threadView) {
return;
}
// Clear pending hints to prevent them from potentially overriding the
// new scroll position.
for (const hint of this.threadView.componentHintList) {
this.threadView.markComponentHintProcessed(hint);
}
this._onScrollThrottled(ev);
}
/**
* @private
* @param {ScrollEvent} ev
*/
_onScrollThrottled(ev) {
if (!this.el) {
// could be unmounted in the meantime (due to throttled behavior)
return;
@@ -6,6 +6,7 @@ const components = {
MessageList: require('mail/static/src/components/message_list/message_list.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;
@@ -18,6 +19,10 @@ class ThreadView extends Component {
constructor(...args) {
super(...args);
useStore((...args) => this._useStoreSelector(...args));
useUpdate({
func: () => this._update(),
priority: 100, // must be executed after composer height adjust
});
/**
* Reference of the composer. Useful to set focus on composer when
* thread has the focus.
@@ -29,14 +34,6 @@ class ThreadView extends Component {
this._messageListRef = useRef('messageList');
}
mounted() {
this._update();
}
patched() {
this._update();
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -131,7 +131,18 @@ QUnit.test('message list desc order', async function (assert) {
hasThreadView: true,
thread: [['link', thread]],
});
await this.createThreadViewComponent(threadViewer.threadView, { order: 'desc' }, { isFixedSize: true });
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => this.createThreadViewComponent(threadViewer.threadView, { order: 'desc' }, { isFixedSize: true }),
message: "should wait until channel 100 loaded initial messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 100
);
},
});
const messageItems = document.querySelectorAll(`.o_MessageList_item`);
assert.notOk(
messageItems[0].classList.contains("o_MessageList_loadMore"),
@@ -148,9 +159,20 @@ QUnit.test('message list desc order', async function (assert) {
);
// scroll to bottom
await afterNextRender(() => {
document.querySelector(`.o_ThreadView_messageList`).scrollTop =
document.querySelector(`.o_ThreadView_messageList`).scrollHeight;
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => {
const messageList = document.querySelector('.o_ThreadView_messageList');
messageList.scrollTop = messageList.scrollHeight - messageList.clientHeight;
},
message: "should wait until channel 100 loaded more messages after scrolling to bottom",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'more-messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 100
);
},
});
assert.strictEqual(
document.querySelectorAll(`.o_Message`).length,
@@ -203,7 +225,18 @@ QUnit.test('message list asc order', async function (assert) {
hasThreadView: true,
thread: [['link', thread]],
});
await this.createThreadViewComponent(threadViewer.threadView, { order: 'asc' }, { isFixedSize: true });
await this.afterEvent({
eventName: 'o-thread-view-hint-processed',
func: () => this.createThreadViewComponent(threadViewer.threadView, { order: 'asc' }, { isFixedSize: true }),
message: "should wait until channel 100 loaded initial messages",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 100
);
},
});
const messageItems = document.querySelectorAll(`.o_MessageList_item`);
assert.notOk(
messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"),
@@ -220,8 +253,17 @@ QUnit.test('message list asc order', async function (assert) {
);
// scroll to top
await afterNextRender(() => {
document.querySelector(`.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 100 loaded more messages after scrolling to top",
predicate: ({ hint, threadViewer }) => {
return (
hint.type === 'more-messages-loaded' &&
threadViewer.thread.model === 'mail.channel' &&
threadViewer.thread.id === 100
);
},
});
assert.strictEqual(
document.querySelectorAll(`.o_Message`).length,
@@ -304,8 +346,8 @@ QUnit.test('mark channel as fetched when a new message is loaded and as seen whe
context: {
mockedUserId: 10,
},
uuid: thread.uuid,
message_content: "new message",
uuid: thread.uuid,
},
}));
assert.verifySteps(
@@ -313,7 +355,17 @@ QUnit.test('mark channel as fetched when a new message is loaded and as seen whe
"Channel should have been fetched but not seen yet"
);
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 === 100 &&
thread.model === 'mail.channel'
);
},
}));
assert.verifySteps(
['rpc:channel_seen'],
"Channel should have been marked as seen after threadView got the focus"
@@ -323,6 +375,16 @@ QUnit.test('mark channel as fetched when a new message is loaded and as seen whe
QUnit.test('mark channel as fetched and seen when a new message is loaded if composer is focused [REQUIRE FOCUS]', async function (assert) {
assert.expect(4);
this.data['res.partner'].records.push({
id: 10,
});
this.data['res.users'].records.push({
id: 10,
partner_id: 10,
});
this.data['mail.channel'].records.push({
id: 100,
});
await this.start({
mockRPC(route, args) {
if (args.method === 'channel_fetched' && args.args[0] === 100) {
@@ -343,44 +405,37 @@ QUnit.test('mark channel as fetched and seen when a new message is loaded if com
return this._super(...arguments);
}
});
const thread = this.env.models['mail.thread'].create({
const thread = this.env.models['mail.thread'].findFromIdentifyingData({
id: 100,
isServerPinned: true, // just to avoid joinChannel to be called
members: [['insert', [
{
email: "john@example.com",
id: this.env.messaging.currentPartner.id,
name: "John",
},
{
email: "fred@example.com",
id: 10,
name: "Fred",
},
]]],
model: 'mail.channel',
serverMessageUnreadCounter: 1, // seen would not be called if not > 0
});
const threadViewer = this.env.models['mail.thread_viewer'].create({
hasThreadView: true,
thread: [['link', thread]],
});
await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
document.querySelector('.o_ComposerTextInput_textarea').focus();
const notifications = [
[['myDB', 'mail.channel', 100], {
channelId: 100,
id: 1,
body: "<p>fdsfsd</p>",
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: "<p>fdsfsd</p>",
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(
@@ -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',
@@ -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.
@@ -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)]];
}
/**
@@ -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
</div>
</form>
`,
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
</div>
</form>
`,
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',
@@ -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",
@@ -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;
},
+1
View File
@@ -59,6 +59,7 @@
<script type="text/javascript" src="/mail/static/src/component_hooks/use_drag_visible_dropzone/use_drag_visible_dropzone.js"></script>
<script type="text/javascript" src="/mail/static/src/component_hooks/use_refs/use_refs.js"></script>
<script type="text/javascript" src="/mail/static/src/component_hooks/use_store/use_store.js"></script>
<script type="text/javascript" src="/mail/static/src/component_hooks/use_update/use_update.js"/>
<script type="text/javascript" src="/mail/static/src/components/activity/activity.js"></script>
<script type="text/javascript" src="/mail/static/src/components/activity_box/activity_box.js"></script>
<script type="text/javascript" src="/mail/static/src/components/activity_mark_done_popover/activity_mark_done_popover.js"></script>