summaryrefslogtreecommitdiff
path: root/addons/im_livechat/static/src
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/im_livechat/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/im_livechat/static/src')
-rw-r--r--addons/im_livechat/static/src/bugfix/bugfix.js34
-rw-r--r--addons/im_livechat/static/src/bugfix/bugfix.scss6
-rw-r--r--addons/im_livechat/static/src/bugfix/bugfix.xml11
-rw-r--r--addons/im_livechat/static/src/bugfix/bugfix_tests.js18
-rw-r--r--addons/im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js73
-rw-r--r--addons/im_livechat/static/src/components/composer/composer.xml10
-rw-r--r--addons/im_livechat/static/src/components/composer/composer_tests.js68
-rw-r--r--addons/im_livechat/static/src/components/discuss/discuss.js29
-rw-r--r--addons/im_livechat/static/src/components/discuss/discuss_tests.js444
-rw-r--r--addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js78
-rw-r--r--addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml25
-rw-r--r--addons/im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js26
-rw-r--r--addons/im_livechat/static/src/components/messaging_menu/messaging_menu_tests.js100
-rw-r--r--addons/im_livechat/static/src/components/notification_list/notification_list.js36
-rw-r--r--addons/im_livechat/static/src/components/thread_icon/thread_icon.xml19
-rw-r--r--addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js96
-rw-r--r--addons/im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js28
-rw-r--r--addons/im_livechat/static/src/components/thread_preview/thread_preview.js28
-rw-r--r--addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js87
-rw-r--r--addons/im_livechat/static/src/img/canned-responses.gifbin0 -> 30131 bytes
-rw-r--r--addons/im_livechat/static/src/img/default.pngbin0 -> 16237 bytes
-rw-r--r--addons/im_livechat/static/src/js/ajax_external.js14
-rw-r--r--addons/im_livechat/static/src/js/im_livechat_channel_form_controller.js54
-rw-r--r--addons/im_livechat/static/src/js/im_livechat_channel_form_view.js19
-rw-r--r--addons/im_livechat/static/src/legacy/public_livechat.js3913
-rw-r--r--addons/im_livechat/static/src/legacy/public_livechat.scss677
-rw-r--r--addons/im_livechat/static/src/legacy/public_livechat.xml658
-rw-r--r--addons/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js33
-rw-r--r--addons/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js42
-rw-r--r--addons/im_livechat/static/src/models/partner/partner.js23
-rw-r--r--addons/im_livechat/static/src/models/thread/thread.js97
-rw-r--r--addons/im_livechat/static/src/scss/im_livechat_bootstrap.scss35
-rw-r--r--addons/im_livechat/static/src/scss/im_livechat_form.scss21
-rw-r--r--addons/im_livechat/static/src/scss/im_livechat_history.scss39
-rw-r--r--addons/im_livechat/static/src/widgets/discuss/discuss.js25
35 files changed, 6866 insertions, 0 deletions
diff --git a/addons/im_livechat/static/src/bugfix/bugfix.js b/addons/im_livechat/static/src/bugfix/bugfix.js
new file mode 100644
index 00000000..2a81f5e2
--- /dev/null
+++ b/addons/im_livechat/static/src/bugfix/bugfix.js
@@ -0,0 +1,34 @@
+/**
+ * This file allows introducing new JS modules without contaminating other files.
+ * This is useful when bug fixing requires adding such JS modules in stable
+ * versions of Odoo. Any module that is defined in this file should be isolated
+ * in its own file in master.
+ */
+odoo.define('im_livechat/static/src/bugfix/bugfix.js', function (require) {
+'use strict';
+
+const {
+ registerInstancePatchModel,
+} = require('mail/static/src/model/model_core.js');
+
+registerInstancePatchModel('mail.chat_window', 'im_livechat/static/src/models/chat_window/chat_window.js', {
+
+ /**
+ * @override
+ */
+ close({ notifyServer } = {}) {
+ if (
+ this.thread &&
+ this.thread.model === 'mail.channel' &&
+ this.thread.channel_type === 'livechat' &&
+ this.thread.mainCache.isLoaded &&
+ this.thread.messages.length === 0
+ ) {
+ notifyServer = true;
+ this.thread.unpin();
+ }
+ this._super({ notifyServer });
+ }
+});
+
+});
diff --git a/addons/im_livechat/static/src/bugfix/bugfix.scss b/addons/im_livechat/static/src/bugfix/bugfix.scss
new file mode 100644
index 00000000..c4272e52
--- /dev/null
+++ b/addons/im_livechat/static/src/bugfix/bugfix.scss
@@ -0,0 +1,6 @@
+/**
+* This file allows introducing new styles without contaminating other files.
+* This is useful when bug fixing requires adding new components for instance in
+* stable versions of Odoo. Any style that is defined in this file should be isolated
+* in its own file in master.
+*/
diff --git a/addons/im_livechat/static/src/bugfix/bugfix.xml b/addons/im_livechat/static/src/bugfix/bugfix.xml
new file mode 100644
index 00000000..c17906f7
--- /dev/null
+++ b/addons/im_livechat/static/src/bugfix/bugfix.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+<!--
+ This file allows introducing new static templates without contaminating other files.
+ This is useful when bug fixing requires adding new components for instance in stable
+ versions of Odoo. Any template that is defined in this file should be isolated
+ in its own file in master.
+-->
+
+</templates>
diff --git a/addons/im_livechat/static/src/bugfix/bugfix_tests.js b/addons/im_livechat/static/src/bugfix/bugfix_tests.js
new file mode 100644
index 00000000..33cfeacf
--- /dev/null
+++ b/addons/im_livechat/static/src/bugfix/bugfix_tests.js
@@ -0,0 +1,18 @@
+odoo.define('im_livechat/static/src/bugfix/bugfix_tests.js', function (require) {
+'use strict';
+
+/**
+ * This file allows introducing new QUnit test modules without contaminating
+ * other test files. This is useful when bug fixing requires adding new
+ * components for instance in stable versions of Odoo. Any test that is defined
+ * in this file should be isolated in its own file in master.
+ */
+QUnit.module('im_livechat', {}, function () {
+QUnit.module('bugfix', {}, function () {
+QUnit.module('bugfix_tests.js', {
+
+});
+});
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js b/addons/im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js
new file mode 100644
index 00000000..8d782924
--- /dev/null
+++ b/addons/im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js
@@ -0,0 +1,73 @@
+odoo.define('im_livechat/static/src/components/chat_window_manager/chat_window_manager_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('im_livechat', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('chat_window_manager', {}, function () {
+QUnit.module('chat_window_manager_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { afterEvent, env, widget } = await start(Object.assign(
+ { hasChatWindow: true, hasMessagingMenu: true },
+ params,
+ { data: this.data }
+ ));
+ this.debug = params && params.debug;
+ this.afterEvent = afterEvent;
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('closing a chat window with no message from admin side unpins it', async function (assert) {
+ assert.expect(1);
+
+ this.data['res.partner'].records.push({ id: 10, name: "Demo" });
+ this.data['res.users'].records.push({
+ id: 42,
+ partner_id: 10,
+ });
+ this.data['mail.channel'].records.push(
+ {
+ channel_type: "livechat",
+ id: 10,
+ is_pinned: true,
+ members: [this.data.currentPartnerId, 10],
+ uuid: 'channel-10-uuid',
+ },
+ );
+ await this.start();
+
+ await afterNextRender(() => document.querySelector(`.o_MessagingMenu_toggler`).click());
+ await afterNextRender(() => document.querySelector(`.o_NotificationList_preview`).click());
+ await afterNextRender(() => document.querySelector(`.o_ChatWindowHeader_commandClose`).click());
+ const channels = await this.env.services.rpc({
+ model: 'mail.channel',
+ method: 'read',
+ args: [10],
+ }, { shadow: true });
+ assert.strictEqual(
+ channels[0].is_pinned,
+ false,
+ 'Livechat channel should not be pinned',
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/composer/composer.xml b/addons/im_livechat/static/src/components/composer/composer.xml
new file mode 100644
index 00000000..52195220
--- /dev/null
+++ b/addons/im_livechat/static/src/components/composer/composer.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+
+ <t t-inherit="mail.Composer" t-inherit-mode="extension">
+ <xpath expr="//*[hasclass('o_Composer_buttonAttachment')]" position="replace">
+ <t t-if="!composer.thread or composer.thread.channel_type !== 'livechat'">$0</t>
+ </xpath>
+ </t>
+
+</templates>
diff --git a/addons/im_livechat/static/src/components/composer/composer_tests.js b/addons/im_livechat/static/src/components/composer/composer_tests.js
new file mode 100644
index 00000000..b0559b98
--- /dev/null
+++ b/addons/im_livechat/static/src/components/composer/composer_tests.js
@@ -0,0 +1,68 @@
+odoo.define('im_livechat/static/src/components/composer/composer_tests.js', function (require) {
+'use strict';
+
+const components = {
+ Composer: require('mail/static/src/components/composer/composer.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('im_livechat', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('composer', {}, function () {
+QUnit.module('composer_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createComposerComponent = async (composer, otherProps) => {
+ const ComposerComponent = components.Composer;
+ ComposerComponent.env = this.env;
+ this.component = new ComposerComponent(null, Object.assign({
+ composerLocalId: composer.localId,
+ }, otherProps));
+ delete ComposerComponent.env;
+ await afterNextRender(() => this.component.mount(this.widget.el));
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('livechat: no add attachment button', async function (assert) {
+ // Attachments are not yet supported in livechat, especially from livechat
+ // visitor PoV. This may likely change in the future with task-2029065.
+ assert.expect(2);
+
+ await this.start();
+ const thread = this.env.models['mail.thread'].create({
+ channel_type: 'livechat',
+ id: 10,
+ model: 'mail.channel',
+ });
+ await this.createComposerComponent(thread.composer);
+ assert.containsOnce(document.body, '.o_Composer', "should have a composer");
+ assert.containsNone(
+ document.body,
+ '.o_Composer_buttonAttachment',
+ "composer linked to livechat should not have a 'Add attachment' button"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/discuss/discuss.js b/addons/im_livechat/static/src/components/discuss/discuss.js
new file mode 100644
index 00000000..5b3c2664
--- /dev/null
+++ b/addons/im_livechat/static/src/components/discuss/discuss.js
@@ -0,0 +1,29 @@
+odoo.define('im_livechat/static/src/components/discuss/discuss.js', function (require) {
+'use strict';
+
+const components = {
+ Discuss: require('mail/static/src/components/discuss/discuss.js'),
+};
+
+const { patch } = require('web.utils');
+
+patch(components.Discuss, 'im_livechat/static/src/components/discuss/discuss.js', {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ mobileNavbarTabs(...args) {
+ return [...this._super(...args), {
+ icon: 'fa fa-comments',
+ id: 'livechat',
+ label: this.env._t("Livechat"),
+ }];
+ }
+
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/discuss/discuss_tests.js b/addons/im_livechat/static/src/components/discuss/discuss_tests.js
new file mode 100644
index 00000000..4cfd207f
--- /dev/null
+++ b/addons/im_livechat/static/src/components/discuss/discuss_tests.js
@@ -0,0 +1,444 @@
+odoo.define('im_livechat/static/src/components/discuss/discuss_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ nextAnimationFrame,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('im_livechat', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('discuss', {}, function () {
+QUnit.module('discuss_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ autoOpenDiscuss: true,
+ data: this.data,
+ hasDiscuss: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('livechat in the sidebar: basic rendering', async function (assert) {
+ assert.expect(5);
+
+ this.data['mail.channel'].records.push({
+ anonymous_name: "Visitor 11",
+ channel_type: 'livechat',
+ id: 11,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ });
+ await this.start();
+ assert.containsOnce(document.body, '.o_Discuss_sidebar',
+ "should have a sidebar section"
+ );
+ const groupLivechat = document.querySelector('.o_DiscussSidebar_groupLivechat');
+ assert.ok(groupLivechat,
+ "should have a channel group livechat"
+ );
+ const grouptitle = groupLivechat.querySelector('.o_DiscussSidebar_groupTitle');
+ assert.strictEqual(
+ grouptitle.textContent.trim(),
+ "Livechat",
+ "should have a channel group named 'Livechat'"
+ );
+ const livechat = groupLivechat.querySelector(`
+ .o_DiscussSidebarItem[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ }).localId
+ }"]
+ `);
+ assert.ok(
+ livechat,
+ "should have a livechat in sidebar"
+ );
+ assert.strictEqual(
+ livechat.textContent,
+ "Visitor 11",
+ "should have 'Visitor 11' as livechat name"
+ );
+});
+
+QUnit.test('livechat in the sidebar: existing user with country', async function (assert) {
+ assert.expect(3);
+
+ this.data['res.country'].records.push({
+ code: 'be',
+ id: 10,
+ name: "Belgium",
+ });
+ this.data['res.partner'].records.push({
+ country_id: 10,
+ id: 10,
+ name: "Jean",
+ });
+ this.data['mail.channel'].records.push({
+ channel_type: 'livechat',
+ id: 11,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, 10],
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should have a channel group livechat in the side bar"
+ );
+ const livechat = document.querySelector('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
+ assert.ok(
+ livechat,
+ "should have a livechat in sidebar"
+ );
+ assert.strictEqual(
+ livechat.textContent,
+ "Jean (Belgium)",
+ "should have user name and country as livechat name"
+ );
+});
+
+QUnit.test('do not add livechat in the sidebar on visitor opening his chat', async function (assert) {
+ assert.expect(2);
+
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ currentUser.im_status = 'online';
+ this.data['im_livechat.channel'].records.push({
+ id: 10,
+ user_ids: [this.data.currentUserId],
+ });
+ await this.start();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should not have any livechat in the sidebar initially"
+ );
+
+ // simulate livechat visitor opening his chat
+ await this.env.services.rpc({
+ route: '/im_livechat/get_session',
+ params: {
+ context: {
+ mockedUserId: false,
+ },
+ channel_id: 10,
+ },
+ });
+ await nextAnimationFrame();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should still not have any livechat in the sidebar after visitor opened his chat"
+ );
+});
+
+QUnit.test('do not add livechat in the sidebar on visitor typing', async function (assert) {
+ assert.expect(2);
+
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ currentUser.im_status = 'online';
+ this.data['im_livechat.channel'].records.push({
+ id: 10,
+ user_ids: [this.data.currentUserId],
+ });
+ this.data['mail.channel'].records.push({
+ channel_type: 'livechat',
+ id: 10,
+ is_pinned: false,
+ livechat_channel_id: 10,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.publicPartnerId, this.data.currentPartnerId],
+ });
+ await this.start();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should not have any livechat in the sidebar initially"
+ );
+
+ // simulate livechat visitor typing
+ const channel = this.data['mail.channel'].records.find(channel => channel.id === 10);
+ await this.env.services.rpc({
+ route: '/im_livechat/notify_typing',
+ params: {
+ context: {
+ mockedPartnerId: this.publicPartnerId,
+ },
+ is_typing: true,
+ uuid: channel.uuid,
+ },
+ });
+ await nextAnimationFrame();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should still not have any livechat in the sidebar after visitor started typing"
+ );
+});
+
+QUnit.test('add livechat in the sidebar on visitor sending first message', async function (assert) {
+ assert.expect(4);
+
+ const currentUser = this.data['res.users'].records.find(user =>
+ user.id === this.data.currentUserId
+ );
+ currentUser.im_status = 'online';
+ this.data['res.country'].records.push({
+ code: 'be',
+ id: 10,
+ name: "Belgium",
+ });
+ this.data['im_livechat.channel'].records.push({
+ id: 10,
+ user_ids: [this.data.currentUserId],
+ });
+ this.data['mail.channel'].records.push({
+ anonymous_name: "Visitor (Belgium)",
+ channel_type: 'livechat',
+ country_id: 10,
+ id: 10,
+ is_pinned: false,
+ livechat_channel_id: 10,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.publicPartnerId, this.data.currentPartnerId],
+ });
+ await this.start();
+ assert.containsNone(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should not have any livechat in the sidebar initially"
+ );
+
+ // simulate livechat visitor sending a message
+ const channel = this.data['mail.channel'].records.find(channel => channel.id === 10);
+ await afterNextRender(async () => this.env.services.rpc({
+ route: '/mail/chat_post',
+ params: {
+ context: {
+ mockedUserId: false,
+ },
+ uuid: channel.uuid,
+ message_content: "new message",
+ },
+ }));
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should have a channel group livechat in the side bar after receiving first message"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat .o_DiscussSidebar_item',
+ "should have a livechat in the sidebar after receiving first message"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_DiscussSidebar_groupLivechat .o_DiscussSidebar_item').textContent,
+ "Visitor (Belgium)",
+ "should have visitor name and country as livechat name"
+ );
+});
+
+QUnit.test('livechats are sorted by last message date in the sidebar: most recent at the top', async function (assert) {
+ /**
+ * For simplicity the code that is covered in this test is considering
+ * messages to be more/less recent than others based on their ids instead of
+ * their actual creation date.
+ */
+ assert.expect(7);
+
+ this.data['mail.message'].records.push(
+ { id: 11, channel_ids: [11] }, // least recent message due to smaller id
+ { id: 12, channel_ids: [12] }, // most recent message due to higher id
+ );
+ this.data['mail.channel'].records.push(
+ {
+ anonymous_name: "Visitor 11",
+ channel_type: 'livechat',
+ id: 11,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ {
+ anonymous_name: "Visitor 12",
+ channel_type: 'livechat',
+ id: 12,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ );
+ await this.start();
+ const livechat11 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ const livechat12 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should have a channel group livechat"
+ );
+ const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
+ assert.strictEqual(
+ initialLivechats.length,
+ 2,
+ "should have 2 livechats in the sidebar"
+ );
+ assert.strictEqual(
+ initialLivechats[0].dataset.threadLocalId,
+ livechat12.localId,
+ "first livechat should be the one with the most recent message"
+ );
+ assert.strictEqual(
+ initialLivechats[1].dataset.threadLocalId,
+ livechat11.localId,
+ "second livechat should be the one with the least recent message"
+ );
+
+ // post a new message on the last channel
+ await afterNextRender(() => initialLivechats[1].click());
+ await afterNextRender(() => document.execCommand('insertText', false, "Blabla"));
+ await afterNextRender(() => document.querySelector('.o_Composer_buttonSend').click());
+ const livechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
+ assert.strictEqual(
+ livechats.length,
+ 2,
+ "should still have 2 livechats in the sidebar after posting a new message"
+ );
+ assert.strictEqual(
+ livechats[0].dataset.threadLocalId,
+ livechat11.localId,
+ "first livechat should now be the one on which the new message was posted"
+ );
+ assert.strictEqual(
+ livechats[1].dataset.threadLocalId,
+ livechat12.localId,
+ "second livechat should now be the one on which the message was not posted"
+ );
+});
+
+QUnit.test('livechats with no messages are sorted by creation date in the sidebar: most recent at the top', async function (assert) {
+ /**
+ * For simplicity the code that is covered in this test is considering
+ * channels to be more/less recent than others based on their ids instead of
+ * their actual creation date.
+ */
+ assert.expect(5);
+
+ this.data['mail.message'].records.push(
+ { id: 13, channel_ids: [13] },
+ );
+ this.data['mail.channel'].records.push(
+ {
+ anonymous_name: "Visitor 11",
+ channel_type: 'livechat',
+ id: 11, // least recent channel due to smallest id
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ {
+ anonymous_name: "Visitor 12",
+ channel_type: 'livechat',
+ id: 12, // most recent channel that does not have a message
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ {
+ anonymous_name: "Visitor 13",
+ channel_type: 'livechat',
+ id: 13, // most recent channel (but it has a message)
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ );
+ await this.start();
+ const livechat11 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ });
+ const livechat12 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 12,
+ model: 'mail.channel',
+ });
+ const livechat13 = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 13,
+ model: 'mail.channel',
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_DiscussSidebar_groupLivechat',
+ "should have a channel group livechat"
+ );
+ const initialLivechats = document.querySelectorAll('.o_DiscussSidebar_groupLivechat .o_DiscussSidebarItem');
+ assert.strictEqual(
+ initialLivechats.length,
+ 3,
+ "should have 3 livechats in the sidebar"
+ );
+ assert.strictEqual(
+ initialLivechats[0].dataset.threadLocalId,
+ livechat12.localId,
+ "first livechat should be the most recent channel without message"
+ );
+ assert.strictEqual(
+ initialLivechats[1].dataset.threadLocalId,
+ livechat11.localId,
+ "second livechat should be the second most recent channel without message"
+ );
+ assert.strictEqual(
+ initialLivechats[2].dataset.threadLocalId,
+ livechat13.localId,
+ "third livechat should be the channel with a message"
+ );
+});
+
+QUnit.test('invite button should be present on livechat', async function (assert) {
+ assert.expect(1);
+
+ this.data['mail.channel'].records.push(
+ {
+ anonymous_name: "Visitor 11",
+ channel_type: 'livechat',
+ id: 11,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ },
+ );
+ await this.start({
+ discuss: {
+ params: {
+ default_active_id: 'mail.channel_11',
+ },
+ },
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_widget_Discuss_controlPanelButtonInvite',
+ "Invite button should be visible in control panel when livechat is active thread"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js
new file mode 100644
index 00000000..35070cb8
--- /dev/null
+++ b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js
@@ -0,0 +1,78 @@
+odoo.define('im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js', function (require) {
+'use strict';
+
+const components = {
+ DiscussSidebar: require('mail/static/src/components/discuss_sidebar/discuss_sidebar.js'),
+};
+
+const { patch } = require('web.utils');
+
+patch(components.DiscussSidebar, 'im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.js', {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Return the list of livechats that match the quick search value input.
+ *
+ * @returns {mail.thread[]}
+ */
+ quickSearchOrderedAndPinnedLivechatList() {
+ const allOrderedAndPinnedLivechats = this.env.models['mail.thread']
+ .all(thread =>
+ thread.channel_type === 'livechat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ ).sort((c1, c2) => {
+ // sort by: last message id (desc), id (desc)
+ if (c1.lastMessage && c2.lastMessage) {
+ return c2.lastMessage.id - c1.lastMessage.id;
+ }
+ // a channel without a last message is assumed to be a new
+ // channel just created with the intent of posting a new
+ // message on it, in which case it should be moved up.
+ if (!c1.lastMessage) {
+ return -1;
+ }
+ if (!c2.lastMessage) {
+ return 1;
+ }
+ return c2.id - c1.id;
+ });
+ if (!this.discuss.sidebarQuickSearchValue) {
+ return allOrderedAndPinnedLivechats;
+ }
+ const qsVal = this.discuss.sidebarQuickSearchValue.toLowerCase();
+ return allOrderedAndPinnedLivechats.filter(livechat => {
+ const nameVal = livechat.displayName.toLowerCase();
+ return nameVal.includes(qsVal);
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _useStoreCompareDepth() {
+ return Object.assign(this._super(...arguments), {
+ allOrderedAndPinnedLivechats: 1,
+ });
+ },
+ /**
+ * Override to include livechat channels on the sidebar.
+ *
+ * @override
+ */
+ _useStoreSelector(props) {
+ return Object.assign(this._super(...arguments), {
+ allOrderedAndPinnedLivechats: this.quickSearchOrderedAndPinnedLivechatList(),
+ });
+ },
+
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml
new file mode 100644
index 00000000..15c6fc35
--- /dev/null
+++ b/addons/im_livechat/static/src/components/discuss_sidebar/discuss_sidebar.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-inherit="mail.DiscussSidebar" t-inherit-mode="extension">
+ <xpath expr="//*[@name='root']" position="inside">
+ <t t-set="livechats" t-value="quickSearchOrderedAndPinnedLivechatList()"/>
+ <t t-if="livechats and livechats.length">
+ <div class="o_DiscussSidebar_group o_DiscussSidebar_groupLivechat">
+ <div class="o_DiscussSidebar_groupHeader">
+ <div class="o_DiscussSidebar_groupHeaderItem o_DiscussSidebar_groupTitle">
+ Livechat
+ </div>
+ </div>
+ <div class="o_DiscussSidebar_list">
+ <t t-foreach="livechats" t-as="livechat" t-key="livechat.localId">
+ <DiscussSidebarItem
+ class="o_DiscussSidebar_item"
+ threadLocalId="livechat.localId"
+ />
+ </t>
+ </div>
+ </div>
+ </t>
+ </xpath>
+ </t>
+</templates>
diff --git a/addons/im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js b/addons/im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js
new file mode 100644
index 00000000..b2273d99
--- /dev/null
+++ b/addons/im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js
@@ -0,0 +1,26 @@
+odoo.define('im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', function (require) {
+'use strict';
+
+const components = {
+ DiscussSidebarItem: require('mail/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js'),
+};
+
+const { patch } = require('web.utils');
+
+patch(components.DiscussSidebarItem, 'im_livechat/static/src/components/discuss_sidebar_item/discuss_sidebar_item.js', {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ hasUnpin(...args) {
+ const res = this._super(...args);
+ return res || this.thread.channel_type === 'livechat';
+ }
+
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/messaging_menu/messaging_menu_tests.js b/addons/im_livechat/static/src/components/messaging_menu/messaging_menu_tests.js
new file mode 100644
index 00000000..03c164c5
--- /dev/null
+++ b/addons/im_livechat/static/src/components/messaging_menu/messaging_menu_tests.js
@@ -0,0 +1,100 @@
+odoo.define('im_livechat/static/src/components/messaging_menu/messaging_menu_tests.js', function (require) {
+'use strict';
+
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('im_livechat', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('messaging_menu', {}, function () {
+QUnit.module('messaging_menu_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.start = async params => {
+ let { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ hasMessagingMenu: true,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('livechats should be in "chat" filter', async function (assert) {
+ assert.expect(7);
+
+ this.data['mail.channel'].records.push({
+ anonymous_name: "Visitor 11",
+ channel_type: 'livechat',
+ id: 11,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ });
+ await this.start();
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu',
+ "should have messaging menu"
+ );
+
+ await afterNextRender(() => document.querySelector('.o_MessagingMenu_toggler').click());
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu_tabButton[data-tab-id="all"]',
+ "should have a tab/filter 'all' in messaging menu"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_MessagingMenu_tabButton[data-tab-id="chat"]',
+ "should have a tab/filter 'chat' in messaging menu"
+ );
+ assert.hasClass(
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="all"]'),
+ 'o-active',
+ "tab/filter 'all' of messaging menu should be active initially"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ }).localId
+ }"]`,
+ "livechat should be listed in 'all' tab/filter of messaging menu"
+ );
+
+ await afterNextRender(() =>
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="chat"]').click()
+ );
+ assert.hasClass(
+ document.querySelector('.o_MessagingMenu_tabButton[data-tab-id="chat"]'),
+ 'o-active',
+ "tab/filter 'chat' of messaging menu should become active after click"
+ );
+ assert.containsOnce(
+ document.body,
+ `.o_ThreadPreview[data-thread-local-id="${
+ this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 11,
+ model: 'mail.channel',
+ }).localId
+ }"]`,
+ "livechat should be listed in 'chat' tab/filter of messaging menu"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/notification_list/notification_list.js b/addons/im_livechat/static/src/components/notification_list/notification_list.js
new file mode 100644
index 00000000..c0ab76b2
--- /dev/null
+++ b/addons/im_livechat/static/src/components/notification_list/notification_list.js
@@ -0,0 +1,36 @@
+odoo.define('im_livechat/static/src/components/notification_list/notification_list.js', function (require) {
+'use strict';
+
+const components = {
+ NotificationList: require('mail/static/src/components/notification_list/notification_list.js'),
+};
+
+const { patch } = require('web.utils');
+
+components.NotificationList._allowedFilters.push('livechat');
+
+patch(components.NotificationList, 'im_livechat/static/src/components/notification_list/notification_list.js', {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to include livechat channels.
+ *
+ * @override
+ */
+ _useStoreSelectorThreads(props) {
+ if (props.filter === 'livechat') {
+ return this.env.models['mail.thread'].all(thread =>
+ thread.channel_type === 'livechat' &&
+ thread.isPinned &&
+ thread.model === 'mail.channel'
+ );
+ }
+ return this._super(...arguments);
+ },
+
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/thread_icon/thread_icon.xml b/addons/im_livechat/static/src/components/thread_icon/thread_icon.xml
new file mode 100644
index 00000000..81c93868
--- /dev/null
+++ b/addons/im_livechat/static/src/components/thread_icon/thread_icon.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates xml:space="preserve">
+ <t t-inherit="mail.ThreadIcon" t-inherit-mode="extension">
+ <xpath expr="//*[@name='rootCondition']" position="inside">
+ <t t-elif="thread.channel_type === 'livechat'">
+ <t t-if="thread.orderedOtherTypingMembers.length > 0">
+ <ThreadTypingIcon
+ class="o_ThreadIcon_typing"
+ animation="'pulse'"
+ title="thread.typingStatusText"
+ />
+ </t>
+ <t t-else="">
+ <div class="fa fa-comments" title="Livechat"/>
+ </t>
+ </t>
+ </xpath>
+ </t>
+</templates>
diff --git a/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js b/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js
new file mode 100644
index 00000000..f37e873e
--- /dev/null
+++ b/addons/im_livechat/static/src/components/thread_icon/thread_icon_tests.js
@@ -0,0 +1,96 @@
+odoo.define('im_livechat/static/src/components/thread_icon/thread_icon_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadIcon: require('mail/static/src/components/thread_icon/thread_icon.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('im_livechat', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_icon', {}, function () {
+QUnit.module('thread_icon_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createThreadIcon = async thread => {
+ await createRootComponent(this, components.ThreadIcon, {
+ props: { threadLocalId: thread.localId },
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('livechat: public website visitor is typing', async function (assert) {
+ assert.expect(4);
+
+ this.data['mail.channel'].records.push({
+ anonymous_name: "Visitor 20",
+ channel_type: 'livechat',
+ id: 20,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createThreadIcon(thread);
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon',
+ "should have thread icon"
+ );
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon .fa.fa-comments',
+ "should have default livechat icon"
+ );
+
+ // simulate receive typing notification from livechat visitor "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ is_typing: true,
+ partner_id: this.env.messaging.publicPartner.id,
+ partner_name: this.env.messaging.publicPartner.name,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.containsOnce(
+ document.body,
+ '.o_ThreadIcon_typing',
+ "should have thread icon with visitor currently typing"
+ );
+ assert.strictEqual(
+ document.querySelector('.o_ThreadIcon_typing').title,
+ "Visitor 20 is typing...",
+ "title of icon should tell visitor is currently typing"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js b/addons/im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js
new file mode 100644
index 00000000..3cd95824
--- /dev/null
+++ b/addons/im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js
@@ -0,0 +1,28 @@
+odoo.define('im_livechat/static/src/components/thread_needaction_preview/thread_needaction_preview.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadNeedactionPreview: require('mail/static/src/components/thread_needaction_preview/thread_needaction_preview.js'),
+};
+
+const { patch } = require('web.utils');
+
+patch(components.ThreadNeedactionPreview, 'thread_needaction_preview', {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ image(...args) {
+ if (this.thread.channel_type === 'livechat') {
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ }
+ return this._super(...args);
+ }
+
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/thread_preview/thread_preview.js b/addons/im_livechat/static/src/components/thread_preview/thread_preview.js
new file mode 100644
index 00000000..ea752b56
--- /dev/null
+++ b/addons/im_livechat/static/src/components/thread_preview/thread_preview.js
@@ -0,0 +1,28 @@
+odoo.define('im_livechat/static/src/components/thread_preview/thread_preview.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadPreview: require('mail/static/src/components/thread_preview/thread_preview.js'),
+};
+
+const { patch } = require('web.utils');
+
+patch(components.ThreadPreview, 'im_livechat/static/src/components/thread_preview/thread_preview.js', {
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ image(...args) {
+ if (this.thread.channel_type === 'livechat') {
+ return '/mail/static/src/img/smiley/avatar.jpg';
+ }
+ return this._super(...args);
+ }
+
+});
+
+});
diff --git a/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js b/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
new file mode 100644
index 00000000..6c05cd74
--- /dev/null
+++ b/addons/im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js
@@ -0,0 +1,87 @@
+odoo.define('im_livechat/static/src/components/thread_textual_typing_status/thread_textual_typing_status_tests.js', function (require) {
+'use strict';
+
+const components = {
+ ThreadTextualTypingStatus: require('mail/static/src/components/thread_textual_typing_status/thread_textual_typing_status.js'),
+};
+const {
+ afterEach,
+ afterNextRender,
+ beforeEach,
+ createRootComponent,
+ start,
+} = require('mail/static/src/utils/test_utils.js');
+
+QUnit.module('im_livechat', {}, function () {
+QUnit.module('components', {}, function () {
+QUnit.module('thread_textual_typing_status', {}, function () {
+QUnit.module('thread_textual_typing_status_tests.js', {
+ beforeEach() {
+ beforeEach(this);
+
+ this.createThreadTextualTypingStatusComponent = async thread => {
+ await createRootComponent(this, components.ThreadTextualTypingStatus, {
+ props: { threadLocalId: thread.localId },
+ target: this.widget.el,
+ });
+ };
+
+ this.start = async params => {
+ const { env, widget } = await start(Object.assign({}, params, {
+ data: this.data,
+ }));
+ this.env = env;
+ this.widget = widget;
+ };
+ },
+ afterEach() {
+ afterEach(this);
+ },
+});
+
+QUnit.test('receive visitor typing status "is typing"', async function (assert) {
+ assert.expect(2);
+
+ this.data['mail.channel'].records.push({
+ anonymous_name: "Visitor 20",
+ channel_type: 'livechat',
+ id: 20,
+ livechat_operator_id: this.data.currentPartnerId,
+ members: [this.data.currentPartnerId, this.data.publicPartnerId],
+ });
+ await this.start();
+ const thread = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: 20,
+ model: 'mail.channel',
+ });
+ await this.createThreadTextualTypingStatusComponent(thread);
+
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "",
+ "Should display no one is currently typing"
+ );
+
+ // simulate receive typing notification from livechat visitor "is typing"
+ await afterNextRender(() => {
+ const typingData = {
+ info: 'typing_status',
+ is_typing: true,
+ partner_id: this.env.messaging.publicPartner.id,
+ partner_name: this.env.messaging.publicPartner.name,
+ };
+ const notification = [[false, 'mail.channel', 20], typingData];
+ this.widget.call('bus_service', 'trigger', 'notification', [notification]);
+ });
+ assert.strictEqual(
+ document.querySelector('.o_ThreadTextualTypingStatus').textContent,
+ "Visitor 20 is typing...",
+ "Should display that visitor is typing"
+ );
+});
+
+});
+});
+});
+
+});
diff --git a/addons/im_livechat/static/src/img/canned-responses.gif b/addons/im_livechat/static/src/img/canned-responses.gif
new file mode 100644
index 00000000..be2fc803
--- /dev/null
+++ b/addons/im_livechat/static/src/img/canned-responses.gif
Binary files differ
diff --git a/addons/im_livechat/static/src/img/default.png b/addons/im_livechat/static/src/img/default.png
new file mode 100644
index 00000000..07d2503c
--- /dev/null
+++ b/addons/im_livechat/static/src/img/default.png
Binary files differ
diff --git a/addons/im_livechat/static/src/js/ajax_external.js b/addons/im_livechat/static/src/js/ajax_external.js
new file mode 100644
index 00000000..9c6bd4cd
--- /dev/null
+++ b/addons/im_livechat/static/src/js/ajax_external.js
@@ -0,0 +1,14 @@
+odoo.define('web.ajax_external', function (require) {
+"use strict";
+
+var ajax = require('web.ajax');
+
+/**
+ * This file should be used in the context of an external widget loading (e.g: live chat in a non-Odoo website)
+ * It overrides the 'loadJS' method that is supposed to load additional scripts, based on a relative URL (e.g: '/web/webclient/locale/en_US')
+ * As we're not in an Odoo website context, the calls will not work, and we avoid a 404 request.
+ */
+ajax.loadJS = function (url) {
+ console.warn('Tried to load the following script on an external website: ' + url);
+};
+});
diff --git a/addons/im_livechat/static/src/js/im_livechat_channel_form_controller.js b/addons/im_livechat/static/src/js/im_livechat_channel_form_controller.js
new file mode 100644
index 00000000..9819e8cb
--- /dev/null
+++ b/addons/im_livechat/static/src/js/im_livechat_channel_form_controller.js
@@ -0,0 +1,54 @@
+odoo.define('im_livechat.ImLivechatChannelFormController', function (require) {
+'use strict';
+
+const FormController = require('web.FormController');
+
+const ImLivechatChannelFormController = FormController.extend({
+ events: Object.assign({}, FormController.prototype.events, {
+ 'click .o_im_livechat_channel_form_button_colors_reset_button': '_onClickLivechatButtonColorsResetButton',
+ 'click .o_im_livechat_channel_form_chat_window_colors_reset_button': '_onClickLivechatChatWindowColorsResetButton',
+ }),
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} colorValues
+ */
+ async _updateColors(colorValues) {
+ for (const name in colorValues) {
+ this.$(`[name="${name}"] .o_field_color`).css('background-color', colorValues[name]);
+ }
+ const result = await this.model.notifyChanges(this.handle, colorValues);
+ this._updateRendererState(this.model.get(this.handle), { fieldNames: result });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ async _onClickLivechatButtonColorsResetButton() {
+ await this._updateColors({
+ button_background_color: "#878787",
+ button_text_color: "#FFFFFF",
+ });
+ },
+ /**
+ * @private
+ */
+ async _onClickLivechatChatWindowColorsResetButton() {
+ await this._updateColors({
+ header_background_color: "#875A7B",
+ title_color: "#FFFFFF",
+ });
+ },
+});
+
+return ImLivechatChannelFormController;
+
+});
diff --git a/addons/im_livechat/static/src/js/im_livechat_channel_form_view.js b/addons/im_livechat/static/src/js/im_livechat_channel_form_view.js
new file mode 100644
index 00000000..af5f42d7
--- /dev/null
+++ b/addons/im_livechat/static/src/js/im_livechat_channel_form_view.js
@@ -0,0 +1,19 @@
+odoo.define('im_livechat.ImLivechatChannelFormView', function (require) {
+"use strict";
+
+const ImLivechatChannelFormController = require('im_livechat.ImLivechatChannelFormController');
+
+const FormView = require('web.FormView');
+const viewRegistry = require('web.view_registry');
+
+const ImLivechatChannelFormView = FormView.extend({
+ config: Object.assign({}, FormView.prototype.config, {
+ Controller: ImLivechatChannelFormController,
+ }),
+});
+
+viewRegistry.add('im_livechat_channel_form_view_js', ImLivechatChannelFormView);
+
+return ImLivechatChannelFormView;
+
+});
diff --git a/addons/im_livechat/static/src/legacy/public_livechat.js b/addons/im_livechat/static/src/legacy/public_livechat.js
new file mode 100644
index 00000000..951ebb9c
--- /dev/null
+++ b/addons/im_livechat/static/src/legacy/public_livechat.js
@@ -0,0 +1,3913 @@
+odoo.define('im_livechat.legacy.im_livechat.im_livechat', function (require) {
+"use strict";
+
+require('bus.BusService');
+var concurrency = require('web.concurrency');
+var config = require('web.config');
+var core = require('web.core');
+var session = require('web.session');
+var time = require('web.time');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+var WebsiteLivechat = require('im_livechat.legacy.im_livechat.model.WebsiteLivechat');
+var WebsiteLivechatMessage = require('im_livechat.legacy.im_livechat.model.WebsiteLivechatMessage');
+var WebsiteLivechatWindow = require('im_livechat.legacy.im_livechat.WebsiteLivechatWindow');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+// Constants
+var LIVECHAT_COOKIE_HISTORY = 'im_livechat_history';
+var HISTORY_LIMIT = 15;
+
+var RATING_TO_EMOJI = {
+ "5": "😊",
+ "3": "😐",
+ "1": "😞"
+};
+
+// History tracking
+var page = window.location.href.replace(/^.*\/\/[^/]+/, '');
+var pageHistory = utils.get_cookie(LIVECHAT_COOKIE_HISTORY);
+var urlHistory = [];
+if (pageHistory) {
+ urlHistory = JSON.parse(pageHistory) || [];
+}
+if (!_.contains(urlHistory, page)) {
+ urlHistory.push(page);
+ while (urlHistory.length > HISTORY_LIMIT) {
+ urlHistory.shift();
+ }
+ utils.set_cookie(LIVECHAT_COOKIE_HISTORY, JSON.stringify(urlHistory), 60 * 60 * 24); // 1 day cookie
+}
+
+var LivechatButton = Widget.extend({
+ className: 'openerp o_livechat_button d-print-none',
+ custom_events: {
+ 'close_chat_window': '_onCloseChatWindow',
+ 'post_message_chat_window': '_onPostMessageChatWindow',
+ 'save_chat_window': '_onSaveChatWindow',
+ 'updated_typing_partners': '_onUpdatedTypingPartners',
+ 'updated_unread_counter': '_onUpdatedUnreadCounter',
+ },
+ events: {
+ 'click': '_openChat'
+ },
+ init: function (parent, serverURL, options) {
+ this._super(parent);
+ this.options = _.defaults(options || {}, {
+ input_placeholder: _t("Ask something ..."),
+ default_username: _t("Visitor"),
+ button_text: _t("Chat with one of our collaborators"),
+ default_message: _t("How may I help you?"),
+ });
+
+ this._history = null;
+ // livechat model
+ this._livechat = null;
+ // livechat window
+ this._chatWindow = null;
+ this._messages = [];
+ this._serverURL = serverURL;
+ },
+ willStart: function () {
+ var self = this;
+ var cookie = utils.get_cookie('im_livechat_session');
+ var ready;
+ if (!cookie) {
+ ready = session.rpc('/im_livechat/init', { channel_id: this.options.channel_id })
+ .then(function (result) {
+ if (!result.available_for_me) {
+ return Promise.reject();
+ }
+ self._rule = result.rule;
+ });
+ } else {
+ var channel = JSON.parse(cookie);
+ ready = session.rpc('/mail/chat_history', { uuid: channel.uuid, limit: 100 })
+ .then(function (history) {
+ self._history = history;
+ });
+ }
+ return ready.then(this._loadQWebTemplate.bind(this));
+ },
+ start: function () {
+ this.$el.text(this.options.button_text);
+ if (this._history) {
+ _.each(this._history.reverse(), this._addMessage.bind(this));
+ this._openChat();
+ } else if (!config.device.isMobile && this._rule.action === 'auto_popup') {
+ var autoPopupCookie = utils.get_cookie('im_livechat_auto_popup');
+ if (!autoPopupCookie || JSON.parse(autoPopupCookie)) {
+ this._autoPopupTimeout =
+ setTimeout(this._openChat.bind(this), this._rule.auto_popup_timer * 1000);
+ }
+ }
+ this.call('bus_service', 'onNotification', this, this._onNotification);
+ if (this.options.button_background_color) {
+ this.$el.css('background-color', this.options.button_background_color);
+ }
+ if (this.options.button_text_color) {
+ this.$el.css('color', this.options.button_text_color);
+ }
+
+ // If website_event_track installed, put the livechat banner above the PWA banner.
+ var pwaBannerHeight = $('.o_pwa_install_banner').outerHeight(true);
+ if (pwaBannerHeight) {
+ this.$el.css('bottom', pwaBannerHeight + 'px');
+ }
+
+ return this._super();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+
+ /**
+ * @private
+ * @param {Object} data
+ * @param {Object} [options={}]
+ */
+ _addMessage: function (data, options) {
+ options = _.extend({}, this.options, options, {
+ serverURL: this._serverURL,
+ });
+ var message = new WebsiteLivechatMessage(this, data, options);
+
+ var hasAlreadyMessage = _.some(this._messages, function (msg) {
+ return message.getID() === msg.getID();
+ });
+ if (hasAlreadyMessage) {
+ return;
+ }
+
+ if (this._livechat) {
+ this._livechat.addMessage(message);
+ }
+
+ if (options && options.prepend) {
+ this._messages.unshift(message);
+ } else {
+ this._messages.push(message);
+ }
+ },
+ /**
+ * @private
+ */
+ _askFeedback: function () {
+ this._chatWindow.$('.o_thread_composer input').prop('disabled', true);
+
+ var feedback = new Feedback(this, this._livechat);
+ this._chatWindow.replaceContentWith(feedback);
+
+ feedback.on('send_message', this, this._sendMessage);
+ feedback.on('feedback_sent', this, this._closeChat);
+ },
+ /**
+ * @private
+ */
+ _closeChat: function () {
+ this._chatWindow.destroy();
+ utils.set_cookie('im_livechat_session', "", -1); // remove cookie
+ },
+ /**
+ * @private
+ * @param {Array} notification
+ */
+ _handleNotification: function (notification) {
+ const [livechatUUID, notificationData] = notification;
+ if (this._livechat && (livechatUUID === this._livechat.getUUID())) {
+ if (notificationData._type === 'history_command') { // history request
+ const cookie = utils.get_cookie(LIVECHAT_COOKIE_HISTORY);
+ const history = cookie ? JSON.parse(cookie) : [];
+ session.rpc('/im_livechat/history', {
+ pid: this._livechat.getOperatorPID()[0],
+ channel_uuid: this._livechat.getUUID(),
+ page_history: history,
+ });
+ } else if (notificationData.info === 'typing_status') {
+ const partnerID = notificationData.partner_id;
+ if (partnerID === this.options.current_partner_id) {
+ // ignore typing display of current partner.
+ return;
+ }
+ if (notificationData.is_typing) {
+ this._livechat.registerTyping({ partnerID });
+ } else {
+ this._livechat.unregisterTyping({ partnerID });
+ }
+ } else if ('body' in notificationData) { // normal message
+ // If message from notif is already in chatter messages, stop handling
+ if (this._messages.some(message => message.getID() === notificationData.id)) {
+ return;
+ }
+ this._addMessage(notificationData);
+ if (this._chatWindow.isFolded() || !this._chatWindow.isAtBottom()) {
+ this._livechat.incrementUnreadCounter();
+ }
+ this._renderMessages();
+ }
+ }
+ },
+ /**
+ * @private
+ */
+ _loadQWebTemplate: function () {
+ return session.rpc('/im_livechat/load_templates').then(function (templates) {
+ _.each(templates, function (template) {
+ QWeb.add_template(template);
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _openChat: _.debounce(function () {
+ if (this._openingChat) {
+ return;
+ }
+ var self = this;
+ var cookie = utils.get_cookie('im_livechat_session');
+ var def;
+ this._openingChat = true;
+ clearTimeout(this._autoPopupTimeout);
+ if (cookie) {
+ def = Promise.resolve(JSON.parse(cookie));
+ } else {
+ this._messages = []; // re-initialize messages cache
+ def = session.rpc('/im_livechat/get_session', {
+ channel_id: this.options.channel_id,
+ anonymous_name: this.options.default_username,
+ previous_operator_id: this._get_previous_operator_id(),
+ }, { shadow: true });
+ }
+ def.then(function (livechatData) {
+ if (!livechatData || !livechatData.operator_pid) {
+ try {
+ self.displayNotification({
+ message: _t("No available collaborator, please try again later."),
+ sticky: true,
+ });
+ } catch (err) {
+ /**
+ * Failure in displaying notification happens when
+ * notification service doesn't exist, which is the case in
+ * external lib. We don't want notifications in external
+ * lib at the moment because they use bootstrap toast and
+ * we don't want to include boostrap in external lib.
+ */
+ console.warn(_t("No available collaborator, please try again later."));
+ }
+ } else {
+ self._livechat = new WebsiteLivechat({
+ parent: self,
+ data: livechatData
+ });
+ return self._openChatWindow().then(function () {
+ if (!self._history) {
+ self._sendWelcomeMessage();
+ }
+ self._renderMessages();
+ self.call('bus_service', 'addChannel', self._livechat.getUUID());
+ self.call('bus_service', 'startPolling');
+
+ utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(self._livechat.toData())), 60 * 60);
+ utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60 * 60);
+ if (livechatData.operator_pid[0]) {
+ // livechatData.operator_pid contains a tuple (id, name)
+ // we are only interested in the id
+ var operatorPidId = livechatData.operator_pid[0];
+ var oneWeek = 7 * 24 * 60 * 60;
+ utils.set_cookie('im_livechat_previous_operator_pid', operatorPidId, oneWeek);
+ }
+ });
+ }
+ }).then(function () {
+ self._openingChat = false;
+ }).guardedCatch(function () {
+ self._openingChat = false;
+ });
+ }, 200, true),
+ /**
+ * Will try to get a previous operator for this visitor.
+ * If the visitor already had visitor A, it's better for his user experience
+ * to get operator A again.
+ *
+ * The information is stored in the 'im_livechat_previous_operator_pid' cookie.
+ *
+ * @private
+ * @return {integer} operator_id.partner_id.id if the cookie is set
+ */
+ _get_previous_operator_id: function () {
+ var cookie = utils.get_cookie('im_livechat_previous_operator_pid');
+ if (cookie) {
+ return cookie;
+ }
+
+ return null;
+ },
+ /**
+ * @private
+ * @return {Promise}
+ */
+ _openChatWindow: function () {
+ var self = this;
+ var options = {
+ displayStars: false,
+ headerBackgroundColor: this.options.header_background_color,
+ placeholder: this.options.input_placeholder || "",
+ titleColor: this.options.title_color,
+ };
+ this._chatWindow = new WebsiteLivechatWindow(this, this._livechat, options);
+ return this._chatWindow.appendTo($('body')).then(function () {
+ var cssProps = { bottom: 0 };
+ cssProps[_t.database.parameters.direction === 'rtl' ? 'left' : 'right'] = 0;
+ self._chatWindow.$el.css(cssProps);
+ self.$el.hide();
+ });
+ },
+ /**
+ * @private
+ */
+ _renderMessages: function () {
+ var shouldScroll = !this._chatWindow.isFolded() && this._chatWindow.isAtBottom();
+ this._livechat.setMessages(this._messages);
+ this._chatWindow.render();
+ if (shouldScroll) {
+ this._chatWindow.scrollToBottom();
+ }
+ },
+ /**
+ * @private
+ * @param {Object} message
+ * @return {Promise}
+ */
+ _sendMessage: function (message) {
+ var self = this;
+ this._livechat._notifyMyselfTyping({ typing: false });
+ return session
+ .rpc('/mail/chat_post', { uuid: this._livechat.getUUID(), message_content: message.content })
+ .then(function (messageId) {
+ if (!messageId) {
+ try {
+ self.displayNotification({
+ message: _t("Session expired... Please refresh and try again."),
+ sticky: true,
+ });
+ } catch (err) {
+ /**
+ * Failure in displaying notification happens when
+ * notification service doesn't exist, which is the case
+ * in external lib. We don't want notifications in
+ * external lib at the moment because they use bootstrap
+ * toast and we don't want to include boostrap in
+ * external lib.
+ */
+ console.warn(_t("Session expired... Please refresh and try again."));
+ }
+ self._closeChat();
+ }
+ self._chatWindow.scrollToBottom();
+ });
+ },
+ /**
+ * @private
+ */
+ _sendWelcomeMessage: function () {
+ if (this.options.default_message) {
+ this._addMessage({
+ id: '_welcome',
+ attachment_ids: [],
+ author_id: this._livechat.getOperatorPID(),
+ body: this.options.default_message,
+ channel_ids: [this._livechat.getID()],
+ date: time.datetime_to_str(new Date()),
+ }, { prepend: true });
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCloseChatWindow: function (ev) {
+ ev.stopPropagation();
+ var isComposerDisabled = this._chatWindow.$('.o_thread_composer input').prop('disabled');
+ var shouldAskFeedback = !isComposerDisabled && _.find(this._messages, function (message) {
+ return message.getID() !== '_welcome';
+ });
+ if (shouldAskFeedback) {
+ this._chatWindow.toggleFold(false);
+ this._askFeedback();
+ } else {
+ this._closeChat();
+ }
+ },
+ /**
+ * @private
+ * @param {Array[]} notifications
+ */
+ _onNotification: function (notifications) {
+ var self = this;
+ _.each(notifications, function (notification) {
+ self._handleNotification(notification);
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {Object} ev.data.messageData
+ */
+ _onPostMessageChatWindow: function (ev) {
+ ev.stopPropagation();
+ var self = this;
+ var messageData = ev.data.messageData;
+ this._sendMessage(messageData).guardedCatch(function (reason) {
+ reason.event.preventDefault();
+ return self._sendMessage(messageData); // try again just in case
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSaveChatWindow: function (ev) {
+ ev.stopPropagation();
+ utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(this._livechat.toData())), 60 * 60);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onUpdatedTypingPartners(ev) {
+ ev.stopPropagation();
+ this._chatWindow.renderHeader();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onUpdatedUnreadCounter: function (ev) {
+ ev.stopPropagation();
+ this._chatWindow.renderHeader();
+ },
+});
+
+/*
+ * Rating for Livechat
+ *
+ * This widget displays the 3 rating smileys, and a textarea to add a reason
+ * (only for red smiley), and sends the user feedback to the server.
+ */
+var Feedback = Widget.extend({
+ template: 'im_livechat.legacy.im_livechat.FeedBack',
+
+ events: {
+ 'click .o_livechat_rating_choices img': '_onClickSmiley',
+ 'click .o_livechat_no_feedback span': '_onClickNoFeedback',
+ 'click .o_rating_submit_button': '_onClickSend',
+ 'click .o_email_chat_button': '_onEmailChat',
+ 'click .o_livechat_email_error .alert-link': '_onTryAgain',
+ },
+
+ /**
+ * @param {?} parent
+ * @param {im_livechat.legacy.im_livechat.model.WebsiteLivechat} livechat
+ */
+ init: function (parent, livechat) {
+ this._super(parent);
+ this._livechat = livechat;
+ this.server_origin = session.origin;
+ this.rating = undefined;
+ this.dp = new concurrency.DropPrevious();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} options
+ */
+ _sendFeedback: function (reason) {
+ var self = this;
+ var args = {
+ uuid: this._livechat.getUUID(),
+ rate: this.rating,
+ reason: reason,
+ };
+ this.dp.add(session.rpc('/im_livechat/feedback', args)).then(function () {
+ var emoji = RATING_TO_EMOJI[self.rating] || "??";
+ var content = _.str.sprintf(_t("Rating: %s"), emoji);
+ if (reason) {
+ content += " \n" + reason;
+ }
+ self.trigger('send_message', { content: content, isFeedback: true });
+ });
+ },
+ /**
+ * @private
+ */
+ _showThanksMessage: function () {
+ this.$('.o_livechat_rating_box').empty().append($('<div />', {
+ text: _t('Thank you for your feedback'),
+ class: 'text-muted'
+ }));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onClickNoFeedback: function () {
+ this.trigger('feedback_sent'); // will close the chat
+ },
+ /**
+ * @private
+ */
+ _onClickSend: function () {
+ this.$('.o_livechat_rating_reason').hide();
+ this._showThanksMessage();
+ if (_.isNumber(this.rating)) {
+ this._sendFeedback(this.$('textarea').val());
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickSmiley: function (ev) {
+ this.rating = parseInt($(ev.currentTarget).data('value'));
+ this.$('.o_livechat_rating_choices img').removeClass('selected');
+ this.$('.o_livechat_rating_choices img[data-value="' + this.rating + '"]').addClass('selected');
+
+ // only display textearea if bad smiley selected
+ if (this.rating !== 5) {
+ this.$('.o_livechat_rating_reason').show();
+ } else {
+ this.$('.o_livechat_rating_reason').hide();
+ this._showThanksMessage();
+ this._sendFeedback();
+ }
+ },
+ /**
+ * @private
+ */
+ _onEmailChat: function () {
+ var self = this;
+ var $email = this.$('#o_email');
+
+ if (utils.is_email($email.val())) {
+ $email.removeAttr('title').removeClass('is-invalid').prop('disabled', true);
+ this.$('.o_email_chat_button').prop('disabled', true);
+ this._rpc({
+ route: '/im_livechat/email_livechat_transcript',
+ params: {
+ uuid: this._livechat.getUUID(),
+ email: $email.val(),
+ }
+ }).then(function () {
+ self.$('.o_livechat_email').html($('<strong />', { text: _t('Conversation Sent') }));
+ }).guardedCatch(function () {
+ self.$('.o_livechat_email').hide();
+ self.$('.o_livechat_email_error').show();
+ });
+ } else {
+ $email.addClass('is-invalid').prop('title', _t('Invalid email address'));
+ }
+ },
+ /**
+ * @private
+ */
+ _onTryAgain: function () {
+ this.$('#o_email').prop('disabled', false);
+ this.$('.o_email_chat_button').prop('disabled', false);
+ this.$('.o_livechat_email_error').hide();
+ this.$('.o_livechat_email').show();
+ },
+});
+
+return {
+ LivechatButton: LivechatButton,
+ Feedback: Feedback,
+};
+
+});
+
+odoo.define('im_livechat.legacy.im_livechat.model.WebsiteLivechat', function (require) {
+"use strict";
+
+var AbstractThread = require('im_livechat.legacy.mail.model.AbstractThread');
+var ThreadTypingMixin = require('im_livechat.legacy.mail.model.ThreadTypingMixin');
+
+var session = require('web.session');
+
+/**
+ * Thread model that represents a livechat on the website-side. This livechat
+ * is not linked to the mail service.
+ */
+var WebsiteLivechat = AbstractThread.extend(ThreadTypingMixin, {
+
+ /**
+ * @override
+ * @private
+ * @param {Object} params
+ * @param {Object} params.data
+ * @param {boolean} [params.data.folded] states whether the livechat is
+ * folded or not. It is considered only if this is defined and it is a
+ * boolean.
+ * @param {integer} params.data.id the ID of this livechat.
+ * @param {integer} [params.data.message_unread_counter] the unread counter
+ * of this livechat.
+ * @param {Array} params.data.operator_pid
+ * @param {string} params.data.name the name of this livechat.
+ * @param {string} [params.data.state] if 'folded', the livechat is folded.
+ * This is ignored if `folded` is provided and is a boolean value.
+ * @param {string} params.data.uuid the UUID of this livechat.
+ * @param {im_livechat.legacy.im_livechat.im_livechat:LivechatButton} params.parent
+ */
+ init: function (params) {
+ this._super.apply(this, arguments);
+ ThreadTypingMixin.init.call(this, arguments);
+
+ this._members = [];
+ this._operatorPID = params.data.operator_pid;
+ this._uuid = params.data.uuid;
+
+ if (params.data.message_unread_counter !== undefined) {
+ this._unreadCounter = params.data.message_unread_counter;
+ }
+
+ if (_.isBoolean(params.data.folded)) {
+ this._folded = params.data.folded;
+ } else {
+ this._folded = params.data.state === 'folded';
+ }
+
+ // Necessary for thread typing mixin to display is typing notification
+ // bar text (at least, for the operator in the members).
+ this._members.push({
+ id: this._operatorPID[0],
+ name: this._operatorPID[1]
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @returns {im_livechat.legacy.im_livechat.model.WebsiteLivechatMessage[]}
+ */
+ getMessages: function () {
+ return this._messages;
+ },
+ /**
+ * @returns {Array}
+ */
+ getOperatorPID: function () {
+ return this._operatorPID;
+ },
+ /**
+ * @returns {string}
+ */
+ getUUID: function () {
+ return this._uuid;
+ },
+ /**
+ * Increments the unread counter of this livechat by 1 unit.
+ *
+ * Note: this public method makes sense because the management of messages
+ * for website livechat is external. This method should be dropped when
+ * this class handles messages by itself.
+ */
+ incrementUnreadCounter: function () {
+ this._incrementUnreadCounter();
+ },
+ /**
+ * AKU: hack for the moment
+ *
+ * @param {im_livechat.legacy.im_livechat.model.WebsiteLivechatMessage[]} messages
+ */
+ setMessages: function (messages) {
+ this._messages = messages;
+ },
+ /**
+ * @returns {Object}
+ */
+ toData: function () {
+ return {
+ folded: this.isFolded(),
+ id: this.getID(),
+ message_unread_counter: this.getUnreadCounter(),
+ operator_pid: this.getOperatorPID(),
+ name: this.getName(),
+ uuid: this.getUUID(),
+ };
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.isWebsiteUser
+ * @returns {boolean}
+ */
+ _isTypingMyselfInfo: function (params) {
+ return params.isWebsiteUser;
+ },
+ /**
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.typing
+ * @returns {Promise}
+ */
+ _notifyMyselfTyping: function (params) {
+ return session.rpc('/im_livechat/notify_typing', {
+ uuid: this.getUUID(),
+ is_typing: params.typing,
+ }, { shadow: true });
+ },
+ /**
+ * Warn views that the list of users that are currently typing on this
+ * livechat has been updated.
+ *
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ */
+ _warnUpdatedTypingPartners: function () {
+ this.trigger_up('updated_typing_partners');
+ },
+ /**
+ * Warn that the unread counter has been updated on this livechat
+ *
+ * @override
+ * @private
+ */
+ _warnUpdatedUnreadCounter: function () {
+ this.trigger_up('updated_unread_counter');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override so that it only unregister typing operators.
+ *
+ * Note that in the frontend, there is no way to identify a message that is
+ * from the current user, because there is no partner ID in the session and
+ * a message with an author sets the partner ID of the author.
+ *
+ * @override {mail.model.ThreadTypingMixin}
+ * @private
+ * @param {mail.model.AbstractMessage} message
+ */
+ _onTypingMessageAdded: function (message) {
+ var operatorID = this.getOperatorPID()[0];
+ if (message.hasAuthor() && message.getAuthorID() === operatorID) {
+ this.unregisterTyping({ partnerID: operatorID });
+ }
+ },
+});
+
+return WebsiteLivechat;
+
+});
+
+odoo.define('im_livechat.legacy.im_livechat.model.WebsiteLivechatMessage', function (require) {
+"use strict";
+
+var AbstractMessage = require('im_livechat.legacy.mail.model.AbstractMessage');
+
+/**
+ * This is a message that is handled by im_livechat, without making use of the
+ * mail.Manager. The purpose of this is to make im_livechat compatible with
+ * mail.widget.Thread.
+ *
+ * @see im_livechat.legacy.mail.model.AbstractMessage for more information.
+ */
+var WebsiteLivechatMessage = AbstractMessage.extend({
+
+ /**
+ * @param {im_livechat.legacy.im_livechat.im_livechat.LivechatButton} parent
+ * @param {Object} data
+ * @param {Object} options
+ * @param {string} options.default_username
+ * @param {string} options.serverURL
+ */
+ init: function (parent, data, options) {
+ this._super.apply(this, arguments);
+
+ this._defaultUsername = options.default_username;
+ this._serverURL = options.serverURL;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the relative url of the avatar to display next to the message
+ *
+ * @override
+ * @return {string}
+ */
+ getAvatarSource: function () {
+ var source = this._serverURL;
+ if (this.hasAuthor()) {
+ source += '/web/partner_image/' + this.getAuthorID();
+ } else {
+ source += '/mail/static/src/img/smiley/avatar.jpg';
+ }
+ return source;
+ },
+ /**
+ * Get the text to display for the author of the message
+ *
+ * Rule of precedence for the displayed author::
+ *
+ * author name > default usernane
+ *
+ * @override
+ * @return {string}
+ */
+ getDisplayedAuthor: function () {
+ return this._super.apply(this, arguments) || this._defaultUsername;
+ },
+
+});
+
+return WebsiteLivechatMessage;
+
+});
+
+odoo.define('im_livechat.legacy.im_livechat.WebsiteLivechatWindow', function (require) {
+"use strict";
+
+var AbstractThreadWindow = require('im_livechat.legacy.mail.AbstractThreadWindow');
+
+/**
+ * This is the widget that represent windows of livechat in the frontend.
+ *
+ * @see im_livechat.legacy.mail.AbstractThreadWindow for more information
+ */
+var LivechatWindow = AbstractThreadWindow.extend({
+ events: _.extend(AbstractThreadWindow.prototype.events, {
+ 'input .o_composer_text_field': '_onInput',
+ }),
+ /**
+ * @override
+ * @param {im_livechat.legacy.im_livechat.im_livechat:LivechatButton} parent
+ * @param {im_livechat.legacy.im_livechat.model.WebsiteLivechat} thread
+ * @param {Object} [options={}]
+ * @param {string} [options.headerBackgroundColor]
+ * @param {string} [options.titleColor]
+ */
+ init(parent, thread, options = {}) {
+ this._super.apply(this, arguments);
+ this._thread = thread;
+ },
+ /**
+ * @override
+ * @return {Promise}
+ */
+ async start() {
+ await this._super(...arguments);
+ if (this.options.headerBackgroundColor) {
+ this.$('.o_thread_window_header').css('background-color', this.options.headerBackgroundColor);
+ }
+ if (this.options.titleColor) {
+ this.$('.o_thread_window_header').css('color', this.options.titleColor);
+ }
+ },
+
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ close: function () {
+ this.trigger_up('close_chat_window');
+ },
+ /**
+ * Replace the thread content with provided new content
+ *
+ * @param {$.Element} $element
+ */
+ replaceContentWith: function ($element) {
+ $element.replace(this._threadWidget.$el);
+ },
+ /**
+ * Warn the parent widget (LivechatButton)
+ *
+ * @override
+ * @param {boolean} folded
+ */
+ toggleFold: function () {
+ this._super.apply(this, arguments);
+ this.trigger_up('save_chat_window');
+ this.updateVisualFoldState();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ * @param {Object} messageData
+ */
+ _postMessage: function (messageData) {
+ this.trigger_up('post_message_chat_window', { messageData: messageData });
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the input in the composer changes
+ *
+ * @private
+ */
+ _onInput: function () {
+ if (this.hasThread() && this._thread.hasTypingNotification()) {
+ var isTyping = this.$input.val().length > 0;
+ this._thread.setMyselfTyping({ typing: isTyping });
+ }
+ },
+});
+
+return LivechatWindow;
+
+});
+
+odoo.define('im_livechat.legacy.mail.model.AbstractThread', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var Mixins = require('web.mixins');
+
+/**
+ * Abstract thread is the super class of all threads, either backend threads
+ * (which are compatible with mail service) or website livechats.
+ *
+ * Abstract threads contain abstract messages
+ */
+var AbstractThread = Class.extend(Mixins.EventDispatcherMixin, {
+ /**
+ * @param {Object} params
+ * @param {Object} params.data
+ * @param {integer|string} params.data.id the ID of this thread
+ * @param {string} params.data.name the name of this thread
+ * @param {string} [params.data.status=''] the status of this thread
+ * @param {Object} params.parent Object with the event-dispatcher mixin
+ * (@see {web.mixins.EventDispatcherMixin})
+ */
+ init: function (params) {
+ Mixins.EventDispatcherMixin.init.call(this, arguments);
+ this.setParent(params.parent);
+
+ this._folded = false; // threads are unfolded by default
+ this._id = params.data.id;
+ this._name = params.data.name;
+ this._status = params.data.status || '';
+ this._unreadCounter = 0; // amount of messages not yet been read
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a message to this thread.
+ *
+ * @param {im_livechat.legacy.mail.model.AbstractMessage} message
+ */
+ addMessage: function (message) {
+ this._addMessage.apply(this, arguments);
+ this.trigger('message_added', message);
+ },
+ /**
+ * Updates the folded state of the thread
+ *
+ * @param {boolean} folded
+ */
+ fold: function (folded) {
+ this._folded = folded;
+ },
+ /**
+ * Get the ID of this thread
+ *
+ * @returns {integer|string}
+ */
+ getID: function () {
+ return this._id;
+ },
+ /**
+ * @abstract
+ * @returns {im_livechat.legacy.mail.model.AbstractMessage[]}
+ */
+ getMessages: function () {},
+ /**
+ * Get the name of this thread. If the name of the thread has been created
+ * by the user from an input, it may be escaped.
+ *
+ * @returns {string}
+ */
+ getName: function () {
+ return this._name;
+ },
+ /**
+ * Get the status of the thread (e.g. 'online', 'offline', etc.)
+ *
+ * @returns {string}
+ */
+ getStatus: function () {
+ return this._status;
+ },
+ /**
+ * Returns the title to display in thread window's headers.
+ *
+ * @returns {string} the name of the thread by default (see @getName)
+ */
+ getTitle: function () {
+ return this.getName();
+ },
+ getType: function () {},
+ /**
+ * @returns {integer}
+ */
+ getUnreadCounter: function () {
+ return this._unreadCounter;
+ },
+ /**
+ * @returns {boolean}
+ */
+ hasMessages: function () {
+ return !_.isEmpty(this.getMessages());
+ },
+ /**
+ * States whether this thread is compatible with the 'seen' feature.
+ * By default, threads do not have thsi feature active.
+ * @see {im_livechat.legacy.mail.model.ThreadSeenMixin} to enable this feature on a thread.
+ *
+ * @returns {boolean}
+ */
+ hasSeenFeature: function () {
+ return false;
+ },
+ /**
+ * States whether this thread is folded or not.
+ *
+ * @return {boolean}
+ */
+ isFolded: function () {
+ return this._folded;
+ },
+ /**
+ * Mark the thread as read, which resets the unread counter to 0. This is
+ * only performed if the unread counter is not 0.
+ *
+ * @returns {Promise}
+ */
+ markAsRead: function () {
+ if (this._unreadCounter > 0) {
+ return this._markAsRead();
+ }
+ return Promise.resolve();
+ },
+ /**
+ * Post a message on this thread
+ *
+ * @returns {Promise} resolved with the message object to be sent to the
+ * server
+ */
+ postMessage: function () {
+ return this._postMessage.apply(this, arguments)
+ .then(this.trigger.bind(this, 'message_posted'));
+ },
+ /**
+ * Resets the unread counter of this thread to 0.
+ */
+ resetUnreadCounter: function () {
+ this._unreadCounter = 0;
+ this._warnUpdatedUnreadCounter();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a message to this thread.
+ *
+ * @abstract
+ * @private
+ * @param {im_livechat.legacy.mail.model.AbstractMessage} message
+ */
+ _addMessage: function (message) {},
+ /**
+ * Increments the unread counter of this thread by 1 unit.
+ *
+ * @private
+ */
+ _incrementUnreadCounter: function () {
+ this._unreadCounter++;
+ },
+ /**
+ * Mark the thread as read
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _markAsRead: function () {
+ this.resetUnreadCounter();
+ return Promise.resolve();
+ },
+ /**
+ * Post a message on this thread
+ *
+ * @abstract
+ * @private
+ * @returns {Promise} resolved with the message object to be sent to the
+ * server
+ */
+ _postMessage: function () {
+ return Promise.resolve();
+ },
+ /**
+ * Warn views (e.g. discuss app, thread window, etc.) to update visually
+ * the unread counter of this thread.
+ *
+ * @abstract
+ * @private
+ */
+ _warnUpdatedUnreadCounter: function () {},
+});
+
+return AbstractThread;
+
+});
+
+odoo.define('im_livechat.legacy.mail.model.ThreadTypingMixin', function (require) {
+"use strict";
+
+var CCThrottleFunction = require('im_livechat.legacy.mail.model.CCThrottleFunction');
+var Timer = require('im_livechat.legacy.mail.model.Timer');
+var Timers = require('im_livechat.legacy.mail.model.Timers');
+
+var core = require('web.core');
+
+var _t = core._t;
+
+/**
+ * Mixin for enabling the "is typing..." notification on a type of thread.
+ */
+var ThreadTypingMixin = {
+ // Default partner infos
+ _DEFAULT_TYPING_PARTNER_ID: '_default',
+ _DEFAULT_TYPING_PARTNER_NAME: 'Someone',
+
+ /**
+ * Initialize the internal data for typing feature on threads.
+ *
+ * Also listens on some internal events of the thread:
+ *
+ * - 'message_added': when a message is added, remove the author in the
+ * typing partners.
+ * - 'message_posted': when a message is posted, let the user have the
+ * possibility to immediately notify if he types something right away,
+ * instead of waiting for a throttle behaviour.
+ */
+ init: function () {
+ // Store the last "myself typing" status that has been sent to the
+ // server. This is useful in order to not notify the same typing
+ // status multiple times.
+ this._lastNotifiedMyselfTyping = false;
+
+ // Timer of current user that is typing a very long text. When the
+ // receivers do not receive any typing notification for a long time,
+ // they assume that the related partner is no longer typing
+ // something (e.g. they have closed the browser tab).
+ // This is a timer to let others know that we are still typing
+ // something, so that they do not assume we stopped typing
+ // something.
+ this._myselfLongTypingTimer = new Timer({
+ duration: 50 * 1000,
+ onTimeout: this._onMyselfLongTypingTimeout.bind(this),
+ });
+
+ // Timer of current user that was currently typing something, but
+ // there is no change on the input for several time. This is used
+ // in order to automatically notify other users that we have stopped
+ // typing something, due to making no changes on the composer for
+ // some time.
+ this._myselfTypingInactivityTimer = new Timer({
+ duration: 5 * 1000,
+ onTimeout: this._onMyselfTypingInactivityTimeout.bind(this),
+ });
+
+ // Timers of users currently typing in the thread. This is useful
+ // in order to automatically unregister typing users when we do not
+ // receive any typing notification after a long time. Timers are
+ // internally indexed by partnerID. The current user is ignored in
+ // this list of timers.
+ this._othersTypingTimers = new Timers({
+ duration: 60 * 1000,
+ onTimeout: this._onOthersTypingTimeout.bind(this),
+ });
+
+ // Clearable and cancellable throttled version of the
+ // `doNotifyMyselfTyping` method. (basically `notifyMyselfTyping`
+ // with slight pre- and post-processing)
+ // @see {mail.model.ResetableThrottleFunction}
+ // This is useful when the user posts a message and types something
+ // else: he must notify immediately that he is typing something,
+ // instead of waiting for the throttle internal timer.
+ this._throttleNotifyMyselfTyping = CCThrottleFunction({
+ duration: 2.5 * 1000,
+ func: this._onNotifyMyselfTyping.bind(this),
+ });
+
+ // This is used to track the order of registered partners typing
+ // something, in order to display the oldest typing partners.
+ this._typingPartnerIDs = [];
+
+ this.on('message_added', this, this._onTypingMessageAdded);
+ this.on('message_posted', this, this._onTypingMessagePosted);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the text to display when some partners are typing something on the
+ * thread:
+ *
+ * - single typing partner:
+ *
+ * A is typing...
+ *
+ * - two typing partners:
+ *
+ * A and B are typing...
+ *
+ * - three or more typing partners:
+ *
+ * A, B and more are typing...
+ *
+ * The choice of the members name for display is not random: it displays
+ * the user that have been typing for the longest time. Also, this function
+ * is hard-coded to display at most 2 partners. This limitation comes from
+ * how translation works in Odoo, for which unevaluated string cannot be
+ * translated.
+ *
+ * @returns {string} list of members that are typing something on the thread
+ * (excluding the current user).
+ */
+ getTypingMembersToText: function () {
+ var typingPartnerIDs = this._typingPartnerIDs;
+ var typingMembers = _.filter(this._members, function (member) {
+ return _.contains(typingPartnerIDs, member.id);
+ });
+ var sortedTypingMembers = _.sortBy(typingMembers, function (member) {
+ return _.indexOf(typingPartnerIDs, member.id);
+ });
+ var displayableTypingMembers = sortedTypingMembers.slice(0, 3);
+
+ if (displayableTypingMembers.length === 0) {
+ return '';
+ } else if (displayableTypingMembers.length === 1) {
+ return _.str.sprintf(_t("%s is typing..."), displayableTypingMembers[0].name);
+ } else if (displayableTypingMembers.length === 2) {
+ return _.str.sprintf(_t("%s and %s are typing..."),
+ displayableTypingMembers[0].name,
+ displayableTypingMembers[1].name);
+ } else {
+ return _.str.sprintf(_t("%s, %s and more are typing..."),
+ displayableTypingMembers[0].name,
+ displayableTypingMembers[1].name);
+ }
+ },
+ /**
+ * Threads with this mixin have the typing notification feature
+ *
+ * @returns {boolean}
+ */
+ hasTypingNotification: function () {
+ return true;
+ },
+ /**
+ * Tells if someone other than current user is typing something on this
+ * thread.
+ *
+ * @returns {boolean}
+ */
+ isSomeoneTyping: function () {
+ return !(_.isEmpty(this._typingPartnerIDs));
+ },
+ /**
+ * Register someone that is currently typing something in this thread.
+ * If this is the current user that is typing something, don't do anything
+ * (we do not have to display anything)
+ *
+ * This method is ignored if we try to register the current user.
+ *
+ * @param {Object} params
+ * @param {integer} params.partnerID ID of the partner linked to the user
+ * currently typing something on the thread.
+ */
+ registerTyping: function (params) {
+ if (this._isTypingMyselfInfo(params)) {
+ return;
+ }
+ var partnerID = params.partnerID;
+ this._othersTypingTimers.registerTimer({
+ timeoutCallbackArguments: [partnerID],
+ timerID: partnerID,
+ });
+ if (_.contains(this._typingPartnerIDs, partnerID)) {
+ return;
+ }
+ this._typingPartnerIDs.push(partnerID);
+ this._warnUpdatedTypingPartners();
+ },
+ /**
+ * This method must be called when the user starts or stops typing something
+ * in the composer of the thread.
+ *
+ * @param {Object} params
+ * @param {boolean} params.typing tell whether the current is typing or not.
+ */
+ setMyselfTyping: function (params) {
+ var typing = params.typing;
+ if (this._lastNotifiedMyselfTyping === typing) {
+ this._throttleNotifyMyselfTyping.cancel();
+ } else {
+ this._throttleNotifyMyselfTyping(params);
+ }
+
+ if (typing) {
+ this._myselfTypingInactivityTimer.reset();
+ } else {
+ this._myselfTypingInactivityTimer.clear();
+ }
+ },
+ /**
+ * Unregister someone from currently typing something in this thread.
+ *
+ * @param {Object} params
+ * @param {integer} params.partnerID ID of the partner related to the user
+ * that is currently typing something
+ */
+ unregisterTyping: function (params) {
+ var partnerID = params.partnerID;
+ this._othersTypingTimers.unregisterTimer({ timerID: partnerID });
+ if (!_.contains(this._typingPartnerIDs, partnerID)) {
+ return;
+ }
+ this._typingPartnerIDs = _.reject(this._typingPartnerIDs, function (id) {
+ return id === partnerID;
+ });
+ this._warnUpdatedTypingPartners();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Tells whether the provided information on a partner is related to the
+ * current user or not.
+ *
+ * @abstract
+ * @private
+ * @param {Object} params
+ * @param {integer} params.partner ID of partner to check
+ */
+ _isTypingMyselfInfo: function (params) {
+ return false;
+ },
+ /**
+ * Notify to the server that the current user either starts or stops typing
+ * something.
+ *
+ * @abstract
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.typing whether we are typing something or not
+ * @returns {Promise} resolved if the server is notified, rejected
+ * otherwise
+ */
+ _notifyMyselfTyping: function (params) {
+ return Promise.resolve();
+ },
+ /**
+ * Warn views that the list of users that are currently typing on this
+ * thread has been updated.
+ *
+ * @abstract
+ * @private
+ */
+ _warnUpdatedTypingPartners: function () {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when current user is typing something for a long time. In order
+ * to not let other users assume that we are no longer typing something, we
+ * must notify again that we are typing something.
+ *
+ * @private
+ */
+ _onMyselfLongTypingTimeout: function () {
+ this._throttleNotifyMyselfTyping.clear();
+ this._throttleNotifyMyselfTyping({ typing: true });
+ },
+ /**
+ * Called when current user has something typed in the composer, but is
+ * inactive for some time. In this case, he automatically notifies that he
+ * is no longer typing something
+ *
+ * @private
+ */
+ _onMyselfTypingInactivityTimeout: function () {
+ this._throttleNotifyMyselfTyping.clear();
+ this._throttleNotifyMyselfTyping({ typing: false });
+ },
+ /**
+ * Called by throttled version of notify myself typing
+ *
+ * Notify to the server that the current user either starts or stops typing
+ * something. Remember last notified stuff from the server, and update
+ * related typing timers.
+ *
+ * @private
+ * @param {Object} params
+ * @param {boolean} params.typing whether we are typing something or not.
+ */
+ _onNotifyMyselfTyping: function (params) {
+ var typing = params.typing;
+ this._lastNotifiedMyselfTyping = typing;
+ this._notifyMyselfTyping(params);
+ if (typing) {
+ this._myselfLongTypingTimer.reset();
+ } else {
+ this._myselfLongTypingTimer.clear();
+ }
+ },
+ /**
+ * Called when current user do not receive a typing notification of someone
+ * else typing for a long time. In this case, we assume that this person is
+ * no longer typing something.
+ *
+ * @private
+ * @param {integer} partnerID partnerID of the person we assume he is no
+ * longer typing something.
+ */
+ _onOthersTypingTimeout: function (partnerID) {
+ this.unregisterTyping({ partnerID: partnerID });
+ },
+ /**
+ * Called when a new message is added to the thread
+ * On receiving a message from a typing partner, unregister this partner
+ * from typing partners (otherwise, it will still display it until timeout).
+ *
+ * @private
+ * @param {mail.model.AbstractMessage} message
+ */
+ _onTypingMessageAdded: function (message) {
+ var partnerID = message.hasAuthor() ?
+ message.getAuthorID() :
+ this._DEFAULT_TYPING_PARTNER_ID;
+ this.unregisterTyping({ partnerID: partnerID });
+ },
+ /**
+ * Called when current user has posted a message on this thread.
+ *
+ * The current user receives the possibility to immediately notify the
+ * other users if he is typing something else.
+ *
+ * Refresh the context for the current user to notify that he starts or
+ * stops typing something. In other words, when this function is called and
+ * then the current user types something, it immediately notifies the
+ * server as if it is the first time he is typing something.
+ *
+ * @private
+ */
+ _onTypingMessagePosted: function () {
+ this._lastNotifiedMyselfTyping = false;
+ this._throttleNotifyMyselfTyping.clear();
+ this._myselfLongTypingTimer.clear();
+ this._myselfTypingInactivityTimer.clear();
+ },
+};
+
+return ThreadTypingMixin;
+
+});
+
+odoo.define('im_livechat.legacy.mail.model.AbstractMessage', function (require) {
+"use strict";
+
+var mailUtils = require('mail.utils');
+
+var Class = require('web.Class');
+var core = require('web.core');
+var session = require('web.session');
+var time = require('web.time');
+
+var _t = core._t;
+
+/**
+ * This is an abstract class for modeling messages in JS.
+ * The purpose of this interface is to make im_livechat compatible with
+ * mail.widget.Thread, as this widget was designed to work with messages that
+ * are instances of mail.model.Messages.
+ *
+ * Ideally, im_livechat should also handle mail.model.Message, but this is not
+ * feasible for the moment, as mail.model.Message requires mail.Manager to work,
+ * and this module should not leak outside of the backend, hence the use of
+ * mail.model.AbstractMessage as a work-around.
+ */
+var AbstractMessage = Class.extend({
+
+ /**
+ * @param {Widget} parent
+ * @param {Object} data
+ * @param {Array} [data.attachment_ids=[]]
+ * @param {Array} [data.author_id]
+ * @param {string} [data.body = ""]
+ * @param {string} [data.date] the server-format date time of the message.
+ * If not provided, use current date time for this message.
+ * @param {integer} data.id
+ * @param {boolean} [data.is_discussion = false]
+ * @param {boolean} [data.is_notification = false]
+ * @param {string} [data.message_type = undefined]
+ */
+ init: function (parent, data) {
+ this._attachmentIDs = data.attachment_ids || [];
+ this._body = data.body || "";
+ // by default: current datetime
+ this._date = data.date ? moment(time.str_to_datetime(data.date)) : moment();
+ this._id = data.id;
+ this._isDiscussion = data.is_discussion;
+ this._isNotification = data.is_notification;
+ this._serverAuthorID = data.author_id;
+ this._type = data.message_type || undefined;
+
+ this._processAttachmentURL();
+ this._attachmentIDs.forEach(function (attachment) {
+ attachment.filename = attachment.filename || attachment.name || _t("unnamed");
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the list of files attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getAttachments: function () {
+ return this._attachmentIDs;
+ },
+ /**
+ * Get the server ID (number) of the author of this message
+ * If there are no author, return -1;
+ *
+ * @return {integer}
+ */
+ getAuthorID: function () {
+ if (!this.hasAuthor()) {
+ return -1;
+ }
+ return this._serverAuthorID[0];
+ },
+ /**
+ * Threads do not have an im status by default
+ *
+ * @return {undefined}
+ */
+ getAuthorImStatus: function () {
+ return undefined;
+ },
+ /**
+ * Get the relative url of the avatar to display next to the message
+ *
+ * @abstract
+ * @return {string}
+ */
+ getAvatarSource: function () {
+ if (this.hasAuthor()) {
+ return '/web/image/res.partner/' + this.getAuthorID() + '/image_128';
+ }
+ },
+ /**
+ * Get the body content of this message
+ *
+ * @return {string}
+ */
+ getBody: function () {
+ return this._body;
+ },
+ /**
+ * @return {moment}
+ */
+ getDate: function () {
+ return this._date;
+ },
+ /**
+ * Get the date day of this message
+ *
+ * @return {string}
+ */
+ getDateDay: function () {
+ var date = this.getDate().format('YYYY-MM-DD');
+ if (date === moment().format('YYYY-MM-DD')) {
+ return _t("Today");
+ } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) {
+ return _t("Yesterday");
+ }
+ return this.getDate().format('LL');
+ },
+ /**
+ * Get the name of the author, if there is an author of this message
+ * If there are no author of this message, returns 'null'
+ *
+ * @return {string}
+ */
+ getDisplayedAuthor: function () {
+ return this.hasAuthor() ? this._getAuthorName() : null;
+ },
+ /**
+ * Get the server ID (number) of this message
+ *
+ * @override
+ * @return {integer}
+ */
+ getID: function () {
+ return this._id;
+ },
+ /**
+ * Get the list of images attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getImageAttachments: function () {
+ return _.filter(this.getAttachments(), function (attachment) {
+ return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
+ });
+ },
+ /**
+ * Get the list of non-images attached to this message.
+ * Note that attachments are stored with server-format
+ *
+ * @return {Object[]}
+ */
+ getNonImageAttachments: function () {
+ return _.difference(this.getAttachments(), this.getImageAttachments());
+ },
+ /**
+ * Gets the class to use as the notification icon.
+ *
+ * @returns {string}
+ */
+ getNotificationIcon() {
+ if (!this.hasNotificationsError()) {
+ return 'fa fa-envelope-o';
+ }
+ return 'fa fa-envelope';
+ },
+ /**
+ * Gets the list of notifications of this message, in no specific order.
+ * By default messages do not have notifications.
+ *
+ * @returns {Object[]}
+ */
+ getNotifications() {
+ return [];
+ },
+ /**
+ * Gets the text to display next to the notification icon.
+ *
+ * @returns {string}
+ */
+ getNotificationText() {
+ return '';
+ },
+ /**
+ * Get the time elapsed between sent message and now
+ *
+ * @return {string}
+ */
+ getTimeElapsed: function () {
+ return mailUtils.timeFromNow(this.getDate());
+ },
+ /**
+ * Get the type of message (e.g. 'comment', 'email', 'notification', ...)
+ * By default, messages are of type 'undefined'
+ *
+ * @override
+ * @return {string|undefined}
+ */
+ getType: function () {
+ return this._type;
+ },
+ /**
+ * State whether this message contains some attachments.
+ *
+ * @override
+ * @return {boolean}
+ */
+ hasAttachments: function () {
+ return this.getAttachments().length > 0;
+ },
+ /**
+ * State whether this message has an author
+ *
+ * @return {boolean}
+ */
+ hasAuthor: function () {
+ return !!(this._serverAuthorID && this._serverAuthorID[0]);
+ },
+ /**
+ * State whether this message has an email of its sender.
+ * By default, messages do not have any email of its sender.
+ *
+ * @return {string}
+ */
+ hasEmailFrom: function () {
+ return false;
+ },
+ /**
+ * State whether this image contains images attachments
+ *
+ * @return {boolean}
+ */
+ hasImageAttachments: function () {
+ return _.some(this.getAttachments(), function (attachment) {
+ return attachment.mimetype && attachment.mimetype.split('/')[0] === 'image';
+ });
+ },
+ /**
+ * State whether this image contains non-images attachments
+ *
+ * @return {boolean}
+ */
+ hasNonImageAttachments: function () {
+ return _.some(this.getAttachments(), function (attachment) {
+ return !(attachment.mimetype && attachment.mimetype.split('/')[0] === 'image');
+ });
+ },
+ /**
+ * States whether this message has some notifications.
+ *
+ * @returns {boolean}
+ */
+ hasNotifications() {
+ return this.getNotifications().length > 0;
+ },
+ /**
+ * States whether this message has notifications that are in error.
+ *
+ * @returns {boolean}
+ */
+ hasNotificationsError() {
+ return this.getNotifications().some(notif =>
+ notif.notification_status === 'exception' ||
+ notif.notification_status === 'bounce'
+ );
+ },
+ /**
+ * State whether this message originates from a channel.
+ * By default, messages do not originate from a channel.
+ *
+ * @override
+ * @return {boolean}
+ */
+ originatesFromChannel: function () {
+ return false;
+ },
+ /**
+ * State whether this message has a subject
+ * By default, messages do not have any subject.
+ *
+ * @return {boolean}
+ */
+ hasSubject: function () {
+ return false;
+ },
+ /**
+ * State whether this message is empty
+ *
+ * @return {boolean}
+ */
+ isEmpty: function () {
+ return !this.hasTrackingValues() &&
+ !this.hasAttachments() &&
+ !this.getBody();
+ },
+ /**
+ * By default, messages do not have any subtype description
+ *
+ * @return {boolean}
+ */
+ hasSubtypeDescription: function () {
+ return false;
+ },
+ /**
+ * State whether this message contains some tracking values
+ * By default, messages do not have any tracking values.
+ *
+ * @return {boolean}
+ */
+ hasTrackingValues: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a discussion
+ *
+ * @return {boolean}
+ */
+ isDiscussion: function () {
+ return this._isDiscussion;
+ },
+ /**
+ * State whether this message is linked to a document thread
+ * By default, messages are not linked to a document thread.
+ *
+ * @return {boolean}
+ */
+ isLinkedToDocumentThread: function () {
+ return false;
+ },
+ /**
+ * State whether this message is needaction
+ * By default, messages are not needaction.
+ *
+ * @return {boolean}
+ */
+ isNeedaction: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a note (i.e. a message from "Log note")
+ *
+ * @return {boolean}
+ */
+ isNote: function () {
+ return this._isNote;
+ },
+ /**
+ * State whether this message is a notification
+ *
+ * User notifications are defined as either
+ * - notes
+ * - pushed to user Inbox or email through classic notification process
+ * - not linked to any document, meaning model and res_id are void
+ *
+ * This is useful in order to display white background for user
+ * notifications in chatter
+ *
+ * @returns {boolean}
+ */
+ isNotification: function () {
+ return this._isNotification;
+ },
+ /**
+ * State whether this message is starred
+ * By default, messages are not starred.
+ *
+ * @return {boolean}
+ */
+ isStarred: function () {
+ return false;
+ },
+ /**
+ * State whether this message is a system notification
+ * By default, messages are not system notifications
+ *
+ * @override
+ * @return {boolean}
+ */
+ isSystemNotification: function () {
+ return false;
+ },
+ /**
+ * States whether the current message needs moderation in general.
+ * By default, messages do not require any moderation.
+ *
+ * @returns {boolean}
+ */
+ needsModeration: function () {
+ return false;
+ },
+ /**
+ * @params {integer[]} attachmentIDs
+ */
+ removeAttachments: function (attachmentIDs) {
+ this._attachmentIDs = _.reject(this._attachmentIDs, function (attachment) {
+ return _.contains(attachmentIDs, attachment.id);
+ });
+ },
+ /**
+ * State whether this message should redirect to the author
+ * when clicking on the author of this message.
+ *
+ * Do not redirect on author clicked of self-posted messages.
+ *
+ * @return {boolean}
+ */
+ shouldRedirectToAuthor: function () {
+ return !this._isMyselfAuthor();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Get the name of the author of this message.
+ * If there are no author of this messages, returns '' (empty string).
+ *
+ * @private
+ * @returns {string}
+ */
+ _getAuthorName: function () {
+ if (!this.hasAuthor()) {
+ return "";
+ }
+ return this._serverAuthorID[1];
+ },
+ /**
+ * State whether the current user is the author of this message
+ *
+ * @private
+ * @return {boolean}
+ */
+ _isMyselfAuthor: function () {
+ return this.hasAuthor() && (this.getAuthorID() === session.partner_id);
+ },
+ /**
+ * Compute url of attachments of this message
+ *
+ * @private
+ */
+ _processAttachmentURL: function () {
+ _.each(this.getAttachments(), function (attachment) {
+ attachment.url = '/web/content/' + attachment.id + '?download=true';
+ });
+ },
+
+});
+
+return AbstractMessage;
+
+});
+
+odoo.define('im_livechat.legacy.mail.AbstractThreadWindow', function (require) {
+"use strict";
+
+var ThreadWidget = require('im_livechat.legacy.mail.widget.Thread');
+
+var config = require('web.config');
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var _t = core._t;
+
+/**
+ * This is an abstract widget for rendering thread windows.
+ * AbstractThreadWindow is kept for legacy reasons.
+ */
+var AbstractThreadWindow = Widget.extend({
+ template: 'im_livechat.legacy.mail.AbstractThreadWindow',
+ custom_events: {
+ document_viewer_closed: '_onDocumentViewerClose',
+ },
+ events: {
+ 'click .o_thread_window_close': '_onClickClose',
+ 'click .o_thread_window_title': '_onClickFold',
+ 'click .o_composer_text_field': '_onComposerClick',
+ 'click .o_mail_thread': '_onThreadWindowClicked',
+ 'keydown .o_composer_text_field': '_onKeydown',
+ 'keypress .o_composer_text_field': '_onKeypress',
+ },
+ FOLD_ANIMATION_DURATION: 200, // duration in ms for (un)fold transition
+ HEIGHT_OPEN: '400px', // height in px of thread window when open
+ HEIGHT_FOLDED: '34px', // height, in px, of thread window when folded
+ /**
+ * Children of this class must make use of `thread`, which is an object that
+ * represent the thread that is linked to this thread window.
+ *
+ * If no thread is provided, this will represent the "blank" thread window.
+ *
+ * @abstract
+ * @param {Widget} parent
+ * @param {im_livechat.legacy.mail.model.AbstractThread} [thread=null] the thread that this
+ * thread window is linked to. If not set, it is the "blank" thread
+ * window.
+ * @param {Object} [options={}]
+ * @param {im_livechat.legacy.mail.model.AbstractThread} [options.thread]
+ */
+ init: function (parent, thread, options) {
+ this._super(parent);
+
+ this.options = _.defaults(options || {}, {
+ autofocus: true,
+ displayStars: true,
+ displayReplyIcons: false,
+ displayNotificationIcons: false,
+ placeholder: _t("Say something"),
+ });
+
+ this._hidden = false;
+ this._thread = thread || null;
+
+ this._debouncedOnScroll = _.debounce(this._onScroll.bind(this), 100);
+
+ if (!this.hasThread()) {
+ // internal fold state of thread window without any thread
+ this._folded = false;
+ }
+ },
+ start: function () {
+ var self = this;
+ this.$input = this.$('.o_composer_text_field');
+ this.$header = this.$('.o_thread_window_header');
+ var options = {
+ displayMarkAsRead: false,
+ displayStars: this.options.displayStars,
+ };
+ if (this._thread && this._thread._type === 'document_thread') {
+ options.displayDocumentLinks = false;
+ }
+ this._threadWidget = new ThreadWidget(this, options);
+
+ // animate the (un)folding of thread windows
+ this.$el.css({ transition: 'height ' + this.FOLD_ANIMATION_DURATION + 'ms linear' });
+ if (this.isFolded()) {
+ this.$el.css('height', this.HEIGHT_FOLDED);
+ } else if (this.options.autofocus) {
+ this._focusInput();
+ }
+ if (!config.device.isMobile) {
+ var margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right";
+ this.$el.css(margin_dir, $.position.scrollbarWidth());
+ }
+ var def = this._threadWidget.replace(this.$('.o_thread_window_content')).then(function () {
+ self._threadWidget.$el.on('scroll', self, self._debouncedOnScroll);
+ });
+ return Promise.all([this._super(), def]);
+ },
+ /**
+ * @override
+ */
+ do_hide: function () {
+ this._hidden = true;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ do_show: function () {
+ this._hidden = false;
+ this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ do_toggle: function (display) {
+ this._hidden = _.isBoolean(display) ? !display : !this._hidden;
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close this window
+ *
+ * @abstract
+ */
+ close: function () {},
+ /**
+ * Get the ID of the thread window, which is equivalent to the ID of the
+ * thread related to this window
+ *
+ * @returns {integer|string}
+ */
+ getID: function () {
+ return this._getThreadID();
+ },
+ /**
+ * @returns {mail.model.Thread|undefined}
+ */
+ getThread: function () {
+ if (!this.hasThread()) {
+ return undefined;
+ }
+ return this._thread;
+ },
+ /**
+ * Get the status of the thread, such as the im status of a DM chat
+ * ('online', 'offline', etc.). If this window has no thread, returns
+ * `undefined`.
+ *
+ * @returns {string|undefined}
+ */
+ getThreadStatus: function () {
+ if (!this.hasThread()) {
+ return undefined;
+ }
+ return this._thread.getStatus();
+ },
+ /**
+ * Get the title of the thread window, which usually contains the name of
+ * the thread.
+ *
+ * @returns {string}
+ */
+ getTitle: function () {
+ if (!this.hasThread()) {
+ return _t("Undefined");
+ }
+ return this._thread.getTitle();
+ },
+ /**
+ * Get the unread counter of the related thread. If there are no thread
+ * linked to this window, returns 0.
+ *
+ * @returns {integer}
+ */
+ getUnreadCounter: function () {
+ if (!this.hasThread()) {
+ return 0;
+ }
+ return this._thread.getUnreadCounter();
+ },
+ /**
+ * States whether this thread window is related to a thread or not.
+ *
+ * This is useful in order to provide specific behaviour for thread windows
+ * without any thread, e.g. let them open a thread from this "blank" thread
+ * window.
+ *
+ * @returns {boolean}
+ */
+ hasThread: function () {
+ return !! this._thread;
+ },
+ /**
+ * Tells whether the bottom of the thread in the thread window is visible
+ * or not.
+ *
+ * @returns {boolean}
+ */
+ isAtBottom: function () {
+ return this._threadWidget.isAtBottom();
+ },
+ /**
+ * State whether the related thread is folded or not. If there are no
+ * thread related to this window, it means this is the "blank" thread
+ * window, therefore we use the internal folded state.
+ *
+ * @returns {boolean}
+ */
+ isFolded: function () {
+ if (!this.hasThread()) {
+ return this._folded;
+ }
+ return this._thread.isFolded();
+ },
+ /**
+ * States whether the current environment is in mobile or not. This is
+ * useful in order to customize the template rendering for mobile view.
+ *
+ * @returns {boolean}
+ */
+ isMobile: function () {
+ return config.device.isMobile;
+ },
+ /**
+ * States whether the thread window is hidden or not.
+ *
+ * @returns {boolean}
+ */
+ isHidden: function () {
+ return this._hidden;
+ },
+ /**
+ * States whether the input of the thread window should be displayed or not.
+ * By default, any thread window with a thread needs a composer.
+ *
+ * @returns {boolean}
+ */
+ needsComposer: function () {
+ return this.hasThread();
+ },
+ /**
+ * Render the thread window
+ */
+ render: function () {
+ this.renderHeader();
+ if (this.hasThread()) {
+ this._threadWidget.render(this._thread, { displayLoadMore: false });
+ }
+ },
+ /**
+ * Render the header of this thread window.
+ * This is useful when some information on the header have be updated such
+ * as the status or the title of the thread that have changed.
+ *
+ * @private
+ */
+ renderHeader: function () {
+ var options = this._getHeaderRenderingOptions();
+ this.$header.html(
+ QWeb.render('im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent', options));
+ },
+ /**
+ * Scroll to the bottom of the thread in the thread window
+ */
+ scrollToBottom: function () {
+ this._threadWidget.scrollToBottom();
+ },
+ /**
+ * Toggle the fold state of this thread window. Also update the fold state
+ * of the thread model. If the boolean parameter `folded` is provided, it
+ * folds/unfolds the window when it is set/unset.
+ *
+ * @param {boolean} [folded] if not a boolean, toggle the fold state.
+ * Otherwise, fold/unfold the window if set/unset.
+ */
+ toggleFold: function (folded) {
+ if (!_.isBoolean(folded)) {
+ folded = !this.isFolded();
+ }
+ this._updateThreadFoldState(folded);
+ },
+ /**
+ * Update the visual state of the window so that it matched the internal
+ * fold state. This is useful in case the related thread has its fold state
+ * that has been changed.
+ */
+ updateVisualFoldState: function () {
+ if (!this.isFolded()) {
+ this._threadWidget.scrollToBottom();
+ if (this.options.autofocus) {
+ this._focusInput();
+ }
+ }
+ var height = this.isFolded() ? this.HEIGHT_FOLDED : this.HEIGHT_OPEN;
+ this.$el.css({ height: height });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Set the focus on the composer of the thread window. This operation is
+ * ignored in mobile context.
+ *
+ * @private
+ * Set the focus on the input of the window
+ */
+ _focusInput: function () {
+ if (
+ config.device.touch &&
+ config.device.size_class <= config.device.SIZES.SM
+ ) {
+ return;
+ }
+ this.$input.focus();
+ },
+ /**
+ * Returns the options used by the rendering of the window's header
+ *
+ * @private
+ * @returns {Object}
+ */
+ _getHeaderRenderingOptions: function () {
+ return {
+ status: this.getThreadStatus(),
+ thread: this.getThread(),
+ title: this.getTitle(),
+ unreadCounter: this.getUnreadCounter(),
+ widget: this,
+ };
+ },
+ /**
+ * Get the ID of the related thread.
+ * If this window is not related to a thread, it means this is the "blank"
+ * thread window, therefore it returns "_blank" as its ID.
+ *
+ * @private
+ * @returns {integer|string} the threadID, or '_blank' for the window that
+ * is not related to any thread.
+ */
+ _getThreadID: function () {
+ if (!this.hasThread()) {
+ return '_blank';
+ }
+ return this._thread.getID();
+ },
+ /**
+ * Tells whether there is focus on this thread. Note that a thread that has
+ * the focus means the input has focus.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _hasFocus: function () {
+ return this.$input.is(':focus');
+ },
+ /**
+ * Post a message on this thread window, and auto-scroll to the bottom of
+ * the thread.
+ *
+ * @private
+ * @param {Object} messageData
+ */
+ _postMessage: function (messageData) {
+ var self = this;
+ if (!this.hasThread()) {
+ return;
+ }
+ this._thread.postMessage(messageData)
+ .then(function () {
+ self._threadWidget.scrollToBottom();
+ });
+ },
+ /**
+ * Update the fold state of the thread.
+ *
+ * This function is called when toggling the fold state of this window.
+ * If there is no thread linked to this window, it means this is the
+ * "blank" thread window, therefore we use the internal state 'folded'
+ *
+ * @private
+ * @param {boolean} folded
+ */
+ _updateThreadFoldState: function (folded) {
+ if (this.hasThread()) {
+ this._thread.fold(folded);
+ } else {
+ this._folded = folded;
+ this.updateVisualFoldState();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Close the thread window.
+ * Mark the thread as read if the thread window was open.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickClose: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ if (
+ this.hasThread() &&
+ this._thread.getUnreadCounter() > 0 &&
+ !this.isFolded()
+ ) {
+ this._thread.markAsRead();
+ }
+ this.close();
+ },
+ /**
+ * Fold/unfold the thread window.
+ * Also mark the thread as read.
+ *
+ * @private
+ */
+ _onClickFold: function () {
+ if (!config.device.isMobile) {
+ this.toggleFold();
+ }
+ },
+ /**
+ * Called when the composer is clicked -> forces focus on input even if
+ * jquery's blockUI is enabled.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onComposerClick: function (ev) {
+ if ($(ev.target).closest('a, button').length) {
+ return;
+ }
+ this._focusInput();
+ },
+ /**
+ * @private
+ */
+ _onDocumentViewerClose: function () {
+ this._focusInput();
+ },
+ /**
+ * Called when typing something on the composer of this thread window.
+ *
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeydown: function (ev) {
+ ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
+ // ENTER key (avoid requiring jquery ui for external livechat)
+ if (ev.which === 13) {
+ var content = _.str.trim(this.$input.val());
+ var messageData = {
+ content: content,
+ attachment_ids: [],
+ partner_ids: [],
+ };
+ this.$input.val('');
+ if (content) {
+ this._postMessage(messageData);
+ }
+ }
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onKeypress: function (ev) {
+ ev.stopPropagation(); // to prevent jquery's blockUI to cancel event
+ },
+ /**
+ * @private
+ */
+ _onScroll: function () {
+ if (this.hasThread() && this.isAtBottom()) {
+ this._thread.markAsRead();
+ }
+ },
+ /**
+ * When a thread window is clicked on, we want to give the focus to the main
+ * input. An exception is made when the user is selecting something.
+ *
+ * @private
+ */
+ _onThreadWindowClicked: function () {
+ var selectObj = window.getSelection();
+ if (selectObj.anchorOffset === selectObj.focusOffset) {
+ this.$input.focus();
+ }
+ },
+});
+
+return AbstractThreadWindow;
+
+});
+
+odoo.define('im_livechat.legacy.mail.model.CCThrottleFunctionObject', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+/**
+ * This object models the behaviour of the clearable and cancellable (CC)
+ * throttle version of a provided function.
+ */
+var CCThrottleFunctionObject = Class.extend({
+
+ /**
+ * @param {Object} params
+ * @param {integer} params.duration duration of the 'cooldown' phase, i.e.
+ * the minimum duration between the most recent function call that has
+ * been made and the following function call.
+ * @param {function} params.func provided function for making the CC
+ * throttled version.
+ */
+ init: function (params) {
+ this._arguments = undefined;
+ this._cooldownTimeout = undefined;
+ this._duration = params.duration;
+ this._func = params.func;
+ this._shouldCallFunctionAfterCD = false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancel any buffered function call, but keep the cooldown phase running.
+ */
+ cancel: function () {
+ this._arguments = undefined;
+ this._shouldCallFunctionAfterCD = false;
+ },
+ /**
+ * Clear the internal throttle timer, so that the following function call
+ * is immediate. For instance, if there is a cooldown stage, it is aborted.
+ */
+ clear: function () {
+ if (this._cooldownTimeout) {
+ clearTimeout(this._cooldownTimeout);
+ this._onCooldownTimeout();
+ }
+ },
+ /**
+ * Called when there is a call to the function. This function is throttled,
+ * so the time it is called depends on whether the "cooldown stage" occurs
+ * or not:
+ *
+ * - no cooldown stage: function is called immediately, and it starts
+ * the cooldown stage when successful.
+ * - in cooldown stage: function is called when the cooldown stage has
+ * ended from timeout.
+ *
+ * Note that after the cooldown stage, only the last attempted function
+ * call will be considered.
+ */
+ do: function () {
+ this._arguments = Array.prototype.slice.call(arguments);
+ if (this._cooldownTimeout === undefined) {
+ this._callFunction();
+ } else {
+ this._shouldCallFunctionAfterCD = true;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Immediately calls the function with arguments of last buffered function
+ * call. It initiates the cooldown stage after this function call.
+ *
+ * @private
+ */
+ _callFunction: function () {
+ this._func.apply(null, this._arguments);
+ this._cooldown();
+ },
+ /**
+ * Called when the function has been successfully called. The following
+ * calls to the function with this object should suffer a "cooldown stage",
+ * which prevents the function from being called until this stage has ended.
+ *
+ * @private
+ */
+ _cooldown: function () {
+ this.cancel();
+ this._cooldownTimeout = setTimeout(
+ this._onCooldownTimeout.bind(this),
+ this._duration
+ );
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the cooldown stage ended from timeout. Calls the function if
+ * a function call was buffered.
+ *
+ * @private
+ */
+ _onCooldownTimeout: function () {
+ if (this._shouldCallFunctionAfterCD) {
+ this._callFunction();
+ } else {
+ this._cooldownTimeout = undefined;
+ }
+ },
+});
+
+return CCThrottleFunctionObject;
+
+});
+
+odoo.define('im_livechat.legacy.mail.model.CCThrottleFunction', function (require) {
+"use strict";
+
+var CCThrottleFunctionObject = require('im_livechat.legacy.mail.model.CCThrottleFunctionObject');
+
+/**
+ * A function that creates a cancellable and clearable (CC) throttle version
+ * of a provided function.
+ *
+ * This throttle mechanism allows calling a function at most once during a
+ * certain period:
+ *
+ * - When a function call is made, it enters a 'cooldown' phase, in which any
+ * attempt to call the function is buffered until the cooldown phase ends.
+ * - At most 1 function call can be buffered during the cooldown phase, and the
+ * latest one in this phase will be considered at its end.
+ * - When a cooldown phase ends, any buffered function call will be performed
+ * and another cooldown phase will follow up.
+ *
+ * This throttle version has the following interesting properties:
+ *
+ * - cancellable: it allows removing a buffered function call during the
+ * cooldown phase, but it keeps the cooldown phase running.
+ * - clearable: it allows to clear the internal clock of the throttled function,
+ * so that any cooldown phase is immediately ending.
+ *
+ * @param {Object} params
+ * @param {integer} params.duration a duration for the throttled behaviour,
+ * in milli-seconds.
+ * @param {function} params.func the function to throttle
+ * @returns {function} the cancellable and clearable throttle version of the
+ * provided function in argument.
+ */
+var CCThrottleFunction = function (params) {
+ var duration = params.duration;
+ var func = params.func;
+
+ var throttleFunctionObject = new CCThrottleFunctionObject({
+ duration: duration,
+ func: func,
+ });
+
+ var callable = function () {
+ return throttleFunctionObject.do.apply(throttleFunctionObject, arguments);
+ };
+ callable.cancel = function () {
+ throttleFunctionObject.cancel();
+ };
+ callable.clear = function () {
+ throttleFunctionObject.clear();
+ };
+
+ return callable;
+};
+
+return CCThrottleFunction;
+
+});
+
+odoo.define('im_livechat.legacy.mail.model.Timer', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+
+/**
+ * This class creates a timer which, when times out, calls a function.
+ */
+var Timer = Class.extend({
+
+ /**
+ * Instantiate a new timer. Note that the timer is not started on
+ * initialization (@see start method).
+ *
+ * @param {Object} params
+ * @param {number} params.duration duration of timer before timeout in
+ * milli-seconds.
+ * @param {function} params.onTimeout function that is called when the
+ * timer times out.
+ */
+ init: function (params) {
+ this._duration = params.duration;
+ this._timeout = undefined;
+ this._timeoutCallback = params.onTimeout;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clears the countdown of the timer.
+ */
+ clear: function () {
+ clearTimeout(this._timeout);
+ },
+ /**
+ * Resets the timer, i.e. resets its duration.
+ */
+ reset: function () {
+ this.clear();
+ this.start();
+ },
+ /**
+ * Starts the timer, i.e. after a certain duration, it times out and calls
+ * a function back.
+ */
+ start: function () {
+ this._timeout = setTimeout(this._onTimeout.bind(this), this._duration);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handler
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when the timer times out, calls back a function on timeout.
+ *
+ * @private
+ */
+ _onTimeout: function () {
+ this._timeoutCallback();
+ },
+
+});
+
+return Timer;
+
+});
+
+odoo.define('im_livechat.legacy.mail.model.Timers', function (require) {
+"use strict";
+
+var Timer = require('im_livechat.legacy.mail.model.Timer');
+
+var Class = require('web.Class');
+
+/**
+ * This class lists several timers that use a same callback and duration.
+ */
+var Timers = Class.extend({
+
+ /**
+ * Instantiate a new list of timers
+ *
+ * @param {Object} params
+ * @param {integer} params.duration duration of the underlying timers from
+ * start to timeout, in milli-seconds.
+ * @param {function} params.onTimeout a function to call back for underlying
+ * timers on timeout.
+ */
+ init: function (params) {
+ this._duration = params.duration;
+ this._timeoutCallback = params.onTimeout;
+ this._timers = {};
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Register a timer with ID `timerID` to start.
+ *
+ * - an already registered timer with this ID is reset.
+ * - (optional) can provide a list of arguments that is passed to the
+ * function callback when timer times out.
+ *
+ * @param {Object} params
+ * @param {Array} [params.timeoutCallbackArguments]
+ * @param {integer} params.timerID
+ */
+ registerTimer: function (params) {
+ var timerID = params.timerID;
+ if (this._timers[timerID]) {
+ this._timers[timerID].clear();
+ }
+ var timerParams = {
+ duration: this._duration,
+ onTimeout: this._timeoutCallback,
+ };
+ if ('timeoutCallbackArguments' in params) {
+ timerParams.onTimeout = this._timeoutCallback.bind.apply(
+ this._timeoutCallback,
+ [null].concat(params.timeoutCallbackArguments)
+ );
+ } else {
+ timerParams.onTimeout = this._timeoutCallback;
+ }
+ this._timers[timerID] = new Timer(timerParams);
+ this._timers[timerID].start();
+ },
+ /**
+ * Unregister a timer with ID `timerID`. The unregistered timer is aborted
+ * and will not time out.
+ *
+ * @param {Object} params
+ * @param {integer} params.timerID
+ */
+ unregisterTimer: function (params) {
+ var timerID = params.timerID;
+ if (this._timers[timerID]) {
+ this._timers[timerID].clear();
+ delete this._timers[timerID];
+ }
+ },
+
+});
+
+return Timers;
+
+});
+
+odoo.define('im_livechat.legacy.mail.widget.Thread', function (require) {
+"use strict";
+
+var DocumentViewer = require('im_livechat.legacy.mail.DocumentViewer');
+var mailUtils = require('mail.utils');
+
+var core = require('web.core');
+var time = require('web.time');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+var _lt = core._lt;
+
+var ORDER = {
+ ASC: 1, // visually, ascending order of message IDs (from top to bottom)
+ DESC: -1, // visually, descending order of message IDs (from top to bottom)
+};
+
+var READ_MORE = _lt("read more");
+var READ_LESS = _lt("read less");
+
+/**
+ * This is a generic widget to render a thread.
+ * Any thread that extends mail.model.AbstractThread can be used with this
+ * widget.
+ */
+var ThreadWidget = Widget.extend({
+ className: 'o_mail_thread',
+
+ events: {
+ 'click a': '_onClickRedirect',
+ 'click img': '_onClickRedirect',
+ 'click strong': '_onClickRedirect',
+ 'click .o_thread_show_more': '_onClickShowMore',
+ 'click .o_attachment_download': '_onAttachmentDownload',
+ 'click .o_attachment_view': '_onAttachmentView',
+ 'click .o_attachment_delete_cross': '_onDeleteAttachment',
+ 'click .o_thread_message_needaction': '_onClickMessageNeedaction',
+ 'click .o_thread_message_star': '_onClickMessageStar',
+ 'click .o_thread_message_reply': '_onClickMessageReply',
+ 'click .oe_mail_expand': '_onClickMailExpand',
+ 'click .o_thread_message': '_onClickMessage',
+ 'click': '_onClick',
+ 'click .o_thread_message_notification_error': '_onClickMessageNotificationError',
+ 'click .o_thread_message_moderation': '_onClickMessageModeration',
+ 'change .moderation_checkbox': '_onChangeModerationCheckbox',
+ },
+
+ /**
+ * @override
+ * @param {widget} parent
+ * @param {Object} options
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.attachments = [];
+ // options when the thread is enabled (e.g. can send message,
+ // interact on messages, etc.)
+ this._enabledOptions = _.defaults(options || {}, {
+ displayOrder: ORDER.ASC,
+ displayMarkAsRead: true,
+ displayModerationCommands: false,
+ displayStars: true,
+ displayDocumentLinks: true,
+ displayAvatars: true,
+ squashCloseMessages: true,
+ displayNotificationIcons: true,
+ displayReplyIcons: false,
+ loadMoreOnScroll: false,
+ hasMessageAttachmentDeletable: false,
+ });
+ // options when the thread is disabled
+ this._disabledOptions = {
+ displayOrder: this._enabledOptions.displayOrder,
+ displayMarkAsRead: false,
+ displayModerationCommands: false,
+ displayStars: false,
+ displayDocumentLinks: false,
+ displayAvatars: this._enabledOptions.displayAvatars,
+ squashCloseMessages: false,
+ displayNotificationIcons: false,
+ displayReplyIcons: false,
+ loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll,
+ hasMessageAttachmentDeletable: false,
+ };
+ this._selectedMessageID = null;
+ this._currentThreadID = null;
+ this._messageMailPopover = null;
+ this._messageSeenPopover = null;
+ // used to track popover IDs to destroy on re-rendering of popovers
+ this._openedSeenPopoverIDs = [];
+ },
+ /**
+ * The message mail popover may still be shown at this moment. If we do not
+ * remove it, it stays visible on the page until a page reload.
+ *
+ * @override
+ */
+ destroy: function () {
+ clearInterval(this._updateTimestampsInterval);
+ if (this._messageMailPopover) {
+ this._messageMailPopover.popover('hide');
+ }
+ if (this._messageSeenPopover) {
+ this._messageSeenPopover.popover('hide');
+ }
+ this._destroyOpenSeenPopoverIDs();
+ this._super();
+ },
+ /**
+ * @param {im_livechat.legacy.mail.model.AbstractThread} thread the thread to render.
+ * @param {Object} [options]
+ * @param {integer} [options.displayOrder=ORDER.ASC] order of displaying
+ * messages in the thread:
+ * - ORDER.ASC: last message is at the bottom of the thread
+ * - ORDER.DESC: last message is at the top of the thread
+ * @param {boolean} [options.displayLoadMore]
+ * @param {Array} [options.domain=[]] the domain for the messages in the
+ * thread.
+ * @param {boolean} [options.isCreateMode]
+ * @param {boolean} [options.scrollToBottom=false]
+ * @param {boolean} [options.squashCloseMessages]
+ */
+ render: function (thread, options) {
+ var self = this;
+
+ var shouldScrollToBottomAfterRendering = false;
+ if (this._currentThreadID === thread.getID() && this.isAtBottom()) {
+ shouldScrollToBottomAfterRendering = true;
+ }
+ this._currentThreadID = thread.getID();
+
+ // copy so that reverse do not alter order in the thread object
+ var messages = _.clone(thread.getMessages({ domain: options.domain || [] }));
+
+ var modeOptions = options.isCreateMode ? this._disabledOptions :
+ this._enabledOptions;
+
+ // attachments ordered by messages order (increasing ID)
+ this.attachments = _.uniq(_.flatten(_.map(messages, function (message) {
+ return message.getAttachments();
+ })));
+
+ options = _.extend({}, modeOptions, options, {
+ selectedMessageID: this._selectedMessageID,
+ });
+
+ // dict where key is message ID, and value is whether it should display
+ // the author of message or not visually
+ var displayAuthorMessages = {};
+
+ // Hide avatar and info of a message if that message and the previous
+ // one are both comments wrote by the same author at the same minute
+ // and in the same document (users can now post message in documents
+ // directly from a channel that follows it)
+ var prevMessage;
+ _.each(messages, function (message) {
+ if (
+ // is first message of thread
+ !prevMessage ||
+ // more than 1 min. elasped
+ (Math.abs(message.getDate().diff(prevMessage.getDate())) > 60000) ||
+ prevMessage.getType() !== 'comment' ||
+ message.getType() !== 'comment' ||
+ // from a different author
+ (prevMessage.getAuthorID() !== message.getAuthorID()) ||
+ (
+ // messages are linked to a document thread
+ (
+ prevMessage.isLinkedToDocumentThread() &&
+ message.isLinkedToDocumentThread()
+ ) &&
+ (
+ // are from different documents
+ prevMessage.getDocumentModel() !== message.getDocumentModel() ||
+ prevMessage.getDocumentID() !== message.getDocumentID()
+ )
+ )
+ ) {
+ displayAuthorMessages[message.getID()] = true;
+ } else {
+ displayAuthorMessages[message.getID()] = !options.squashCloseMessages;
+ }
+ prevMessage = message;
+ });
+
+ if (modeOptions.displayOrder === ORDER.DESC) {
+ messages.reverse();
+ }
+
+ this.$el.html(QWeb.render('im_livechat.legacy.mail.widget.Thread', {
+ thread: thread,
+ displayAuthorMessages: displayAuthorMessages,
+ options: options,
+ ORDER: ORDER,
+ dateFormat: time.getLangDatetimeFormat(),
+ }));
+
+ _.each(messages, function (message) {
+ var $message = self.$('.o_thread_message[data-message-id="' + message.getID() + '"]');
+ $message.find('.o_mail_timestamp').data('date', message.getDate());
+
+ self._insertReadMore($message);
+ });
+
+ if (shouldScrollToBottomAfterRendering) {
+ this.scrollToBottom();
+ }
+
+ if (!this._updateTimestampsInterval) {
+ this.updateTimestampsInterval = setInterval(function () {
+ self._updateTimestamps();
+ }, 1000 * 60);
+ }
+
+ this._renderMessageNotificationPopover(messages);
+ if (thread.hasSeenFeature()) {
+ this._renderMessageSeenPopover(thread, messages);
+ }
+ },
+
+ /**
+ * Render thread widget when loading, i.e. when messaging is not yet ready.
+ * @see /mail/init_messaging
+ */
+ renderLoading: function () {
+ this.$el.html(QWeb.render('im_livechat.legacy.mail.widget.ThreadLoading'));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ getScrolltop: function () {
+ return this.$el.scrollTop();
+ },
+ /**
+ * State whether the bottom of the thread is visible or not,
+ * with a tolerance of 5 pixels
+ *
+ * @return {boolean}
+ */
+ isAtBottom: function () {
+ var fullHeight = this.el.scrollHeight;
+ var topHiddenHeight = this.$el.scrollTop();
+ var visibleHeight = this.$el.outerHeight();
+ var bottomHiddenHeight = fullHeight - topHiddenHeight - visibleHeight;
+ return bottomHiddenHeight < 5;
+ },
+ /**
+ * Removes a message and re-renders the thread
+ *
+ * @param {integer} [messageID] the id of the removed message
+ * @param {mail.model.AbstractThread} thread the thread which contains
+ * updated list of messages (so it does not contain any message with ID
+ * `messageID`).
+ * @param {Object} [options] options for the thread rendering
+ */
+ removeMessageAndRender: function (messageID, thread, options) {
+ var self = this;
+ this._currentThreadID = thread.getID();
+ return new Promise(function (resolve, reject) {
+ self.$('.o_thread_message[data-message-id="' + messageID + '"]')
+ .fadeOut({
+ done: function () {
+ if (self._currentThreadID === thread.getID()) {
+ self.render(thread, options);
+ }
+ resolve();
+ },
+ duration: 200,
+ });
+ });
+ },
+ /**
+ * Scroll to the bottom of the thread
+ */
+ scrollToBottom: function () {
+ this.$el.scrollTop(this.el.scrollHeight);
+ },
+ /**
+ * Scrolls the thread to a given message
+ *
+ * @param {integer} options.msgID the ID of the message to scroll to
+ * @param {integer} [options.duration]
+ * @param {boolean} [options.onlyIfNecessary]
+ */
+ scrollToMessage: function (options) {
+ var $target = this.$('.o_thread_message[data-message-id="' + options.messageID + '"]');
+ if (options.onlyIfNecessary) {
+ var delta = $target.parent().height() - $target.height();
+ var offset = delta < 0 ?
+ 0 :
+ delta - ($target.offset().top - $target.offsetParent().offset().top);
+ offset = - Math.min(offset, 0);
+ this.$el.scrollTo("+=" + offset + "px", options.duration);
+ } else if ($target.length) {
+ this.$el.scrollTo($target);
+ }
+ },
+ /**
+ * Scroll to the specific position in pixel
+ *
+ * If no position is provided, scroll to the bottom of the thread
+ *
+ * @param {integer} [position] distance from top to position in pixels.
+ * If not provided, scroll to the bottom.
+ */
+ scrollToPosition: function (position) {
+ if (position) {
+ this.$el.scrollTop(position);
+ } else {
+ this.scrollToBottom();
+ }
+ },
+ /**
+ * Toggle all the moderation checkboxes in the thread
+ *
+ * @param {boolean} checked if true, check the boxes,
+ * otherwise uncheck them.
+ */
+ toggleModerationCheckboxes: function (checked) {
+ this.$('.moderation_checkbox').prop('checked', checked);
+ },
+ /**
+ * Unselect the selected message
+ */
+ unselectMessage: function () {
+ this.$('.o_thread_message').removeClass('o_thread_selected_message');
+ this._selectedMessageID = null;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _destroyOpenSeenPopoverIDs: function () {
+ _.each(this._openedSeenPopoverIDs, function (popoverID) {
+ $('#' + popoverID).remove();
+ });
+ this._openedSeenPopoverIDs = [];
+ },
+ /**
+ * Modifies $element to add the 'read more/read less' functionality
+ * All element nodes with 'data-o-mail-quote' attribute are concerned.
+ * All text nodes after a ``#stopSpelling`` element are concerned.
+ * Those text nodes need to be wrapped in a span (toggle functionality).
+ * All consecutive elements are joined in one 'read more/read less'.
+ *
+ * @private
+ * @param {jQuery} $element
+ */
+ _insertReadMore: function ($element) {
+ var self = this;
+
+ var groups = [];
+ var readMoreNodes;
+
+ // nodeType 1: element_node
+ // nodeType 3: text_node
+ var $children = $element.contents()
+ .filter(function () {
+ return this.nodeType === 1 ||
+ this.nodeType === 3 &&
+ this.nodeValue.trim();
+ });
+
+ _.each($children, function (child) {
+ var $child = $(child);
+
+ // Hide Text nodes if "stopSpelling"
+ if (
+ child.nodeType === 3 &&
+ $child.prevAll('[id*="stopSpelling"]').length > 0
+ ) {
+ // Convert Text nodes to Element nodes
+ $child = $('<span>', {
+ text: child.textContent,
+ 'data-o-mail-quote': '1',
+ });
+ child.parentNode.replaceChild($child[0], child);
+ }
+
+ // Create array for each 'read more' with nodes to toggle
+ if (
+ $child.attr('data-o-mail-quote') ||
+ (
+ $child.get(0).nodeName === 'BR' &&
+ $child.prev('[data-o-mail-quote="1"]').length > 0
+ )
+ ) {
+ if (!readMoreNodes) {
+ readMoreNodes = [];
+ groups.push(readMoreNodes);
+ }
+ $child.hide();
+ readMoreNodes.push($child);
+ } else {
+ readMoreNodes = undefined;
+ self._insertReadMore($child);
+ }
+ });
+
+ _.each(groups, function (group) {
+ // Insert link just before the first node
+ var $readMore = $('<a>', {
+ class: 'o_mail_read_more',
+ href: '#',
+ text: READ_MORE,
+ }).insertBefore(group[0]);
+
+ // Toggle All next nodes
+ var isReadMore = true;
+ $readMore.click(function (e) {
+ e.preventDefault();
+ isReadMore = !isReadMore;
+ _.each(group, function ($child) {
+ $child.hide();
+ $child.toggle(!isReadMore);
+ });
+ $readMore.text(isReadMore ? READ_MORE : READ_LESS);
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onDeleteAttachment: function (ev) {
+ ev.stopPropagation();
+ var $target = $(ev.currentTarget);
+ this.trigger_up('delete_attachment', {
+ attachmentId: $target.data('id'),
+ attachmentName: $target.data('name')
+ });
+ },
+ /**
+ * @private
+ * @param {Object} options
+ * @param {integer} [options.channelID]
+ * @param {string} options.model
+ * @param {integer} options.id
+ */
+ _redirect: _.debounce(function (options) {
+ if ('channelID' in options) {
+ this.trigger('redirect_to_channel', options.channelID);
+ } else {
+ this.trigger('redirect', options.model, options.id);
+ }
+ }, 500, true),
+ /**
+ * Render the popover when mouse-hovering on the notification icon of a
+ * message in the thread.
+ * There is at most one such popover at any given time.
+ *
+ * @private
+ * @param {im_livechat.legacy.mail.model.AbstractMessage[]} messages list of messages in the
+ * rendered thread, for which popover on mouseover interaction is
+ * permitted.
+ */
+ _renderMessageNotificationPopover(messages) {
+ if (this._messageMailPopover) {
+ this._messageMailPopover.popover('hide');
+ }
+ if (!this.$('.o_thread_tooltip').length) {
+ return;
+ }
+ this._messageMailPopover = this.$('.o_thread_tooltip').popover({
+ html: true,
+ boundary: 'viewport',
+ placement: 'auto',
+ trigger: 'hover',
+ offset: '0, 1',
+ content: function () {
+ var messageID = $(this).data('message-id');
+ var message = _.find(messages, function (message) {
+ return message.getID() === messageID;
+ });
+ return QWeb.render('im_livechat.legacy.mail.widget.Thread.Message.MailTooltip', {
+ notifications: message.getNotifications(),
+ });
+ },
+ });
+ },
+ /**
+ * Render the popover when mouse hovering on the seen icon of a message
+ * in the thread. Only seen icons in non-squashed message have popover,
+ * because squashed messages hides this icon on message mouseover.
+ *
+ * @private
+ * @param {im_livechat.legacy.mail.model.AbstractThread} thread with thread seen mixin,
+ * @see {im_livechat.legacy.mail.model.ThreadSeenMixin}
+ * @param {im_livechat.legacy.mail.model.Message[]} messages list of messages in the
+ * rendered thread.
+ */
+ _renderMessageSeenPopover: function (thread, messages) {
+ var self = this;
+ this._destroyOpenSeenPopoverIDs();
+ if (this._messageSeenPopover) {
+ this._messageSeenPopover.popover('hide');
+ }
+ if (!this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').length) {
+ return;
+ }
+ this._messageSeenPopover = this.$('.o_thread_message_core .o_mail_thread_message_seen_icon').popover({
+ html: true,
+ boundary: 'viewport',
+ placement: 'auto',
+ trigger: 'hover',
+ offset: '0, 1',
+ content: function () {
+ var $this = $(this);
+ self._openedSeenPopoverIDs.push($this.attr('aria-describedby'));
+ var messageID = $this.data('message-id');
+ var message = _.find(messages, function (message) {
+ return message.getID() === messageID;
+ });
+ return QWeb.render('im_livechat.legacy.mail.widget.Thread.Message.SeenIconPopoverContent', {
+ thread: thread,
+ message: message,
+ });
+ },
+ });
+ },
+ /**
+ * @private
+ */
+ _updateTimestamps: function () {
+ var isAtBottom = this.isAtBottom();
+ this.$('.o_mail_timestamp').each(function () {
+ var date = $(this).data('date');
+ $(this).html(mailUtils.timeFromNow(date));
+ });
+ if (isAtBottom && !this.isAtBottom()) {
+ this.scrollToBottom();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onAttachmentDownload: function (event) {
+ event.stopPropagation();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onAttachmentView: function (event) {
+ event.stopPropagation();
+ var activeAttachmentID = $(event.currentTarget).data('id');
+ if (activeAttachmentID) {
+ var attachmentViewer = new DocumentViewer(this, this.attachments, activeAttachmentID);
+ attachmentViewer.appendTo($('body'));
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onChangeModerationCheckbox: function (ev) {
+ this.trigger_up('update_moderation_buttons');
+ },
+ /**
+ * @private
+ */
+ _onClick: function () {
+ if (this._selectedMessageID) {
+ this.unselectMessage();
+ this.trigger('unselect_message');
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMailExpand: function (ev) {
+ ev.preventDefault();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessage: function (ev) {
+ $(ev.currentTarget).toggleClass('o_thread_selected_message');
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessageNeedaction: function (ev) {
+ var messageID = $(ev.currentTarget).data('message-id');
+ this.trigger('mark_as_read', messageID);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessageNotificationError(ev) {
+ const messageID = $(ev.currentTarget).data('message-id');
+ this.do_action('mail.mail_resend_message_action', {
+ additional_context: {
+ mail_message_to_resend: messageID,
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessageReply: function (ev) {
+ this._selectedMessageID = $(ev.currentTarget).data('message-id');
+ this.$('.o_thread_message').removeClass('o_thread_selected_message');
+ this.$('.o_thread_message[data-message-id="' + this._selectedMessageID + '"]')
+ .addClass('o_thread_selected_message');
+ this.trigger('select_message', this._selectedMessageID);
+ ev.stopPropagation();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessageStar: function (ev) {
+ var messageID = $(ev.currentTarget).data('message-id');
+ this.trigger('toggle_star_status', messageID);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickMessageModeration: function (ev) {
+ var $button = $(ev.currentTarget);
+ var messageID = $button.data('message-id');
+ var decision = $button.data('decision');
+ this.trigger_up('message_moderation', {
+ messageID: messageID,
+ decision: decision,
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onClickRedirect: function (ev) {
+ // ignore inherited branding
+ if ($(ev.target).data('oe-field') !== undefined) {
+ return;
+ }
+ var id = $(ev.target).data('oe-id');
+ if (id) {
+ ev.preventDefault();
+ var model = $(ev.target).data('oe-model');
+ var options;
+ if (model && (model !== 'mail.channel')) {
+ options = {
+ model: model,
+ id: id
+ };
+ } else {
+ options = { channelID: id };
+ }
+ this._redirect(options);
+ }
+ },
+ /**
+ * @private
+ */
+ _onClickShowMore: function () {
+ this.trigger('load_more_messages');
+ },
+});
+
+ThreadWidget.ORDER = ORDER;
+
+return ThreadWidget;
+
+});
+
+odoo.define('im_livechat.legacy.mail.DocumentViewer', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Widget = require('web.Widget');
+
+var QWeb = core.qweb;
+
+var SCROLL_ZOOM_STEP = 0.1;
+var ZOOM_STEP = 0.5;
+
+var DocumentViewer = Widget.extend({
+ template: "im_livechat.legacy.mail.DocumentViewer",
+ events: {
+ 'click .o_download_btn': '_onDownload',
+ 'click .o_viewer_img': '_onImageClicked',
+ 'click .o_viewer_video': '_onVideoClicked',
+ 'click .move_next': '_onNext',
+ 'click .move_previous': '_onPrevious',
+ 'click .o_rotate': '_onRotate',
+ 'click .o_zoom_in': '_onZoomIn',
+ 'click .o_zoom_out': '_onZoomOut',
+ 'click .o_zoom_reset': '_onZoomReset',
+ 'click .o_close_btn, .o_viewer_img_wrapper': '_onClose',
+ 'click .o_print_btn': '_onPrint',
+ 'DOMMouseScroll .o_viewer_content': '_onScroll', // Firefox
+ 'mousewheel .o_viewer_content': '_onScroll', // Chrome, Safari, IE
+ 'keydown': '_onKeydown',
+ 'keyup': '_onKeyUp',
+ 'mousedown .o_viewer_img': '_onStartDrag',
+ 'mousemove .o_viewer_content': '_onDrag',
+ 'mouseup .o_viewer_content': '_onEndDrag'
+ },
+ /**
+ * The documentViewer takes an array of objects describing attachments in
+ * argument, and the ID of an active attachment (the one to display first).
+ * Documents that are not of type image or video are filtered out.
+ *
+ * @override
+ * @param {Array<Object>} attachments list of attachments
+ * @param {integer} activeAttachmentID
+ */
+ init: function (parent, attachments, activeAttachmentID) {
+ this._super.apply(this, arguments);
+ this.attachment = _.filter(attachments, function (attachment) {
+ var match = attachment.type === 'url' ? attachment.url.match("(youtu|.png|.jpg|.gif)") : attachment.mimetype.match("(image|video|application/pdf|text)");
+ if (match) {
+ attachment.fileType = match[1];
+ if (match[1].match("(.png|.jpg|.gif)")) {
+ attachment.fileType = 'image';
+ }
+ if (match[1] === 'youtu') {
+ var youtube_array = attachment.url.split('/');
+ var youtube_token = youtube_array[youtube_array.length - 1];
+ if (youtube_token.indexOf('watch') !== -1) {
+ youtube_token = youtube_token.split('v=')[1];
+ var amp = youtube_token.indexOf('&');
+ if (amp !== -1) {
+ youtube_token = youtube_token.substring(0, amp);
+ }
+ }
+ attachment.youtube = youtube_token;
+ }
+ return true;
+ }
+ });
+ this.activeAttachment = _.findWhere(attachments, { id: activeAttachmentID });
+ this.modelName = 'ir.attachment';
+ this._reset();
+ },
+ /**
+ * Open a modal displaying the active attachment
+ * @override
+ */
+ start: function () {
+ this.$el.modal('show');
+ this.$el.on('hidden.bs.modal', _.bind(this._onDestroy, this));
+ this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this));
+ this.$('[data-toggle="tooltip"]').tooltip({ delay: 0 });
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * @override
+ */
+ destroy: function () {
+ if (this.isDestroyed()) {
+ return;
+ }
+ this.trigger_up('document_viewer_closed');
+ this.$el.modal('hide');
+ this.$el.remove();
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _next: function () {
+ var index = _.findIndex(this.attachment, this.activeAttachment);
+ index = (index + 1) % this.attachment.length;
+ this.activeAttachment = this.attachment[index];
+ this._updateContent();
+ },
+ /**
+ * @private
+ */
+ _previous: function () {
+ var index = _.findIndex(this.attachment, this.activeAttachment);
+ index = index === 0 ? this.attachment.length - 1 : index - 1;
+ this.activeAttachment = this.attachment[index];
+ this._updateContent();
+ },
+ /**
+ * @private
+ */
+ _reset: function () {
+ this.scale = 1;
+ this.dragStartX = this.dragstopX = 0;
+ this.dragStartY = this.dragstopY = 0;
+ },
+ /**
+ * Render the active attachment
+ *
+ * @private
+ */
+ _updateContent: function () {
+ this.$('.o_viewer_content').html(QWeb.render('im_livechat.legacy.mail.DocumentViewer.Content', {
+ widget: this
+ }));
+ this.$('.o_viewer_img').on("load", _.bind(this._onImageLoaded, this));
+ this.$('[data-toggle="tooltip"]').tooltip({ delay: 0 });
+ this._reset();
+ },
+ /**
+ * Get CSS transform property based on scale and angle
+ *
+ * @private
+ * @param {float} scale
+ * @param {float} angle
+ */
+ _getTransform: function (scale, angle) {
+ return 'scale3d(' + scale + ', ' + scale + ', 1) rotate(' + angle + 'deg)';
+ },
+ /**
+ * Rotate image clockwise by provided angle
+ *
+ * @private
+ * @param {float} angle
+ */
+ _rotate: function (angle) {
+ this._reset();
+ var new_angle = (this.angle || 0) + angle;
+ this.$('.o_viewer_img').css('transform', this._getTransform(this.scale, new_angle));
+ this.$('.o_viewer_img').css('max-width', new_angle % 180 !== 0 ? $(document).height() : '100%');
+ this.$('.o_viewer_img').css('max-height', new_angle % 180 !== 0 ? $(document).width() : '100%');
+ this.angle = new_angle;
+ },
+ /**
+ * Zoom in/out image by provided scale
+ *
+ * @private
+ * @param {integer} scale
+ */
+ _zoom: function (scale) {
+ if (scale > 0.5) {
+ this.$('.o_viewer_img').css('transform', this._getTransform(scale, this.angle || 0));
+ this.scale = scale;
+ }
+ this.$('.o_zoom_reset').add('.o_zoom_out').toggleClass('disabled', scale === 1);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onClose: function (e) {
+ e.preventDefault();
+ this.destroy();
+ },
+ /**
+ * When popup close complete destroyed modal even DOM footprint too
+ *
+ * @private
+ */
+ _onDestroy: function () {
+ this.destroy();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onDownload: function (e) {
+ e.preventDefault();
+ window.location = '/web/content/' + this.modelName + '/' + this.activeAttachment.id + '/' + 'datas' + '?download=true';
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onDrag: function (e) {
+ e.preventDefault();
+ if (this.enableDrag) {
+ var $image = this.$('.o_viewer_img');
+ var $zoomer = this.$('.o_viewer_zoomer');
+ var top = $image.prop('offsetHeight') * this.scale > $zoomer.height() ? e.clientY - this.dragStartY : 0;
+ var left = $image.prop('offsetWidth') * this.scale > $zoomer.width() ? e.clientX - this.dragStartX : 0;
+ $zoomer.css("transform", "translate3d(" + left + "px, " + top + "px, 0)");
+ $image.css('cursor', 'move');
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onEndDrag: function (e) {
+ e.preventDefault();
+ if (this.enableDrag) {
+ this.enableDrag = false;
+ this.dragstopX = e.clientX - this.dragStartX;
+ this.dragstopY = e.clientY - this.dragStartY;
+ this.$('.o_viewer_img').css('cursor', '');
+ }
+ },
+ /**
+ * On click of image do not close modal so stop event propagation
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onImageClicked: function (e) {
+ e.stopPropagation();
+ },
+ /**
+ * Remove loading indicator when image loaded
+ * @private
+ */
+ _onImageLoaded: function () {
+ this.$('.o_loading_img').hide();
+ },
+ /**
+ * Move next previous attachment on keyboard right left key
+ *
+ * @private
+ * @param {KeyEvent} e
+ */
+ _onKeydown: function (e) {
+ switch (e.which) {
+ case $.ui.keyCode.RIGHT:
+ e.preventDefault();
+ this._next();
+ break;
+ case $.ui.keyCode.LEFT:
+ e.preventDefault();
+ this._previous();
+ break;
+ }
+ },
+ /**
+ * Close popup on ESCAPE keyup
+ *
+ * @private
+ * @param {KeyEvent} e
+ */
+ _onKeyUp: function (e) {
+ switch (e.which) {
+ case $.ui.keyCode.ESCAPE:
+ e.preventDefault();
+ this._onClose(e);
+ break;
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onNext: function (e) {
+ e.preventDefault();
+ this._next();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onPrevious: function (e) {
+ e.preventDefault();
+ this._previous();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onPrint: function (e) {
+ e.preventDefault();
+ var src = this.$('.o_viewer_img').prop('src');
+ var script = QWeb.render('im_livechat.legacy.mail.PrintImage', {
+ src: src
+ });
+ var printWindow = window.open('about:blank', "_new");
+ printWindow.document.open();
+ printWindow.document.write(script);
+ printWindow.document.close();
+ },
+ /**
+ * Zoom image on scroll
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onScroll: function (e) {
+ var scale;
+ if (e.originalEvent.wheelDelta > 0 || e.originalEvent.detail < 0) {
+ scale = this.scale + SCROLL_ZOOM_STEP;
+ this._zoom(scale);
+ } else {
+ scale = this.scale - SCROLL_ZOOM_STEP;
+ this._zoom(scale);
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onStartDrag: function (e) {
+ e.preventDefault();
+ this.enableDrag = true;
+ this.dragStartX = e.clientX - (this.dragstopX || 0);
+ this.dragStartY = e.clientY - (this.dragstopY || 0);
+ },
+ /**
+ * On click of video do not close modal so stop event propagation
+ * and provide play/pause the video instead of quitting it
+ *
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onVideoClicked: function (e) {
+ e.stopPropagation();
+ var videoElement = e.target;
+ if (videoElement.paused) {
+ videoElement.play();
+ } else {
+ videoElement.pause();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onRotate: function (e) {
+ e.preventDefault();
+ this._rotate(90);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomIn: function (e) {
+ e.preventDefault();
+ var scale = this.scale + ZOOM_STEP;
+ this._zoom(scale);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomOut: function (e) {
+ e.preventDefault();
+ var scale = this.scale - ZOOM_STEP;
+ this._zoom(scale);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} e
+ */
+ _onZoomReset: function (e) {
+ e.preventDefault();
+ this.$('.o_viewer_zoomer').css("transform", "");
+ this._zoom(1);
+ },
+});
+return DocumentViewer;
+});
diff --git a/addons/im_livechat/static/src/legacy/public_livechat.scss b/addons/im_livechat/static/src/legacy/public_livechat.scss
new file mode 100644
index 00000000..3710e11b
--- /dev/null
+++ b/addons/im_livechat/static/src/legacy/public_livechat.scss
@@ -0,0 +1,677 @@
+//-------------------------------------
+// legacy: mail/abstract_thread_window.scss
+//-------------------------------------
+
+$o-mail-thread-window-zindex: $zindex-modal + 1 !default;
+
+.o_thread_window {
+ direction: ltr;
+ display: flex;
+ flex-flow: column nowrap;
+ position: fixed;
+ width: $o-mail-thread-window-width;
+ max-width: 100%;
+ height: 400px;
+ max-height: 100%;
+ font-size: 12px;
+ background-color: $o-mail-thread-window-bg;
+ border-radius: 6px 6px 0 0;
+ z-index: $o-mail-thread-window-zindex;
+ box-shadow: -5px -5px 10px rgba(black, 0.18);
+
+ @include media-breakpoint-down(sm) {
+ width: 100%;
+ height: 100%!important;
+ box-shadow: none;
+ &.o_folded {
+ display: none;
+ }
+ }
+
+ @media print {
+ display: none;
+ }
+
+ .o_thread_window_header {
+ align-items: center;
+ display: flex;
+ flex: 0 0 auto;
+ color: white;
+ padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap;
+ border-radius: 3px 3px 0 0;
+ border-bottom: 1px solid gray('300');
+ background-color: $o-brand-odoo;
+ padding: 8px;
+
+ @include media-breakpoint-down(sm) {
+ align-items: center;
+ height: $o-mail-chat-header-height;
+ padding: 0;
+ border-radius: 0px;
+ .o_thread_window_title {
+ font-size: 16px;
+ margin-left: 10px;
+ }
+
+ .o_thread_window_close {
+ $o-close-font-size: 17px;
+ padding: (($o-mail-chat-header-height - $o-close-font-size) / 2);
+ font-size: $o-close-font-size;
+ color: white;
+ }
+ }
+ .o_thread_window_avatar {
+ margin: -6px 6px -6px 0;
+ position: relative;
+
+ img {
+ height: 25px;
+ width: 25px;
+ border-radius: 50%;
+ }
+ span {
+ bottom: -4px;
+ right: -2px;
+ position: absolute;
+
+ .fa-circle-o {
+ display: none;
+ }
+ }
+ }
+
+ .o_thread_window_title {
+ cursor: pointer;
+ flex: 1 1 auto;
+ @include o-text-overflow;
+
+ .o_mail_thread_typing_icon {
+ padding-left: 2px;
+
+ .o_mail_thread_typing_icon_dot {
+ background: gray('300');
+ }
+ }
+ }
+
+ .o_thread_window_buttons {
+ flex: 0 0 auto;
+ .o_thread_window_close, .o_thread_window_expand {
+ color: white;
+ padding: 0px 3px;
+ margin-left: 5px;
+ @include o-hover-opacity(0.7, 1);
+ }
+ }
+
+ }
+
+ .o_mail_thread {
+ flex: 1 1 100%;
+ overflow: auto;
+ -webkit-overflow-scrolling: touch; // smooth scrolling in iOS app (Safari)
+
+ .o_thread_date_separator {
+ margin: 0px 0px 15px 0px;
+ .o_thread_date {
+ background-color: $o-mail-thread-window-bg;
+ }
+ }
+ .o_thread_message {
+ padding: 4px 5px;
+ .o_thread_message_sidebar {
+ margin-right: 5px;
+ }
+ .o_attachment {
+ @include media-breakpoint-up(md) {
+ width: percentage(1/3);
+ }
+ }
+ }
+ }
+
+ .o_thread_composer input {
+ width: 100%;
+ }
+}
+
+.o_thread_window_dropdown {
+ width: auto;
+ height: 28px;
+ color: white;
+ background-color: gray('900');
+ cursor: pointer;
+ box-shadow: none;
+
+ @include media-breakpoint-down(sm) {
+ display: none;
+ }
+
+ .o_thread_window_header {
+ border-radius: 0;
+ }
+
+ .o_thread_window_dropdown_toggler {
+ padding: 5px;
+
+ .o_total_unread_counter {
+ @include o-position-absolute(-10px, 0, auto, auto);
+ background-color: $o-brand-primary;
+ padding: 0 2px;
+ font-size: smaller;
+ }
+ }
+ &.show .o_thread_window_dropdown_toggler .o_total_unread_counter, .o_thread_window_expand {
+ display: none;
+ }
+
+ > ul {
+ max-width: $o-mail-thread-window-width;
+ padding: 0;
+
+ > li.o_thread_window_header {
+ font-size: 12px;
+ padding: 3px 5px;
+ &~li.o_thread_window_header {
+ border-top: 1px solid white;
+ }
+ &:hover {
+ background-color: darken($o-brand-odoo, 10%);
+ }
+ }
+ }
+}
+
+.o_ui_blocked .o_thread_window {
+ // We cannot put the z-index of thread windows directly to be greater than
+ // blockUI's as ui-autocomplete dropdowns (which are below blockUI) would
+ // appear under the thread windows (and ui-autocomplete is used to choose the
+ // person you want to chat with). So we only raise the z-index value when
+ // the ui is really blocked (in that case, the ui-autocomplete dropdowns
+ // will disappear under the thread windows but this is not really an issue as
+ // there should not be any at that time).
+ z-index: 1101; // blockUI's z-index is 1100
+}
+
+.o_no_thread_window .o_thread_window {
+ display: none;
+}
+
+//-------------------------------------
+// legacy: mail/thread.scss
+//-------------------------------------
+
+
+.o_mail_thread_loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.o_mail_thread_loading_icon {
+ margin-right: 5px;
+}
+
+.o_mail_thread, .o_mail_activity {
+ .o_thread_show_more {
+ text-align: center;
+ }
+
+ .o_mail_thread_content {
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+ }
+
+ .o_thread_bottom_free_space {
+ height: 15px;
+ }
+
+ .o_thread_tooltip_container {
+ display: inline;
+ position: relative;
+ }
+
+ .o_thread_date_separator {
+ margin-top: 15px;
+ margin-bottom: 30px;
+ @include media-breakpoint-down(sm) {
+ margin-top: 0px;
+ margin-bottom: 15px;
+ }
+ border-bottom: 1px solid gray('400');
+ text-align: center;
+
+ .o_thread_date {
+ position: relative;
+ top: 10px;
+ margin: 0 auto;
+ padding: 0 10px;
+ font-weight: bold;
+ background: white;
+ }
+ }
+
+ .o_thread_new_messages_separator {
+ margin-bottom: 15px;
+ border-bottom: solid lighten($o-brand-odoo, 15%) 1px;
+ text-align: right;
+ .o_thread_separator_label {
+ position: relative;
+ top: 8px;
+ padding: 0 10px;
+ background: white;
+ color: lighten($o-brand-odoo, 15%);
+ font-size: smaller;
+ }
+ }
+
+ .o_thread_message {
+ display: flex;
+ padding: 4px $o-horizontal-padding;
+ margin-bottom: 0px;
+
+ &.o_mail_not_discussion {
+ background-color: rgba(gray('300'), 0.5);
+ border-bottom: 1px solid gray('400');
+ }
+
+ .o_thread_message_sidebar {
+ flex: 0 0 $o-mail-thread-avatar-size;
+ margin-right: 10px;
+ margin-top: 2px;
+ text-align: center;
+ font-size: smaller;
+ .o_thread_message_sidebar_image {
+ position: relative;
+ height: $o-mail-thread-avatar-size;
+
+ .o_updatable_im_status {
+ width: $o-mail-thread-avatar-size;
+ }
+ .o_mail_user_status {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+
+ &.fa-circle-o {
+ display: none;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ margin-top: 4px;
+ font-size: x-small;
+ }
+
+ .o_thread_message_avatar {
+ width: $o-mail-thread-avatar-size;
+ height: $o-mail-thread-avatar-size;
+ object-fit: cover;
+ }
+ .o_thread_message_side_date {
+ display: none;
+ margin-left: -5px;
+ }
+ .o_thread_message_star {
+ display: none;
+ margin-right: -5px;
+ }
+
+ .o_thread_message_side_date {
+ opacity: 0;
+ }
+
+ .o_mail_thread_message_seen_icon:not(.o_all_seen) {
+ opacity: $o-mail-thread-icon-opacity*0.5;
+ }
+ }
+ .o_thread_icon {
+ cursor: pointer;
+ opacity: 0;
+ &.fa-star {
+ opacity: $o-mail-thread-icon-opacity;
+ color: gold;
+ }
+ }
+
+ &:hover, &.o_thread_selected_message {
+ .o_thread_message_side_date {
+ display: inline-block;
+ opacity: $o-mail-thread-side-date-opacity;
+ }
+ .o_thread_icon {
+ display: inline-block;
+ opacity: $o-mail-thread-icon-opacity;
+ &:hover {
+ opacity: 1;
+ }
+ }
+ .o_thread_message_sidebar {
+ .o_mail_thread_message_seen_icon {
+ display: none;
+ }
+ }
+ }
+
+ .o_mail_redirect {
+ cursor: pointer;
+ }
+
+ .o_thread_message_core {
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 100%;
+ word-wrap: break-word;
+ > pre {
+ white-space: pre-wrap;
+ word-break: break-word;
+ text-align: justify;
+ }
+
+ .o_mail_note_title {
+ margin-top: 9px;
+ }
+
+ .o_mail_subject {
+ font-style: italic;
+ }
+
+ .o_mail_notification {
+ font-style: italic;
+ color: gray;
+ }
+
+ [summary~=o_mail_notification] { // name conflicts with channel notifications, but is odoo notification buttons to hide in chatter if present
+ display: none;
+ }
+
+ p {
+ margin: 0 0 9px; // Required by the old design to override a general rule on p's
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ a {
+ display: inline-block;
+ word-break: break-all;
+ }
+ :not(.o_image_box) > img {
+ max-width: 100%;
+ height: auto;
+ }
+
+ .o_mail_body_long {
+ display: none;
+ }
+
+ .o_mail_info {
+ margin-bottom: 2px;
+
+ strong {
+ color: $headings-color;
+ }
+ }
+
+ .o_thread_message_star, .o_thread_message_needaction, .o_thread_message_reply, .o_thread_message_notification {
+ padding: 4px;
+ }
+
+ .o_thread_message_notification {
+ color: grey;
+ &.o_thread_message_notification_error {
+ color: red;
+ opacity: 1;
+ cursor: pointer;
+ }
+ }
+
+ .o_attachments_list, .o_attachments_previews {
+ &:last-child {
+ margin-bottom: $grid-gutter-width;
+ }
+ }
+
+ .o_thread_tooltip_container {
+ display: inline;
+ position: relative;
+ }
+ }
+ }
+ .o_thread_title {
+ margin-top: 20px;
+ margin-bottom: 20px;
+ font-weight: bold;
+ font-size: 125%;
+
+ &.o_neutral_face_icon:before {
+ @extend %o-nocontent-init-image;
+ @include size(120px, 140px);
+ background: transparent url(/web/static/src/img/neutral_face.svg) no-repeat center;
+ }
+ }
+
+ .o_mail_no_content {
+ @include o-position-absolute(30%, 0, 0, 0);
+ text-align: center;
+ font-size: 115%;
+ }
+
+ .o_thread_message .o_thread_message_core .o_mail_read_more {
+ display: block;
+ }
+}
+
+.o_web_client .popover .o_thread_tooltip_icon {
+ min-width: 1rem;
+}
+
+.o_web_client.o_touch_device {
+ .o_mail_thread .o_thread_icon {
+ opacity: $o-mail-thread-icon-opacity;
+ }
+}
+
+// ------------------------------------------------------------------
+// Thread typing icon: shared between discuss and chat windows
+// ------------------------------------------------------------------
+
+.o_mail_thread_typing_icon {
+ position: relative;
+ text-align: center;
+ margin-left: auto;
+ margin-right: auto;
+
+ .o_mail_thread_typing_icon_dot {
+ display: inline-block;
+ width: 3px;
+ height: 3px;
+ border-radius: 50%;
+ background: gray('800');
+ animation: o_mail_thread_typing_icon_dot 1.5s linear infinite;
+
+ &:nth-child(2) {
+ animation-delay: -1.35s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: -1.2s;
+ }
+ }
+}
+
+@keyframes o_mail_thread_typing_icon_dot {
+ 0%, 40%, 100% {
+ transform: initial;
+ }
+ 20% {
+ transform: translateY(-5px);
+ }
+}
+
+// ------------------------------------------------------------------
+// Thread seen icon: shared between discuss and chat windows
+// ------------------------------------------------------------------
+
+.o_mail_thread_message_seen_icon {
+ position: relative;
+ opacity: 0.6;
+
+ &.o_all_seen {
+ color: $o-enterprise-color;
+ }
+
+ &:hover {
+ cursor: pointer;
+ opacity: 0.8;
+ }
+
+ .fa-check:nth-child(1) {
+ padding-left: 3px;
+ }
+
+ .fa-check:nth-child(2) {
+ position: absolute;
+ top: -1px;
+ left: 0px;
+ }
+}
+
+//-------------------------------------
+// legacy: im_livechat/im_livechat.scss
+//-------------------------------------
+
+.o_livechat_button {
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ margin-right: 12px;
+ min-width: 100px;
+ cursor: pointer;
+ white-space: nowrap;
+ background-color: rgba(60, 60, 60, 0.6);
+ font-family: 'Lucida Grande', 'Lucida Sans Unicode', Arial, Verdana, sans-serif;
+ font-size: 14px;
+ font-weight: bold;
+ padding: 10px;
+ color: white;
+ text-shadow: rgb(59, 76, 88) 1px 1px 0px;
+ border: 1px solid rgb(80, 80, 80);
+ border-bottom: 0px;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ z-index: 5;
+}
+
+.o_thread_window {
+ z-index: $zindex-modal - 9; // to go over the navbar
+ .o_thread_date_separator {
+ display: none;
+ }
+
+ .btn {
+ color: #FFFFFF;
+ background-color: #30908e;
+ border-color: #2d8685;
+ border: 1px solid transparent;
+ }
+
+ .btn-sm {
+ padding: 0.0625rem 0.3125rem;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ border-radius: 0.2rem;
+ }
+
+ .o_livechat_rating {
+ /* Livechat Rating : feedback smiley */
+ flex: 1 1 auto;
+ overflow: auto;
+ padding: 15px;
+ font-size: 14px;
+
+ .o_livechat_email {
+ font-size: 12px;
+ > div {
+ display: flex;
+ padding: 5px 0;
+ input {
+ display: block;
+ width: 100%;
+ height: calc(1.5em + 0.75rem + 2px);
+ padding: 0.375rem 0.75rem;
+ font-size: 0.875rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: #495057;
+ background-color: #FFFFFF;
+ background-clip: padding-box;
+ border: 1px solid #CED4DA;
+ }
+ button {
+ display: inline-block;
+ font-weight: 400;
+ text-align: center;
+ vertical-align: middle;
+ user-select: none;
+ padding: 0.375rem 0.75rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ }
+ }
+ }
+
+ .o_livechat_no_feedback {
+ text-decoration: underline;
+ cursor: pointer;
+ margin: 20px 0;
+ }
+
+ .o_livechat_rating_box {
+ margin: 40px 0 30px 0;
+ }
+
+ .o_livechat_rating_choices {
+ margin: 10px 0;
+
+ > img {
+ width: 65px;
+ opacity: 0.60;
+ cursor: pointer;
+ margin: 10px;
+ &:hover, &.selected {
+ opacity: 1;
+ }
+ }
+ }
+
+ /* feedback reason */
+ .o_livechat_rating_reason {
+ margin: 10px 0px 25px 0px;
+ display: none; /* hidden by default */
+
+ > textarea {
+ width: 100%;
+ height: 70px;
+ resize: none;
+ }
+ }
+
+ .o_livechat_rating_reason_button > input {
+ float: right;
+ }
+ }
+
+ .o_composer_text_field {
+ line-height: 1.3em;
+ }
+}
+
+.o_livechat_operator_avatar {
+ padding-right: 8px;
+}
+
+.o_livechat_no_rating {
+ opacity: 0.5;
+}
diff --git a/addons/im_livechat/static/src/legacy/public_livechat.xml b/addons/im_livechat/static/src/legacy/public_livechat.xml
new file mode 100644
index 00000000..821edc30
--- /dev/null
+++ b/addons/im_livechat/static/src/legacy/public_livechat.xml
@@ -0,0 +1,658 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<templates xml:space="preserve">
+
+ <t t-name="im_livechat.legacy.im_livechat.FeedBack">
+ <div class="o_livechat_rating text-center">
+ <div class="o_livechat_rating_box">
+ <div class="o_livechat_rating_feedback_text">
+ Did we correctly answer your question ?
+ </div>
+ <div class="o_livechat_rating_choices">
+ <img t-att-src="widget.server_origin + '/rating/static/src/img/rating_5.png'" alt="Good" data-value="5"/>
+ <img t-att-src="widget.server_origin + '/rating/static/src/img/rating_3.png'" alt="OK" data-value="3"/>
+ <img t-att-src="widget.server_origin + '/rating/static/src/img/rating_1.png'" alt="Bad" data-value="1" />
+ </div>
+ </div>
+ <div class="o_livechat_rating_reason">
+ <textarea id="reason" placeholder="Explain your note"></textarea>
+ <div class="o_livechat_rating_reason_button">
+ <input type="button" class="btn btn-primary btn-sm o_rating_submit_button" value="Send" />
+ </div>
+ </div>
+ <div class="o_livechat_email text-left">
+ <span class="text-muted">Receive a copy of this conversation</span>
+ <div class="input-group">
+ <input id="o_email" type="text" class="form-control" placeholder="mail@example.com"/>
+ <button type="button" class="o_email_chat_button btn btn-primary rounded-0">
+ <i class="fa fa-paper-plane"/>
+ </button>
+ </div>
+ </div>
+ <div class="alert alert-danger px-0 o_livechat_email_error" style="display: none;" role="alert">
+ Oops! Something went wrong.<br />Please check your internet connection.<br />
+ <a href="#" class="alert-link">Try again</a>
+ </div>
+ <div class="o_livechat_no_feedback text-muted">
+ <span>Close conversation</span>
+ </div>
+ </div>
+ </t>
+
+ <!--
+ @param {im_livechat.legacy.mail.AbstractThreadWindow} widget
+ -->
+ <t t-name="im_livechat.legacy.mail.AbstractThreadWindow">
+ <div class="o_thread_window o_in_home_menu" t-att-data-thread-id="widget.getID()">
+ <div class="o_thread_window_header">
+ <t t-call="im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent">
+ <t t-set="status" t-value="widget.getThreadStatus()"/>
+ <t t-set="title" t-value="widget.getTitle()"/>
+ <t t-set="unreadCounter" t-value="widget.getUnreadCounter()"/>
+ <t t-set="thread" t-value="widget.getThread()"/>
+ </t>
+ </div>
+ <div class="o_thread_window_content">
+ </div>
+ <div t-if="widget.needsComposer()" class="o_thread_composer o_chat_mini_composer">
+ <input class="o_composer_text_field" t-att-placeholder="widget.options.placeholder"/>
+ </div>
+ </div>
+ </t>
+
+ <!--
+ @param {string} [status] e.g. 'online', 'offline'.
+ @param {string} title the title of the thread window, e.g. the record
+ name of the document.
+ @param {integer} [unreadCounter] the number of unread messages on the
+ thread.
+ @param {im_livechat.legacy.mail.model.Thread|undefined} thread
+ @param {Object} widget
+ @param {function} widget.isMobile function without any param that
+ states whether it should render for desktop or mobile screen.
+ -->
+ <t t-name="im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent">
+ <span t-if="widget.isMobile()">
+ <a href="#" class="o_thread_window_close fa fa-1x fa-arrow-left" aria-label="Close chat window" title="Close chat window"/>
+ </span>
+ <span class="o_thread_window_title">
+ <t t-esc="title"/>
+ <span t-if="unreadCounter"> (<t t-esc="unreadCounter"/>)</span>
+ <t t-if="thread and thread.hasTypingNotification() and thread.isSomeoneTyping()" t-call="im_livechat.legacy.mail.ThreadTypingIcon"/>
+ </span>
+ <span t-if="!widget.isMobile()" class="o_thread_window_buttons">
+ <a href="#" class="o_thread_window_close fa fa-close"/>
+ </span>
+ </t>
+
+ <!--
+ @param {string} status
+ @param {integer|undefined} [partnerID]
+ -->
+ <t t-name="im_livechat.legacy.mail.UserStatus">
+ <span t-att-class="partnerID ? 'o_updatable_im_status' : ''" t-att-data-partner-id="partnerID">
+ <i t-if="status == 'online'" class="o_mail_user_status o_user_online fa fa-circle" title="Online" role="img" aria-label="User is online"/>
+ <i t-if="status == 'away'" class="o_mail_user_status o_user_idle fa fa-circle" title="Idle" role="img" aria-label="User is idle"/>
+ <i t-if="status == 'offline'" class="o_mail_user_status fa fa-circle-o" title="Offline" role="img" aria-label="User is offline"/>
+ </span>
+ </t>
+
+ <!--
+ @param {mail.model.AbstractThread} thread
+ @param {Object} options
+ @param {boolean} [options.displayEmptyThread]
+ @param {boolean} [options.displayModerationCommands]
+ @param {boolean} [options.displayNoMatchFound]
+ @param {Array} [options.domain=[]] the domain to restrict messages on the thread.
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread">
+ <t t-if="thread.hasMessages({ 'domain': options.domain || [] })">
+ <t t-call="im_livechat.legacy.mail.widget.Thread.Content"/>
+ </t>
+ </t>
+
+ <!-- Rendering of thread when messaging not yet ready -->
+ <div t-name="im_livechat.legacy.mail.widget.ThreadLoading" class="o_mail_thread_loading">
+ <i class="o_mail_thread_loading_icon fa fa-spinner fa-spin"/>
+ <span>Please wait...</span>
+ </div>
+
+ <!--
+ @param {im_livechat.legacy.mail.DocumentViewer} widget
+ -->
+ <t t-name="im_livechat.legacy.mail.DocumentViewer.Content">
+ <div class="o_viewer_content">
+ <t t-set="model" t-value="widget.modelName"/>
+ <div class="o_viewer-header">
+ <span class="o_image_caption">
+ <i class="fa fa-picture-o mr8" t-if="widget.activeAttachment.fileType == 'image'" role="img" aria-label="Image" title="Image"/>
+ <i class="fa fa-file-text mr8" t-if="widget.activeAttachment.fileType == 'application/pdf'" role="img" aria-label="PDF file" title="PDF file"/>
+ <i class="fa fa-video-camera mr8" t-if="widget.activeAttachment.fileType == 'video'" role="img" aria-label="Video" title="Video"/>
+ <t t-esc="widget.activeAttachment.name"/>
+ <a role="button" href="#" class="o_download_btn ml8 small" data-toggle="tooltip" data-placement="right" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
+ </span>
+ <a role="button" class="o_close_btn float-right" href="#" aria-label="Close" title="Close">×</a>
+ </div>
+ <div class="o_viewer_img_wrapper">
+ <div class="o_viewer_zoomer">
+ <t t-if="widget.activeAttachment.fileType === 'image'">
+ <div class="o_loading_img text-center">
+ <i class="fa fa-circle-o-notch fa-spin text-gray-light fa-3x fa-fw" role="img" aria-label="Loading" title="Loading"/>
+ </div>
+ <t t-set="unique" t-value="widget.activeAttachment.checksum ? widget.activeAttachment.checksum.slice(-8) : ''"/>
+ <img class="o_viewer_img" t-attf-src="/web/image/#{widget.activeAttachment.id}?unique=#{unique}&amp;model=#{model}" alt="Viewer"/>
+ </t>
+ <iframe t-if="widget.activeAttachment.fileType == 'application/pdf'" class="mt32 o_viewer_pdf" t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/web/content/#{widget.activeAttachment.id}?model%3D#{model}" />
+ <iframe t-if="(widget.activeAttachment.fileType || '').indexOf('text') !== -1" class="mt32 o_viewer_text" t-attf-src="/web/content/#{widget.activeAttachment.id}?model=#{model}" />
+ <iframe t-if="widget.activeAttachment.fileType == 'youtu'" class="mt32 o_viewer_text" allow="autoplay; encrypted-media" width="560" height="315" t-attf-src="https://www.youtube.com/embed/#{widget.activeAttachment.youtube}"/>
+ <video t-if="widget.activeAttachment.fileType == 'video'" class="o_viewer_video" controls="controls">
+ <source t-attf-src="/web/image/#{widget.activeAttachment.id}?model=#{model}" t-att-data-type="widget.activeAttachment.mimetype"/>
+ </video>
+ </div>
+ </div>
+ <div t-if="widget.activeAttachment.fileType == 'image'" class="o_viewer_toolbar btn-toolbar" role="toolbar">
+ <div class="btn-group" role="group">
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_in" data-toggle="tooltip" title="Zoom In"><i class="fa fa-fw fa-plus" role="img" aria-label="Zoom In"/></a>
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_reset disabled" data-toggle="tooltip" title="Reset Zoom"><i class="fa fa-fw fa-search" role="img" aria-label="Reset Zoom"/></a>
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_zoom_out disabled" data-toggle="tooltip" title="Zoom Out"><i class="fa fa-fw fa-minus" role="img" aria-label="Zoom Out"/></a>
+ </div>
+ <div class="btn-group" role="group">
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_rotate" data-toggle="tooltip" title="Rotate"><i class="fa fa-fw fa-repeat" role="img" aria-label="Rotate"/></a>
+ </div>
+ <div class="btn-group" role="group">
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_print_btn" data-toggle="tooltip" title="Print"><i class="fa fa-fw fa-print" role="img" aria-label="Print"/></a>
+ <a role="button" href="#" class="o_viewer_toolbar_btn btn o_download_btn" data-toggle="tooltip" title="Download"><i class="fa fa-fw fa-download" role="img" aria-label="Download"/></a>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <!--
+ @param {im_livechat.legacy.mail.DocumentViewer} widget
+ -->
+ <t t-name="im_livechat.legacy.mail.DocumentViewer">
+ <div class="modal o_modal_fullscreen" tabindex="-1" data-keyboard="false" role="dialog">
+ <t class="o_document_viewer_content_call" t-call="im_livechat.legacy.mail.DocumentViewer.Content"/>
+
+ <t t-if="widget.attachment.length !== 1">
+ <a class="arrow arrow-left move_previous" href="#">
+ <span class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"/>
+ </a>
+ <a class="arrow arrow-right move_next" href="#">
+ <span class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"/>
+ </a>
+ </t>
+ </div>
+ </t>
+
+ <!--
+ @param {string} src
+ -->
+ <t t-name="im_livechat.legacy.mail.PrintImage">
+ <html>
+ <head>
+ <script>
+ function onload_img() {
+ setTimeout('print_img()', 10);
+ }
+ function print_img() {
+ window.print();
+ window.close();
+ }
+ </script>
+ </head>
+ <body onload='onload_img()'>
+ <img t-att-src='src' alt=""/>
+ </body>
+ </html>
+ </t>
+
+ <!--
+ @param {mail.model.AbstractThread} thread
+ @param {Object} options
+ @param {integer} [options.displayOrder] 1 or -1 ascending (respectively, descending) order for
+ the thread messages (from top to bottom)
+ @param {Array} [options.domain=[]] the domain to restrict messages on the thread.
+ @param {Object} ORDER
+ @param {integer} ORDER.ASC=1 messages are ordered by ascending order of IDs, (from top to bottom)
+ @param {integer} ORDER.DESC=-1 messages are ordered by descending IDs, (from top to bottom)
+
+ _____________ _____________
+ | | | |
+ | message 1 | | message n |
+ | message 2 | | ... |
+ | ... | | message 2 |
+ | message n | | message 1 |
+ |_____________| |_____________|
+
+ ORDER: ASC DESC
+
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.Content">
+ <t t-set="messages" t-value="thread.getMessages({ 'domain': options.domain || [] })"/>
+ <t t-if="options.displayOrder === ORDER.ASC" t-call="im_livechat.legacy.mail.widget.Thread.Content.ASC"/>
+ <t t-else="" t-call="im_livechat.legacy.mail.widget.Thread.Content.DESC"/>
+ </t>
+
+ <!--
+ @param {mail.model.AbstractThread} thread
+ @param {Object} options
+ @param {boolean} [options.displayBottomThreadFreeSpace=false]
+ @param {boolean} [options.displayLoadMore=false]
+
+ _____________
+ | |
+ | message 1 |
+ | message 2 |
+ | ... |
+ | message n |
+ |_____________|
+
+ ASC Order
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.Content.ASC">
+ <div class="o_mail_thread_content">
+ <t t-if="options.displayLoadMore" t-call="im_livechat.legacy.mail.widget.Thread.LoadMore"/>
+ <t t-call="im_livechat.legacy.mail.widget.Thread.Messages"/>
+ <t t-if="options.displayBottomThreadFreeSpace">
+ <div class="o_thread_bottom_free_space"/>
+ </t>
+ </div>
+ </t>
+
+ <!--
+ @param {mail.model.AbstractThread} thread
+ @param {Object} options
+ @param {boolean} [options.displayLoadMore=false]
+ @param {string|integer} [options.messagesSeparatorPosition] 'top' or
+ message ID, the separator is placed just after this message.
+
+ _____________
+ | |
+ | message n |
+ | ... |
+ | message 2 |
+ | message 1 |
+ |_____________|
+
+ DESC Order
+
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.Content.DESC">
+ <div class="o_mail_thread_content">
+ <t t-if="options.messagesSeparatorPosition == 'top'" t-call="im_livechat.legacy.mail.MessagesSeparator"/>
+ <t t-set="messages" t-value="messages.slice().reverse()"/>
+ <t t-call="im_livechat.legacy.mail.widget.Thread.Messages"/>
+ <t t-if="options.displayLoadMore" t-call="im_livechat.legacy.mail.widget.Thread.LoadMore"/>
+ </div>
+ </t>
+
+ <!--
+ @param {mail.model.AbstractMessage[]} messages messages are ordered based
+ on desired display order
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.Messages">
+ <t t-set="current_day" t-value="0"/>
+ <t t-foreach="messages" t-as="message">
+ <div t-if="current_day !== message.getDateDay()" class="o_thread_date_separator">
+ <span class="o_thread_date">
+ <t t-esc="message.getDateDay()"/>
+ </span>
+ <t t-set="current_day" t-value="message.getDateDay()"/>
+ </div>
+
+ <t t-call="im_livechat.legacy.mail.widget.Thread.Message"/>
+ </t>
+ </t>
+
+ <!--
+ @param {mail.model.AbstractThread} thread
+ @param {string} dateFormat
+ @param {Object} options
+ @param {mail.model.AbstractMessage} message
+ @param {Object} options
+ @param {boolean} [options.displayAvatars]
+ @param {boolean} [options.displayDocumentLinks]
+ @param {boolean} [options.displayNotificationIcons]
+ @param {boolean} [options.displayMarkAsRead]
+ @param {boolean} [options.displayModerationCommands] when set, display the moderation commands on
+ the message. This includes the moderation checkboxes (needs a control panel such as in Discuss app).
+ @param {boolean} [options.displayReplyIcons]
+ @param {boolean} [options.displayStars]
+ @param {boolean} [options.displaySubjectsOnMessages]
+ @param {boolean} options.hasMessageAttachmentDeletable
+ @param {string|integer} [options.messagesSeparatorPosition] 'top' or
+ message ID, the separator is placed just after this message.
+ @param {integer} [options.selectedMessageID]
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.Message">
+ <div t-if="!message.isEmpty()" t-att-class="'o_thread_message ' + (message.getID() === options.selectedMessageID ? 'o_thread_selected_message ' : ' ') + (message.isDiscussion() or message.isNotification() ? ' o_mail_discussion ' : ' o_mail_not_discussion ')" t-att-data-message-id="message.getID()">
+ <div t-if="options.displayAvatars" class="o_thread_message_sidebar">
+ <t t-if="message.hasAuthor()">
+ <div t-if="displayAuthorMessages[message.getID()]" class="o_thread_message_sidebar_image">
+ <img
+ alt=""
+ t-att-src="message.getAvatarSource()"
+ data-oe-model="res.partner"
+ t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
+ t-attf-class="o_thread_message_avatar rounded-circle #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}"/>
+ <t t-call="im_livechat.legacy.mail.UserStatus">
+ <t t-set="status" t-value="message.getAuthorImStatus()"/>
+ <t t-set="partnerID" t-value="message.getAuthorID()"/>
+ </t>
+ </div>
+ </t>
+ <t t-else="">
+ <img t-if="displayAuthorMessages[message.getID()]"
+ alt=""
+ t-att-src="message.getAvatarSource()"
+ class="o_thread_message_avatar rounded-circle"/>
+ </t>
+ <span t-if="!displayAuthorMessages[message.getID()]" t-att-title="message.getDate().format(dateFormat)" class="o_thread_message_side_date">
+ <t t-esc="message.getDate().format('hh:mm')"/>
+ </span>
+ <i t-if="!displayAuthorMessages[message.getID()] and options.displayStars and message.getType() !== 'notification'"
+ t-att-class="'fa o_thread_message_star o_thread_icon ' + (message.isStarred() ? 'fa-star' : 'fa-star-o')"
+ t-att-data-message-id="message.getID()" title="Mark as Todo" role="img" aria-label="Mark as todo"/>
+ <t t-if="!displayAuthorMessages[message.getID()] and thread.hasSeenFeature()" t-call="im_livechat.legacy.mail.widget.Thread.Message.SeenIcon"/>
+ </div>
+ <div class="o_thread_message_core">
+ <p t-if="displayAuthorMessages[message.getID()]" class="o_mail_info text-muted">
+ <t t-if="message.isNote()">
+ Note by
+ </t>
+
+ <strong t-if="message.hasAuthor()"
+ data-oe-model="res.partner" t-att-data-oe-id="message.shouldRedirectToAuthor() ? message.getAuthorID() : ''"
+ t-attf-class="o_thread_author #{message.shouldRedirectToAuthor() ? 'o_mail_redirect' : ''}">
+ <t t-esc="message.getDisplayedAuthor()"/>
+ </strong>
+ <strong t-elif="message.hasEmailFrom()">
+ <a class="text-muted" t-attf-href="mailto:#{message.getEmailFrom()}?subject=Re: #{message.hasSubject() ? message.getSubject() : ''}">
+ <t t-esc="message.getEmailFrom()"/>
+ </a>
+ </strong>
+ <strong t-else="" class="o_thread_author">
+ <t t-esc="message.getDisplayedAuthor()"/>
+ </strong>
+
+ - <small class="o_mail_timestamp" t-att-title="message.getDate().format(dateFormat)"><t t-esc="message.getTimeElapsed()"/></small>
+ <t t-if="message.isLinkedToDocumentThread() and options.displayDocumentLinks">
+ <small>on</small> <a t-att-href="message.getURL()" t-att-data-oe-model="message.getDocumentModel()" t-att-data-oe-id="message.getDocumentID()" class="o_document_link"><t t-esc="message.getDocumentName()"/></a>
+ </t>
+ <t t-if="message.originatesFromChannel() and (message.getOriginChannelID() !== thread.getID())">
+ (<small>from</small> <a t-att-data-oe-id="message.getOriginChannelID()" href="#">#<t t-esc="message.getOriginChannelName()"/></a>)
+ </t>
+ <span t-if="options.displayNotificationIcons and message.hasNotifications()" class="o_thread_tooltip_container">
+ <span name="notification_icon" t-attf-class="d-inline-flex align-items-center o_thread_tooltip o_thread_message_notification {{ message.hasNotificationsError() ? 'o_thread_message_notification_error' : '' }}" t-att-data-message-id="message.getID()" t-att-data-message-type="message.getType()">
+ <i t-att-class="message.getNotificationIcon()"/>
+ <small t-if="message.getNotificationText()" t-esc="message.getNotificationText()" class="font-weight-bold ml-1"/>
+ </span>
+ </span>
+ <span t-attf-class="o_thread_icons">
+ <t t-if="thread.hasSeenFeature()" t-call="im_livechat.legacy.mail.widget.Thread.Message.SeenIcon"/>
+ <i t-if="message.isLinkedToDocumentThread() and options.displayReplyIcons"
+ class="fa fa-reply o_thread_icon o_thread_message_reply"
+ t-att-data-message-id="message.getID()" title="Reply" role="img" aria-label="Reply"/>
+ <i t-if="message.isNeedaction() and options.displayMarkAsRead"
+ class="fa fa-check o_thread_icon o_thread_message_needaction"
+ t-att-data-message-id="message.getID()" title="Mark as Read" role="img" aria-label="Mark as Read"/>
+ </span>
+ </p>
+ <div class="o_thread_message_content">
+ <t t-raw="message.getBody()"/>
+ <t t-if="message.hasTrackingValues()">
+ <t t-if="message.hasSubtypeDescription()">
+ <p><t t-esc="message.getSubtypeDescription()"/></p>
+ </t>
+ <t t-call="im_livechat.legacy.mail.widget.Thread.MessageTracking"/>
+ </t>
+ <t t-if="message.hasAttachments()">
+ <div t-if="message.hasImageAttachments()" class="o_attachments_previews">
+ <t t-foreach="message.getImageAttachments()" t-as="attachment">
+ <t t-call="im_livechat.legacy.mail.AttachmentPreview">
+ <t t-set="isDeletable" t-value="options.hasMessageAttachmentDeletable"/>
+ </t>
+ </t>
+ </div>
+ <div t-if="message.hasNonImageAttachments()" class="o_attachments_list">
+ <t t-foreach="message.getNonImageAttachments()" t-as="attachment">
+ <t t-call="im_livechat.legacy.mail.Attachment">
+ <t t-set="isDeletable" t-value="options.hasMessageAttachmentDeletable"/>
+ </t>
+ </t>
+ </div>
+ </t>
+ </div>
+ </div>
+ </div>
+ <t t-if="options.messagesSeparatorPosition == message.getID()">
+ <t t-call="im_livechat.legacy.mail.MessagesSeparator"/>
+ </t>
+ </t>
+
+ <!--
+ Display the seen icon of a message in the thread.
+ It shows the fetch 'check' before the seen 'check'. We change the order in the template
+ so that fetch 'check' is on top of 'seen' check (z-index does not work with absolute positioning)
+
+ @param {mail.model.Thread} thread
+ @param {mail.model.Message} message
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.Message.SeenIcon">
+ <t t-if="message.isMyselfAuthor() and message.getID() >= thread.getLastMessageIDSeenByEveryone()">
+ <span t-attf-class="o_mail_thread_message_seen_icon #{thread.hasEveryoneSeen(message) ? 'o_all_seen' : ''}" t-att-data-message-id="message.getID()">
+ <t t-if="thread.hasSomeoneFetched(message)">
+ <i class="fa fa-check"/>
+ </t>
+ <t t-if="thread.hasSomeoneSeen(message)">
+ <i class="fa fa-check"/>
+ </t>
+ </span>
+ </t>
+ </t>
+
+ <!--
+ Display the popover content when clicking the seen icion of a message in the thread.
+
+ List the members that have received the messages, and the members that have seen it.
+
+ @param {mail.model.Thread} thread
+ @param {mail.model.Message} message
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.Message.SeenIconPopoverContent">
+ <div class="o_mail_thread_message_seen_icon_content">
+ <t t-if="thread.hasEveryoneSeen(message)">
+ <p>Seen by Everyone</p>
+ </t>
+ <t t-else="">
+ <t t-if="thread.hasSomeoneSeen(message)">
+ <t t-set="seen_members" t-value="thread.getSeenMembers(message)"/>
+ Seen by:
+ <ul>
+ <li t-foreach="seen_members" t-as="member">
+ <t t-esc="member.name"/> (<t t-esc="member.email"/>)
+ </li>
+ </ul>
+ </t>
+ <t t-if="thread.hasSomeoneFetched(message)">
+ <t t-if="thread.hasEveryoneFetched(message)">
+ <p>Received by Everyone</p>
+ </t>
+ <t t-else="">
+ <t t-set="fetched_members" t-value="thread.getFetchedNotSeenMembers(message)"/>
+ <t t-if="!_.isEmpty(fetched_members)">
+ Received by:
+ <ul>
+ <li t-foreach="fetched_members" t-as="member">
+ <t t-esc="member.name"/> (<t t-esc="member.email"/>)
+ </li>
+ </ul>
+ </t>
+ </t>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <!--
+ @param {Object[]} notifications: list of notifications
+ A notification is an object with at least the following keys:
+ {notification_status, partner_name}
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.Message.MailTooltip">
+ <div t-foreach="notifications" t-as="notification">
+ <span name="notification_status" class="d-inline-block text-center o_thread_tooltip_icon">
+ <i t-if="notification.notification_status === 'sent'" class='fa fa-check' title="Sent" role="img" aria-label="Sent"/>
+ <i t-if="notification.notification_status === 'bounce'" class='fa fa-exclamation text-danger' title="Bounced" role="img" aria-label="Bounced"/>
+ <i t-if="notification.notification_status === 'exception'" class='fa fa-exclamation text-danger' title="Error" role="img" aria-label="Error"/>
+ <i t-if="notification.notification_status === 'ready'" class='fa fa-send-o' title="Ready" role="img" aria-label="Ready"/>
+ <i t-if="notification.notification_status === 'canceled'" class='fa fa-trash-o' title="Canceled" role="img" aria-label="Canceled"/>
+ </span>
+ <span name="partner_name" t-esc="notification.partner_name"/>
+ </div>
+ </t>
+
+ <t t-name="im_livechat.legacy.mail.MessagesSeparator">
+ <div class="o_thread_new_messages_separator">
+ <span class="o_thread_separator_label">New messages</span>
+ </div>
+ </t>
+
+ <!--
+ @param {mail.model.Message} message
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.MessageTracking">
+ <ul class="o_mail_thread_message_tracking">
+ <t t-foreach='message.getTrackingValues()' t-as='value'>
+ <li>
+ <t t-esc="value.changed_field"/>:
+ <t t-if="value.old_value">
+ <span> <t t-esc="value.old_value || ((value.field_type !== 'boolean') and '')"/> </span>
+ <span t-if="value.old_value !== value.new_value" class="fa fa-long-arrow-right" role="img" aria-label="Changed" title="Changed"/>
+ </t>
+ <span t-if="value.old_value !== value.new_value">
+ <t t-esc="value.new_value || ((value.field_type !== 'boolean') and '')"/>
+ </span>
+ </li>
+ </t>
+ </ul>
+ </t>
+
+ <!--
+ @param {Array} attachments
+ -->
+ <t t-name="im_livechat.legacy.mail.composer.Attachments">
+ <div t-if="attachments.length > 0" class="o_attachments o_attachments_list">
+ <t t-foreach="attachments" t-as='attachment'>
+ <t t-call="im_livechat.legacy.mail.Attachment">
+ <t t-set="editable" t-value="true"/>
+ </t>
+ </t>
+ </div>
+ </t>
+
+ <!--
+ @param {Object} attachment
+ @param {integer} attachment.id
+ @param {string} attachment.name
+ @param {string} attachment.url
+ @param {boolean} [isDeletable=false]
+ -->
+ <t t-name="im_livechat.legacy.mail.AttachmentPreview">
+ <div class="o_attachment" t-att-title="attachment.name">
+ <div class="o_attachment_wrap">
+ <div class="o_image_box">
+ <div class="o_attachment_image" t-attf-style="background-image:url('/web/image/#{attachment.id}/160x160/?crop=true')"/>
+ <div t-attf-class="o_image_overlay o_attachment_view" t-att-data-id="attachment.id">
+ <span t-if="isDeletable" class="fa fa-times o_attachment_delete_cross" t-att-title="'Delete ' + attachment.name" t-att-data-id="attachment.id" t-att-data-name="attachment.name"/>
+ <span class="o_attachment_title text-white"><t t-esc="attachment.name"/></span>
+ <a class="o_attachment_download" t-att-href='attachment.url'>
+ <i t-attf-class="fa fa-download text-white" t-att-title="'Download ' + attachment.name" role="img" aria-label="Download"></i>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </t>
+
+ <!--
+ @param {Object} attachment
+ @param {string} attachment.filename
+ @param {integer} attachment.id
+ @param {string} [attachment.mimetype]
+ @param {string} attachment.name
+ @param {boolean} attachment.upload
+ @param {string} attachment.url
+ @param {boolean} [editable=false] if set, it means the attachment is rendered in the composer.
+ Some changes are required in that case, such as "delete" button is not visible (pretty unlink is used instead).
+ @param {boolean} [isDeletable=false]
+ -->
+ <t t-name="im_livechat.legacy.mail.Attachment">
+ <t t-set="type" t-value="attachment.mimetype and attachment.mimetype.split('/').shift()"/>
+ <div t-attf-class="o_attachment #{ editable ? 'o_attachment_editable' : '' } #{attachment.upload ? 'o_attachment_uploading' : ''}" t-att-title="attachment.name">
+ <div class="o_attachment_wrap">
+ <span t-if="!editable and isDeletable" class="fa fa-times o_attachment_delete_cross" t-att-title="'Delete ' + attachment.name" t-att-data-id="attachment.id" t-att-data-name="attachment.name"/>
+ <t t-set="has_preview" t-value="type == 'image' or type == 'video' or attachment.mimetype == 'application/pdf'"/>
+ <t t-set="ext" t-value="attachment.filename.split('.').pop()"/>
+
+ <div t-attf-class="o_image_box float-left #{has_preview ? 'o_attachment_view' : ''}" t-att-data-id="attachment.id">
+ <div t-if="has_preview"
+ class="o_image o_hover"
+ t-att-style="type == 'image' ? 'background-image:url(/web/image/' + attachment.id + '/38x38/?crop=true' : '' "
+ t-att-data-mimetype="attachment.mimetype">
+ </div>
+ <a t-elif="!editable" t-att-href='attachment.url' t-att-title="'Download ' + attachment.name" aria-label="Download">
+ <span class="o_image o_hover" t-att-data-mimetype="attachment.mimetype" t-att-data-ext="ext"/>
+ </a>
+ <span t-else="" class="o_image" t-att-data-mimetype="attachment.mimetype" t-att-data-ext="ext" role="img" aria-label="Document not downloadable"/>
+ </div>
+
+ <div class="caption">
+ <span t-if="has_preview or editable" t-attf-class="ml4 #{has_preview? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"><t t-esc='attachment.name'/></span>
+ <a t-else="" class="ml4" t-att-href="attachment.url" t-att-title="'Download ' + attachment.name"><t t-esc='attachment.name'/></a>
+ </div>
+ <div t-if="editable" class="caption small">
+ <b t-attf-class="ml4 small text-uppercase #{has_preview? 'o_attachment_view' : ''}" t-att-data-id="attachment.id"><t t-esc="ext"/></b>
+ <div class="progress o_attachment_progress_bar">
+ <div class="progress-bar progress-bar-striped active" style="width: 100%">Uploading</div>
+ </div>
+ </div>
+ <div t-if="!editable" class="caption small">
+ <b t-if="has_preview" class="ml4 small text-uppercase o_attachment_view" t-att-data-id="attachment.id"><t t-esc="ext"/></b>
+ <a t-else="" class="ml4 small text-uppercase" t-att-href="attachment.url" t-att-title="'Download ' + attachment.name"><b><t t-esc='ext'/></b></a>
+ <a class="ml4 o_attachment_download float-right" t-att-title="'Download ' + attachment.name" t-att-href='attachment.url'><i t-attf-class="fa fa-download" role="img" aria-label="Download"/></a>
+ </div>
+ <div t-if="editable" class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div>
+ <div t-if="editable" class="o_attachment_delete" t-att-data-id="attachment.id"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div>
+ </div>
+ </div>
+ </t>
+
+ <!--
+ @param {Object} options
+ @param {boolean} [options.loadMoreOnScroll]
+ -->
+ <t t-name="im_livechat.legacy.mail.widget.Thread.LoadMore">
+ <div class="o_thread_show_more">
+ <t t-if="options.loadMoreOnScroll">
+ <span><i class="fa fa-spinner fa-spin" role="img" aria-label="Please wait" title="Please wait"/> Loading older messages... </span>
+ </t>
+ <t t-else="">
+ <button class="btn btn-link">-------- Show older messages --------</button>
+ </t>
+ </div>
+ </t>
+
+ <!--
+ @param {mail.model.Thread} thread with typing feature
+ -->
+ <t t-name="im_livechat.legacy.mail.ThreadTypingIcon">
+ <span class="o_mail_thread_typing_icon" t-att-title="thread.getTypingMembersToText()">
+ <span class="o_mail_thread_typing_icon_dot"/>
+ <span class="o_mail_thread_typing_icon_dot"/>
+ <span class="o_mail_thread_typing_icon_dot"/>
+ </span>
+ </t>
+
+</templates>
diff --git a/addons/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js b/addons/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js
new file mode 100644
index 00000000..56e02d9b
--- /dev/null
+++ b/addons/im_livechat/static/src/models/messaging_initializer/messaging_initializer.js
@@ -0,0 +1,33 @@
+odoo.define('im_livechat/static/src/models/messaging_initializer/messaging_initializer.js', function (require) {
+'use strict';
+
+const { registerInstancePatchModel } = require('mail/static/src/model/model_core.js');
+const { executeGracefully } = require('mail/static/src/utils/utils.js');
+
+registerInstancePatchModel('mail.messaging_initializer', 'im_livechat/static/src/models/messaging_initializer/messaging_initializer.js', {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ * @param {Object[]} [param0.channel_livechat=[]]
+ */
+ async _initChannels(initMessagingData) {
+ await this.async(() => this._super(initMessagingData));
+ const { channel_livechat = [] } = initMessagingData;
+ return executeGracefully(channel_livechat.map(data => () => {
+ const channel = this.env.models['mail.thread'].insert(
+ this.env.models['mail.thread'].convertData(data),
+ );
+ // flux specific: channels received at init have to be
+ // considered pinned. task-2284357
+ if (!channel.isPinned) {
+ channel.pin();
+ }
+ }));
+ },
+});
+
+});
diff --git a/addons/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js b/addons/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js
new file mode 100644
index 00000000..efae3eed
--- /dev/null
+++ b/addons/im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js
@@ -0,0 +1,42 @@
+odoo.define('im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js', function (require) {
+'use strict';
+
+const { registerInstancePatchModel } = require('mail/static/src/model/model_core.js');
+
+registerInstancePatchModel('mail.messaging_notification_handler', 'im_livechat/static/src/models/messaging_notification_handler/messaging_notification_handler.js', {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _handleNotificationChannelTypingStatus(channelId, data) {
+ const { partner_id, partner_name } = data;
+ const channel = this.env.models['mail.thread'].findFromIdentifyingData({
+ id: channelId,
+ model: 'mail.channel',
+ });
+ if (!channel) {
+ return;
+ }
+ let partnerId;
+ let partnerName;
+ if (this.env.messaging.publicPartners.some(publicPartner => publicPartner.id === partner_id)) {
+ // Some shenanigans that this is a typing notification
+ // from public partner.
+ partnerId = channel.correspondent.id;
+ partnerName = channel.correspondent.name;
+ } else {
+ partnerId = partner_id;
+ partnerName = partner_name;
+ }
+ this._super(channelId, Object.assign(data, {
+ partner_id: partnerId,
+ partner_name: partnerName,
+ }));
+ },
+});
+
+});
diff --git a/addons/im_livechat/static/src/models/partner/partner.js b/addons/im_livechat/static/src/models/partner/partner.js
new file mode 100644
index 00000000..7392c8d6
--- /dev/null
+++ b/addons/im_livechat/static/src/models/partner/partner.js
@@ -0,0 +1,23 @@
+odoo.define('im_livechat/static/src/models/partner/partner.js', function (require) {
+'use strict';
+
+const {
+ registerClassPatchModel,
+} = require('mail/static/src/model/model_core.js');
+
+let nextPublicId = -1;
+
+registerClassPatchModel('mail.partner', 'im_livechat/static/src/models/partner/partner.js', {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ getNextPublicId() {
+ const id = nextPublicId;
+ nextPublicId -= 1;
+ return id;
+ },
+});
+
+});
diff --git a/addons/im_livechat/static/src/models/thread/thread.js b/addons/im_livechat/static/src/models/thread/thread.js
new file mode 100644
index 00000000..745aff78
--- /dev/null
+++ b/addons/im_livechat/static/src/models/thread/thread.js
@@ -0,0 +1,97 @@
+odoo.define('im_livechat/static/src/models/thread/thread.js', function (require) {
+'use strict';
+
+const {
+ registerClassPatchModel,
+ registerInstancePatchModel,
+} = require('mail/static/src/model/model_core.js');
+
+registerClassPatchModel('mail.thread', 'im_livechat/static/src/models/thread/thread.js', {
+
+ //----------------------------------------------------------------------
+ // Public
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ convertData(data) {
+ const data2 = this._super(data);
+ if ('livechat_visitor' in data && data.livechat_visitor) {
+ if (!data2.members) {
+ data2.members = [];
+ }
+ // `livechat_visitor` without `id` is the anonymous visitor.
+ if (!data.livechat_visitor.id) {
+ /**
+ * Create partner derived from public partner and replace the
+ * public partner.
+ *
+ * Indeed the anonymous visitor is registered as a member of the
+ * channel as the public partner in the database to avoid
+ * polluting the contact list with many temporary partners.
+ *
+ * But the issue with public partner is that it is the same
+ * record for every livechat, whereas every correspondent should
+ * actually have its own visitor name, typing status, etc.
+ *
+ * Due to JS being temporary by nature there is no such notion
+ * of polluting the database, it is therefore acceptable and
+ * easier to handle one temporary partner per channel.
+ */
+ data2.members.push(['unlink', this.env.messaging.publicPartners]);
+ const partner = this.env.models['mail.partner'].create(
+ Object.assign(
+ this.env.models['mail.partner'].convertData(data.livechat_visitor),
+ { id: this.env.models['mail.partner'].getNextPublicId() }
+ )
+ );
+ data2.members.push(['link', partner]);
+ data2.correspondent = [['link', partner]];
+ } else {
+ const partnerData = this.env.models['mail.partner'].convertData(data.livechat_visitor);
+ data2.members.push(['insert', partnerData]);
+ data2.correspondent = [['insert', partnerData]];
+ }
+ }
+ return data2;
+ },
+});
+
+registerInstancePatchModel('mail.thread', 'im_livechat/static/src/models/thread/thread.js', {
+
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeCorrespondent() {
+ if (this.channel_type === 'livechat') {
+ // livechat correspondent never change: always the public member.
+ return [];
+ }
+ return this._super();
+ },
+ /**
+ * @override
+ */
+ _computeDisplayName() {
+ if (this.channel_type === 'livechat' && this.correspondent) {
+ if (this.correspondent.country) {
+ return `${this.correspondent.nameOrDisplayName} (${this.correspondent.country.name})`;
+ }
+ return this.correspondent.nameOrDisplayName;
+ }
+ return this._super();
+ },
+ /**
+ * @override
+ */
+ _computeIsChatChannel() {
+ return this.channel_type === 'livechat' || this._super();
+ },
+});
+
+});
diff --git a/addons/im_livechat/static/src/scss/im_livechat_bootstrap.scss b/addons/im_livechat/static/src/scss/im_livechat_bootstrap.scss
new file mode 100644
index 00000000..dddf5baa
--- /dev/null
+++ b/addons/im_livechat/static/src/scss/im_livechat_bootstrap.scss
@@ -0,0 +1,35 @@
+.text-muted {
+ color: gray('600');
+}
+
+.text-left {
+ text-align: left;
+}
+
+.text-center {
+ text-align: center;
+}
+
+.o_thread_window {
+ &,* {
+ box-sizing: border-box;
+ }
+ .o_thread_window_header {
+ height: 28px;
+ .fa-close {
+ text-decoration: none;
+ font-weight: bold;
+ &:before {
+ content: "\00d7";
+ font-size: initial;
+ }
+ }
+ > span {
+ margin: auto 0;
+ }
+ }
+
+ .o_email_chat_button:after {
+ content:' \27A4';
+ }
+}
diff --git a/addons/im_livechat/static/src/scss/im_livechat_form.scss b/addons/im_livechat/static/src/scss/im_livechat_form.scss
new file mode 100644
index 00000000..808e5ba8
--- /dev/null
+++ b/addons/im_livechat/static/src/scss/im_livechat_form.scss
@@ -0,0 +1,21 @@
+.o_livechat_rules_form {
+ .o_form_sheet_bg {
+ .o_form_sheet {
+ min-height: unset;
+ }
+ }
+}
+
+.o_livechat_layout_colors {
+ vertical-align: middle;
+ align-items: center;
+}
+
+.o_form_view .o_group {
+ // long selector in order to be more specific...
+ .o_im_livechat_field_widget_color {
+ width: 30px;
+ margin: 0 5px 0 0;
+ }
+}
+
diff --git a/addons/im_livechat/static/src/scss/im_livechat_history.scss b/addons/im_livechat/static/src/scss/im_livechat_history.scss
new file mode 100644
index 00000000..ee4d6b4f
--- /dev/null
+++ b/addons/im_livechat/static/src/scss/im_livechat_history.scss
@@ -0,0 +1,39 @@
+.o_history_container{
+ table-layout: fixed;
+ width: 200% !important;
+ > tbody > tr > td {
+ padding: 0px !important;
+ }
+ .o_history_kanban_container {
+ text-align:center;
+ .o_history_kanban_sub_container {
+ .o_kanban_ungrouped {
+ flex-flow: column;
+ max-height:500px;
+ overflow:auto;
+ padding:2px 0px;
+ .rounded-circle {
+ width:32px;
+ height: 32px;
+ }
+ .o_kanban_record {
+ padding: 0px 8px;
+ margin: -1px 8px;
+ min-width: 300px;
+ width: 98%;
+ word-break: break-all;
+ }
+ }
+ .oe_module_vignette {
+ text-align:left;
+ }
+ .o_kanban_image {
+ padding-top: 8px;
+ }
+ .oe_module_desc {
+ padding: 8px 8px 0px 64px;
+ }
+ }
+
+ }
+}
diff --git a/addons/im_livechat/static/src/widgets/discuss/discuss.js b/addons/im_livechat/static/src/widgets/discuss/discuss.js
new file mode 100644
index 00000000..37673669
--- /dev/null
+++ b/addons/im_livechat/static/src/widgets/discuss/discuss.js
@@ -0,0 +1,25 @@
+odoo.define('im_livechat/static/src/widgets/discuss/discuss.js', function (require) {
+'use strict';
+
+const Discuss = require('mail/static/src/widgets/discuss/discuss.js');
+
+Discuss.include({
+ //----------------------------------------------------------------------
+ // Private
+ //----------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _shouldHaveInviteButton() {
+ if (
+ this.discuss.thread &&
+ this.discuss.thread.channel_type === 'livechat'
+ ) {
+ return true;
+ }
+ return this._super();
+ },
+});
+
+});