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 | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/im_livechat/static')
39 files changed, 7198 insertions, 0 deletions
diff --git a/addons/im_livechat/static/description/icon.png b/addons/im_livechat/static/description/icon.png Binary files differnew file mode 100644 index 00000000..feb27485 --- /dev/null +++ b/addons/im_livechat/static/description/icon.png diff --git a/addons/im_livechat/static/description/icon.svg b/addons/im_livechat/static/description/icon.svg new file mode 100644 index 00000000..5c3dc544 --- /dev/null +++ b/addons/im_livechat/static/description/icon.svg @@ -0,0 +1,27 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"> + <defs> + <path id="icon-a" d="M4,5.35309892e-14 C36.4160122,9.87060235e-15 58.0836068,-3.97961823e-14 65,5.07020818e-14 C69,6.733808e-14 70,1 70,5 C70,43.0488877 70,62.4235458 70,65 C70,69 69,70 65,70 C61,70 9,70 4,70 C1,70 7.10542736e-15,69 7.10542736e-15,65 C7.25721566e-15,62.4676575 3.83358709e-14,41.8005206 3.60818146e-14,5 C-1.13686838e-13,1 1,5.75716207e-14 4,5.35309892e-14 Z"/> + <linearGradient id="icon-c" x1="100%" x2="0%" y1="0%" y2="100%"> + <stop offset="0%" stop-color="#B06161"/> + <stop offset="45.785%" stop-color="#984E4E"/> + <stop offset="100%" stop-color="#7C3838"/> + </linearGradient> + <path id="icon-d" d="M43.2723381,47 L4.024685,47 C2.0123425,47 0,45.9810543 0,42.9242174 L1.48938458e-14,18.2012896 L16.225629,0 L25.9851921,2.3438768 L45.134626,15.7216414 L52.320905,8.28006517 L57.3517613,13.3747934 L52.0323386,25.8943714 L59,31.065596 L43.4971227,46.7652986 L43.2723381,47 Z"/> + <path id="icon-e" d="M29.8148059,45.4374895 C26.7685921,45.4374895 23.8982694,44.8827122 21.3772984,43.9035051 C18.8353437,45.9172903 15.7166184,47.1443958 12.4033677,47.4961458 C12.3794138,47.4986872 12.3553463,47.4999741 12.3312612,47.5000012 C12.0285762,47.5000012 11.7551388,47.2950872 11.6817361,47.0028098 C11.602338,46.6778841 11.8509027,46.4778919 12.0970368,46.2395091 C13.3136913,45.0550598 14.7886327,44.1239231 15.3654843,40.1448333 C13.0451961,37.8929934 11.6666667,35.0822747 11.6666667,32.0312864 C11.6666667,24.6264075 19.792577,18.6250012 29.8148059,18.6250012 C39.8370347,18.6250012 47.962945,24.6263255 47.962945,32.0312864 C47.962864,39.4413333 39.8370347,45.4374895 29.8148059,45.4374895 Z M57.9336461,54.2291067 C56.8039245,53.1522825 55.4343071,52.305802 54.8986939,48.68847 C60.4734134,43.3918762 59.125509,35.8148958 51.8464038,31.7026692 C51.8489153,31.8120989 51.851751,31.9214466 51.851751,32.0312864 C51.851751,42.0795403 41.3531335,49.7823567 28.8220864,49.3580911 C31.9105919,51.8978606 36.4369322,53.500013 41.4813857,53.500013 C44.3100649,53.500013 46.9753298,52.9956848 49.3161967,52.1054817 C51.676589,53.9361731 54.5725135,55.0517161 57.6491903,55.3714739 C57.9559262,55.4038762 58.2457293,55.2096262 58.3192131,54.9230091 C58.3930209,54.6276145 58.1621993,54.4458333 57.9336461,54.2291067 Z"/> + </defs> + <g fill="none" fill-rule="evenodd"> + <mask id="icon-b" fill="#fff"> + <use xlink:href="#icon-a"/> + </mask> + <g mask="url(#icon-b)"> + <rect width="70" height="70" fill="url(#icon-c)"/> + <path fill="#FFF" fill-opacity=".383" d="M4,1.8 L65,1.8 C67.6666667,1.8 69.3333333,1.13333333 70,-0.2 C70,2.46666667 70,3.46666667 70,2.8 L1.10547097e-14,2.8 C-1.65952376e-14,3.46666667 -2.9161925e-14,2.46666667 -2.66453526e-14,-0.2 C0.666666667,1.13333333 2,1.8 4,1.8 Z" transform="matrix(1 0 0 -1 0 2.8)"/> + <g transform="translate(0 22)"> + <use fill="#000" fill-opacity=".151" xlink:href="#icon-d"/> + </g> + <path fill="#000" fill-opacity=".383" d="M4,4 L65,4 C67.6666667,4 69.3333333,3 70,1 C70,3.66666667 70,5 70,5 L1.77635684e-15,5 C1.77635684e-15,5 1.77635684e-15,3.66666667 1.77635684e-15,1 C0.666666667,3 2,4 4,4 Z" transform="translate(0 65)"/> + <use fill="#000" fill-rule="nonzero" opacity=".345" xlink:href="#icon-e"/> + <path fill="#FFF" fill-rule="nonzero" d="M29.8148059,43.4374895 C26.7685921,43.4374895 23.8982694,42.8827122 21.3772984,41.9035051 C18.8353437,43.9172903 15.7166184,45.1443958 12.4033677,45.4961458 C12.3794138,45.4986872 12.3553463,45.4999741 12.3312612,45.5000012 C12.0285762,45.5000012 11.7551388,45.2950872 11.6817361,45.0028098 C11.602338,44.6778841 11.8509027,44.4778919 12.0970368,44.2395091 C13.3136913,43.0550598 14.7886327,42.1239231 15.3654843,38.1448333 C13.0451961,35.8929934 11.6666667,33.0822747 11.6666667,30.0312864 C11.6666667,22.6264075 19.792577,16.6250012 29.8148059,16.6250012 C39.8370347,16.6250012 47.962945,22.6263255 47.962945,30.0312864 C47.962864,37.4413333 39.8370347,43.4374895 29.8148059,43.4374895 Z M57.9336461,52.2291067 C56.8039245,51.1522825 55.4343071,50.305802 54.8986939,46.68847 C60.4734134,41.3918762 59.125509,33.8148958 51.8464038,29.7026692 C51.8489153,29.8120989 51.851751,29.9214466 51.851751,30.0312864 C51.851751,40.0795403 41.3531335,47.7823567 28.8220864,47.3580911 C31.9105919,49.8978606 36.4369322,51.500013 41.4813857,51.500013 C44.3100649,51.500013 46.9753298,50.9956848 49.3161967,50.1054817 C51.676589,51.9361731 54.5725135,53.0517161 57.6491903,53.3714739 C57.9559262,53.4038762 58.2457293,53.2096262 58.3192131,52.9230091 C58.3930209,52.6276145 58.1621993,52.4458333 57.9336461,52.2291067 Z"/> + </g> + </g> +</svg> 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(); + }, +}); + +}); diff --git a/addons/im_livechat/static/tests/helpers/mock_models.js b/addons/im_livechat/static/tests/helpers/mock_models.js new file mode 100644 index 00000000..db18e8f7 --- /dev/null +++ b/addons/im_livechat/static/tests/helpers/mock_models.js @@ -0,0 +1,42 @@ +odoo.define('im_livechat/static/tests/helpers/mock_models.js', function (require) { +'use strict'; + +const MockModels = require('mail/static/tests/helpers/mock_models.js'); + +MockModels.patch('im_livechat/static/tests/helpers/mock_models.js', T => + class extends T { + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + /** + * @override + */ + static generateData() { + const data = super.generateData(...arguments); + Object.assign(data, { + 'im_livechat.channel': { + fields: { + user_ids: { string: "Operators", type: 'many2many', relation: 'res.users' } + }, + records: [], + } + }); + Object.assign(data['mail.channel'].fields, { + anonymous_name: { string: "Anonymous Name", type: 'char' }, + country_id: { string: "Country", type: 'many2one', relation: 'res.country' }, + livechat_active: { string: "Is livechat ongoing?", type: 'boolean', default: false }, + livechat_channel_id: { string: "Channel", type: 'many2one', relation: 'im_livechat.channel' }, + livechat_operator_id: { string: "Operator", type: 'many2one', relation: 'res.partner' }, + }); + Object.assign(data['res.users'].fields, { + livechat_username: { string: 'Livechat Username', type: 'string' }, + }); + return data; + } + + } +); + +}); diff --git a/addons/im_livechat/static/tests/helpers/mock_server.js b/addons/im_livechat/static/tests/helpers/mock_server.js new file mode 100644 index 00000000..e940cd5e --- /dev/null +++ b/addons/im_livechat/static/tests/helpers/mock_server.js @@ -0,0 +1,263 @@ +odoo.define('im_livechat/static/tests/helpers/mock_server.js', function (require) { +'use strict'; + +require('mail.MockServer'); // ensure mail overrides are applied first + +const MockServer = require('web.MockServer'); + +MockServer.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + */ + async _performRpc(route, args) { + if (route === '/im_livechat/get_session') { + const channel_id = args.channel_id; + const anonymous_name = args.anonymous_name; + const previous_operator_id = args.previous_operator_id; + const context = args.context; + return this._mockRouteImLivechatGetSession(channel_id, anonymous_name, previous_operator_id, context); + } + if (route === '/im_livechat/notify_typing') { + const uuid = args.uuid; + const is_typing = args.is_typing; + const context = args.context; + return this._mockRouteImLivechatNotifyTyping(uuid, is_typing, context); + } + return this._super(...arguments); + }, + + //-------------------------------------------------------------------------- + // Private Mocked Routes + //-------------------------------------------------------------------------- + + /** + * Simulates the `/im_livechat/get_session` route. + * + * @private + * @param {integer} channel_id + * @param {string} anonymous_name + * @param {integer} [previous_operator_id] + * @param {Object} [context={}] + * @returns {Object} + */ + _mockRouteImLivechatGetSession(channel_id, anonymous_name, previous_operator_id, context = {}) { + let user_id; + let country_id; + if ('mockedUserId' in context) { + // can be falsy to simulate not being logged in + user_id = context.mockedUserId; + } else { + user_id = this.currentUserId; + } + // don't use the anonymous name if the user is logged in + if (user_id) { + const user = this._getRecords('res.users', [['id', '=', user_id]])[0]; + country_id = user.country_id; + } else { + // simulate geoip + const countryCode = context.mockedCountryCode; + const country = this._getRecords('res.country', [['code', '=', countryCode]])[0]; + if (country) { + country_id = country.id; + anonymous_name = anonymous_name + ' (' + country.name + ')'; + } + } + return this._mockImLivechatChannel_openLivechatMailChannel(channel_id, anonymous_name, previous_operator_id, user_id, country_id); + }, + /** + * Simulates the `/im_livechat/notify_typing` route. + * + * @private + * @param {string} uuid + * @param {boolean} is_typing + * @param {Object} [context] + */ + _mockRouteImLivechatNotifyTyping(uuid, is_typing, context) { + const mailChannel = this._getRecords('mail.channel', [['uuid', '=', uuid]])[0]; + this._mockMailChannelNotifyTyping([mailChannel.id], is_typing, context); + }, + /** + * @override + */ + _mockRouteMailInitMessaging() { + const initMessaging = this._super(...arguments); + + const livechats = this._getRecords('mail.channel', [ + ['channel_type', '=', 'livechat'], + ['is_pinned', '=', true], + ['members', 'in', this.currentPartnerId], + ]); + initMessaging.channel_slots.channel_livechat = this._mockMailChannelChannelInfo( + livechats.map(channel => channel.id) + ); + + return initMessaging; + }, + + //-------------------------------------------------------------------------- + // Private Mocked Methods + //-------------------------------------------------------------------------- + + /** + * Simulates `_channel_get_livechat_visitor_info` on `mail.channel`. + * + * @private + * @param {integer[]} ids + * @returns {Object} + */ + _mockMailChannel_ChannelGetLivechatVisitorInfo(ids) { + const id = ids[0]; // ensure_one + const mailChannel = this._getRecords('mail.channel', [['id', '=', id]])[0]; + // remove active test to ensure public partner is taken into account + let members = this._getRecords( + 'res.partner', + [['id', 'in', mailChannel.members]], + { active_test: false }, + ); + members = members.filter(member => member.id !== mailChannel.livechat_operator_id); + if (members.length === 0 && mailChannel.livechat_operator_id) { + // operator probably testing the livechat with his own user + members = [mailChannel.livechat_operator_id]; + } + if (members.length > 0 && members[0].id !== this.publicPartnerId) { + // legit non-public partner + const country = this._getRecords('res.country', [['id', '=', members[0].country_id]])[0]; + return { + 'country': country ? [country.id, country.name] : false, + 'id': members[0].id, + 'name': members[0].name, + }; + } + return { + 'country': false, + 'id': false, + 'name': mailChannel.anonymous_name || "Visitor", + }; + }, + /** + * @override + */ + _mockMailChannelChannelInfo(ids, extra_info) { + const channelInfos = this._super(...arguments); + for (const channelInfo of channelInfos) { + const channel = this._getRecords('mail.channel', [['id', '=', channelInfo.id]])[0]; + // add the last message date + if (channel.channel_type === 'livechat') { + // add the operator id + if (channel.livechat_operator_id) { + const operator = this._getRecords('res.partner', [['id', '=', channel.livechat_operator_id]])[0]; + // livechat_username ignored for simplicity + channelInfo.operator_pid = [operator.id, operator.display_name.replace(',', '')]; + } + // add the anonymous or partner name + channelInfo.livechat_visitor = this._mockMailChannel_ChannelGetLivechatVisitorInfo([channel.id]); + } + } + return channelInfos; + }, + /** + * Simulates `_get_available_users` on `im_livechat.channel`. + * + * @private + * @param {integer} id + * @returns {Object} + */ + _mockImLivechatChannel_getAvailableUsers(id) { + const livechatChannel = this._getRecords('im_livechat.channel', [['id', '=', id]])[0]; + const users = this._getRecords('res.users', [['id', 'in', livechatChannel.user_ids]]); + return users.filter(user => user.im_status === 'online'); + }, + /** + * Simulates `_get_livechat_mail_channel_vals` on `im_livechat.channel`. + * + * @private + * @param {integer} id + * @returns {Object} + */ + _mockImLivechatChannel_getLivechatMailChannelVals(id, anonymous_name, operator, user_id, country_id) { + // partner to add to the mail.channel + const operator_partner_id = operator.partner_id; + const members = [[4, operator_partner_id]]; + let visitor_user; + if (user_id) { + const visitor_user = this._getRecords('res.users', [['id', '=', user_id]])[0]; + if (visitor_user && visitor_user.active) { + // valid session user (not public) + members.push([4, visitor_user.partner_id.id]); + } + } else { + // for simplicity of not having mocked channel.partner, add public partner here + members.push([4, this.publicPartnerId]); + } + const membersName = [ + visitor_user ? visitor_user.display_name : anonymous_name, + operator.livechat_username ? operator.livechat_username : operator.name, + ]; + return { + // Limitation of mocked models not having channel.partner: is_pinned + // is not supposed to be false for the visitor but must be false for + // the operator (writing on channel_partner_ids does not trigger the + // defaults that would set it to true) and here operator and visitor + // can't be differentiated in that regard. + 'is_pinned': false, + 'members': members, // channel_partner_ids + 'livechat_active': true, + 'livechat_operator_id': operator_partner_id, + 'livechat_channel_id': id, + 'anonymous_name': user_id ? false : anonymous_name, + 'country_id': country_id, + 'channel_type': 'livechat', + 'name': membersName.join(' '), + 'public': 'private', + 'mass_mailing': false, // email_send + }; + }, + /** + * Simulates `_get_random_operator` on `im_livechat.channel`. + * Simplified mock implementation: returns the first available operator. + * + * @private + * @param {integer} id + * @returns {Object} + */ + _mockImLivechatChannel_getRandomOperator(id) { + const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id); + return availableUsers[0]; + }, + /** + * Simulates `_open_livechat_mail_channel` on `im_livechat.channel`. + * + * @private + * @param {integer} id + * @param {string} anonymous_name + * @param {integer} [previous_operator_id] + * @param {integer} [user_id] + * @param {integer} [country_id] + * @returns {Object} + */ + _mockImLivechatChannel_openLivechatMailChannel(id, anonymous_name, previous_operator_id, user_id, country_id) { + let operator; + if (previous_operator_id) { + const availableUsers = this._mockImLivechatChannel_getAvailableUsers(id); + operator = availableUsers.find(user => user.partner_id === previous_operator_id); + } + if (!operator) { + operator = this._mockImLivechatChannel_getRandomOperator(id); + } + if (!operator) { + // no one available + return false; + } + // create the session, and add the link with the given channel + const mailChannelVals = this._mockImLivechatChannel_getLivechatMailChannelVals(id, anonymous_name, operator, user_id, country_id); + const mailChannelId = this._mockCreate('mail.channel', mailChannelVals); + this._mockMailChannel_broadcast([mailChannelId], [operator.partner_id]); + return this._mockMailChannelChannelInfo([mailChannelId])[0]; + }, +}); + +}); |
