diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/im_livechat/static/src | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/im_livechat/static/src')
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 Binary files differnew file mode 100644 index 00000000..be2fc803 --- /dev/null +++ b/addons/im_livechat/static/src/img/canned-responses.gif diff --git a/addons/im_livechat/static/src/img/default.png b/addons/im_livechat/static/src/img/default.png Binary files differnew file mode 100644 index 00000000..07d2503c --- /dev/null +++ b/addons/im_livechat/static/src/img/default.png 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}&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(); + }, +}); + +}); |
