summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/components/thread_view
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/components/thread_view
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/mail/static/src/components/thread_view')
-rw-r--r--addons/mail/static/src/components/thread_view/thread_view.js222
-rw-r--r--addons/mail/static/src/components/thread_view/thread_view.scss38
-rw-r--r--addons/mail/static/src/components/thread_view/thread_view.xml50
-rw-r--r--addons/mail/static/src/components/thread_view/thread_view_tests.js1809
4 files changed, 2119 insertions, 0 deletions
diff --git a/addons/mail/static/src/components/thread_view/thread_view.js b/addons/mail/static/src/components/thread_view/thread_view.js
new file mode 100644
index 00000000..2399fd16
--- /dev/null
+++ b/addons/mail/static/src/components/thread_view/thread_view.js
@@ -0,0 +1,222 @@
+odoo.define('mail/static/src/components/thread_view/thread_view.js', function (require) {
+'use strict';
+
+const components = {
+ Composer: require('mail/static/src/components/composer/composer.js'),
+ MessageList: require('mail/static/src/components/message_list/message_list.js'),
+};
+const useShouldUpdateBasedOnProps = require('mail/static/src/component_hooks/use_should_update_based_on_props/use_should_update_based_on_props.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;
+
+class ThreadView extends Component {
+
+ /**
+ * @param {...any} args
+ */
+ constructor(...args) {
+ super(...args);
+ useShouldUpdateBasedOnProps();
+ useStore((...args) => this._useStoreSelector(...args), {
+ compareDepth: {
+ threadTextInputSendShortcuts: 1,
+ },
+ });
+ useUpdate({ func: () => this._update() });
+ /**
+ * Reference of the composer. Useful to set focus on composer when
+ * thread has the focus.
+ */
+ this._composerRef = useRef('composer');
+ /**
+ * Reference of the message list. Useful to determine scroll positions.
+ */
+ this._messageListRef = useRef('messageList');
+ }
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Focus the thread. If it has a composer, focus it.
+ */
+ focus() {
+ if (!this._composerRef.comp) {
+ return;
+ }
+ this._composerRef.comp.focus();
+ }
+
+ /**
+ * Focusout the thread.
+ */
+ focusout() {
+ if (!this._composerRef.comp) {
+ return;
+ }
+ this._composerRef.comp.focusout();
+ }
+
+ /**
+ * Get the scroll height in the message list.
+ *
+ * @returns {integer|undefined}
+ */
+ getScrollHeight() {
+ if (!this._messageListRef.comp) {
+ return undefined;
+ }
+ return this._messageListRef.comp.getScrollHeight();
+ }
+
+ /**
+ * Get the scroll position in the message list.
+ *
+ * @returns {integer|undefined}
+ */
+ getScrollTop() {
+ if (!this._messageListRef.comp) {
+ return undefined;
+ }
+ return this._messageListRef.comp.getScrollTop();
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ onScroll(ev) {
+ if (!this._messageListRef.comp) {
+ return;
+ }
+ this._messageListRef.comp.onScroll(ev);
+ }
+
+ /**
+ * @returns {mail.thread_view}
+ */
+ get threadView() {
+ return this.env.models['mail.thread_view'].get(this.props.threadViewLocalId);
+ }
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when thread component is mounted or patched.
+ *
+ * @private
+ */
+ _update() {
+ this.trigger('o-rendered');
+ }
+
+ /**
+ * Returns data selected from the store.
+ *
+ * @private
+ * @param {Object} props
+ * @returns {Object}
+ */
+ _useStoreSelector(props) {
+ const threadView = this.env.models['mail.thread_view'].get(props.threadViewLocalId);
+ const thread = threadView ? threadView.thread : undefined;
+ const threadCache = threadView ? threadView.threadCache : undefined;
+ const correspondent = thread && thread.correspondent;
+ return {
+ composer: thread && thread.composer,
+ correspondentId: correspondent && correspondent.id,
+ isDeviceMobile: this.env.messaging.device.isMobile,
+ thread,
+ threadCacheIsLoaded: threadCache && threadCache.isLoaded,
+ threadIsTemporary: thread && thread.isTemporary,
+ threadMassMailing: thread && thread.mass_mailing,
+ threadModel: thread && thread.model,
+ threadTextInputSendShortcuts: thread && thread.textInputSendShortcuts || [],
+ threadView,
+ threadViewIsLoading: threadView && threadView.isLoading,
+ };
+ }
+
+}
+
+Object.assign(ThreadView, {
+ components,
+ defaultProps: {
+ composerAttachmentsDetailsMode: 'auto',
+ hasComposer: false,
+ hasMessageCheckbox: false,
+ hasSquashCloseMessages: false,
+ haveMessagesMarkAsReadIcon: false,
+ haveMessagesReplyIcon: false,
+ isDoFocus: false,
+ order: 'asc',
+ showComposerAttachmentsExtensions: true,
+ showComposerAttachmentsFilenames: true,
+ },
+ props: {
+ composerAttachmentsDetailsMode: {
+ type: String,
+ validate: prop => ['auto', 'card', 'hover', 'none'].includes(prop),
+ },
+ hasComposer: Boolean,
+ hasComposerCurrentPartnerAvatar: {
+ type: Boolean,
+ optional: true,
+ },
+ hasComposerSendButton: {
+ type: Boolean,
+ optional: true,
+ },
+ /**
+ * If set, determines whether the composer should display status of
+ * members typing on related thread. When this prop is not provided,
+ * it defaults to composer component default value.
+ */
+ hasComposerThreadTyping: {
+ type: Boolean,
+ optional: true,
+ },
+ hasMessageCheckbox: Boolean,
+ hasScrollAdjust: {
+ type: Boolean,
+ optional: true,
+ },
+ hasSquashCloseMessages: Boolean,
+ haveMessagesMarkAsReadIcon: Boolean,
+ haveMessagesReplyIcon: Boolean,
+ /**
+ * Determines whether this should become focused.
+ */
+ isDoFocus: Boolean,
+ order: {
+ type: String,
+ validate: prop => ['asc', 'desc'].includes(prop),
+ },
+ selectedMessageLocalId: {
+ type: String,
+ optional: true,
+ },
+ /**
+ * Function returns the exact scrollable element from the parent
+ * to manage proper scroll heights which affects the load more messages.
+ */
+ getScrollableElement: {
+ type: Function,
+ optional: true,
+ },
+ showComposerAttachmentsExtensions: Boolean,
+ showComposerAttachmentsFilenames: Boolean,
+ threadViewLocalId: String,
+ },
+ template: 'mail.ThreadView',
+});
+
+return ThreadView;
+
+});
diff --git a/addons/mail/static/src/components/thread_view/thread_view.scss b/addons/mail/static/src/components/thread_view/thread_view.scss
new file mode 100644
index 00000000..0db54501
--- /dev/null
+++ b/addons/mail/static/src/components/thread_view/thread_view.scss
@@ -0,0 +1,38 @@
+// ------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------
+
+.o_ThreadView {
+ display: flex;
+ position: relative;
+ flex-flow: column;
+ overflow: auto;
+}
+
+.o_ThreadView_composer {
+ flex: 0 0 auto;
+}
+
+.o_ThreadView_loading {
+ display: flex;
+ align-self: center;
+ flex: 1 1 auto;
+ align-items: center;
+}
+
+.o_ThreadView_messageList {
+ flex: 1 1 auto;
+}
+
+// ------------------------------------------------------------------
+// Style
+// ------------------------------------------------------------------
+
+.o_ThreadView {
+ background-color: gray('100');
+
+}
+
+.o_ThreadView_loadingIcon {
+ margin-right: 3px;
+}
diff --git a/addons/mail/static/src/components/thread_view/thread_view.xml b/addons/mail/static/src/components/thread_view/thread_view.xml
new file mode 100644
index 00000000..8f06bf39
--- /dev/null
+++ b/addons/mail/static/src/components/thread_view/thread_view.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-name="mail.ThreadView" owl="1">
+ <div class="o_ThreadView" t-att-data-correspondent-id="threadView and threadView.thread and threadView.thread.correspondent and threadView.thread.correspondent.id" t-att-data-thread-local-id="threadView and threadView.thread and threadView.thread.localId">
+ <t t-if="threadView">
+ <t t-if="threadView.isLoading and !threadView.threadCache.isLoaded" name="loadingCondition">
+ <div class="o_ThreadView_loading">
+ <span><i class="o_ThreadView_loadingIcon fa fa-spinner fa-spin" title="Loading..." role="img"/>Loading...</span>
+ </div>
+ </t>
+ <t t-elif="threadView.threadCache.isLoaded or threadView.thread.isTemporary">
+ <MessageList
+ class="o_ThreadView_messageList"
+ getScrollableElement= "props.getScrollableElement"
+ hasMessageCheckbox="props.hasMessageCheckbox"
+ hasScrollAdjust="props.hasScrollAdjust"
+ hasSquashCloseMessages="props.hasSquashCloseMessages"
+ haveMessagesMarkAsReadIcon="props.haveMessagesMarkAsReadIcon"
+ haveMessagesReplyIcon="props.haveMessagesReplyIcon"
+ order="props.order"
+ selectedMessageLocalId="props.selectedMessageLocalId"
+ threadViewLocalId="threadView.localId"
+ t-ref="messageList"
+ />
+ </t>
+ <t t-elif="props.hasComposer">
+ <div class="o-autogrow"/>
+ </t>
+ <t t-if="props.hasComposer">
+ <Composer
+ class="o_ThreadView_composer"
+ attachmentsDetailsMode="props.composerAttachmentsDetailsMode"
+ composerLocalId="threadView.thread.composer.localId"
+ hasCurrentPartnerAvatar="props.hasComposerCurrentPartnerAvatar"
+ hasSendButton="props.hasComposerSendButton"
+ hasThreadTyping="props.hasComposerThreadTyping"
+ isCompact="(threadView.thread.model === 'mail.channel' and threadView.thread.mass_mailing) ? false : undefined"
+ isDoFocus="props.isDoFocus"
+ showAttachmentsExtensions="props.showComposerAttachmentsExtensions"
+ showAttachmentsFilenames="props.showComposerAttachmentsFilenames"
+ textInputSendShortcuts="threadView.textInputSendShortcuts"
+ t-ref="composer"
+ />
+ </t>
+ </t>
+ </div>
+ </t>
+
+</templates>
diff --git a/addons/mail/static/src/components/thread_view/thread_view_tests.js b/addons/mail/static/src/components/thread_view/thread_view_tests.js
new file mode 100644
index 00000000..58a05989
--- /dev/null
+++ b/addons/mail/static/src/components/thread_view/thread_view_tests.js
@@ -0,0 +1,1809 @@
+odoo.define('mail/static/src/components/thread_view/thread_view_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadView: require('mail/static/src/components/thread_view/thread_view.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ dragenterFiles,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('mail', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_view', {}, function () {
+QUnit.module('thread_view_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ /**
+ * @param {mail.thread_view} threadView
+ * @param {Object} [otherProps={}]
+ * @param {Object} [param2={}]
+ * @param {boolean} [param2.isFixedSize=false]
+ */
+ this.createThreadViewComponent = async (threadView, otherProps = {}, { isFixedSize = false } = {}) => {
+ let target;
+ if (isFixedSize) {
+ // needed to allow scrolling in some tests
+ const div = document.createElement('div');
+ Object.assign(div.style, {
+ display: 'flex',
+ 'flex-flow': 'column',
+ height: '300px',
+ });
+ this.widget.el.append(div);
+ target = div;
+ } else {
+ target = this.widget.el;
+ }
+ const props = Object.assign({ threadViewLocalId: threadView.localId }, otherProps);
+ await createRootComponent(this, components.ThreadView, { props, target });
+ };
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('dragover files on thread with composer', async function (assert) {
+ assert.expect(1);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [
+ {
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ },
+ {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ await afterNextRender(() =>
+ dragenterFiles(document.querySelector('.o_ThreadView'))
+ );
+ assert.ok(
+ document.querySelector('.o_Composer_dropZone'),
+ "should have dropzone when dragging file over the thread"
+ );
+});
+
+QUnit.test('message list desc order', async function (assert) {
+ assert.expect(5);
+
+ for (let i = 0; i <= 60; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ });
+ }
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [
+ {
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ },
+ {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ 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"),
+ "load more link should NOT be before messages"
+ );
+ assert.ok(
+ messageItems[messageItems.length - 1].classList.contains("o_MessageList_loadMore"),
+ "load more link should be after messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 30,
+ "should have 30 messages at the beginning"
+ );
+
+ // scroll to bottom
+ 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,
+ 60,
+ "should have 60 messages after scrolled to bottom"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop = 0;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "scrolling to top should not trigger any message fetching"
+ );
+});
+
+QUnit.test('message list asc order', async function (assert) {
+ assert.expect(5);
+
+ for (let i = 0; i <= 60; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ });
+ }
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ members: [['insert', [
+ {
+ email: "john@example.com",
+ id: 9,
+ name: "John",
+ },
+ {
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ },
+ ]]],
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ 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"),
+ "load more link should be before messages"
+ );
+ assert.ok(
+ messageItems[0].classList.contains("o_MessageList_loadMore"),
+ "load more link should NOT be after messages"
+ );
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 30,
+ "should have 30 messages at the beginning"
+ );
+
+ // scroll to top
+ 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,
+ 60,
+ "should have 60 messages after scrolled to top"
+ );
+
+ // scroll to bottom
+ await afterNextRender(() => {
+ document.querySelector(`.o_ThreadView_messageList`).scrollTop =
+ document.querySelector(`.o_ThreadView_messageList`).scrollHeight;
+ });
+ assert.strictEqual(
+ document.querySelectorAll(`.o_Message`).length,
+ 60,
+ "scrolling to bottom should not trigger any message fetching"
+ );
+});
+
+QUnit.test('mark channel as fetched when a new message is loaded and as seen when focusing composer [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(8);
+
+ this.data['res.partner'].records.push({
+ email: "fred@example.com",
+ id: 10,
+ name: "Fred",
+ });
+ this.data['res.users'].records.push({
+ id: 10,
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records.push({
+ channel_type: 'chat',
+ id: 100,
+ is_pinned: true,
+ members: [this.data.currentPartnerId, 10],
+ });
+ await this.start({
+ mockRPC(route, args) {
+ if (args.method === 'channel_fetched') {
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ 'channel_fetched is called on the right channel id'
+ );
+ assert.strictEqual(
+ args.model,
+ 'mail.channel',
+ 'channel_fetched is called on the right channel model'
+ );
+ assert.step('rpc:channel_fetch');
+ } else if (args.method === 'channel_seen') {
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ 'channel_seen is called on the right channel id'
+ );
+ assert.strictEqual(
+ args.model,
+ 'mail.channel',
+ 'channel_seeb is called on the right channel model'
+ );
+ assert.step('rpc:channel_seen');
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+ await afterNextRender(async () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 10,
+ },
+ message_content: "new message",
+ uuid: thread.uuid,
+ },
+ }));
+ assert.verifySteps(
+ ['rpc:channel_fetch'],
+ "Channel should have been fetched but not seen yet"
+ );
+
+ 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"
+ );
+});
+
+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) {
+ throw new Error("'channel_fetched' RPC must not be called for created channel as message is directly seen");
+ } else if (args.method === 'channel_seen') {
+ assert.strictEqual(
+ args.args[0][0],
+ 100,
+ 'channel_seen is called on the right channel id'
+ );
+ assert.strictEqual(
+ args.model,
+ 'mail.channel',
+ 'channel_seen is called on the right channel model'
+ );
+ assert.step('rpc:channel_seen');
+ }
+ return this._super(...arguments);
+ }
+ });
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 100,
+ model: 'mail.channel',
+ });
+ 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();
+ // 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"
+ );
+});
+
+QUnit.test('show message subject if thread is mailing channel', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [100],
+ model: 'mail.channel',
+ res_id: 100,
+ subject: "Salutations, voyageur",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'channel',
+ id: 100,
+ mass_mailing: true,
+ model: 'mail.channel',
+ name: "General",
+ public: 'public',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView);
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display a single message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_subject',
+ "should display subject of the message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_Message_subject').textContent,
+ "Subject: Salutations, voyageur",
+ "Subject of the message should be 'Salutations, voyageur'"
+ );
+});
+
+QUnit.test('[technical] new messages separator on posting message', async function (assert) {
+ // technical as we need to remove focus from text input to avoid `channel_seen` call
+ assert.expect(4);
+
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 20,
+ is_pinned: true,
+ message_unread_counter: 0,
+ seen_message_id: 10,
+ name: "General",
+ }];
+ this.data['mail.message'].records.push({
+ body: "first message",
+ channel_ids: [20],
+ id: 10,
+ });
+ 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_Message',
+ "should display one message in thread initially"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should not display 'new messages' separator"
+ );
+
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "hey !"));
+ await afterNextRender(() => {
+ // need to remove focus from text area to avoid channel_seen
+ document.querySelector('.o_Composer_buttonSend').focus();
+ document.querySelector('.o_Composer_buttonSend').click();
+
+ });
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 2,
+ "should display 2 messages (initial & newly posted), after posting a message"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "still no separator shown when current partner posted a message"
+ );
+});
+
+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,
+ 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 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,
+ },
+ }),
+ 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',
+ 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(() => 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',
+ "'new messages' separator should no longer be shown as last message has been seen"
+ );
+});
+
+QUnit.test('new messages separator on posting message', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records = [{
+ channel_type: 'channel',
+ id: 20,
+ is_pinned: true,
+ message_unread_counter: 0,
+ name: "General",
+ }];
+ 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.containsNone(
+ document.body,
+ '.o_MessageList_message',
+ "should have no messages"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "should not display 'new messages' separator"
+ );
+
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "hey !"));
+ await afterNextRender(() =>
+ document.querySelector('.o_Composer_buttonSend').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should have the message current partner just posted"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "still no separator shown when current partner posted a message"
+ );
+});
+
+QUnit.test('basic rendering of canceled notification', async function (assert) {
+ assert.expect(8);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['res.partner'].records.push({ id: 12, name: "Someone" });
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [11],
+ id: 10,
+ message_type: 'email',
+ model: 'mail.channel',
+ notification_ids: [11],
+ res_id: 11,
+ });
+ this.data['mail.notification'].records.push({
+ failure_type: 'SMTP',
+ id: 11,
+ mail_message_id: 10,
+ notification_status: 'canceled',
+ notification_type: 'email',
+ res_partner_id: 12,
+ });
+ await this.start();
+ const threadViewer = await this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIconClickable',
+ "should display the notification icon container on the message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message_notificationIcon',
+ "should display the notification icon on the message"
+ );
+ assert.hasClass(
+ document.querySelector('.o_Message_notificationIcon'),
+ 'fa-envelope-o',
+ "notification icon shown on the message should represent email"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector('.o_Message_notificationIconClickable').click();
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover',
+ "notification popover should be opened after notification has been clicked"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationIcon',
+ "an icon should be shown in notification popover"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationIcon.fa.fa-trash-o',
+ "the icon shown in notification popover should be the canceled icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_NotificationPopover_notificationPartnerName',
+ "partner name should be shown in notification popover"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_NotificationPopover_notificationPartnerName').textContent.trim(),
+ "Someone",
+ "partner name shown in notification popover should be the one concerned by the notification"
+ );
+});
+
+QUnit.test('should scroll to bottom on receiving new message if the list is initially scrolled to bottom (asc order)', async function (assert) {
+ assert.expect(2);
+
+ // Needed partner & user to allow simulation of message reception
+ 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({ id: 20 });
+ for (let i = 0; i <= 10; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ });
+ }
+ 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.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => this.createThreadViewComponent(
+ threadViewer.threadView,
+ { order: 'asc' },
+ { isFixedSize: true },
+ ),
+ message: "should wait until channel 20 scrolled initially",
+ predicate: data => threadViewer === data.threadViewer,
+ });
+ const initialMessageList = document.querySelector('.o_ThreadView_messageList');
+ assert.strictEqual(
+ initialMessageList.scrollTop,
+ initialMessageList.scrollHeight - initialMessageList.clientHeight,
+ "should have scrolled to bottom of channel 20 initially"
+ );
+
+ // simulate receiving a message
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () =>
+ this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hello",
+ uuid: thread.uuid,
+ },
+ }),
+ message: "should wait until channel 20 scrolled after receiving a message",
+ predicate: data => threadViewer === data.threadViewer,
+ });
+ const messageList = document.querySelector('.o_ThreadView_messageList');
+ assert.strictEqual(
+ messageList.scrollTop,
+ messageList.scrollHeight - messageList.clientHeight,
+ "should scroll to bottom on receiving new message because the list is initially scrolled to bottom"
+ );
+});
+
+QUnit.test('should not scroll on receiving new message if the list is initially scrolled anywhere else than bottom (asc order)', async function (assert) {
+ assert.expect(3);
+
+ // Needed partner & user to allow simulation of message reception
+ 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({ id: 20 });
+ for (let i = 0; i <= 10; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [20],
+ });
+ }
+ 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.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => this.createThreadViewComponent(
+ threadViewer.threadView,
+ { order: 'asc' },
+ { isFixedSize: true },
+ ),
+ message: "should wait until channel 20 scrolled initially",
+ predicate: data => threadViewer === data.threadViewer,
+ });
+ const initialMessageList = document.querySelector('.o_ThreadView_messageList');
+ assert.strictEqual(
+ initialMessageList.scrollTop,
+ initialMessageList.scrollHeight - initialMessageList.clientHeight,
+ "should have scrolled to bottom of channel 20 initially"
+ );
+
+ await this.afterEvent({
+ eventName: 'o-component-message-list-scrolled',
+ func: () => initialMessageList.scrollTop = 0,
+ message: "should wait until channel 20 processed manual scroll",
+ predicate: data => threadViewer === data.threadViewer,
+ });
+ assert.strictEqual(
+ initialMessageList.scrollTop,
+ 0,
+ "should have scrolled to the top of channel 20 manually"
+ );
+
+ // simulate receiving a message
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () =>
+ this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "hello",
+ uuid: thread.uuid,
+ },
+ }),
+ message: "should wait until channel 20 processed new message hint",
+ predicate: data => threadViewer === data.threadViewer && data.hint.type === 'message-received',
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadView_messageList').scrollTop,
+ 0,
+ "should not scroll on receiving new message because the list is initially scrolled anywhere else than bottom"
+ );
+});
+
+QUnit.test("delete all attachments of message without content should no longer display the message", async function (assert) {
+ assert.expect(2);
+
+ this.data['ir.attachment'].records.push({
+ id: 143,
+ mimetype: 'text/plain',
+ name: "Blah.txt",
+ });
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ attachment_ids: [143],
+ channel_ids: [11],
+ id: 101,
+ }
+ );
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', { id: 11, model: 'mail.channel' }]],
+ });
+ // wait for messages of the thread to be loaded
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be 1 message displayed initially"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Attachment[data-attachment-local-id="${
+ this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
+ }"] .o_Attachment_asideItemUnlink`).click();
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "message should no longer be displayed after removing all its attachments (empty content)"
+ );
+});
+
+QUnit.test('delete all attachments of a message with some text content should still keep it displayed', async function (assert) {
+ assert.expect(2);
+
+ this.data['ir.attachment'].records.push({
+ id: 143,
+ mimetype: 'text/plain',
+ name: "Blah.txt",
+ });
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ attachment_ids: [143],
+ body: "Some content",
+ channel_ids: [11],
+ id: 101,
+ },
+ );
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', { id: 11, model: 'mail.channel' }]],
+ });
+ // wait for messages of the thread to be loaded
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be 1 message displayed initially"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Attachment[data-attachment-local-id="${
+ this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
+ }"] .o_Attachment_asideItemUnlink`).click();
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "message should still be displayed after removing its attachments (non-empty content)"
+ );
+});
+
+QUnit.test('delete all attachments of a message with tracking fields should still keep it displayed', async function (assert) {
+ assert.expect(2);
+
+ this.data['ir.attachment'].records.push({
+ id: 143,
+ mimetype: 'text/plain',
+ name: "Blah.txt",
+ });
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ attachment_ids: [143],
+ channel_ids: [11],
+ id: 101,
+ tracking_value_ids: [6]
+ },
+ );
+ this.data['mail.tracking.value'].records.push({
+ changed_field: "Name",
+ field_type: "char",
+ id: 6,
+ new_value: "New name",
+ old_value: "Old name",
+ });
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', { id: 11, model: 'mail.channel' }]],
+ });
+ // wait for messages of the thread to be loaded
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "there should be 1 message displayed initially"
+ );
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_Attachment[data-attachment-local-id="${
+ this.env.models['mail.attachment'].findFromIdentifyingData({ id: 143 }).localId
+ }"] .o_Attachment_asideItemUnlink`).click();
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_AttachmentDeleteConfirmDialog_confirmButton').click()
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "message should still be displayed after removing its attachments (non-empty content)"
+ );
+});
+
+QUnit.test('Post a message containing an email address followed by a mention on another line', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['res.partner'].records.push({
+ id: 25,
+ email: "testpartner@odoo.com",
+ name: "TestPartner",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ 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();
+ await afterNextRender(() => document.execCommand('insertText', false, "email@odoo.com\n"));
+ await afterNextRender(() => {
+ ["@", "T", "e"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_mail_redirect[data-oe-id="25"][data-oe-model="res.partner"]:contains("@TestPartner")`,
+ "Conversation should have a message that has been posted, which contains partner mention"
+ );
+});
+
+QUnit.test(`Mention a partner with special character (e.g. apostrophe ')`, async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['res.partner'].records.push({
+ id: 1952,
+ email: "usatyi@example.com",
+ name: "Pynya's spokesman",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ 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();
+ await afterNextRender(() => {
+ ["@", "P", "y", "n"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_mail_redirect[data-oe-id="1952"][data-oe-model="res.partner"]:contains("@Pynya's spokesman")`,
+ "Conversation should have a message that has been posted, which contains partner mention"
+ );
+});
+
+QUnit.test('mention 2 different partners that have the same name', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['res.partner'].records.push(
+ {
+ id: 25,
+ email: "partner1@example.com",
+ name: "TestPartner",
+ }, {
+ id: 26,
+ email: "partner2@example.com",
+ name: "TestPartner",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ 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();
+ await afterNextRender(() => {
+ ["@", "T", "e"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[0].click());
+ await afterNextRender(() => {
+ ["@", "T", "e"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[1].click());
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
+ assert.containsOnce(document.body, '.o_Message_content', 'should have one message after posting it');
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_mail_redirect[data-oe-id="25"][data-oe-model="res.partner"]:contains("@TestPartner")`,
+ "message should contain the first partner mention"
+ );
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_mail_redirect[data-oe-id="26"][data-oe-model="res.partner"]:contains("@TestPartner")`,
+ "message should also contain the second partner mention"
+ );
+});
+
+QUnit.test('mention a channel with space in the name', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General good boy",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 7,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "#");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ '.o_channel_redirect',
+ "message must contain a link to the mentioned channel"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_channel_redirect').textContent,
+ '#General good boy',
+ "link to the channel must contains # + the channel name"
+ );
+});
+
+QUnit.test('mention a channel with "&" in the name', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General & good",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 7,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "#");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ await afterNextRender(() =>
+ document.querySelector('.o_ComposerSuggestion').click()
+ );
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ '.o_channel_redirect',
+ "message should contain a link to the mentioned channel"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_channel_redirect').textContent,
+ '#General & good',
+ "link to the channel must contains # + the channel name"
+ );
+});
+
+QUnit.test('mention a channel on a second line when the first line contains #', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General good",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 7,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "#blabla\n#");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_ComposerSuggestion').click();
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ '.o_channel_redirect',
+ "message should contain a link to the mentioned channel"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_channel_redirect').textContent,
+ '#General good',
+ "link to the channel must contains # + the channel name"
+ );
+});
+
+QUnit.test('mention a channel when replacing the space after the mention by another char', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 7,
+ name: "General good",
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 7,
+ model: 'mail.channel',
+ });
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['link', thread]],
+ });
+ await this.createThreadViewComponent(threadViewer.threadView, { hasComposer: true });
+
+ await afterNextRender(() => {
+ document.querySelector(`.o_ComposerTextInput_textarea`).focus();
+ document.execCommand('insertText', false, "#");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_ComposerSuggestion').click();
+ });
+ await afterNextRender(() => {
+ const text = document.querySelector(`.o_ComposerTextInput_textarea`).value;
+ document.querySelector(`.o_ComposerTextInput_textarea`).value = text.slice(0, -1);
+ document.execCommand('insertText', false, ", test");
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.containsOnce(
+ document.querySelector('.o_Message_content'),
+ '.o_channel_redirect',
+ "message should contain a link to the mentioned channel"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_channel_redirect').textContent,
+ '#General good',
+ "link to the channel must contains # + the channel name"
+ );
+});
+
+QUnit.test('mention 2 different channels that have the same name', async function (assert) {
+ assert.expect(3);
+
+ this.data['mail.channel'].records.push(
+ {
+ id: 11,
+ name: "my channel",
+ public: 'public', // mentioning another channel is possible only from a public channel
+ },
+ {
+ id: 12,
+ name: "my channel",
+ },
+ );
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ 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();
+ await afterNextRender(() => {
+ ["#", "m", "y"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[0].click());
+ await afterNextRender(() => {
+ ["#", "m", "y"].forEach((char)=>{
+ document.execCommand('insertText', false, char);
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keydown'));
+ document.querySelector(`.o_ComposerTextInput_textarea`)
+ .dispatchEvent(new window.KeyboardEvent('keyup'));
+ });
+ });
+ await afterNextRender(() => document.querySelectorAll('.o_ComposerSuggestion')[1].click());
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
+ assert.containsOnce(document.body, '.o_Message_content', 'should have one message after posting it');
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_channel_redirect[data-oe-id="11"][data-oe-model="mail.channel"]:contains("#my channel")`,
+ "message should contain the first channel mention"
+ );
+ assert.containsOnce(
+ document.querySelector(`.o_Message_content`),
+ `.o_channel_redirect[data-oe-id="12"][data-oe-model="mail.channel"]:contains("#my channel")`,
+ "message should also contain the second channel mention"
+ );
+});
+
+QUnit.test('show empty placeholder when thread contains no message', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ await this.start();
+ const threadViewer = await this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "should wait until thread becomes loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_empty',
+ "message list empty placeholder should be shown as thread does not contain any messages"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "no message should be shown as thread does not contain any"
+ );
+});
+
+QUnit.test('show empty placeholder when thread contains only empty messages', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [11],
+ id: 101,
+ },
+ );
+ await this.start();
+ const threadViewer = await this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "thread become loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_empty',
+ "message list empty placeholder should be shown as thread contain only empty messages"
+ );
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "no message should be shown as thread contains only empty ones"
+ );
+});
+
+QUnit.test('message with subtype should be displayed (and not considered as empty)', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({ id: 11 });
+ this.data['mail.message.subtype'].records.push({
+ description: "Task created",
+ id: 10,
+ });
+ this.data['mail.message'].records.push(
+ {
+ channel_ids: [11],
+ id: 101,
+ subtype_id: 10,
+ },
+ );
+ await this.start();
+ const threadViewer = await this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView);
+ },
+ message: "should wait until thread becomes loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_Message',
+ "should display 1 message (message with subtype description 'task created')"
+ );
+ assert.strictEqual(
+ document.body.querySelector('.o_Message_content').textContent,
+ "Task created",
+ "message should have 'Task created' (from its subtype description)"
+ );
+});
+
+QUnit.test('[technical] message list with a full page of empty messages should show load more if there are other messages', async function (assert) {
+ // Technical assumptions :
+ // - message_fetch fetching exactly 30 messages,
+ // - empty messages not being displayed
+ // - auto-load more being triggered on scroll, not automatically when the 30 first messages are empty
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ id: 11,
+ });
+ for (let i = 0; i <= 30; i++) {
+ this.data['mail.message'].records.push({
+ body: "not empty",
+ channel_ids: [11],
+ });
+ }
+ for (let i = 0; i <= 30; i++) {
+ this.data['mail.message'].records.push({
+ channel_ids: [11],
+ });
+ }
+ await this.start();
+ const threadViewer = this.env.models['mail.thread_viewer'].create({
+ hasThreadView: true,
+ thread: [['insert', {
+ id: 11,
+ model: 'mail.channel',
+ }]],
+ });
+ await this.afterEvent({
+ eventName: 'o-thread-view-hint-processed',
+ func: () => {
+ this.createThreadViewComponent(threadViewer.threadView, { order: 'asc' }, { isFixedSize: true });
+ },
+ message: "should wait until thread becomes loaded with messages",
+ predicate: ({ hint, threadViewer }) => {
+ return (
+ hint.type === 'messages-loaded' &&
+ threadViewer.thread.model === 'mail.channel' &&
+ threadViewer.thread.id === 11
+ );
+ },
+ });
+ assert.containsNone(
+ document.body,
+ '.o_Message',
+ "No message should be shown as all 30 first messages are empty"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_loadMore',
+ "Load more button should be shown as there are more messages to show"
+ );
+});
+
+QUnit.test('first unseen message should be directly preceded by the new message separator if there is a transient message just before it while composer is not focused [REQUIRE FOCUS]', async function (assert) {
+ // The goal of removing the focus is to ensure the thread is not marked as seen automatically.
+ // Indeed that would trigger channel_seen no matter what, which is already covered by other tests.
+ // The goal of this test is to cover the conditions specific to transient messages,
+ // and the conditions from focus would otherwise shadow them.
+ assert.expect(3);
+
+ this.data['mail.channel_command'].records.push({ name: 'who' });
+ // Needed partner & user to allow simulation of message reception
+ 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 = [{
+ channel_type: 'channel',
+ id: 20,
+ is_pinned: true,
+ name: "General",
+ uuid: 'channel20uuid',
+ }];
+ 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 });
+ // send a command that leads to receiving a transient message
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "/who"));
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+
+ // composer is focused by default, we remove that focus
+ document.querySelector('.o_ComposerTextInput_textarea').blur();
+ // simulate receiving a message
+ await afterNextRender(() => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: 42,
+ },
+ message_content: "test",
+ uuid: 'channel20uuid',
+ },
+ }));
+ assert.containsN(
+ document.body,
+ '.o_Message',
+ 2,
+ "should display 2 messages (the transient & the received message), after posting a command"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessageList_separatorNewMessages',
+ "separator should be shown as a message has been received"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_Message[data-message-local-id="${
+ this.env.models['mail.message'].find(m => m.isTransient).localId
+ }"] + .o_MessageList_separatorNewMessages`,
+ "separator should be shown just after transient message"
+ );
+});
+
+QUnit.test('composer should be focused automatically after clicking on the send button [REQUIRE FOCUS]', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push({id: 20,});
+ 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 });
+ document.querySelector('.o_ComposerTextInput_textarea').focus();
+ await afterNextRender(() => document.execCommand('insertText', false, "Dummy Message"));
+ await afterNextRender(() => {
+ document.querySelector('.o_Composer_buttonSend').click();
+ });
+ assert.hasClass(
+ document.querySelector('.o_Composer'),
+ 'o-focused',
+ "composer should be focused automatically after clicking on the send button"
+ );
+});
+
+});
+});
+});
+
+});