From cd34f6de727d5b3858420cff3c9d3c5995c4e75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20K=C3=BChn?= Date: Tue, 12 Jun 2018 15:46:46 +0200 Subject: [PATCH] [REF] mail: JS mail refactoring ---------------------- Summary ---------------------- The purpose of this commit is to improve the JS code of the `mail` module. It applies the new coding guidelines and makes some changes on the design of some modules, such as the old ChatManager module. Here is a short summary of the changes that have been made: 1. New coding guidelines - snake_case to camelCase - prefix private attributes and methods with '_' - jsdoc on most methods - one class per module 2. Rename/Merge some classes - 'chat manager' becomes 'mail manager' (internal) and 'mail service' (external) - 'chat window manager' is now included in mail manager - 'thread' widget now named 'thread widget' 3. New model abstraction for mail objects: - modules 'mail.model.*' - modeling: 0..1 0..1 * * ThreadWindow <------> Thread <-------> Message / \ / \ Thread With Cache Document Thread / | \ / | \ Mailbox Channel Support Channel | | DM - Thread: the superclass of threads. - ThreadWindow: the window component of a thread. - Message: mail objects representing messages. - Thread With Cache: threads that can be used with search view (Discuss app compatible). - Document Thread: represents the thread part of a chatter. - Mailbox: represents what was previously called 'static' channel, e.g. 'Inbox'. - Channel: mail objects representing channels, including livechat. - DM: special kind of Channel for 1:1 communication in the backend. - Support Channel: special channel for im_support module. This new modeling approach let us easily add features on all threads, such as the possibility to put any thread in a small window. ---------------------- Known issues ---------------------- [Already Present in Master] 1. When the Discuss app is in the background with 'Inbox' as the selected Thread, when clicking on a document thread preview in the messaging menu of the systray, the rainbow man appears. 2. When a document with the chatter is in the background, when receiving an inbox notification from this document thread, the document thread is automatically marked as read, which removes the notification right away. 3. Sometimes, opening a DM window from the "blank" thread window does not work. 4. Reply-to feature on Inbox is not working: no message is sent in the document thread. 5. On the first login of admin user with demo data, the inbox counter is wrong (it displays 6, instead of 3). Explanation after investigation: > On page load, it fetches the correct number of Inbox messages (3), but the server notifies of 3 needaction messages right away, so it wrongly assumes these are new needaction messages. > Not possible for web client to detect that these messages should not increment the Inbox counter while keeping same API. 6. Notifications for new document thread messages only work when the user sets 'handle with Odoo' for the Notification Management in the preferences. > due to notifications on the longpoll bus for document thread messages that come from needaction notifications. > requires server-side changes to send notification on the longpoll bus to mentionned user. [New] 7. When receiving a message on a unjoined channel, thread window flickers ('open' > 'close' > 'open') Explanation after investigation: > JS logic: a) On auto-join, ask server to join the channel and get channel infos. b) The info tells the channel is not detached, but JS code makes decision to detach it, and tells server the channel is now detached. c) From (a), server notifies on longpoll bus the state of channel, which is not detached. The web client thinks that the window state of the channel has been changed somewhere else, and the channel is now closed. d) From (b), server notifies on longpoll bus the state of channel, which is detached. The web client opens the thread window of this channel. > The flicker didn't occur before refactoring because the web client was only updating the model of channel when it receives the longpoll notification. > Server behaviour on 1st longpoll notification is necessary for cross-tab synchronization for channel window state. > New design implies that model and view should be synchronized, hence the issue now. > Solution: remove server-side thread window synchronization and replace with client-side synchronization. ---------------------- Hacks ---------------------- The module `im_livechat` now uses mail objects that are compatible with Message and Window objects: Modeling for messages: AbstractMessage / \ / \ LivechatMessage Message - AbstractMessage: message compatible with the thread widget. - LivechatMessage: message used by im_livechat. - Message: message used with the mail manager. Modeling for thread windows: AbstractThreadWindow / \ / \ LivechatWindow ThreadWindow - AbstractThreadWindow: behaviour share between all types of thread windows. - LivechatWindow: window used by im_livechat. - ThreadWindow: window used with mail_manager. The reason for these hacks are twofold: 1. Use the thread widget in the frontend and livechat external lib bundles. 2. Do not have a dependency with the mail manager in the frontend and external lib bundles. --- .../bus/static/src/js/services/bus_service.js | 14 +- .../{systray.js => systray_activity_menu.js} | 6 +- .../calendar/static/src/xml/base_calendar.xml | 4 +- ...ests.js => systray_activity_menu_tests.js} | 20 +- addons/calendar/views/calendar_templates.xml | 4 +- .../im_livechat/static/src/js/im_livechat.js | 424 ++-- .../static/src/js/im_livechat_backend.js | 21 +- .../static/src/js/livechat_window.js | 135 ++ .../static/src/js/models/livechat_message.js | 65 + .../static/src/scss/im_livechat.scss | 2 +- .../src/scss/im_livechat_bootstrap.scss | 4 +- .../static/src/xml/im_livechat_backend.xml | 16 +- .../views/im_livechat_channel_templates.xml | 13 +- .../im_support/static/src/js/chat_manager.js | 455 ---- addons/im_support/static/src/js/discuss.js | 26 +- .../static/src/js/extended_chat_window.js | 47 - .../im_support/static/src/js/mail_manager.js | 260 +++ .../static/src/js/support_channel.js | 233 ++ .../static/src/js/support_message.js | 67 + .../static/src/js/support_session.js | 7 +- .../{systray.js => systray_messaging_menu.js} | 26 +- .../im_support/static/src/js/thread_window.js | 67 + .../im_support/static/src/scss/systray.scss | 6 +- addons/im_support/static/src/xml/discuss.xml | 16 +- addons/im_support/static/src/xml/systray.xml | 10 +- addons/im_support/views/assets.xml | 8 +- addons/mail/__manifest__.py | 4 +- addons/mail/data/mail_data.xml | 2 +- addons/mail/static/src/js/activity.js | 293 ++- addons/mail/static/src/js/chat_window.js | 199 -- addons/mail/static/src/js/chatter.js | 137 +- addons/mail/static/src/js/chatter_composer.js | 254 --- addons/mail/static/src/js/composer.js | 922 -------- .../static/src/js/composers/basic_composer.js | 640 ++++++ .../src/js/composers/chatter_composer.js | 295 +++ .../src/js/composers/extended_composer.js | 100 + .../src/js/composers/mention_manager.js | 432 ++++ addons/mail/static/src/js/discuss.js | 956 ++++---- addons/mail/static/src/js/discuss_mobile.js | 176 +- addons/mail/static/src/js/emojis.js | 149 ++ .../static/src/js/extended_chat_window.js | 63 - addons/mail/static/src/js/followers.js | 107 +- addons/mail/static/src/js/form_controller.js | 8 +- addons/mail/static/src/js/models/channel.js | 442 ++++ addons/mail/static/src/js/models/dm.js | 94 + .../static/src/js/models/document_thread.js | 276 +++ .../mail/static/src/js/models/mail_failure.js | 102 + .../mail/static/src/js/models/mail_model.js | 20 + addons/mail/static/src/js/models/mailbox.js | 213 ++ .../js/models/messages/abstract_message.js | 409 ++++ .../static/src/js/models/messages/message.js | 780 +++++++ addons/mail/static/src/js/models/thread.js | 446 ++++ .../static/src/js/models/thread_with_cache.js | 204 ++ .../static/src/js/services/chat_manager.js | 2016 ----------------- .../src/js/services/chat_window_manager.js | 534 ----- .../static/src/js/services/mail_manager.js | 1246 ++++++++++ .../js/services/mail_notification_manager.js | 484 ++++ .../static/src/js/services/mail_service.js | 15 + .../src/js/services/mail_window_manager.js | 684 ++++++ addons/mail/static/src/js/systray.js | 353 --- .../src/js/systray/systray_activity_menu.js | 150 ++ .../src/js/systray/systray_messaging_menu.js | 329 +++ addons/mail/static/src/js/thread.js | 700 +++--- addons/mail/static/src/js/thread_field.js | 225 +- .../thread_windows/abstract_thread_window.js | 390 ++++ .../src/js/thread_windows/thread_window.js | 428 ++++ addons/mail/static/src/js/tour.js | 14 +- addons/mail/static/src/js/utils.js | 64 +- ...indow.scss => abstract_thread_window.scss} | 50 +- addons/mail/static/src/scss/composer.scss | 8 +- addons/mail/static/src/scss/discuss.scss | 76 +- addons/mail/static/src/scss/systray.scss | 32 +- ...ed_chat_window.scss => thread_window.scss} | 8 +- addons/mail/static/src/scss/variables.scss | 4 +- .../static/src/xml/abstract_thread_window.xml | 79 + addons/mail/static/src/xml/activity.xml | 4 +- addons/mail/static/src/xml/chat_window.xml | 57 - addons/mail/static/src/xml/chatter.xml | 28 +- addons/mail/static/src/xml/composer.xml | 26 +- addons/mail/static/src/xml/discuss.xml | 332 +-- .../static/src/xml/extended_chat_window.xml | 19 - addons/mail/static/src/xml/systray.xml | 56 +- addons/mail/static/src/xml/thread.xml | 215 +- addons/mail/static/src/xml/thread_window.xml | 25 + .../static/src/xml/web_kanban_activity.xml | 4 +- addons/mail/static/tests/chat_window_tests.js | 214 -- addons/mail/static/tests/chatter_tests.js | 168 +- .../mail/static/tests/discuss_mobile_tests.js | 22 +- .../static/tests/discuss_moderation_tests.js | 126 +- addons/mail/static/tests/discuss_tests.js | 65 +- .../mail/static/tests/helpers/mock_server.js | 113 +- .../mail/static/tests/helpers/test_utils.js | 88 +- addons/mail/static/tests/mail_utils_tests.js | 2 +- .../systray/systray_activity_menu_tests.js | 129 ++ .../systray/systray_messaging_menu_tests.js | 372 +++ addons/mail/static/tests/systray_tests.js | 375 --- .../mail/static/tests/thread_window_tests.js | 156 ++ addons/mail/views/mail_channel_views.xml | 2 +- addons/mail/views/mail_templates.xml | 54 +- .../{systray.js => systray_activity_menu.js} | 14 +- addons/note/static/src/scss/note.scss | 8 +- addons/note/static/src/xml/systray.xml | 12 +- ...ests.js => systray_activity_menu_tests.js} | 16 +- addons/note/views/note_templates.xml | 4 +- .../src/js/chrome/abstract_web_client.js | 12 + .../static/src/js/core/abstract_service.js | 4 + .../web/static/src/js/core/service_mixins.js | 4 +- .../src/js/services/notification_service.js | 5 +- addons/web/static/tests/helpers/test_utils.js | 5 + .../views/website_livechat.xml | 11 +- 110 files changed, 12707 insertions(+), 7608 deletions(-) rename addons/calendar/static/src/js/{systray.js => systray_activity_menu.js} (91%) rename addons/calendar/static/tests/{systray_tests.js => systray_activity_menu_tests.js} (83%) create mode 100644 addons/im_livechat/static/src/js/livechat_window.js create mode 100644 addons/im_livechat/static/src/js/models/livechat_message.js delete mode 100644 addons/im_support/static/src/js/chat_manager.js delete mode 100644 addons/im_support/static/src/js/extended_chat_window.js create mode 100644 addons/im_support/static/src/js/mail_manager.js create mode 100644 addons/im_support/static/src/js/support_channel.js create mode 100644 addons/im_support/static/src/js/support_message.js rename addons/im_support/static/src/js/{systray.js => systray_messaging_menu.js} (75%) create mode 100644 addons/im_support/static/src/js/thread_window.js delete mode 100644 addons/mail/static/src/js/chat_window.js delete mode 100644 addons/mail/static/src/js/chatter_composer.js delete mode 100644 addons/mail/static/src/js/composer.js create mode 100644 addons/mail/static/src/js/composers/basic_composer.js create mode 100644 addons/mail/static/src/js/composers/chatter_composer.js create mode 100644 addons/mail/static/src/js/composers/extended_composer.js create mode 100644 addons/mail/static/src/js/composers/mention_manager.js create mode 100644 addons/mail/static/src/js/emojis.js delete mode 100644 addons/mail/static/src/js/extended_chat_window.js create mode 100644 addons/mail/static/src/js/models/channel.js create mode 100644 addons/mail/static/src/js/models/dm.js create mode 100644 addons/mail/static/src/js/models/document_thread.js create mode 100644 addons/mail/static/src/js/models/mail_failure.js create mode 100644 addons/mail/static/src/js/models/mail_model.js create mode 100644 addons/mail/static/src/js/models/mailbox.js create mode 100644 addons/mail/static/src/js/models/messages/abstract_message.js create mode 100644 addons/mail/static/src/js/models/messages/message.js create mode 100644 addons/mail/static/src/js/models/thread.js create mode 100644 addons/mail/static/src/js/models/thread_with_cache.js delete mode 100644 addons/mail/static/src/js/services/chat_manager.js delete mode 100644 addons/mail/static/src/js/services/chat_window_manager.js create mode 100644 addons/mail/static/src/js/services/mail_manager.js create mode 100644 addons/mail/static/src/js/services/mail_notification_manager.js create mode 100644 addons/mail/static/src/js/services/mail_service.js create mode 100644 addons/mail/static/src/js/services/mail_window_manager.js delete mode 100644 addons/mail/static/src/js/systray.js create mode 100644 addons/mail/static/src/js/systray/systray_activity_menu.js create mode 100644 addons/mail/static/src/js/systray/systray_messaging_menu.js create mode 100644 addons/mail/static/src/js/thread_windows/abstract_thread_window.js create mode 100644 addons/mail/static/src/js/thread_windows/thread_window.js rename addons/mail/static/src/scss/{chat_window.scss => abstract_thread_window.scss} (72%) rename addons/mail/static/src/scss/{extended_chat_window.scss => thread_window.scss} (93%) create mode 100644 addons/mail/static/src/xml/abstract_thread_window.xml delete mode 100644 addons/mail/static/src/xml/chat_window.xml delete mode 100644 addons/mail/static/src/xml/extended_chat_window.xml create mode 100644 addons/mail/static/src/xml/thread_window.xml delete mode 100644 addons/mail/static/tests/chat_window_tests.js create mode 100644 addons/mail/static/tests/systray/systray_activity_menu_tests.js create mode 100644 addons/mail/static/tests/systray/systray_messaging_menu_tests.js delete mode 100644 addons/mail/static/tests/systray_tests.js create mode 100644 addons/mail/static/tests/thread_window_tests.js rename addons/note/static/src/js/{systray.js => systray_activity_menu.js} (93%) rename addons/note/static/tests/{systray_tests.js => systray_activity_menu_tests.js} (83%) diff --git a/addons/bus/static/src/js/services/bus_service.js b/addons/bus/static/src/js/services/bus_service.js index c6e9517acc6..9a40abae930 100644 --- a/addons/bus/static/src/js/services/bus_service.js +++ b/addons/bus/static/src/js/services/bus_service.js @@ -11,7 +11,7 @@ var BusService = AbstractService.extend({ /** * @override */ - init: function () { + start: function () { this._super.apply(this, arguments); this.bus = bus; this._audio = null; @@ -33,19 +33,18 @@ var BusService = AbstractService.extend({ /** * Send a notification, and notify once per browser's tab * - * @param {web.Widget} widget the widget that called sendNotification * @param {string} title * @param {string} content */ - sendNotification: function (widget, title, content) { + sendNotification: function (title, content) { if (window.Notification && Notification.permission === "granted") { if (this.bus.is_master) { this._sendNativeNotification(title, content); } } else { - widget.do_notify(title, content); + this.do_notify(title, content); if (this.bus.is_master) { - this._beep(widget); + this._beep(); } } }, @@ -58,14 +57,13 @@ var BusService = AbstractService.extend({ * Lazily play the 'beep' audio on sent notification * * @private - * @param {web.Widget} widget */ - _beep: function (widget) { + _beep: function () { if (typeof(Audio) !== "undefined") { if (!this._audio) { this._audio = new Audio(); var ext = this._audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3"; - var session = widget.getSession(); + var session = this.getSession(); this._audio.src = session.url("/mail/static/src/audio/ting" + ext); } this._audio.play(); diff --git a/addons/calendar/static/src/js/systray.js b/addons/calendar/static/src/js/systray_activity_menu.js similarity index 91% rename from addons/calendar/static/src/js/systray.js rename to addons/calendar/static/src/js/systray_activity_menu.js index f0ad7b35304..1962ef3c17c 100644 --- a/addons/calendar/static/src/js/systray.js +++ b/addons/calendar/static/src/js/systray_activity_menu.js @@ -1,7 +1,7 @@ -odoo.define('calendar.systray', function (require) { +odoo.define('calendar.systray.ActivityMenu', function (require) { "use strict"; -var ActivityMenu = require('mail.systray').ActivityMenu; +var ActivityMenu = require('mail.systray.ActivityMenu'); var fieldUtils = require('web.field_utils'); ActivityMenu.include({ @@ -22,7 +22,7 @@ ActivityMenu.include({ _getActivityData: function () { var self = this; return this._super.apply(this, arguments).then(function () { - var meeting = _.find(self.activities, {type: 'meeting'}); + var meeting = _.find(self._activities, {type: 'meeting'}); if (meeting && meeting.meetings) { _.each(meeting.meetings, function (res) { res.start = fieldUtils.parse.datetime(res.start, false, {isUTC: true}); diff --git a/addons/calendar/static/src/xml/base_calendar.xml b/addons/calendar/static/src/xml/base_calendar.xml index 56d10a45afe..0bb242ef530 100644 --- a/addons/calendar/static/src/xml/base_calendar.xml +++ b/addons/calendar/static/src/xml/base_calendar.xml @@ -20,8 +20,8 @@ - - + +
diff --git a/addons/calendar/static/tests/systray_tests.js b/addons/calendar/static/tests/systray_activity_menu_tests.js similarity index 83% rename from addons/calendar/static/tests/systray_tests.js rename to addons/calendar/static/tests/systray_activity_menu_tests.js index 072a1b5f4f1..803b43bb9e9 100644 --- a/addons/calendar/static/tests/systray_tests.js +++ b/addons/calendar/static/tests/systray_activity_menu_tests.js @@ -1,17 +1,17 @@ -odoo.define('calendar.systray_tests', function (require) { +odoo.define('calendar.systray.ActivityMenuTests', function (require) { "use strict"; -var ChatManager = require('mail.ChatManager'); -var systray = require('mail.systray'); +var ActivityMenu = require('mail.systray.ActivityMenu'); +var mailUtils = require('mail.testUtils'); + var testUtils = require('web.test_utils'); -var createBusService = require('mail.testUtils').createBusService; QUnit.module('calendar', {}, function () { QUnit.module('ActivityMenu', { beforeEach: function () { - this.services = [ChatManager, createBusService()]; + this.services = mailUtils.getMailServices(); this.data = { 'calendar.event': { records: [{ @@ -40,7 +40,7 @@ QUnit.module('ActivityMenu', { QUnit.test('activity menu widget:today meetings', function (assert) { assert.expect(8); var self = this; - var activityMenu = new systray.ActivityMenu(); + var activityMenu = new ActivityMenu(); testUtils.addMockEnvironment(activityMenu, { services: this.services, mockRPC: function (route, args) { @@ -53,19 +53,19 @@ QUnit.test('activity menu widget:today meetings', function (assert) { activityMenu.appendTo($('#qunit-fixture')); - assert.ok(activityMenu.$el.hasClass('o_mail_navbar_item'), 'should be the instance of widget'); + assert.ok(activityMenu.$el.hasClass('o_mail_systray_item'), 'should be the instance of widget'); testUtils.intercept(activityMenu, 'do_action', function (event) { assert.ok(true, "should have triggered a do_action"); - assert.strictEqual(event.data.action.res_id, activityMenu.$('.o_meeting_filter').eq(0).data('res_id'), 'should open particular meeting form view') + assert.strictEqual(event.data.action.res_id, activityMenu.$('.o_meeting_filter').eq(0).data('res_id'), 'should open particular meeting form view'); }); activityMenu.$('.dropdown-toggle').click(); activityMenu.$('.o_meeting_filter').eq(0).click(); testUtils.intercept(activityMenu, 'do_action', function (event) { - assert.strictEqual(event.data.action, "calendar.action_calendar_event", 'should open meeting calendar view in day mode') + assert.strictEqual(event.data.action, "calendar.action_calendar_event", 'should open meeting calendar view in day mode'); }); - activityMenu.$('.o_mail_channel_preview').click(); + activityMenu.$('.o_mail_preview').click(); assert.ok(activityMenu.$('.o_meeting_filter'), "should be a meeting"); assert.strictEqual(activityMenu.$('.o_meeting_filter').length, 2, 'there should be 2 meetings'); diff --git a/addons/calendar/views/calendar_templates.xml b/addons/calendar/views/calendar_templates.xml index dac0a52bad0..ab5117a88b9 100644 --- a/addons/calendar/views/calendar_templates.xml +++ b/addons/calendar/views/calendar_templates.xml @@ -5,14 +5,14 @@ - + diff --git a/addons/im_support/static/src/js/chat_manager.js b/addons/im_support/static/src/js/chat_manager.js deleted file mode 100644 index 4fabcd5deb9..00000000000 --- a/addons/im_support/static/src/js/chat_manager.js +++ /dev/null @@ -1,455 +0,0 @@ -odoo.define('im_support.ChatManager', function (require) { -"use strict"; - -/** - * This module includes the ChatManager to handle the case of the Support - * channel, allowing the users of the current database to communicate with - * livechat operators from another database (the Support database). - */ - -var ChatManager = require('mail.ChatManager'); - -var core = require('web.core'); -var session = require('web.session'); -var WebClient = require('web.WebClient'); - -var supportSession = require('im_support.SupportSession'); -var supportBus = require('im_support.SupportBus'); - -var _t = core._t; -var CHANNEL_STATES = { - CLOSED: 'closed', - FOLDED: 'folded', - OPEN: 'open', -}; -var ODOOBOT_ID = "ODOOBOT"; // FIXME: should be exported by the ChatManager -var POLL_TIMEOUT_DELAY = 1000 * 60 * 30; // 30 minutes -var POLL_TIMEOUT_KEY = 'im_support.poll_timeout'; -var SUPPORT_CHANNEL_LIMIT = 30; // limit of messages to fetch -var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state'; - -ChatManager.include({ - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - closeChatSession: function (channelID) { - if (channelID === this.supportChannelUUID) { - this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.CLOSED); - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - detachChannel: function (channelID) { - if (channelID === this.supportChannelUUID) { - this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.OPEN); - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - foldChannel: function (channelID, folded) { - if (channelID === this.supportChannelUUID) { - var value = folded ? CHANNEL_STATES.FOLDED : CHANNEL_STATES.OPEN; - this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, value); - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to filter out the Support channel from the previews. - * - * @override - */ - getChannelsPreview: function () { - var self = this; - return this._super.apply(this, arguments).then(function (channelsPreview) { - return _.reject(channelsPreview, {id: self.supportChannelUUID}); - }); - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - getMentionPartnerSuggestions: function (channel) { - if (channel && channel.id === this.supportChannelUUID) { - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Initialises the Support: checks if there is a pending chat session - * between the user and Support, and if so, re-opens it. - * Note: we can't directly override init(), because it is already called - * when the include is applied, so we use this hook instead (called at - * webclient startup) - */ - initSupport: function () { - var self = this; - this.supportChannelDef = null; - this.supportChannelUUID = null; - this.pollTimeout = null; - this.pollingSupport = false; - - // listen to notifications coming from Support longpolling - supportBus.on('notification', this, this._onSupportNotification); - - // check if there is a pending chat session with the Support - var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY); - var pollingDelay = timeoutTimestamp && (JSON.parse(timeoutTimestamp) - Date.now()); - if (pollingDelay && pollingDelay > 0) { - var channelState = this.call('local_storage', 'getItem', SUPPORT_CHANNEL_STATE_KEY); - this.startSupportLivechat(channelState).then(function () { - var supportChannel = self.getChannel(self.supportChannelUUID); - if (supportChannel.available) { - self._startPollingSupport(pollingDelay); - } - }); - } - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - markChannelAsSeen: function (channel) { - if (channel.id === this.supportChannelUUID) { - this._updateChannelUnreadCounter(channel, 0); - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to call a specific route for messages posted in the Support - * channel. - * - * @override - */ - postMessage: function (data, options) { - if (options.channelID && options.channelID === this.supportChannelUUID) { - // ensure that the poll is active before posting the message - if (!this.pollingSupport) { - this._startPollingSupport(); - } - return supportSession.rpc("/odoo_im_support/chat_post", { - uuid: this.supportChannelUUID, - message_content: data.content, - }); - } - return this._super.apply(this, arguments); - }, - /** - * Opens the support channel between a livechat operator from the Support - * database and the current user (if there is an available operator). - * Ensures to perform only once the request to create/retrieve the Support - * channel. - * - * @param {string} [channelState=CHANNEL_STATES.OPEN] state of the Support - * channel (see CHANNEL_STATES for accepted values) - * @returns {Deferred} - */ - startSupportLivechat: function (channelState) { - var self = this; - if (!this.supportChannelDef) { - // retrieve or create the channel - this.supportChannelDef = supportSession.rpc('/odoo_im_support/get_support_channel', { - channel_uuid: session.support_token, - db_uuid: session.db_uuid, - user_name: session.name, - }); - } - return this.supportChannelDef.then(function (channel) { - if (!channel) { - // there is no channel (because there is no online operator, and - // no support channel has been created yet), so create one to - // open in a chat window - channel = { - available: false, - support_channel: true, - type: 'livechat', - uuid: "support_unavailable", - }; - } - if (!channelState) { - channelState = self.discussOpen ? CHANNEL_STATES.CLOSED : CHANNEL_STATES.OPEN; - self.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, channelState); - } - if (!self.supportChannelUUID) { - // this part is only executed the first time the RPC is resolved - self.supportChannelUUID = channel.uuid; - - // add the channel to the ChatManager - self._addChannel(_.extend(channel, { - id: channel.uuid, - is_minimized: _.contains([CHANNEL_STATES.OPEN, CHANNEL_STATES.FOLDED], channelState), - state: channelState, - })); - - // display automatic messages in the channel - if (!channel.available) { - self._addSupportNotAvailableMessage(); - } else { - self._addSupportWelcomeMessage(); - } - } else { - // the channel has already been added to the ChatManager, so - // simply re-open it - channel = self.getChannel(self.supportChannelUUID); - if (self.discussOpen) { - self.openChannel(channel); - } else { - channel.is_folded = channelState === CHANNEL_STATES.FOLDED; - self.call('chat_window_manager', 'openChat', channel); - } - } - }).fail(function () { - self.do_warn(_t("The Support server can't be reached.")); - }); - }, - /** - * Overrides to prevent from calling the server for messages of the Support - * channel (which are records on the Support database). - * - * @override - */ - toggleStarStatus: function (msgID) { - var message = this.getMessage(msgID); - if (_.contains(message.channel_ids, this.supportChannelUUID)) { - return $.when(); - } - return this._super.apply(this, arguments); - }, - /** - * Overrides to prevent from calling the server with the Support channel - * (which is a record on the Support database). - * - * @override - */ - unsubscribe: function (channel) { - if (channel.id === this.supportChannelUUID) { - return $.when(); - } - return this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Adds a message in the Support channel indicating that this channel is - * not available for now. - * - * @private - */ - _addSupportNotAvailableMessage: function () { - var msg = { - author_id: ODOOBOT_ID, - body: _t("None of our operators are available. Submit a ticket to ask your question now."), - channel_ids: [this.supportChannelUUID], - id: Number.MAX_SAFE_INTEGER, // last message in the channel - }; - this._addMessage(msg, {silent: true}); - }, - /** - * Adds a welcome message as first message of the Support channel. - * - * @private - */ - _addSupportWelcomeMessage: function () { - var channel = this.getChannel(this.supportChannelUUID); - if (channel.welcome_message) { - var msg = { - author_id: channel.operator, - body: channel.welcome_message, - channel_ids: [this.supportChannelUUID], - id: -1, // first message of the channel - }; - this._addMessage(msg, {silent: true}); - } - }, - /** - * Overrides to handle the Support channel case: fetches the messages from - * the Support database in that case. - * - * @override - * @private - */ - _fetchFromChannel: function (channel, options) { - var self = this; - if (channel.id === this.supportChannelUUID) { - var domain = options && options.domain || []; - var cache = this._getChannelCache(channel, domain); - if (options && options.loadMore) { - var minMessageID = cache.messages[0].id; - domain = [['id', '<', minMessageID]].concat(domain); - } - - return supportSession.rpc('/odoo_im_support/fetch_messages', { - domain: domain, - channel_uuid: session.support_token, - limit: SUPPORT_CHANNEL_LIMIT, - }).then(function (msgs) { - if (!cache.all_history_loaded) { - cache.all_history_loaded = msgs.length < SUPPORT_CHANNEL_LIMIT; - } - cache.loaded = true; - - _.each(msgs, function (msg) { - _.extend(msg, {channel_ids: [self.supportChannelUUID]}); - self._addMessage(msg, { - channel_id: self.supportChannelUUID, - silent: true, - domain: domain, - }); - }); - var channelCache = self._getChannelCache(channel, domain); - return channelCache.messages; - }); - } - return this._super.apply(this, arguments); - }, - /** - * @override - * @private - */ - _makeChannel: function (data) { - var channel = this._super.apply(this, arguments); - if (channel.id === this.supportChannelUUID) { - channel.supportChannel = true; - channel.name = _t('Support'); - channel.available = data.available; - if (!channel.available) { - channel.name += _t(' (offline)'); - } - channel.welcome_message = data.welcome_message; - channel.operator = data.operator; - } - return channel; - }, - /** - * @override - * @private - */ - _makeMessage: function (data) { - if (_.contains(data.channel_ids, this.supportChannelUUID) && data.author_id !== ODOOBOT_ID) { - if (!data.author_id[0]) { // the author is the client - data.author_id = [session.partner_id, session.name]; - } else { // the author is the operator - data.author_id[0] = -1; // prevent from conflicting with partners of this instance - var msg = this._super.apply(this, arguments); - msg.author_redirect = false; - msg.avatar_src = "/mail/static/src/img/odoo_o.png"; - return msg; - } - } - return this._super.apply(this, arguments); - }, - /** - * Automatically stop polling for Support messages after a given delay of - * inactivity. - * - * @private - * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling - * timeout delay to set - */ - _setPollTimeout: function (pollingDelay) { - pollingDelay = pollingDelay || POLL_TIMEOUT_DELAY; - clearTimeout(this.pollTimeout); - this.pollTimeout = setTimeout(this._stopPollingSupport.bind(this), pollingDelay); - // save the timeout expiration datetime into the LocalStorage so that - // we can re-open the Support channel if necessary on F5 - var timeoutTimestamp = Date.now() + pollingDelay; - this.call('local_storage', 'setItem', POLL_TIMEOUT_KEY, timeoutTimestamp); - }, - /** - * Initiates a longpoll with the server hosting the Support channel. - * - * @private - * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling - * timeout delay to set - */ - _startPollingSupport: function (pollingDelay) { - this.pollingSupport = true; - supportBus.add_channel(this.supportChannelUUID); - supportBus.start_polling(); - this._setPollTimeout(pollingDelay); - }, - /** - * Stops the longpoll with the server hosting the Support channel. - * - * @private - */ - _stopPollingSupport: function () { - this.pollingSupport = false; - supportBus.stop_polling(); - this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Handles poll notifications from the Support server. - * - * @private - * @param {Object[]} notifications - */ - _onSupportNotification: function (notifications) { - var self = this; - if (notifications && notifications.length) { - this._setPollTimeout(); - } - _.each(notifications, function (notification) { - if (notification[1]._type === 'history_command') { - // ignore history requests - return; - } - var msg = _.extend(notification[1], { - channel_ids: [self.supportChannelUUID], - }); - self._manageChannelNotification(msg); - }); - }, -}); - - -// Unfortunately, we can't override init() of ChatManager because it is called -// before the include is applied, so we override the WebClient instead to call -// an initialization hook for Livechat Support in the ChatManager service. -WebClient.include({ - /** - * Overrides to ask the ChatManager service to check whether there is a - * pending chat session with Support, and if so, to re-open it. - * - * @override - */ - show_application: function () { - this.call('chat_manager', 'initSupport'); - return this._super.apply(this, arguments); - }, -}); - -}); diff --git a/addons/im_support/static/src/js/discuss.js b/addons/im_support/static/src/js/discuss.js index 0f29370b049..8f26f272346 100644 --- a/addons/im_support/static/src/js/discuss.js +++ b/addons/im_support/static/src/js/discuss.js @@ -1,13 +1,13 @@ -odoo.define('im_support.chat_discuss', function (require) { +odoo.define('im_support.Discuss', function (require) { "use strict"; +var Discuss = require('mail.Discuss'); + /** * This module includes Discuss to handle the case of the Support channel. */ - -var Discuss = require('mail.chat_discuss'); - Discuss.include({ + //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- @@ -21,8 +21,8 @@ Discuss.include({ */ _getThreadRenderingOptions: function () { var options = this._super.apply(this, arguments); - if (this.channel.supportChannel) { - options.display_stars = false; + if (this._thread.getType() === 'support_channel') { + options.displayStars = false; } return options; }, @@ -34,13 +34,13 @@ Discuss.include({ * @override * @private */ - _setChannel: function (channel) { + _setThread: function () { var self = this; return this._super.apply(this, arguments).then(function () { - var $buttonAddAttachment = self.basicComposer.$('.o_composer_button_add_attachment'); - if (channel.supportChannel) { - if (!channel.available) { - self.basicComposer.do_hide(); + var $buttonAddAttachment = self._basicComposer.$('.o_composer_button_add_attachment'); + if (self._thread.getType() === 'support_channel') { + if (!self._thread.isAvailable()) { + self._basicComposer.do_hide(); } $buttonAddAttachment.toggleClass('o_hidden', true); } else { @@ -54,9 +54,9 @@ Discuss.include({ * @override * @private */ - _updateControlPanelButtons: function (channel) { + _updateControlPanelButtons: function (thread) { this._super.apply(this, arguments); - if (channel.supportChannel) { + if (thread.getType() === 'support_channel') { this.$buttons.find('button').hide(); } }, diff --git a/addons/im_support/static/src/js/extended_chat_window.js b/addons/im_support/static/src/js/extended_chat_window.js deleted file mode 100644 index 6f598d16cba..00000000000 --- a/addons/im_support/static/src/js/extended_chat_window.js +++ /dev/null @@ -1,47 +0,0 @@ -odoo.define('im_support.ExtendedChatWindow', function (require) { -"use strict"; - -/** - * This module includes ExtendedChatWindow to handle the case of the Support - * channel. - */ - - -var ExtendedChatWindow = require('mail.ExtendedChatWindow'); - -ExtendedChatWindow.include({ - /** - * Overrides to tweak the options in the case of the Support channel (no - * star, and no input if no operator available). - * - * @override - */ - init: function () { - this._super.apply(this, arguments); - var channel = this.call('chat_manager', 'getChannel', this.channel_id); - if (channel && channel.supportChannel) { - this.options.display_stars = false; - if (!channel.available) { - this.options.input_less = true; - } - } - }, - /** - * Overrides to remove the attachment button in the Support channel as this - * feature does not work in that channel. - * - * @note: this feature could be enabled by storing the attachments on the - * Support server. - * @override - */ - start: function () { - var def = this._super.apply(this, arguments); - var channel = this.call('chat_manager', 'getChannel', this.channel_id); - if (channel && channel.supportChannel) { - this.$('.o_composer .o_composer_button_add_attachment').remove(); - } - return def; - }, -}); - -}); diff --git a/addons/im_support/static/src/js/mail_manager.js b/addons/im_support/static/src/js/mail_manager.js new file mode 100644 index 00000000000..b25b47cc1ba --- /dev/null +++ b/addons/im_support/static/src/js/mail_manager.js @@ -0,0 +1,260 @@ +odoo.define('im_support.MailManager', function (require) { +"use strict"; + +var MailManager = require('mail.Manager'); + +var core = require('web.core'); +var session = require('web.session'); +var WebClient = require('web.WebClient'); + +var SupportChannel = require('im_support.SupportChannel'); +var SupportMessage = require('im_support.SupportMessage'); +var supportSession = require('im_support.SupportSession'); +var supportBus = require('im_support.SupportBus'); + +var _t = core._t; + +var POLL_TIMEOUT_DELAY = 1000 * 60 * 30; // 30 minutes +var POLL_TIMEOUT_KEY = 'im_support.poll_timeout'; +var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state'; + +/** + * This module includes the MailService to handle the case of the Support + * channel, allowing the users of the current database to communicate with + * livechat operators from another database (the Support database). + */ +MailManager.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Initialises the Support: checks if there is a pending chat session + * between the user and Support, and if so, re-opens it. + * Note: we can't directly override init(), because it is already called + * when the include is applied, so we use this hook instead (called at + * webclient startup) + */ + initSupport: function () { + var self = this; + this.supportChannelDef = null; + this.supportChannelUUID = null; + this.pollTimeout = null; + this.pollingSupport = false; + + // listen to notifications coming from Support longpolling + supportBus.on('notification', this, this._onSupportNotification); + + // check if there is a pending chat session with the Support + var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY); + var pollingDelay = timeoutTimestamp && (JSON.parse(timeoutTimestamp) - Date.now()); + if (pollingDelay && pollingDelay > 0) { + var channelState = this.call('local_storage', 'getItem', SUPPORT_CHANNEL_STATE_KEY); + this.startSupportLivechat(channelState).then(function () { + var supportChannel = self.getChannel(self.supportChannelUUID); + if (supportChannel.isAvailable()) { + self.startPollingSupport(pollingDelay); + } + }); + } + }, + /** + * Overrides to filter out the Support channel from the previews. + * + * @override + * @returns {$.Promise} list of valid objects for mail.Preview + * template + */ + getChannelPreviews: function () { + var self = this; + return this._super.apply(this, arguments) + .then(function (channelsPreview) { + return _.reject(channelsPreview, { id: self.supportChannelUUID }); + }); + }, + /** + * Initiates a longpoll with the server hosting the Support channel. + * + * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling + * timeout delay to set + */ + startPollingSupport: function (pollingDelay) { + if (!this.pollingSupport) { + this.pollingSupport = true; + supportBus.add_channel(this.supportChannelUUID); + supportBus.start_polling(); + this._setPollTimeout(pollingDelay); + } + }, + /** + * Opens the Support channel between a livechat operator from the Support + * database and the current user (if there is an available operator). + * Ensures to perform only once the request to create/retrieve the Support + * channel. + * + * @param {string} [channelState='open'] state of the Support + * channel (see CHANNEL_STATES for accepted values) + * @returns {Deferred} + */ + startSupportLivechat: function (channelState) { + var self = this; + if (!this.supportChannelDef) { + // retrieve or create the channel + this.supportChannelDef = supportSession.rpc('/odoo_im_support/get_support_channel', { + channel_uuid: session.support_token, + db_uuid: session.db_uuid, + user_name: session.name, + }); + } + return this.supportChannelDef.then(function (channel) { + if (!channel) { + // there is no channel (because there is no online operator, and + // no support channel has been created yet), so create one to + // open in a chat window + channel = { + available: false, + support_channel: true, + type: 'livechat', + uuid: "support_unavailable", + }; + } + if (!channelState) { + channelState = self._isDiscussOpen() ? 'closed' : 'open'; + self.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, channelState); + } + if (!self.supportChannelUUID) { + // this part is only executed the first time the RPC is resolved + self.supportChannelUUID = channel.uuid; + + // add the channel to the MailManager + self._addChannel(_.extend(channel, { + id: channel.uuid, + is_minimized: _.contains(['open', 'folded'], channelState), + state: channelState, + })); + + // display an automatic message in the channel + var supportChannel = self.getChannel(channel.uuid); + supportChannel.addDefaultMessage(); + } else { + // the channel has already been added to the MailManager, so + // simply re-open it + if (self._isDiscussOpen()) { + self._openThreadInDiscuss(self.supportChannelUUID); + } else { + channel = self.getChannel(self.supportChannelUUID); + channel.fold(channelState === 'folded'); + } + } + }).fail(function () { + self.do_warn(_t("The Support server can't be reached.")); + }); + }, + /** + * Updates the state of the Support channel (stored in the localStorage). + * + * @param {string} state ('closed', 'folded' or 'open') + */ + updateSupportChannelState: function (state) { + this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, state); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + */ + _makeChannel: function (data, options) { + if (data.id === this.supportChannelUUID) { + return new SupportChannel(this, data, options, this._commands); + } + return this._super.apply(this, arguments); + }, + /** + * Overrides to instantiate a SupportMessage when necessary. + * @override + */ + _makeMessage: function (data) { + if (_.contains(data.channel_ids, this.supportChannelUUID)) { + return new SupportMessage(this, data, this._emojis); + } + return this._super.apply(this, arguments); + }, + /** + * Automatically stop polling for Support messages after a given delay of + * inactivity. + * + * @private + * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling + * timeout delay to set + */ + _setPollTimeout: function (pollingDelay) { + pollingDelay = pollingDelay || POLL_TIMEOUT_DELAY; + clearTimeout(this.pollTimeout); + this.pollTimeout = setTimeout(this._stopPollingSupport.bind(this), pollingDelay); + // save the timeout expiration datetime into the LocalStorage so that + // we can re-open the Support channel if necessary on F5 + var timeoutTimestamp = Date.now() + pollingDelay; + this.call('local_storage', 'setItem', POLL_TIMEOUT_KEY, timeoutTimestamp); + }, + /** + * Stops the longpoll with the server hosting the Support channel. + * + * @private + */ + _stopPollingSupport: function () { + this.pollingSupport = false; + supportBus.stop_polling(); + this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handles poll notifications from the Support server. + * + * @private + * @param {Object[]} notifications + */ + _onSupportNotification: function (notifications) { + var self = this; + if (notifications && notifications.length) { + this._setPollTimeout(); + } + _.each(notifications, function (notification) { + if (notification[1]._type === 'history_command') { + // ignore history requests + return; + } + var message = _.extend(notification[1], { + channel_ids: [self.supportChannelUUID], + }); + self._handleChannelNotification(message); + }); + }, +}); + + +// Unfortunately, we can't override init() of MailService because it is called +// before the include is applied, so we override the WebClient instead to call +// an initialization hook for Livechat Support in the Mail service. +WebClient.include({ + /** + * Overrides to ask the Mail service to check whether there is a + * pending chat session with Support, and if so, to re-open it. + * + * @override + */ + show_application: function () { + this.call('mail_service', 'initSupport'); + return this._super.apply(this, arguments); + }, +}); + +}); + diff --git a/addons/im_support/static/src/js/support_channel.js b/addons/im_support/static/src/js/support_channel.js new file mode 100644 index 00000000000..adcf2a0c51e --- /dev/null +++ b/addons/im_support/static/src/js/support_channel.js @@ -0,0 +1,233 @@ +odoo.define('im_support.SupportChannel', function (require) { +"use strict"; + +var supportSession = require('im_support.SupportSession'); + +var ThreadWithCache = require('mail.model.ThreadWithCache'); + +var core = require('web.core'); +var session = require('web.session'); + +var _t = core._t; + +/** + * This mail model represents support channel, which are communication channels + * between two different databases for support-related reasons. It is like + * livechat, but both users are in different databases, and both users + * communicate from their respective 'backend' access. + * + * FIXME: it should inherit from mail.model.Channel, not from + * mail.model.ThreadWithCache + */ +var SupportChannel = ThreadWithCache.extend({ + init: function (parent, data, options) { + + data.type = 'support_channel'; + data.name = _t("Support"); + + this._available = data.available; + this._operator = data.operator; + this._supportChannelUUID = data.id; + this._welcomeMessage = data.welcome_message; + if (!this._available) { + data.name += _t(" (offline)"); + } + + this._super.apply(this, arguments); + + // force stuff that should probably be in Thread (or at least + // ThreadWithCache), but that are currently in Channel + this._detached = data.is_minimized; + this._folded = data.state === 'folded'; + this._uuid = data.uuid; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Adds the default message in the Support channel (depending on its + * availability). + */ + addDefaultMessage: function () { + if (!this._available) { + this._addSupportNotAvailableMessage(); + } else { + this._addSupportWelcomeMessage(); + } + }, + /** + * Overrides to store the state of the Support channel in the localStorage. + * + * @override + */ + close: function () { + this._super.apply(this, arguments); + this.call('mail_service', 'updateSupportChannelState', 'closed'); + }, + /** + * Overrides to store the state of the Support channel in the localStorage. + * + * @override + */ + detach: function () { + this._super.apply(this, arguments); + this.call('mail_service', 'updateSupportChannelState', 'open'); + }, + /** + * Overrides to store the state of the Support channel in the localStorage. + * + * @override + */ + fold: function (folded) { + this._super.apply(this, arguments); + var value = folded ? 'folded' : 'open'; + this.call('mail_service', 'updateSupportChannelState', value); + }, + /** + * FIXME: this method is necessary just because the support channel is + * considered as a channel, even though it does not inherit from + * mail.model.Channel. + * + * @returns {boolean} + */ + hasBeenPreviewed: function () { + return true; + }, + /** + * @returns {boolean} true iff the Support channel is available + */ + isAvailable: function () { + return this._available; + }, + /** + * FIXME: this override is necessary just because the support channel is + * considered as a channel, even though it does not inherit from + * mail.model.Channel. + * + * @override + * @returns {boolean} + */ + isChannel: function () { + return true; + }, + /** + * Support channels are not considered as chat, so they are not displayed + * in the 'chat' filter of the systray messaging menu. + * + * @override + * @returns {boolean} + */ + isChat: function () { + return false; + }, + /** + * FIXME: this override is necessary just because the support channel is + * considered as a channel, even though it does not inherit from + * mail.model.Channel. + * + * @returns {integer} + */ + getNeedactionCounter: function () { + return 0; + }, + /** + * @return {string} uuid of this channel + */ + getUUID: function () { + return this._uuid; + }, + /** + * Posts the message on the Support server. + * + * @override + * @return {$.Promise} + */ + postMessage: function (data) { + // ensure that the poll is active before posting the message + this.call('mail_service', 'startPollingSupport'); + return supportSession.rpc('/odoo_im_support/chat_post', { + uuid: this._supportChannelUUID, + message_content: data.content, + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a message in the Support channel indicating that this channel is + * not available for now. + * + * @private + */ + _addSupportNotAvailableMessage: function () { + var msg = { + author_id: this.call('mail_service', 'getOdoobotID'), + body: _t("None of our operators are available. Submit a ticket to ask your question now."), + channel_ids: [this.getID()], + id: Number.MAX_SAFE_INTEGER, // last message in the channel + }; + this.call('mail_service', 'addMessage', msg, { silent: true }); + }, + /** + * Adds the welcome message as first message of the Support channel. + * + * @private + */ + _addSupportWelcomeMessage: function () { + if (this._welcomeMessage) { + var msg = { + author_id: this._operator, + body: this._welcomeMessage, + channel_ids: [this.getID()], + id: -1, // first message of the channel + }; + this.call('mail_service', 'addMessage', msg, { silent: true }); + } + }, + /** + * Fetches the messages from the Support server. + * + * @override + * @private + */ + _fetchMessages: function (channel, options) { + var self = this; + var domain = options && options.domain || []; + var cache = this._getCache(channel, domain); + if (options && options.loadMore) { + var minMessageID = cache.messages[0].id; + domain = [['id', '<', minMessageID]].concat(domain); + } + + return supportSession.rpc('/odoo_im_support/fetch_messages', { + domain: domain, + channel_uuid: session.support_token, + limit: self._FETCH_LIMIT, + }).then(function (msgs) { + if (!cache.all_history_loaded) { + cache.all_history_loaded = msgs.length < self._FETCH_LIMIT; + } + cache.loaded = true; + + _.each(msgs, function (msg) { + _.extend(msg, {channel_ids: [self.getID()]}); + self.call('mail_service', 'addMessage', msg, { + channel_id: self.getID(), + silent: true, + domain: domain, + }); + }); + var channelCache = self._getCache(channel, domain); + return channelCache.messages; + }); + }, +}); + +return SupportChannel; + +}); diff --git a/addons/im_support/static/src/js/support_message.js b/addons/im_support/static/src/js/support_message.js new file mode 100644 index 00000000000..df03d0fa477 --- /dev/null +++ b/addons/im_support/static/src/js/support_message.js @@ -0,0 +1,67 @@ +odoo.define('im_support.SupportMessage', function (require) { +"use strict"; + +var Message = require('mail.model.Message'); + +var session = require('web.session'); + +/** + * This is a model for messages that are in the the support channel + */ +var SupportMessage = Message.extend({ + /** + * @override + * @param {Class} parent + * @param {Object} data + * @param {Array} [data.author_id=[]] + */ + init: function (parent, data) { + data.author_id = data.author_id || []; + if (data.author_id !== this.call('mail_service', 'getOdoobotID')) { + if (!data.author_id[0]) { + // the author is the client + data.author_id = [session.partner_id, session.name]; + } else { + // the author is the operator + // prevent from conflicting with partners of this instance + data.author_id[0] = -1; + } + } + this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + * @returns {string} + */ + getAvatarSource: function () { + return '/mail/static/src/img/odoo_o.png'; + }, + /** + * Overrides to prevent clicks on the Support operator from redirecting. + * + * @override + * @returns {boolean} + */ + shouldRedirectToAuthor: function () { + return false; + }, + /** + * Overrides to prevent from calling the server for messages of the Support + * channel (which are records on the Support database). + * + * @override + * @returns {$.Promise} + */ + toggleStarStatus: function () { + return $.when(); + }, +}); + +return SupportMessage; + +}); diff --git a/addons/im_support/static/src/js/support_session.js b/addons/im_support/static/src/js/support_session.js index d1228674737..1c6268c40db 100644 --- a/addons/im_support/static/src/js/support_session.js +++ b/addons/im_support/static/src/js/support_session.js @@ -1,15 +1,14 @@ odoo.define('im_support.SupportSession', function (require) { "use strict"; +var session = require('web.session'); +var Session = require('web.Session'); + /** * This module returns an instance of Session which is linked to the Support * server, allowing the current instance to communicate with the Support * server (CORS). */ - -var session = require('web.session'); -var Session = require('web.Session'); - return new Session(null, session.support_origin, { modules: odoo._modules, use_cors: true, diff --git a/addons/im_support/static/src/js/systray.js b/addons/im_support/static/src/js/systray_messaging_menu.js similarity index 75% rename from addons/im_support/static/src/js/systray.js rename to addons/im_support/static/src/js/systray_messaging_menu.js index 99c6f7f077b..c6779dfbb4e 100644 --- a/addons/im_support/static/src/js/systray.js +++ b/addons/im_support/static/src/js/systray_messaging_menu.js @@ -1,18 +1,13 @@ -odoo.define('im_support.systray', function (require) { +odoo.define('im_support.systray.MessagingMenu', function (require) { "use strict"; -/** - * This module adds a fake Support channel in the messaging menu of the systray. - */ - -var mailSystray = require('mail.systray'); +var MessagingMenu = require('mail.systray.MessagingMenu'); var config = require('web.config'); var core = require('web.core'); var session = require('web.session'); var _t = core._t; -var MessagingMenu = mailSystray.MessagingMenu; var SUPPORT_CHANNEL_ID = 'SupportChannel'; // Disable Support in mobile for design purposes (for now at least), and don't @@ -21,6 +16,9 @@ if (config.device.isMobile || !session.support_token || !session.support_origin) return; } +/** + * This module adds a fake Support channel in the messaging menu of the systray. + */ MessagingMenu.include({ /** * @override @@ -29,8 +27,8 @@ MessagingMenu.include({ this._super.apply(this, arguments); this.supportChannel = { id: SUPPORT_CHANNEL_ID, - name: _t('Support'), - image_src: '/mail/static/src/img/odoo_o.png', + name: _t("Support"), + imageSRC: '/mail/static/src/img/odoo_o.png', }; }, /** @@ -45,7 +43,7 @@ MessagingMenu.include({ start: function () { var self = this; return this._super.apply(this, arguments).then(function () { - self.$('.o_mail_navbar_dropdown_bottom').addClass('o_mail_navbar_dropdown_channels'); + self.$('.o_mail_systray_dropdown_bottom').addClass('o_mail_systray_dropdown_items'); }); }, @@ -59,10 +57,10 @@ MessagingMenu.include({ * @override * @private */ - _onClickChannel: function (ev) { - var channelID = $(ev.currentTarget).data('channel_id'); - if (channelID === SUPPORT_CHANNEL_ID) { - this.call('chat_manager', 'startSupportLivechat'); + _onClickPreview: function (ev) { + var id = $(ev.currentTarget).data('preview-id'); + if (id === SUPPORT_CHANNEL_ID) { + this.call('mail_service', 'startSupportLivechat'); } else { this._super.apply(this, arguments); } diff --git a/addons/im_support/static/src/js/thread_window.js b/addons/im_support/static/src/js/thread_window.js new file mode 100644 index 00000000000..9173ef21d41 --- /dev/null +++ b/addons/im_support/static/src/js/thread_window.js @@ -0,0 +1,67 @@ +odoo.define('im_support.ThreadWindow', function (require) { +"use strict"; + +/** + * This module includes ThreadWindow to handle the case of the Support channel. + */ +var ThreadWindow = require('mail.ThreadWindow'); + +ThreadWindow.include({ + /** + * Overrides to tweak the options in the case of the Support channel (no + * star). + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + var channel = this.call('mail_service', 'getChannel', this._getThreadID()); + if (channel && channel.getType() === 'support_channel') { + this.options.displayStars = false; + } + }, + /** + * Overrides to remove the attachment button in the Support channel as this + * feature does not work in that channel. + * + * @note: this feature could be enabled by storing the attachments on the + * Support server. + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + var channel = this.call('mail_service', 'getChannel', this._getThreadID()); + if (channel && channel.getType() === 'support_channel') { + this.$('.o_composer .o_composer_button_add_attachment').remove(); + if (!channel.isAvailable()) { + this.$('.o_thread_composer').remove(); + } + } + return def; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Overrides to prevent from displaying a composer if there is no operator + * available. + * + * @override + * @private + */ + _needsComposer: function () { + var channel = this.call('mail_service', 'getChannel', this._getThreadID()); + if ( + channel && + channel.getType() === 'support_channel' && + !channel.isAvailable() + ) { + return false; + } + return this._super.apply(this, arguments); + }, +}); + +}); diff --git a/addons/im_support/static/src/scss/systray.scss b/addons/im_support/static/src/scss/systray.scss index 0e208fb3479..bb834da98f0 100644 --- a/addons/im_support/static/src/scss/systray.scss +++ b/addons/im_support/static/src/scss/systray.scss @@ -1,6 +1,6 @@ -.o_mail_navbar_item { - .o_mail_navbar_dropdown { - .o_mail_navbar_dropdown_bottom { +.o_mail_systray_item { + .o_mail_systray_dropdown { + .o_mail_systray_dropdown_bottom { border-top: 1px solid $gray-lighter-darker; box-sizing: content-box; diff --git a/addons/im_support/static/src/xml/discuss.xml b/addons/im_support/static/src/xml/discuss.xml index 4797c688d1a..ac428d02612 100644 --- a/addons/im_support/static/src/xml/discuss.xml +++ b/addons/im_support/static/src/xml/discuss.xml @@ -1,15 +1,15 @@ diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index 1385da9f61e..1151fa0a81e 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -52,8 +52,8 @@ 'static/src/xml/followers.xml', 'static/src/xml/systray.xml', 'static/src/xml/thread.xml', - 'static/src/xml/chat_window.xml', - 'static/src/xml/extended_chat_window.xml', + 'static/src/xml/abstract_thread_window.xml', + 'static/src/xml/thread_window.xml', 'static/src/xml/announcement.xml', 'static/src/xml/web_kanban_activity.xml', ], diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index 799e8eb6d76..fcbd97bc7ae 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -233,7 +233,7 @@

Hello

You have messages to moderate, please go for the proceedings.



Thank you!

diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index fce7eb29f6f..828e29e195d 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -1,12 +1,13 @@ odoo.define('mail.Activity', function (require) { "use strict"; +var mailUtils = require('mail.utils'); + var AbstractField = require('web.AbstractField'); var BasicModel = require('web.BasicModel'); var core = require('web.core'); var field_registry = require('web.field_registry'); var time = require('web.time'); -var utils = require('mail.utils'); var QWeb = core.qweb; var _t = core._t; @@ -71,38 +72,49 @@ BasicModel.include({ /** * Set the 'label_delay' entry in activity data according to the deadline date + * * @param {Array} activities list of activity Object * @return {Array} : list of modified activity Object */ -var setDelayLabel = function(activities){ +var setDelayLabel = function (activities){ var today = moment().startOf('day'); - _.each(activities, function(activity){ - var to_display = ''; + _.each(activities, function (activity){ + var toDisplay = ''; var diff = activity.date_deadline.diff(today, 'days', true); // true means no rounding - if(diff === 0){ - to_display = _t('Today'); - }else{ - if(diff < 0){ // overdue - if(diff === -1){ - to_display = _t('Yesterday'); - }else{ - to_display = _.str.sprintf(_t('%d days overdue'), Math.abs(diff)); + if (diff === 0){ + toDisplay = _t("Today"); + } else { + if (diff < 0){ // overdue + if (diff === -1){ + toDisplay = _t("Yesterday"); + } else { + toDisplay = _.str.sprintf(_t("%d days overdue"), Math.abs(diff)); } - }else{ // due - if(diff === 1){ - to_display = _t('Tomorrow'); - }else{ - to_display = _.str.sprintf(_t('Due in %d days'), Math.abs(diff)); + } else { // due + if (diff === 1){ + toDisplay = _t("Tomorrow"); + } else { + toDisplay = _.str.sprintf(_t("Due in %d days"), Math.abs(diff)); } } } - activity.label_delay = to_display; + activity.label_delay = toDisplay; }); return activities; }; var AbstractActivityField = AbstractField.extend({ - // private + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * @private + * @param {integer} id + * @param {string} feedback + * @return {$.Promise} + */ _markActivityDone: function (id, feedback) { return this._rpc({ model: 'mail.activity', @@ -112,7 +124,14 @@ var AbstractActivityField = AbstractField.extend({ context: this.record.getContext(), }); }, - _scheduleActivity: function (id, previous_activity_type_id, callback) { + /** + * @private + * @param {integer} id + * @param {integer} previousActivityTypeID + * @param {function} callback + * @return {$.Promise} + */ + _scheduleActivity: function (id, previousActivityTypeID, callback) { var action = { type: 'ir.actions.act_window', res_model: 'mail.activity', @@ -123,7 +142,7 @@ var AbstractActivityField = AbstractField.extend({ context: { default_res_id: this.res_id, default_res_model: this.model, - default_previous_activity_type_id: previous_activity_type_id, + default_previous_activity_type_id: previousActivityTypeID, }, res_id: id || false, }; @@ -144,18 +163,49 @@ var Activity = AbstractActivityField.extend({ }, specialData: '_fetchSpecialActivity', - // inherited + /** + * @override + */ init: function () { this._super.apply(this, arguments); - this.activities = this.record.specialData[this.name]; + this._activities = this.record.specialData[this.name]; }, + + //------------------------------------------------------------ + // Public + //------------------------------------------------------------ + + /** + * @param {integer} previousActivityTypeID + * @return {$.Promise} + */ + scheduleActivity: function (previousActivityTypeID) { + var callback = this._reload.bind(this, { activity: true, thread: true }); + return this._scheduleActivity(false, previousActivityTypeID, callback); + }, + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * @private + * @param {Object} fieldsToReload + */ + _reload: function (fieldsToReload) { + this.trigger_up('reload_mail_fields', fieldsToReload); + }, + /** + * @override + * @private + */ _render: function () { - _.each(this.activities, function (activity) { + _.each(this._activities, function (activity) { if (activity.note) { - activity.note = utils.parse_and_transform(activity.note, utils.add_link); + activity.note = mailUtils.parseAndTransform(activity.note, mailUtils.addLink); } }); - var activities = setDelayLabel(this.activities); + var activities = setDelayLabel(this._activities); if (activities.length) { var nbActivities = _.countBy(activities, 'state'); this.$el.html(QWeb.render('mail.activity_items', { @@ -163,31 +213,33 @@ var Activity = AbstractActivityField.extend({ nbPlannedActivities: nbActivities.planned, nbTodayActivities: nbActivities.today, nbOverdueActivities: nbActivities.overdue, - date_format: time.getLangDateFormat(), - datetime_format: time.getLangDatetimeFormat(), + dateFormat: time.getLangDateFormat(), + datetimeFormat: time.getLangDatetimeFormat(), })); } else { this.$el.empty(); } }, + /** + * @override + * @private + * @param {Object} record + */ _reset: function (record) { this._super.apply(this, arguments); - this.activities = this.record.specialData[this.name]; + this._activities = this.record.specialData[this.name]; // the mail widgets being persistent, one need to update the res_id on reset this.res_id = record.res_id; }, - // public - scheduleActivity: function (previous_activity_type_id) { - var callback = this._reload.bind(this, {activity: true, thread: true}); - return this._scheduleActivity(false, previous_activity_type_id, callback); - }, - // private - _reload: function (fieldsToReload) { - this.trigger_up('reload_mail_fields', fieldsToReload); - }, + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ - // handlers + /** + * @private + * @param {MouseEvent} ev + */ _onClickRedirect: function (ev) { var id = $(ev.target).data('oe-id'); if (id) { @@ -199,10 +251,15 @@ var Activity = AbstractActivityField.extend({ }); } }, - _onEditActivity: function (event, options) { - event.preventDefault(); + /** + * @private + * @param {MouseEvent} ev + * @param {Object} options + */ + _onEditActivity: function (ev, options) { + ev.preventDefault(); var self = this; - var activity_id = $(event.currentTarget).data('activity-id'); + var activity_id = $(ev.currentTarget).data('activity-id'); var action = _.defaults(options || {}, { type: 'ir.actions.act_window', res_model: 'mail.activity', @@ -222,12 +279,17 @@ var Activity = AbstractActivityField.extend({ }, }); }, - _onUnlinkActivity: function (event, options) { - event.preventDefault(); - var activity_id = $(event.currentTarget).data('activity-id'); + /** + * @private + * @param {MouseEvent} ev + * @param {Object} options + */ + _onUnlinkActivity: function (ev, options) { + ev.preventDefault(); + var activityID = $(ev.currentTarget).data('activity-id'); options = _.defaults(options || {}, { model: 'mail.activity', - args: [[activity_id]], + args: [[activityID]], }); return this._rpc({ model: options.model, @@ -236,47 +298,51 @@ var Activity = AbstractActivityField.extend({ }) .then(this._reload.bind(this, {activity: true})); }, - _onMarkActivityDone: function (event) { - event.preventDefault(); + /** + * @private + * @param {MouseEvent} ev + */ + _onMarkActivityDone: function (ev) { + ev.preventDefault(); var self = this; - var $popover_el = $(event.currentTarget); - var activity_id = $popover_el.data('activity-id'); - var previous_activity_type_id = $popover_el.data('previous-activity-type-id'); - if (!$popover_el.data('bs.popover')) { - $popover_el.popover({ - title : _t('Feedback'), + var $popoverElement = $(ev.currentTarget); + var activityID = $popoverElement.data('activity-id'); + var previousActivityTypeID = $popoverElement.data('previous-activity-type-id'); + if (!$popoverElement.data('bs.popover')) { + $popoverElement.popover({ + title : _t("Feedback"), html: 'true', trigger:'click', - content : function() { - var $popover = $(QWeb.render("mail.activity_feedback_form", {'previous_activity_type_id': previous_activity_type_id})); + content : function () { + var $popover = $(QWeb.render('mail.activity_feedback_form', { 'previous_activity_type_id': previousActivityTypeID })); $popover.on('click', '.o_activity_popover_done_next', function () { var feedback = _.escape($popover.find('#activity_feedback').val()); - var previous_activity_type_id = $popover_el.data('previous-activity-type-id'); - self._markActivityDone(activity_id, feedback) - .then(self.scheduleActivity.bind(self, previous_activity_type_id)); + var previousActivityTypeID = $popoverElement.data('previous-activity-type-id'); + self._markActivityDone(activityID, feedback) + .then(self.scheduleActivity.bind(self, previousActivityTypeID)); }); $popover.on('click', '.o_activity_popover_done', function () { var feedback = _.escape($popover.find('#activity_feedback').val()); - self._markActivityDone(activity_id, feedback) - .then(self._reload.bind(self, {activity: true, thread: true})); + self._markActivityDone(activityID, feedback) + .then(self._reload.bind(self, { activity: true, thread: true })); }); $popover.on('click', '.o_activity_popover_discard', function () { - $popover_el.popover('hide'); + $popoverElement.popover('hide'); }); return $popover; }, - }).on("show.bs.popover", function (e) { - var $popover = $(this).data("bs.popover").tip(); + }).on('show.bs.popover', function (e) { + var $popover = $(this).data('bs.popover').tip(); $popover.addClass('o_mail_activity_feedback').attr('tabindex', 0); - $(".o_mail_activity_feedback.popover").not(e.target).popover("hide"); - }).on("shown.bs.popover", function () { - var $popover = $(this).data("bs.popover").tip(); + $('.o_mail_activity_feedback.popover').not(e.target).popover('hide'); + }).on('shown.bs.popover', function () { + var $popover = $(this).data('bs.popover').tip(); $popover.find('#activity_feedback').focus(); $popover.off('focusout'); $popover.focusout(function (e) { // outside click of popover hide the popover // e.relatedTarget is the element receiving the focus - if(!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) { + if (!$popover.is(e.relatedTarget) && !$popover.find(e.relatedTarget).length) { $popover.popover('hide'); } }); @@ -296,7 +362,9 @@ var KanbanActivity = AbstractActivityField.extend({ 'click .o_mark_as_done': '_onMarkActivityDone', }, - // inherited + /** + * @override + */ init: function (parent, name, record) { this._super.apply(this, arguments); var selection = {}; @@ -306,59 +374,98 @@ var KanbanActivity = AbstractActivityField.extend({ this.selection = selection; this._setState(record); }, + + //------------------------------------------------------------ + // Private + //------------------------------------------------------------ + + /** + * @private + */ + _reload: function () { + this.trigger_up('reload', { db_id: this.record_id }); + }, + /** + * @override + * @private + */ _render: function () { - var $span = this.$(".o_activity_btn > span"); + var $span = this.$('.o_activity_btn > span'); $span.removeClass(function (index, classNames) { return classNames.split(/\s+/).filter(function (className) { return _.str.startsWith(className, 'o_activity_color_'); }).join(' '); }); - $span.addClass('o_activity_color_' + (this.activity_state || 'default')); + $span.addClass('o_activity_color_' + (this.activityState || 'default')); if (this.$el.hasClass('open')) { // note: this part of the rendering might be asynchronous this._renderDropdown(); } }, - _reset: function (record) { - this._super.apply(this, arguments); - this._setState(record); - }, - - // private - _reload: function () { - this.trigger_up('reload', {db_id: this.record_id}); - }, + /** + * @private + */ _renderDropdown: function () { var self = this; - this.$('.o_activity').html(QWeb.render("mail.KanbanActivityLoading")); + this.$('.o_activity').html(QWeb.render('mail.KanbanActivityLoading')); return _readActivities(this, this.value.res_ids).then(function (activities) { - self.$('.o_activity').html(QWeb.render("mail.KanbanActivityDropdown", { + self.$('.o_activity').html(QWeb.render('mail.KanbanActivityDropdown', { selection: self.selection, records: _.groupBy(setDelayLabel(activities), 'state'), uid: self.getSession().uid, })); }); }, + /** + * @override + * @private + * @param {Object} record + */ + _reset: function (record) { + this._super.apply(this, arguments); + this._setState(record); + }, + /** + * @private + * @param {Object} record + */ _setState: function (record) { this.record_id = record.id; - this.activity_state = this.recordData.activity_state; + this.activityState = this.recordData.activity_state; }, - // handlers - _onButtonClicked: function (event) { - event.preventDefault(); + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * @private + * @param {MouseEvent} ev + */ + _onButtonClicked: function (ev) { + ev.preventDefault(); if (!this.$el.hasClass('open')) { this._renderDropdown(); } }, - _onMarkActivityDone: function (event) { - event.stopPropagation(); - var activity_id = $(event.currentTarget).data('activity-id'); - this._markActivityDone(activity_id).then(this._reload.bind(this)); + /** + * @private + * @param {MouseEvent} ev + */ + _onMarkActivityDone: function (ev) { + ev.stopPropagation(); + var activityID = $(ev.currentTarget).data('activity-id'); + this._markActivityDone(activityID).then(this._reload.bind(this)); }, - _onScheduleActivity: function (event) { - var activity_id = $(event.currentTarget).data('activity-id') || false; - return this._scheduleActivity(activity_id, false, this._reload.bind(this)); + /** + * @private + * @param {MouseEvent} ev + * @returns {$.Promise} + */ + _onScheduleActivity: function (ev) { + var activityID = $(ev.currentTarget).data('activity-id') || false; + return this._scheduleActivity(activityID, false, this._reload.bind(this)); }, }); diff --git a/addons/mail/static/src/js/chat_window.js b/addons/mail/static/src/js/chat_window.js deleted file mode 100644 index 9e080f3a248..00000000000 --- a/addons/mail/static/src/js/chat_window.js +++ /dev/null @@ -1,199 +0,0 @@ -odoo.define('mail.ChatWindow', function (require) { -"use strict"; - -var ChatThread = require('mail.ChatThread'); - -var config = require('web.config'); -var core = require('web.core'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -var HEIGHT_OPEN = '400px'; -var HEIGHT_FOLDED = '34px'; - -return Widget.extend({ - template: "mail.ChatWindow", - custom_events: { - escape_pressed: '_onEscapePressed', - document_viewer_closed: '_onDocumentViewerClose', - }, - events: { - 'click .o_chat_composer': '_onComposerClick', - "click .o_mail_thread": "_onChatWindowClicked", - "keydown .o_chat_composer": "on_keydown", - "keypress .o_chat_composer": "on_keypress", - "click .o_chat_window_close": "on_click_close", - "click .o_chat_title": "on_click_fold", - }, - - init: function (parent, channel_id, title, is_folded, unread_msgs, options) { - this._super(parent); - this.title = title; - this.channel_id = channel_id; - this.folded = is_folded; - this.options = _.defaults(options || {}, { - autofocus: true, - display_stars: true, - display_reply_icon: false, - display_email_icon: false, - placeholder: _t("Say something"), - input_less: false, - }); - this.status = this.options.status; - this.unread_msgs = unread_msgs || 0; - this.is_hidden = false; - this.isMobile = config.device.isMobile; - }, - start: function () { - this.$input = this.$('.o_composer_text_field'); - this.$header = this.$('.o_chat_header'); - - this.thread = new ChatThread(this, { - channel_id: this.channel_id, - display_needactions: false, - display_stars: this.options.display_stars, - }); - this.thread.on('toggle_star_status', null, this.trigger.bind(this, 'toggle_star_status')); - this.thread.on('redirect_to_channel', null, this.trigger.bind(this, 'redirect_to_channel')); - this.thread.on('redirect', null, this.trigger.bind(this, 'redirect')); - - if (this.folded) { - this.$el.css('height', HEIGHT_FOLDED); - } else if (this.options.autofocus) { - this.focus_input(); - } - if (!config.device.isMobile) { - this.$el.css('margin-right', $.position.scrollbarWidth()); - } - var def = this.thread.replace(this.$('.o_chat_content')); - return $.when(this._super(), def); - }, - render: function (messages) { - this.update_unread(this.unread_msgs); - this.thread.render(messages, {display_load_more: false}); - }, - update_unread: function (counter) { - this.unread_msgs = counter; - this.render_header(); - }, - update_status: function (status) { - this.status = status; - this.render_header(); - }, - render_header: function () { - this.$header.html(QWeb.render('mail.ChatWindowHeaderContent', { - status: this.status, - title: this.title, - unread_counter: this.unread_msgs, - widget: this, - })); - }, - fold: function () { - this.$el.animate({ - height: this.folded ? HEIGHT_FOLDED : HEIGHT_OPEN - }, 200); - }, - toggle_fold: function (fold) { - this.folded = _.isBoolean(fold) ? fold : !this.folded; - if (!this.folded) { - this.thread.scroll_to(); - this.focus_input(); - } - this.fold(); - }, - focus_input: function () { - if (config.device.touch && config.device.size_class <= config.device.SIZES.SM) { - return; - } - this.$input.focus(); - }, - do_show: function () { - this.is_hidden = false; - this._super.apply(this, arguments); - }, - do_hide: function () { - this.is_hidden = true; - this._super.apply(this, arguments); - }, - do_toggle: function (display) { - this.is_hidden = _.isBoolean(display) ? !display : !this.is_hidden; - this._super.apply(this, arguments); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - on_keypress: function (event) { - event.stopPropagation(); // to prevent jquery's blockUI to cancel event - }, - on_keydown: function (event) { - event.stopPropagation(); // to prevent jquery's blockUI to cancel event - // ENTER key (avoid requiring jquery ui for external livechat) - if (event.which === 13) { - var content = _.str.trim(this.$input.val()); - var message = { - content: content, - attachment_ids: [], - partner_ids: [], - }; - this.$input.val(''); - if (content) { - this.trigger('post_message', message, { - channelID: this.channel_id, - }); - } - } - }, - on_click_close: function (event) { - event.stopPropagation(); - event.preventDefault(); - this.trigger("close_chat_session"); - }, - on_click_fold: function () { - if (!config.device.isMobile) { - this.toggle_fold(); - this.trigger("fold_channel", this.channel_id, this.folded); - } - }, - /** - * When a chat 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 - */ - _onChatWindowClicked: function () { - var selectObj = window.getSelection(); - if (selectObj.anchorOffset === selectObj.focusOffset) { - this.$input.focus(); - } - }, - /** - * 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.focus_input(); - }, - _onDocumentViewerClose: function (ev) { - this.focus_input(); - }, - /** - * @private - */ - _onEscapePressed: function () { - if (!this.folded) { - this.trigger("close_chat_session"); - } - }, -}); - -}); diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index 63948b8429a..01f6c3ec3ff 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -2,10 +2,10 @@ odoo.define('mail.Chatter', function (require) { "use strict"; var Activity = require('mail.Activity'); -var ChatterComposer = require('mail.ChatterComposer'); +var ChatterComposer = require('mail.composer.Chatter'); var Followers = require('mail.Followers'); var ThreadField = require('mail.ThreadField'); -var utils = require('mail.utils'); +var mailUtils = require('mail.utils'); var concurrency = require('web.concurrency'); var config = require('web.config'); @@ -47,13 +47,13 @@ var Chatter = Widget.extend({ this._super.apply(this, arguments); this._setState(record); - this.dp = new concurrency.DropPrevious(); + this._dp = new concurrency.DropPrevious(); // mention: get the prefetched partners and use them as mention suggestions // if there is a follower widget, the followers will be added to the // suggestions as well once fetched - this.mentionPartnerSuggestions = this.call('chat_manager', 'getMentionPartnerSuggestions'); - this.mentionSuggestions = this.mentionPartnerSuggestions; + this._mentionPartnerSuggestions = this.call('mail_service', 'getMentionPartnerSuggestions'); + this._mentionSuggestions = this._mentionPartnerSuggestions; this.fields = {}; if (mailFields.mail_activity) { @@ -73,19 +73,19 @@ var Chatter = Widget.extend({ * @override */ start: function () { - this.$topbar = this.$('.o_chatter_topbar'); + this._$topbar = this.$('.o_chatter_topbar'); // render and append the buttons - this.$topbar.append(QWeb.render('mail.Chatter.Buttons', { - new_message_btn: !!this.fields.thread, - log_note_btn: this.hasLogButton, - schedule_activity_btn: !!this.fields.activity, + this._$topbar.append(QWeb.render('mail.chatter.Buttons', { + newMessageButton: !!this.fields.thread, + logNoteButton: this.hasLogButton, + scheduleActivityButton: !!this.fields.activity, isMobile: config.device.isMobile, })); // start and append the widgets var fieldDefs = _.invoke(this.fields, 'appendTo', $('
')); - var def = this.dp.add($.when.apply($, fieldDefs)); + var def = this._dp.add($.when.apply($, fieldDefs)); this._render(def).then(this._updateMentionSuggestions.bind(this)); return this._super.apply(this, arguments); @@ -132,7 +132,7 @@ var Chatter = Widget.extend({ fieldsToReset = this.fields; } var fieldDefs = _.invoke(fieldsToReset, 'reset', record); - var def = this.dp.add($.when.apply($, fieldDefs)); + var def = this._dp.add($.when.apply($, fieldDefs)); this._render(def).then(function () { self.$el.height('auto'); self._updateMentionSuggestions(); @@ -148,13 +148,19 @@ var Chatter = Widget.extend({ * @param {boolean} force */ _closeComposer: function (force) { - if (this.composer && (this.composer.is_empty() || force)) { + if (this._composer && (this._composer.isEmpty() || force)) { this.$el.removeClass('o_chatter_composer_active'); this.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active'); - this.composer.do_hide(); - this.composer.clear_composer(); + this._composer.do_hide(); + this._composer.clearComposer(); } }, + /** + * @private + */ + _disableChatter: function () { + this.$('.btn').prop('disabled', true); // disable buttons + }, /** * Discard changes on the record. * @@ -176,22 +182,16 @@ var Chatter = Widget.extend({ * after posting it * * @private - * @param {Object} message + * @param {Object} messageData * @return {$.Deferred} resolved if no reload or proceed to discard the * changes on the record, rejected otherwise */ - _discardOnReload: function (message) { - if (this._reloadAfterPost(message)) { + _discardOnReload: function (messageData) { + if (this._reloadAfterPost(messageData)) { return this._discardChanges(); } return $.when(); }, - /** - * @private - */ - _disableChatter: function () { - this.$('.btn').prop('disabled', true); // disable buttons - }, /** * @private */ @@ -202,51 +202,49 @@ var Chatter = Widget.extend({ * @private * @param {Object} options * @param {Object[]} [options.suggested_partners=[]] - * @param {boolean} [options.is_log] + * @param {boolean} [options.isLog] */ _openComposer: function (options) { var self = this; - var old_composer = this.composer; + var oldComposer = this._composer; // create the new composer - this.composer = new ChatterComposer(this, this.record.model, options.suggested_partners || [], { - commands_enabled: false, + this._composer = new ChatterComposer(this, this.record.model, options.suggested_partners || [], { + commandsEnabled: false, context: this.context, - input_min_height: 50, - input_max_height: Number.MAX_VALUE, // no max_height limit for the chatter - input_baseline: 14, - is_log: options && options.is_log, - record_name: this.record_name, - default_body: old_composer && old_composer.$input && old_composer.$input.val(), - default_mention_selections: old_composer && old_composer.mention_get_listener_selections(), + inputMinHeight: 50, + isLog: options && options.isLog, + recordName: this.recordName, + defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(), + defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(), }); - this.composer.on('input_focused', this, function () { - this.composer.mention_set_prefetched_partners(this.mentionSuggestions || []); + this._composer.on('input_focused', this, function () { + this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []); }); - this.composer.insertAfter(this.$('.o_chatter_topbar')).then(function () { + this._composer.insertAfter(this.$('.o_chatter_topbar')).then(function () { // destroy existing composer - if (old_composer) { - old_composer.destroy(); + if (oldComposer) { + oldComposer.destroy(); } if (!config.device.touch) { - self.composer.focus(); + self._composer.focus(); } - self.composer.on('post_message', self, function (message) { - self._discardOnReload(message).then(function () { - self.fields.thread.postMessage(message).then(function () { + self._composer.on('post_message', self, function (messageData) { + self._discardOnReload(messageData).then(function () { + self.fields.thread.postMessage(messageData).then(function () { self._closeComposer(true); - if (self._reloadAfterPost(message)) { + if (self._reloadAfterPost(messageData)) { self.trigger_up('reload'); } }); }); }); - self.composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload')); - self.composer.on('close_composer', null, self._closeComposer.bind(self, true)); + self._composer.on('need_refresh', self, self.trigger_up.bind(self, 'reload')); + self._composer.on('close_composer', null, self._closeComposer.bind(self, true)); self.$el.addClass('o_chatter_composer_active'); self.$('.o_chatter_button_new_message, .o_chatter_button_log_note').removeClass('o_active'); - self.$('.o_chatter_button_new_message').toggleClass('o_active', !self.composer.options.is_log); - self.$('.o_chatter_button_log_note').toggleClass('o_active', self.composer.options.is_log); + self.$('.o_chatter_button_new_message').toggleClass('o_active', !self._composer.options.isLog); + self.$('.o_chatter_button_log_note').toggleClass('o_active', self._composer.options.isLog); }); }, /** @@ -254,17 +252,17 @@ var Chatter = Widget.extend({ * Useful to warn the user of unsaved changes if the record is dirty. * * @private - * @param {Object} message - * @param {Array} [message.partner_ids] list of recipients of a message + * @param {Object} messageData + * @param {Array} [messageData.partner_ids] list of recipients of a message * @return {boolean} true if record will be reloaded after posting the * message, false otherwise */ - _reloadAfterPost: function (message) { + _reloadAfterPost: function (messageData) { return this.postRefresh === 'always' || ( this.postRefresh === 'recipients' && - message.partner_ids && - message.partner_ids.length + messageData.partner_ids && + messageData.partner_ids.length ); }, /** @@ -291,14 +289,14 @@ var Chatter = Widget.extend({ self.fields.activity.$el.appendTo(self.$el); } if (self.fields.followers) { - self.fields.followers.$el.appendTo(self.$topbar); + self.fields.followers.$el.appendTo(self._$topbar); } if (self.fields.thread) { self.fields.thread.$el.appendTo(self.$el); } }).always(function () { // disable widgets in create mode, otherwise enable - self.isCreateMode ? self._disableChatter() : self._enableChatter(); + self._isCreateMode ? self._disableChatter() : self._enableChatter(); $spinner.remove(); }); }, @@ -311,8 +309,7 @@ var Chatter = Widget.extend({ */ _setState: function (record) { - this.wasCreateMode = !!this.isCreateMode; - this.isCreateMode = !record.res_id; + this._isCreateMode = !record.res_id; if (!this.record || this.record.res_id !== record.res_id) { this.context = { @@ -324,7 +321,7 @@ var Chatter = Widget.extend({ this.suggested_partners_def = undefined; } this.record = record; - this.record_name = record.data.display_name; + this.recordName = record.data.display_name; }, /** * @private @@ -335,28 +332,28 @@ var Chatter = Widget.extend({ } var self = this; - this.mentionSuggestions = []; + this._mentionSuggestions = []; // add the followers to the mention suggestions - var follower_suggestions = []; + var followerSuggestions = []; var followers = this.fields.followers.getFollowers(); _.each(followers, function (follower) { if (follower.res_model === 'res.partner') { - follower_suggestions.push({ + followerSuggestions.push({ id: follower.res_id, name: follower.name, email: follower.email, }); } }); - if (follower_suggestions.length) { - this.mentionSuggestions.push(follower_suggestions); + if (followerSuggestions.length) { + this._mentionSuggestions.push(followerSuggestions); } // add the partners (followers filtered out) to the mention suggestions - _.each(this.mentionPartnerSuggestions, function (partners) { - self.mentionSuggestions.push(_.filter(partners, function (partner) { - return !_.findWhere(follower_suggestions, { id: partner.id }); + _.each(this._mentionPartnerSuggestions, function (partners) { + self._mentionSuggestions.push(_.filter(partners, function (partner) { + return !_.findWhere(followerSuggestions, { id: partner.id }); })); }); }, @@ -390,7 +387,7 @@ var Chatter = Widget.extend({ var suggested_partners = []; var thread_recipients = result[self.context.default_res_id]; _.each(thread_recipients, function (recipient) { - var parsed_email = recipient[1] && utils.parse_email(recipient[1]); + var parsed_email = recipient[1] && mailUtils.parseEmail(recipient[1]); suggested_partners.push({ checked: true, partner_id: recipient[0], @@ -404,14 +401,14 @@ var Chatter = Widget.extend({ }); } this.suggested_partners_def.then(function (suggested_partners) { - self._openComposer({ is_log: false, suggested_partners: suggested_partners }); + self._openComposer({ isLog: false, suggested_partners: suggested_partners }); }); }, /** * @private */ _onOpenComposerNote: function () { - this._openComposer({is_log: true}); + this._openComposer({ isLog: true }); }, /** * @private diff --git a/addons/mail/static/src/js/chatter_composer.js b/addons/mail/static/src/js/chatter_composer.js deleted file mode 100644 index b5c17f3f593..00000000000 --- a/addons/mail/static/src/js/chatter_composer.js +++ /dev/null @@ -1,254 +0,0 @@ -odoo.define('mail.ChatterComposer', function (require) { -"use strict"; - -var composer = require('mail.composer'); -var utils = require('mail.utils'); - -var core = require('web.core'); -var view_dialogs = require('web.view_dialogs'); - -var _t = core._t; - -// Chat Composer for the Chatter -// -// Extends the basic Composer Widget to add 'suggested partner' layer (open -// popup when suggested partner is selected without email, or other -// informations), and the button to open the full composer wizard. -var ChatterComposer = composer.BasicComposer.extend({ - template: 'mail.chatter.ChatComposer', - - init: function (parent, model, suggested_partners, options) { - this._super(parent, options); - this.model = model; - this.suggested_partners = suggested_partners; - this.options = _.defaults(this.options, { - display_mode: 'textarea', - record_name: false, - is_log: false, - }); - if (this.options.is_log) { - this.options.send_text = _t('Log'); - } - this.events = _.extend(this.events, { - 'click .o_composer_button_full_composer': 'on_open_full_composer', - }); - this.notInline = true; - }, - - should_send: function () { - return false; - }, - - preprocess_message: function () { - var self = this; - var def = $.Deferred(); - this._super().then(function (message) { - message = _.extend(message, { - subtype: 'mail.mt_comment', - message_type: 'comment', - context: self.context, - }); - - // Subtype - if (self.options.is_log) { - message.subtype = 'mail.mt_note'; - } - - // Partner_ids - if (!self.options.is_log) { - var checked_suggested_partners = self.get_checked_suggested_partners(); - self.check_suggested_partners(checked_suggested_partners).done(function (partner_ids) { - message.partner_ids = (message.partner_ids || []).concat(partner_ids); - // update context - message.context = _.defaults({}, message.context, { - mail_post_autofollow: true, - }); - def.resolve(message); - }); - } else { - def.resolve(message); - } - - }); - - return def; - }, - - clear_composer_on_send: function() { - /* chatter don't clear message on sent but after successful sent */ - }, - - /** - * Send the message on SHIFT+ENTER, but go to new line on ENTER - */ - prevent_send: function (event) { - return !event.shiftKey; - }, - - /** - * Get the list of selected suggested partners - * @returns {Array} list of 'recipient' selected partners (may not be created in db) - **/ - get_checked_suggested_partners: function () { - var self = this; - var checked_partners = []; - this.$('.o_composer_suggested_partners input:checked').each(function() { - var full_name = $(this).data('fullname'); - checked_partners = checked_partners.concat(_.filter(self.suggested_partners, function(item) { - return full_name === item.full_name; - })); - }); - return checked_partners; - }, - - /** - * Check the additional partners (not necessary registered partners), and open a popup form view - * for the ones who informations is missing. - * @param {Array} checked_suggested_partners list of 'recipient' partners to complete informations or validate - * @returns {Deferred} resolved with the list of checked suggested partners (real partner) - **/ - check_suggested_partners: function (checked_suggested_partners) { - var self = this; - var check_done = $.Deferred(); - - var recipients = _.filter(checked_suggested_partners, function (recipient) { return recipient.checked; }); - var recipients_to_find = _.filter(recipients, function (recipient) { return (! recipient.partner_id); }); - var names_to_find = _.pluck(recipients_to_find, 'full_name'); - var recipients_to_check = _.filter(recipients, function (recipient) { return (recipient.partner_id && ! recipient.email_address); }); - var recipient_ids = _.pluck(_.filter(recipients, function (recipient) { return recipient.partner_id && recipient.email_address; }), 'partner_id'); - - var names_to_remove = []; - var recipient_ids_to_remove = []; - - // have unknown names -> call message_get_partner_info_from_emails to try to find partner_id - var def; - if (names_to_find.length > 0) { - def = this._rpc({ - model: this.model, - method: 'message_partner_info_from_emails', - args: [[this.context.default_res_id], names_to_find], - }); - } - - // for unknown names + incomplete partners -> open popup - cancel = remove from recipients - $.when(def).pipe(function (result) { - result = result || []; - var emails_deferred = []; - var recipient_popups = result.concat(recipients_to_check); - - _.each(recipient_popups, function (partner_info) { - var deferred = $.Deferred(); - emails_deferred.push(deferred); - - var partner_name = partner_info.full_name; - var partner_id = partner_info.partner_id; - var parsed_email = utils.parse_email(partner_name); - - var dialog = new view_dialogs.FormViewDialog(self, { - res_model: 'res.partner', - res_id: partner_id, - context: { - active_model: self.model, - active_id: self.context.default_res_id, - force_email: true, - ref: "compound_context", - default_name: parsed_email[0], - default_email: parsed_email[1], - }, - title: _t("Please complete customer's informations"), - disable_multiple_selection: true, - }).open(); - dialog.on('closed', self, function () { - deferred.resolve(); - }); - dialog.opened().then(function () { - dialog.form_view.on('on_button_cancel', self, function () { - names_to_remove.push(partner_name); - if (partner_id) { - recipient_ids_to_remove.push(partner_id); - } - }); - }); - }); - $.when.apply($, emails_deferred).then(function () { - var new_names_to_find = _.difference(names_to_find, names_to_remove); - var def; - if (new_names_to_find.length > 0) { - def = self._rpc({ - model: self.model, - method: 'message_partner_info_from_emails', - args: [[self.context.default_res_id], new_names_to_find, true], - }); - } - $.when(def).pipe(function (result) { - result = result || []; - var recipient_popups = result.concat(recipients_to_check); - _.each(recipient_popups, function (partner_info) { - if (partner_info.partner_id && _.indexOf(partner_info.partner_id, recipient_ids_to_remove) === -1) { - recipient_ids.push(partner_info.partner_id); - } - }); - }).pipe(function () { - check_done.resolve(recipient_ids); - }); - }); - }); - return check_done; - }, - - on_open_full_composer: function() { - if (!this.do_check_attachment_upload()){ - return false; - } - - var self = this; - var recipientDoneDef = $.Deferred(); - - // any operation on the full-composer will reload the record, so - // warn the user that any unsaved changes on the record will be lost. - this.trigger_up('discard_record_changes', { - proceed: function () { - if (self.options.is_log) { - recipientDoneDef.resolve([]); - } else { - var checked_suggested_partners = self.get_checked_suggested_partners(); - self.check_suggested_partners(checked_suggested_partners) - .then(recipientDoneDef.resolve.bind(recipientDoneDef)); - } - }, - }); - - recipientDoneDef.then(function (partner_ids) { - var context = { - default_parent_id: self.id, - default_body: utils.get_text2html(self.$input.val()), - default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'), - default_partner_ids: partner_ids, - default_is_log: self.options.is_log, - mail_post_autofollow: true, - }; - - if (self.context.default_model && self.context.default_res_id) { - context.default_model = self.context.default_model; - context.default_res_id = self.context.default_res_id; - } - - var action = { - type: 'ir.actions.act_window', - res_model: 'mail.compose.message', - view_mode: 'form', - view_type: 'form', - views: [[false, 'form']], - target: 'new', - context: context, - }; - self.do_action(action, { - on_close: self.trigger.bind(self, 'need_refresh'), - }).then(self.trigger.bind(self, 'close_composer')); - }); - } -}); - -return ChatterComposer; - -}); diff --git a/addons/mail/static/src/js/composer.js b/addons/mail/static/src/js/composer.js deleted file mode 100644 index f89c0382554..00000000000 --- a/addons/mail/static/src/js/composer.js +++ /dev/null @@ -1,922 +0,0 @@ -odoo.define('mail.composer', function (require) { -"use strict"; - -var DocumentViewer = require('mail.DocumentViewer'); -var utils = require('mail.utils'); - -var config = require('web.config'); -var core = require('web.core'); -var data = require('web.data'); -var dom = require('web.dom'); -var session = require('web.session'); -var Widget = require('web.Widget'); - -var QWeb = core.qweb; -var _t = core._t; - -var NON_BREAKING_SPACE = '\u00a0'; - -var MENTION_PARTNER_DELIMITER = '@'; -var MENTION_CHANNEL_DELIMITER = '#'; -var MENTION_CANNED_RESPONSE_DELIMITER = ':'; -var MENTION_COMMAND_DELIMITER = '/'; - -// The MentionManager allows the Composer to register listeners. For each -// listener, it detects if the user is currently typing a mention (starting by a -// given delimiter). If so, if fetches mention suggestions and renders them. On -// suggestion clicked, it updates the selection for the corresponding listener. -var MentionManager = Widget.extend({ - className: 'dropup o_composer_mention_dropdown', - - events: { - "mouseover .o_mention_proposition": "on_hover_mention_proposition", - "click .o_mention_proposition": "on_click_mention_item", - }, - - init: function (parent, options) { - this._super.apply(this, arguments); - - this.composer = parent; - this.options = _.extend({}, options, { - min_length: 0, - }); - - this.open = false; - this.listeners = []; - this.set('mention_suggestions', []); - this.on('change:mention_suggestions', this, this._render_suggestions); - }, - - // Events - on_hover_mention_proposition: function (event) { - var $elem = $(event.currentTarget); - this.$('.o_mention_proposition').removeClass('active'); - $elem.addClass('active'); - }, - on_click_mention_item: function (event) { - event.preventDefault(); - - var text_input = this.composer.$input.val(); - var id = $(event.currentTarget).data('id'); - var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) { - return s.id === id; - }); - var substitution = selected_suggestion.substitution; - if (!substitution) { // no substitution string given, so use the mention name instead - // replace white spaces with non-breaking spaces to facilitate mentions detection in text - selected_suggestion.name = selected_suggestion.name.replace(/ /g, NON_BREAKING_SPACE); - substitution = this.active_listener.delimiter + selected_suggestion.name; - } - var get_mention_index = function (matches, cursor_position) { - for (var i=0; i links - * @param {string} [model] (optional) the model used for redirection - * @param {string} [redirect_classname] (optional) the classname of the wrapping the mention - * @param {array} [selection] (optional) initial mentions for each listener - * @param {string} [suggestion_template] the template of suggestions' dropdown - */ - register: function (listener) { - this.listeners.push(_.defaults(listener, { - model: '', - redirect_classname: '', - selection: [], - })); - }, - - /** - * Returns true if the mention suggestions dropdown is open, false otherwise - */ - is_open: function () { - return this.open; - }, - - /** - * Returns the mentions of the given listener that haven't been erased from the composer's input - */ - get_listener_selection: function (delimiter) { - var listener = _.findWhere(this.listeners, {delimiter: delimiter}); - if (listener) { - var input_mentions = this.composer.$input.val().match(new RegExp(delimiter+'[^ ]+(?= | )', 'g')); - return this._validate_selection(listener.selection, input_mentions); - } - return []; - }, - - get_listener_selections: function () { - var selections = {}; - _.each(this.listeners, function (listener) { - selections[listener.delimiter] = listener.selection; - }); - return selections; - }, - - /** - * Detects if the user is currently typing a mention word - * @return the search string if it is, false otherwise - */ - detect_delimiter: function () { - var self = this; - var text_val = this.composer.$input.val(); - var cursor_position = this._get_selection_positions().start; - var left_string = text_val.substring(0, cursor_position); - function validate_keyword (delimiter, beginning_only) { - var delimiter_position = left_string.lastIndexOf(delimiter) - 1; - if (beginning_only && delimiter_position > 0) { - return false; - } - var search_str = text_val.substring(delimiter_position, cursor_position); - var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))"; - var regex_start = new RegExp(pattern, "g"); - search_str = search_str.replace(/^\s\s*|^[\n\r]/g, ''); - if (regex_start.test(search_str) && search_str.length > self.options.min_length) { - search_str = search_str.replace(pattern, ''); - return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false; - } - return false; - } - - this.active_listener = undefined; - for (var i=0; i Flatten and insert dividers between groups - var insert_divider = false; - _.each(this.get('mention_suggestions'), function (suggestion_group) { - if (suggestion_group.length > 0) { - if (insert_divider) { - suggestions.push({ divider: true }); - } - suggestions = suggestions.concat(suggestion_group); - insert_divider = true; - } - }); - } else { - suggestions = this.get('mention_suggestions'); - } - if (suggestions.length) { - this.$el.html(QWeb.render(this.active_listener.suggestion_template, { - suggestions: suggestions, - })); - this.$el - .addClass('open') - .find('ul').css("max-width", this.composer.$input.width()) - .find('.o_mention_proposition').first().addClass('active'); - this.open = true; - } else { - this.$el.removeClass('open'); - this.$el.empty(); - this.open = false; - } - }, - _validate_selection: function (selection, input_mentions) { - var validated_selection = []; - _.each(input_mentions, function (mention) { - var validated_mention = _.findWhere(selection, {name: mention.slice(1)}); - if (validated_mention) { - validated_selection.push(validated_mention); - } - }); - return validated_selection; - }, - - // Cursor position and selection utils - _get_selection_positions: function () { - var el = this.composer.$input.get(0); - return el ? dom.getSelectionRange(el) : {start: 0, end: 0}; - }, - _set_cursor_position: function (pos) { - this.composer.$input.each(function (index, elem) { - dom.setSelectionRange(elem, {start: pos, end: pos}); - }); - }, - -}); - -var BasicComposer = Widget.extend({ - template: "mail.ChatComposer", - events: { - "keydown .o_composer_input textarea": "on_keydown", - "keyup .o_composer_input": "on_keyup", - "change input.o_input_file": "on_attachment_change", - "click .o_composer_button_send": "send_message", - "click .o_composer_button_add_attachment": "on_click_add_attachment", - "click .o_attachment_delete": "on_attachment_delete", - "click .o_attachment_download": "_onAttachmentDownload", - "click .o_attachment_view": "_onAttachmentView", - 'click .o_composer_button_emoji': '_onEmojiButtonClick', - 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout', - 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus', - 'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick', - }, - // RPCs done to fetch the mention suggestions are throttled with the following value - MENTION_THROTTLE: 200, - - init: function (parent, options) { - this._super.apply(this, arguments); - this.options = _.defaults(options || {}, { - commands_enabled: true, - context: {}, - input_baseline: 18, - input_max_height: 150, - input_min_height: 28, - mention_fetch_limit: 8, - mention_partners_restricted: false, // set to true to only suggest prefetched partners - send_text: _t('Send'), - default_body: '', - default_mention_selections: {}, - isMobile: config.device.isMobile - }); - this.context = this.options.context; - - // Attachments - this.AttachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); - this.fileupload_id = _.uniqueId('o_chat_fileupload'); - this.set('attachment_ids', []); - - // Mention - this.mention_manager = new MentionManager(this); - this.mention_manager.register({ - delimiter: MENTION_PARTNER_DELIMITER, - fetch_callback: this.mention_fetch_partners.bind(this), - generate_links: true, - model: 'res.partner', - redirect_classname: 'o_mail_redirect', - selection: this.options.default_mention_selections[MENTION_PARTNER_DELIMITER], - suggestion_template: 'mail.MentionPartnerSuggestions', - }); - this.mention_manager.register({ - delimiter: MENTION_CHANNEL_DELIMITER, - fetch_callback: this.mention_fetch_channels.bind(this), - generate_links: true, - model: 'mail.channel', - redirect_classname: 'o_channel_redirect', - selection: this.options.default_mention_selections[MENTION_CHANNEL_DELIMITER], - suggestion_template: 'mail.MentionChannelSuggestions', - }); - this.mention_manager.register({ - delimiter: MENTION_CANNED_RESPONSE_DELIMITER, - fetch_callback: this.mention_get_canned_responses.bind(this), - selection: this.options.default_mention_selections[MENTION_CANNED_RESPONSE_DELIMITER], - suggestion_template: 'mail.MentionCannedResponseSuggestions', - }); - if (this.options.commands_enabled) { - this.mention_manager.register({ - beginning_only: true, - delimiter: MENTION_COMMAND_DELIMITER, - fetch_callback: this.mention_get_commands.bind(this), - selection: this.options.default_mention_selections[MENTION_COMMAND_DELIMITER], - suggestion_template: 'mail.MentionCommandSuggestions', - }); - } - - this.isMini = options.isMini; - - this.avatarURL = session.uid > 0 ? session.url('/web/image', { - model: 'res.users', - field: 'image_small', - id: session.uid, - }) : '/web/static/src/img/user_menu_avatar.png'; - }, - - start: function () { - var self = this; - - this.$attachment_button = this.$(".o_composer_button_add_attachment"); - this.$attachments_list = this.$('.o_composer_attachments_list'); - this.$input = this.$('.o_composer_input textarea'); - this.$input.focus(function () { - self.trigger('input_focused'); - }); - this.$input.val(this.options.default_body); - dom.autoresize(this.$input, {parent: this, min_height: this.options.input_min_height}); - - // Attachments - $(window).on(this.fileupload_id, this.on_attachment_loaded); - this.on("change:attachment_ids", this, this.render_attachments); - - // Mention - this.mention_manager.prependTo(this.$('.o_composer')); - - return this._super(); - }, - - destroy: function () { - $(window).off(this.fileupload_id); - return this._super.apply(this, arguments); - }, - - preprocess_message: function () { - // Return a deferred as this function is extended with asynchronous - // behavior for the chatter composer - - //Removing unwanted extra spaces from message - var value = _.escape(this.$input.val()).trim(); - value = value.replace(/(\r|\n){2,}/g, '

'); - value = value.replace(/(\r|\n)/g, '
'); - - // prevent html space collapsing - value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); - var commands = this.options.commands_enabled ? this.mention_manager.get_listener_selection('/') : []; - return $.when({ - content: this.mention_manager.generate_links(value), - attachment_ids: _.pluck(this.get('attachment_ids'), 'id'), - partner_ids: _.uniq(_.pluck(this.mention_manager.get_listener_selection('@'), 'id')), - command: commands.length > 0 ? commands[0].name : undefined, - }); - }, - - send_message: function () { - if (this.is_empty() || !this.do_check_attachment_upload()) { - return; - } - - clearTimeout(this.canned_timeout); - var self = this; - this.preprocess_message().then(function (message) { - self.trigger('post_message', message); - self.clear_composer_on_send(); - self.$input.focus(); - }); - }, - - clear_composer: function() { - // Empty input, selected partners and attachments - this.$input.val(''); - this.mention_manager.reset_selections(); - this.set('attachment_ids', []); - }, - - clear_composer_on_send: function() { - this.clear_composer(); - }, - - getState: function () { - return { - attachments: this.get('attachment_ids'), - text: this.$input.val(), - }; - }, - - // Events - on_click_add_attachment: function () { - this.$('input.o_input_file').click(); - this.$input.focus(); - // set ignoreEscape to avoid escape_pressed event when file selector dialog is opened - // when user press escape to cancel file selector dialog then escape_pressed event should not be trigerred - this.ignoreEscape = true; - }, - - setState: function (state) { - this.set('attachment_ids', state.attachments); - this.$input.val(state.text); - }, - - /** - * Send the message on ENTER, but go to new line on SHIFT+ENTER - */ - should_send: function (event) { - return !event.shiftKey; - }, - - on_keydown: function (event) { - switch(event.which) { - // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - case $.ui.keyCode.TAB: - if (this.mention_manager.is_open()) { - event.preventDefault(); - } - break; - // ENTER: submit the message only if the dropdown mention proposition is not displayed - case $.ui.keyCode.ENTER: - if (this.mention_manager.is_open()) { - event.preventDefault(); - } else { - var send_message = event.ctrlKey || this.should_send(event); - if (send_message) { - event.preventDefault(); - this.send_message(); - } - } - break; - } - }, - - on_keyup: function (event) { - switch(event.which) { - // ESCAPED KEYS: do nothing - case $.ui.keyCode.END: - case $.ui.keyCode.PAGE_UP: - case $.ui.keyCode.PAGE_DOWN: - break; - // ESCAPE: close mention propositions - case $.ui.keyCode.ESCAPE: - if (this.mention_manager.is_open()) { - event.stopPropagation(); - this.mention_manager.reset_suggestions(); - } else if (this.ignoreEscape) { - this.ignoreEscape = false; - } else { - this.trigger_up("escape_pressed"); - } - break; - // ENTER, UP, DOWN, TAB: check if navigation in mention propositions - case $.ui.keyCode.ENTER: - case $.ui.keyCode.UP: - case $.ui.keyCode.DOWN: - case $.ui.keyCode.TAB: - if (this.mention_manager.is_open()) { - this.mention_manager._propositionNavigation(event.which); - } - break; - // Otherwise, check if a mention is typed - default: - this.mention_manager.detect_delimiter(); - } - }, - - // Attachments - on_attachment_change: function(event) { - var self = this, - files = event.target.files, - attachments = self.get('attachment_ids'); - - _.each(files, function(file){ - var attachment = _.findWhere(attachments, {name: file.name}); - // if the files already exits, delete the file before upload - if(attachment){ - self.AttachmentDataSet.unlink([attachment.id]); - attachments = _.without(attachments, attachment); - } - }); - - this.$('form.o_form_binary_form').submit(); - this.$attachment_button.prop('disabled', true); - var upload_attachments = _.map(files, function(file){ - return { - 'id': 0, - 'name': file.name, - 'filename': file.name, - 'url': '', - 'upload': true, - 'mimetype': '', - }; - }); - attachments = attachments.concat(upload_attachments); - this.set('attachment_ids', attachments); - }, - on_attachment_loaded: function(event) { - var self = this, - attachments = this.get('attachment_ids'), - files = Array.prototype.slice.call(arguments, 1); - - _.each(files, function(file){ - if(file.error || !file.id){ - this.do_warn(file.error); - attachments = _.filter(attachments, function (attachment) { return !attachment.upload; }); - }else{ - var attachment = _.findWhere(attachments, {filename: file.filename, upload: true}); - if(attachment){ - attachments = _.without(attachments, attachment); - attachments.push({ - 'id': file.id, - 'name': file.name || file.filename, - 'filename': file.filename, - 'mimetype': file.mimetype, - 'url': session.url('/web/content', {'id': file.id, download: true}), - }); - } - } - }.bind(this)); - this.set('attachment_ids', attachments); - this.$attachment_button.prop('disabled', false); - }, - on_attachment_delete: function(event){ - event.stopPropagation(); - var self = this; - var attachment_id = $(event.target).data("id"); - if (attachment_id) { - var attachments = []; - _.each(this.get('attachment_ids'), function(attachment){ - if (attachment_id !== attachment.id) { - attachments.push(attachment); - } else { - self.AttachmentDataSet.unlink([attachment_id]); - } - }); - this.set('attachment_ids', attachments); - } - }, - do_check_attachment_upload: function () { - if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) { - this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading.")); - return false; - } - return true; - }, - render_attachments: function() { - this.$attachments_list.html(QWeb.render('mail.ChatComposer.Attachments', { - attachments: this.get('attachment_ids'), - })); - }, - - // Mention - mention_fetch_throttled: function (model, method, kwargs) { - var self = this; - // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing - var def = $.Deferred(); - clearTimeout(this.mention_fetch_timer); - this.mention_fetch_timer = setTimeout(function () { - return self._rpc({model: model, method: method, kwargs: kwargs}) - .then(function (results) { - def.resolve(results); - }); - }, this.MENTION_THROTTLE); - return def; - }, - mention_fetch_channels: function (search) { - return this.mention_fetch_throttled('mail.channel', 'get_mention_suggestions', { - limit: this.options.mention_fetch_limit, - search: search, - }).then(function (suggestions) { - return _.partition(suggestions, function (suggestion) { - return _.contains(['public', 'groups'], suggestion.public); - }); - }); - }, - mention_fetch_partners: function (search) { - var self = this; - return $.when(this.mention_prefetched_partners).then(function (prefetched_partners) { - // filter prefetched partners with the given search string - var suggestions = []; - var limit = self.options.mention_fetch_limit; - var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i'); - _.each(prefetched_partners, function (partners) { - if (limit > 0) { - var filtered_partners = _.filter(partners, function (partner) { - return partner.email && search_regexp.test(partner.email) || - partner.name && search_regexp.test(utils.unaccent(partner.name)); - }); - if (filtered_partners.length) { - suggestions.push(filtered_partners.slice(0, limit)); - limit -= filtered_partners.length; - } - } - }); - if (!suggestions.length && !self.options.mention_partners_restricted) { - // no result found among prefetched partners, fetch other suggestions - suggestions = self.mention_fetch_throttled('res.partner', 'get_mention_suggestions', { - limit: limit, - search: search, - }); - } - return suggestions; - }); - }, - mention_get_canned_responses: function (search) { - var self = this; - var def = $.Deferred(); - clearTimeout(this.canned_timeout); - this.canned_timeout = setTimeout(function () { - var cannedResponses = self.call('chat_manager', 'getCannedResponses'); - var matches = fuzzy.filter(utils.unaccent(search), _.pluck(cannedResponses, 'source')); - var indexes = _.pluck(matches.slice(0, self.options.mention_fetch_limit), 'index'); - def.resolve(_.map(indexes, function (i) { - return cannedResponses[i]; - })); - }, 500); - return def; - }, - mention_get_commands: function (search) { - var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search)), 'i'); - return _.filter(this.mention_commands, function (command) { - return search_regexp.test(command.name); - }).slice(0, this.options.mention_fetch_limit); - }, - mention_set_prefetched_partners: function (prefetched_partners) { - this.mention_prefetched_partners = prefetched_partners; - }, - mention_set_enabled_commands: function (commands) { - this.mention_commands = commands; - }, - mention_get_listener_selections: function () { - return this.mention_manager.get_listener_selections(); - }, - - // Others - is_empty: function () { - return !this.$input.val().trim() && !this.$('.o_attachments').children().length; - }, - focus: function () { - this.$input.focus(); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Hides the emojis container. - * - * @private - */ - _hideEmojis: function () { - this.$emojisContainer.remove(); - }, - - //-------------------------------------------------------------------------- - // 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'); - var attachments = this.get('attachment_ids'); - if (activeAttachmentID) { - var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID); - attachmentViewer.appendTo($('body')); - } - }, - /** - * Called when the emoji button is clicked -> opens/hides the emoji panel. - * Also, this method is in charge of the rendering of this panel the first - * time it is opened. - * - * @private - */ - _onEmojiButtonClick: function () { - if (!this.$emojisContainer) { // lazy rendering - this.$emojisContainer = $(QWeb.render('mail.ChatComposer.emojis', { - emojis: this.call('chat_manager', 'getEmojis'), - })); - } - if (this.$emojisContainer.parent().length) { - this._hideEmojis(); - } else { - this.$emojisContainer.appendTo(this.$('.o_composer')); - } - }, - /** - * Called when the emoji button is blurred -> closes the emoji panel. The - * closing is scheduled to be done at the end of the current execution - * stack to allow stoping this closing if the button was focusout to select - * an emoji (for example). - * - * @private - */ - _onEmojiButtonFocusout: function () { - if (this.$emojisContainer) { - this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0); - } - }, - /** - * Called when an emoji is focused -> @see _onEmojiButtonFocusout - * - * @private - */ - _onEmojiImageFocus: function () { - clearTimeout(this._hideEmojisTimeout); - }, - /** - * Called when an emoji is clicked -> adds it in the , focuses the - * and closes the emoji panel. - * - * @private - * @param {Event} ev - */ - _onEmojiImageClick: function (ev) { - this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " "); - this.$input.focus(); - this._hideEmojis(); - }, -}); - -var ExtendedComposer = BasicComposer.extend({ - init: function (parent, options) { - options = _.defaults(options || {}, { - input_min_height: 120, - }); - this._super(parent, options); - this.extended = true; - }, - - start: function () { - this.$subject_input = this.$(".o_composer_subject input"); - return this._super.apply(this, arguments); - }, - - preprocess_message: function () { - var self = this; - return this._super().then(function (message) { - var subject = self.$subject_input.val(); - self.$subject_input.val(""); - message.subject = subject; - return message; - }); - }, - clear_composer: function () { - this._super.apply(this, arguments); - this.$subject_input.val(''); - }, - getState: function () { - var state = this._super.apply(this, arguments); - state.subject = this.$subject_input.val(); - return state; - }, - should_send: function () { - return false; - }, - focus: function (target) { - if (target === 'body') { - this.$input.focus(); - } else { - this.$subject_input.focus(); - } - }, - set_subject: function(subject) { - this.$('.o_composer_subject input').val(subject); - }, - setState: function (state) { - this._super.apply(this, arguments); - this.set_subject(state.subject); - }, -}); - -return { - BasicComposer: BasicComposer, - ExtendedComposer: ExtendedComposer, -}; - -}); diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js new file mode 100644 index 00000000000..ce7c1017ffa --- /dev/null +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -0,0 +1,640 @@ +odoo.define('mail.composer.Basic', function (require) { +"use strict"; + +var emojis = require('mail.emojis'); +var MentionManager = require('mail.composer.MentionManager'); +var DocumentViewer = require('mail.DocumentViewer'); +var mailUtils = require('mail.utils'); + +var config = require('web.config'); +var core = require('web.core'); +var data = require('web.data'); +var dom = require('web.dom'); +var session = require('web.session'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; +var _t = core._t; + +var BasicComposer = Widget.extend({ + template: "mail.Composer", + events: { + 'change input.o_input_file': '_onAttachmentChange', + 'click .o_attachment_delete': '_onAttachmentDelete', + 'click .o_attachment_download': '_onAttachmentDownload', + 'click .o_attachment_view': '_onAttachmentView', + 'click .o_composer_button_add_attachment': '_onClickAddAttachment', + 'click .o_composer_button_emoji': '_onEmojiButtonClick', + 'focusout .o_composer_button_emoji': '_onEmojiButtonFocusout', + 'click .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageClick', + 'focus .o_mail_emoji_container .o_mail_emoji': '_onEmojiImageFocus', + 'keydown .o_composer_input textarea': '_onKeydown', + 'keyup .o_composer_input': '_onKeyup', + 'click .o_composer_button_send': '_sendMessage', + }, + // RPCs done to fetch the mention suggestions are throttled with the + // following value + MENTION_THROTTLE: 200, + + init: function (parent, options) { + this._super.apply(this, arguments); + this.options = _.defaults(options || {}, { + commandsEnabled: true, + context: {}, + inputMinHeight: 28, + mentionFetchLimit: 8, + // set to true to only suggest prefetched partners + mentionPartnersRestricted: false, + sendText: _t("Send"), + defaultBody: '', + defaultMentionSelections: {}, + isMobile: config.device.isMobile + }); + this.context = this.options.context; + + // Attachments + this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); + this.fileuploadID = _.uniqueId('o_chat_fileupload'); + this.set('attachment_ids', []); + + // Mention + this._mentionManager = new MentionManager(this); + this._mentionManager.register({ + delimiter: '@', + fetchCallback: this._mentionFetchPartners.bind(this), + generateLinks: true, + model: 'res.partner', + redirectClassname: 'o_mail_redirect', + selection: this.options.defaultMentionSelections['@'], + suggestionTemplate: 'mail.MentionSuggestions.Partner', + }); + this._mentionManager.register({ + delimiter: '#', + fetchCallback: this._mentionFetchChannels.bind(this), + generateLinks: true, + model: 'mail.channel', + redirectClassname: 'o_channel_redirect', + selection: this.options.defaultMentionSelections['#'], + suggestionTemplate: 'mail.MentionSuggestions.Channel', + }); + this._mentionManager.register({ + delimiter: ':', + fetchCallback: this._mentionGetCannedResponses.bind(this), + selection: this.options.defaultMentionSelections[':'], + suggestionTemplate: 'mail.MentionSuggestions.CannedResponse', + }); + if (this.options.commandsEnabled) { + this._mentionManager.register({ + beginningOnly: true, + delimiter: '/', + fetchCallback: this._mentionGetCommands.bind(this), + selection: this.options.defaultMentionSelections['/'], + suggestionTemplate: 'mail.MentionSuggestions.Command', + }); + } + + this.isMini = options.isMini; + + this.avatarURL = session.uid > 0 ? session.url('/web/image', { + model: 'res.users', + field: 'image_small', + id: session.uid, + }) : '/web/static/src/img/user_menu_avatar.png'; + }, + start: function () { + var self = this; + + this._$attachmentButton = this.$('.o_composer_button_add_attachment'); + this._$attachmentsList = this.$('.o_composer_attachments_list'); + this.$input = this.$('.o_composer_input textarea'); + this.$input.focus(function () { + self.trigger('input_focused'); + }); + this.$input.val(this.options.defaultBody); + dom.autoresize(this.$input, { + parent: this, + min_height: this.options.inputMinHeight + }); + + // Attachments + $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this)); + this.on('change:attachment_ids', this, this._renderAttachments); + + // Mention + this._mentionManager.prependTo(this.$('.o_composer')); + + return this._super(); + }, + destroy: function () { + $(window).off(this.fileuploadID); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Empty input, selected partners and attachments + */ + clearComposer: function () { + this.$input.val(''); + this._mentionManager.resetSelections(); + this.set('attachment_ids', []); + }, + /** + * Set the focus on the input + */ + focus: function () { + this.$input.focus(); + }, + /** + * Get the state of the composer. + * + * This is useful in order to (re)store its state when switchng to another + * thread with a composer, and coming back to the thread with this composer. + * + * @returns {Object} + */ + getState: function () { + return { + attachments: this.get('attachment_ids'), + text: this.$input.val(), + }; + }, + /** + * @returns {integer} it returns length of attachment and trim content of + * input box to check for empty. + */ + isEmpty: function () { + return !this.$input.val().trim() && + !this.$('.o_attachments').children().length; + }, + /** + * Set the state of the composer. + * + * This is useful in order to (re)store its state when switching to another + * thread with a composer, and coming back to the thread with this composer. + * + * @param {Object} state + * @param {Array} state.attachments + * @param {string} state.text + */ + setState: function (state) { + this.set('attachment_ids', state.attachments); + this.$input.val(state.text); + }, + /** + * Set the list of command suggestions on the thread. + * + * When the user partially types a command, matching commands are displayed + * to the user (@see _mentionGetCommands). + * + * @param {Object[]} commands + */ + mentionSetCommands: function (commands) { + this._mentionCommands = commands; + }, + /** + * Set the list of partner mentions suggestions that has been prefetched. + * + * When the user partially types a mention, matching prefetched partners are + * displayed to the user. If none of them match, then it will fetch for more + * partner suggestions (@see _mentionFetchPartners). + * + * @param {$.Deferred} prefetchedPartners + */ + mentionSetPrefetchedPartners: function (prefetchedPartners) { + this._mentionPrefetchedPartners = prefetchedPartners; + }, + /** + * @returns {Object} containing listener selections, grouped by delimiter + * as key of the object. + */ + getMentionListenerSelections: function () { + return this._mentionManager.getListenerSelections(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _clearComposerOnSend: function () { + this.clearComposer(); + }, + /** + * @private + */ + _doCheckAttachmentUpload: function () { + if (_.find(this.get('attachment_ids'), function (file) { return file.upload; })) { + this.do_warn(_t("Uploading error"), _t("Please, wait while the file is uploading.")); + return false; + } + return true; + }, + /** + * Hides the emojis container. + * + * @private + */ + _hideEmojis: function () { + this._$emojisContainer.remove(); + }, + /** + * @private + * @param {string} search + * @returns {$.Deferred} + */ + _mentionGetCannedResponses: function (search) { + var self = this; + var def = $.Deferred(); + clearTimeout(this._cannedTimeout); + this._cannedTimeout = setTimeout(function () { + var cannedResponses = self.call('mail_service', 'getCannedResponses'); + var matches = fuzzy.filter(mailUtils.unaccent(search), _.pluck(cannedResponses, 'source')); + var indexes = _.pluck(matches.slice(0, self.options.mentionFetchLimit), 'index'); + def.resolve(_.map(indexes, function (index) { + return cannedResponses[index]; + })); + }, 500); + return def; + }, + /** + * @private + * @param {string} search + * @returns {Array} + */ + _mentionGetCommands: function (search) { + var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i'); + return _.filter(this._mentionCommands, function (command) { + return searchRegexp.test(command.name); + }).slice(0, this.options.mentionFetchLimit); + }, + /** + * @private + * @param {string} search + */ + _mentionFetchChannels: function (search) { + return this._mentionFetchThrottled('mail.channel', 'get_mention_suggestions', { + limit: this.options.mentionFetchLimit, + search: search, + }).then(function (suggestions) { + return _.partition(suggestions, function (suggestion) { + return _.contains(['public', 'groups'], suggestion.public); + }); + }); + }, + /** + * @private + * @param {string} search + */ + _mentionFetchPartners: function (search) { + var self = this; + return $.when(this._mentionPrefetchedPartners).then(function (partners) { + // filter prefetched partners with the given search string + var suggestions = []; + var limit = self.options.mentionFetchLimit; + var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(search)), 'i'); + if (limit > 0) { + var filteredPartners = _.filter(partners, function (partner) { + return partner.email && searchRegexp.test(partner.email) || + partner.name && searchRegexp.test(mailUtils.unaccent(partner.name)); + }); + if (filteredPartners.length) { + suggestions.push(filteredPartners.slice(0, limit)); + limit -= filteredPartners.length; + } + } + if (!suggestions.length && !self.options.mentionPartnersRestricted) { + // no result found among prefetched partners, fetch other suggestions + suggestions = self._mentionFetchThrottled( + 'res.partner', + 'get_mention_suggestions', + { limit: limit, search: search } + ); + } + return suggestions; + }); + }, + /** + * @private + * @param {string} model + * @param {string} method + * @param {Object} kwargs + * @return {$.Deferred} + */ + _mentionFetchThrottled: function (model, method, kwargs) { + var self = this; + // Delays the execution of the RPC to prevent unnecessary RPCs when the user is still typing + var def = $.Deferred(); + clearTimeout(this.mentionFetchTimer); + this.mentionFetchTimer = setTimeout(function () { + return self._rpc({model: model, method: method, kwargs: kwargs}) + .then(function (results) { + def.resolve(results); + }); + }, this.MENTION_THROTTLE); + return def; + }, + /** + * @private + * @returns {$.Deferred} + */ + _preprocessMessage: function () { + // Return a deferred as this function is extended with asynchronous + // behavior for the chatter composer + + //Removing unwanted extra spaces from message + var value = _.escape(this.$input.val()).trim(); + value = value.replace(/(\r|\n){2,}/g, '

'); + value = value.replace(/(\r|\n)/g, '
'); + + // prevent html space collapsing + value = value.replace(/ /g, ' ').replace(/([^>]) ([^<])/g, '$1 $2'); + var commands = this.options.commandsEnabled ? + this._mentionManager.getListenerSelection('/') : + []; + return $.when({ + content: this._mentionManager.generateLinks(value), + attachment_ids: _.pluck(this.get('attachment_ids'), 'id'), + partner_ids: _.uniq(_.pluck(this._mentionManager.getListenerSelection('@'), 'id')), + command: commands.length > 0 ? commands[0].name : undefined, + }); + }, + /** + * @private + */ + _renderAttachments: function () { + this._$attachmentsList.html(QWeb.render('mail.composer.Attachments', { + attachments: this.get('attachment_ids'), + })); + }, + /** + * @private + */ + _sendMessage: function () { + if (this.isEmpty() || !this._doCheckAttachmentUpload()) { + return; + } + + clearTimeout(this._cannedTimeout); + var self = this; + this._preprocessMessage().then(function (message) { + self.trigger('post_message', message); + self._clearComposerOnSend(); + self.$input.focus(); + }); + }, + /** + * Send the message on ENTER, but go to new line on SHIFT+ENTER + * + * @private + */ + _shouldSend: function (ev) { + return !ev.shiftKey; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {jQuery.Event} ev + */ + _onAttachmentChange: function (ev) { + var self = this; + var files = ev.target.files; + var attachments = this.get('attachment_ids'); + + _.each(files, function (file){ + var attachment = _.findWhere(attachments, {name: file.name}); + // if the files already exits, delete the file before upload + if (attachment){ + self._attachmentDataSet.unlink([attachment.id]); + attachments = _.without(attachments, attachment); + } + }); + + this.$('form.o_form_binary_form').submit(); + this._$attachmentButton.prop('disabled', true); + var uploadAttachments = _.map(files, function (file){ + return { + id: 0, + name: file.name, + filename: file.name, + url: '', + upload: true, + mimetype: '', + }; + }); + attachments = attachments.concat(uploadAttachments); + this.set('attachment_ids', attachments); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onAttachmentDelete: function (ev){ + ev.stopPropagation(); + var self = this; + var attachmentID = $(ev.target).data('id'); + if (attachmentID) { + var attachments = []; + _.each(this.get('attachment_ids'), function (attachment){ + if (attachmentID !== attachment.id) { + attachments.push(attachment); + } else { + self._attachmentDataSet.unlink([attachmentID]); + } + }); + this.set('attachment_ids', attachments); + } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onAttachmentDownload: function (ev) { + ev.stopPropagation(); + }, + /** + * @private + */ + _onAttachmentLoaded: function () { + var attachments = this.get('attachment_ids'); + var files = Array.prototype.slice.call(arguments, 1); + + _.each(files, function (file) { + if (file.error || !file.id){ + this.do_warn(file.error); + attachments = _.filter(attachments, function (attachment) { + return !attachment.upload; + }); + } else { + var attachment = _.findWhere(attachments, { filename: file.filename, upload: true }); + if (attachment) { + attachments = _.without(attachments, attachment); + attachments.push({ + id: file.id, + name: file.name || file.filename, + filename: file.filename, + mimetype: file.mimetype, + url: session.url('/web/content', { id: file.id, download: true }), + }); + } + } + }.bind(this)); + this.set('attachment_ids', attachments); + this._$attachmentButton.prop('disabled', false); + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onAttachmentView: function (ev) { + var activeAttachmentID = $(ev.currentTarget).data('id'); + var attachments = this.get('attachment_ids'); + if (activeAttachmentID) { + var attachmentViewer = new DocumentViewer(this, attachments, activeAttachmentID); + attachmentViewer.appendTo($('body')); + } + }, + _onClickAddAttachment: function () { + this.$('input.o_input_file').click(); + this.$input.focus(); + }, + /** + * Called when the emoji button is clicked -> opens/hides the emoji panel. + * Also, this method is in charge of the rendering of this panel the first + * time it is opened. + * + * @private + */ + _onEmojiButtonClick: function () { + if (!this._$emojisContainer) { // lazy rendering + this._$emojisContainer = $(QWeb.render('mail.Composer.emojis', { + emojis: emojis, + })); + } + if (this._$emojisContainer.parent().length) { + this._hideEmojis(); + } else { + this._$emojisContainer.appendTo(this.$('.o_composer')); + } + }, + /** + * Called when the emoji button is blurred -> closes the emoji panel. The + * closing is scheduled to be done at the end of the current execution + * stack to allow stoping this closing if the button was focusout to select + * an emoji (for example). + * + * @private + */ + _onEmojiButtonFocusout: function () { + if (this._$emojisContainer) { + this._hideEmojisTimeout = setTimeout(this._hideEmojis.bind(this), 0); + } + }, + /** + * Called when an emoji is clicked -> adds it in the , focuses the + * and closes the emoji panel. + * + * @private + * @param {Event} ev + */ + _onEmojiImageClick: function (ev) { + this.$input.val(this.$input.val() + " " + $(ev.currentTarget).data('emoji') + " "); + this.$input.focus(); + this._hideEmojis(); + }, + /** + * Called when an emoji is focused -> @see _onEmojiButtonFocusout + * + * @private + */ + _onEmojiImageFocus: function () { + clearTimeout(this._hideEmojisTimeout); + }, + /** + * _onKeydown event is triggered when is key is pressed + * - on UP and DOWN arrow is pressed then event prevents it's default + * behaviour if mention manager is open else it break it. + * - on ENTER key is pressed and mention manager is open then event + * prevents it's default behaviour else check if ControlKey is + * pressed or not if yes then it send a message + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown: function (ev) { + switch (ev.which) { + // UP, DOWN, TAB: prevent moving cursor if navigation in mention propositions + case $.ui.keyCode.UP: + case $.ui.keyCode.DOWN: + case $.ui.keyCode.TAB: + if (this._mentionManager.isOpen()) { + ev.preventDefault(); + } + break; + // ENTER: submit the message only if the dropdown mention proposition is not displayed + case $.ui.keyCode.ENTER: + if (this._mentionManager.isOpen()) { + ev.preventDefault(); + } else { + var sendMessage = ev.ctrlKey || this._shouldSend(ev); + if (sendMessage) { + ev.preventDefault(); + this._sendMessage(); + } + } + break; + } + }, + /** + * _onKeyup event is triggered when key is released. + * on ESCAP key it close the mention suggestion dropdown menu. + * on ENTER key it selects that mention and add to a content editable div. + * on UP or DOWN key it highlights previous or next mention respectively. + * + * @private + * @param {KeyboardEvent} ev + */ + _onKeyup: function (ev) { + switch (ev.which) { + // ESCAPED KEYS: do nothing + case $.ui.keyCode.END: + case $.ui.keyCode.PAGE_UP: + case $.ui.keyCode.PAGE_DOWN: + break; + // ESCAPE: close mention propositions + case $.ui.keyCode.ESCAPE: + if (this._mentionManager.isOpen()) { + ev.stopPropagation(); + this._mentionManager.resetSuggestions(); + } else { + this.trigger_up('escape_pressed'); + } + break; + // ENTER, UP, DOWN, TAB: check if navigation in mention propositions + case $.ui.keyCode.ENTER: + case $.ui.keyCode.UP: + case $.ui.keyCode.DOWN: + case $.ui.keyCode.TAB: + if (this._mentionManager.isOpen()) { + this._mentionManager.propositionNavigation(ev.which); + } + break; + // Otherwise, check if a mention is typed + default: + this._mentionManager.detectDelimiter(); + } + }, +}); + +return BasicComposer; + +}); diff --git a/addons/mail/static/src/js/composers/chatter_composer.js b/addons/mail/static/src/js/composers/chatter_composer.js new file mode 100644 index 00000000000..d1a990b31e4 --- /dev/null +++ b/addons/mail/static/src/js/composers/chatter_composer.js @@ -0,0 +1,295 @@ +odoo.define('mail.composer.Chatter', function (require) { +"use strict"; + +var BasicComposer = require('mail.composer.Basic'); +var mailUtils = require('mail.utils'); + +var core = require('web.core'); +var viewDialogs = require('web.view_dialogs'); + +var _t = core._t; + +/** + * Chat Composer for the Chatter + * + * Extends the basic Composer Widget to add 'suggested partner' layer (open + * popup when suggested partner is selected without email, or other + * informations), and the button to open the full composer wizard. + */ +var ChatterComposer = BasicComposer.extend({ + template: 'mail.chatter.Composer', + events: _.extend({}, BasicComposer.prototype.events, { + 'click .o_composer_button_full_composer': '_onOpenFullComposer', + }), + init: function (parent, model, suggestedPartners, options) { + this._super(parent, options); + this._model = model; + this.suggestedPartners = suggestedPartners; + this.options = _.defaults(this.options, { + display_mode: 'textarea', + recordName: false, + isLog: false, + }); + if (this.options.isLog) { + this.options.sendText = _t("Log"); + } + this.notInline = true; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Chatter don't clear message on sent but after successful sent + * + * @override + */ + clearComposerOnSend: function () {}, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Check the additional partners (not necessary registered partners), and + * open a popup form view for the ones who informations is missing. + * + * @private + * @param {Array} checkedSuggestedPartners list of 'recipient' partners to + * complete informations or validate + * @returns {Deferred} resolved with the list of checked suggested partners + * (real partner) + **/ + _checkSuggestedPartners: function (checkedSuggestedPartners) { + var self = this; + var checkDone = $.Deferred(); + + var recipients = _.filter(checkedSuggestedPartners, function (recipient) { + return recipient.checked; + }); + var recipientsToFind = _.filter(recipients, function (recipient) { + return (! recipient.partner_id); + }); + var namesToFind = _.pluck(recipientsToFind, 'full_name'); + var recipientsToCheck = _.filter(recipients, function (recipient) { + return (recipient.partner_id && ! recipient.email_address); + }); + var recipientIDs = _.pluck(_.filter(recipients, function (recipient) { + return recipient.partner_id && recipient.email_address; + }), 'partner_id'); + + var namesToRemove = []; + var recipientIDsToRemove = []; + + // have unknown names + // -> call message_get_partner_info_from_emails to try to find + // partner_id + var def; + if (namesToFind.length > 0) { + def = this._rpc({ + model: this._model, + method: 'message_partner_info_from_emails', + args: [[this.context.default_res_id], namesToFind], + }); + } + + // for unknown names + incomplete partners + // -> open popup - cancel = remove from recipients + $.when(def).pipe(function (result) { + result = result || []; + var emailDefs = []; + var recipientPopups = result.concat(recipientsToCheck); + + _.each(recipientPopups, function (partnerInfo) { + var deferred = $.Deferred(); + emailDefs.push(deferred); + + var partnerName = partnerInfo.full_name; + var partnerID = partnerInfo.partner_id; + var parsedEmail = mailUtils.parseEmail(partnerName); + + var dialog = new viewDialogs.FormViewDialog(self, { + res_model: 'res.partner', + res_id: partnerID, + context: { + active_model: self._model, + active_id: self.context.default_res_id, + force_email: true, + ref: 'compound_context', + default_name: parsedEmail[0], + default_email: parsedEmail[1], + }, + title: _t("Please complete customer's informations"), + disable_multiple_selection: true, + }).open(); + dialog.on('closed', self, function () { + deferred.resolve(); + }); + dialog.opened().then(function () { + dialog.form_view.on('on_button_cancel', self, function () { + namesToRemove.push(partnerName); + if (partnerID) { + recipientIDsToRemove.push(partnerID); + } + }); + }); + }); + $.when.apply($, emailDefs).then(function () { + var newNamesToFind = _.difference(namesToFind, namesToRemove); + var def; + if (newNamesToFind.length > 0) { + def = self._rpc({ + model: self._model, + method: 'message_partner_info_from_emails', + args: [[self.context.default_res_id], newNamesToFind, true], + }); + } + $.when(def).pipe(function (result) { + result = result || []; + var recipientPopups = result.concat(recipientsToCheck); + _.each(recipientPopups, function (partnerInfo) { + if ( + partnerInfo.partner_id && + _.indexOf(partnerInfo.partner_id, recipientIDsToRemove) === -1 + ) { + recipientIDs.push(partnerInfo.partner_id); + } + }); + }).pipe(function () { + checkDone.resolve(recipientIDs); + }); + }); + }); + return checkDone; + }, + /** + * Get the list of selected suggested partners + * + * @private + * @returns {Array} list of 'recipient' selected partners (may not be + * created in db) + **/ + _getCheckedSuggestedPartners: function () { + var self = this; + var checkedPartners = []; + this.$('.o_composer_suggested_partners input:checked').each(function () { + var fullName = $(this).data('fullname'); + checkedPartners = checkedPartners.concat( + _.filter(self.suggestedPartners, function (item) { + return fullName === item.full_name; + }) + ); + }); + return checkedPartners; + }, + /** + * @override + * @private + * @returns {$.Deferred} + */ + _preprocessMessage: function () { + var self = this; + var def = $.Deferred(); + this._super().then(function (message) { + message = _.extend(message, { + subtype: 'mail.mt_comment', + message_type: 'comment', + context: self.context, + }); + + // Subtype + if (self.options.isLog) { + message.subtype = 'mail.mt_note'; + } + + // Partner_ids + if (!self.options.isLog) { + var checkedSuggestedPartners = self._getCheckedSuggestedPartners(); + self._checkSuggestedPartners(checkedSuggestedPartners).done(function (partnerIDs) { + message.partner_ids = (message.partner_ids || []).concat(partnerIDs); + // update context + message.context = _.defaults({}, message.context, { + mail_post_autofollow: true, + }); + def.resolve(message); + }); + } else { + def.resolve(message); + } + + }); + + return def; + }, + /** + * @override + * @private + */ + _shouldSend: function () { + return false; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onOpenFullComposer: function () { + if (!this._doCheckAttachmentUpload()){ + return false; + } + + var self = this; + var recipientDoneDef = $.Deferred(); + + // any operation on the full-composer will reload the record, so + // warn the user that any unsaved changes on the record will be lost. + this.trigger_up('discard_record_changes', { + proceed: function () { + if (self.options.isLog) { + recipientDoneDef.resolve([]); + } else { + var checkedSuggestedPartners = self._getCheckedSuggestedPartners(); + self._checkSuggestedPartners(checkedSuggestedPartners) + .then(recipientDoneDef.resolve.bind(recipientDoneDef)); + } + }, + }); + + recipientDoneDef.then(function (partnerIDs) { + var context = { + default_parent_id: self.id, + default_body: mailUtils.getTextToHTML(self.$input.val()), + default_attachment_ids: _.pluck(self.get('attachment_ids'), 'id'), + default_partner_ids: partnerIDs, + default_is_log: self.options.isLog, + mail_post_autofollow: true, + }; + + if (self.context.default_model && self.context.default_res_id) { + context.default_model = self.context.default_model; + context.default_res_id = self.context.default_res_id; + } + + var action = { + type: 'ir.actions.act_window', + res_model: 'mail.compose.message', + view_mode: 'form', + view_type: 'form', + views: [[false, 'form']], + target: 'new', + context: context, + }; + self.do_action(action, { + on_close: self.trigger.bind(self, 'need_refresh'), + }).then(self.trigger.bind(self, 'close_composer')); + }); + } +}); + +return ChatterComposer; + +}); diff --git a/addons/mail/static/src/js/composers/extended_composer.js b/addons/mail/static/src/js/composers/extended_composer.js new file mode 100644 index 00000000000..f6e8560da8f --- /dev/null +++ b/addons/mail/static/src/js/composers/extended_composer.js @@ -0,0 +1,100 @@ +odoo.define('mail.composer.Extended', function (require) { +"use strict"; + +var BasicComposer = require('mail.composer.Basic'); + +var ExtendedComposer = BasicComposer.extend({ + /** + * @override + */ + init: function (parent, options) { + options = _.defaults(options || {}, { + inputMinHeight: 120, + }); + this._super(parent, options); + this.extended = true; + }, + /** + * @override + */ + start: function () { + this._$subjectInput = this.$('.o_composer_subject input'); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + clearComposer: function () { + this._super.apply(this, arguments); + this._$subjectInput.val(''); + }, + /** + * @override + * @param {string} target + */ + focus: function (target) { + if (target === 'body') { + this.$input.focus(); + } else { + this._$subjectInput.focus(); + } + }, + /** + * @override + */ + getState: function () { + var state = this._super.apply(this, arguments); + state.subject = this._$subjectInput.val(); + return state; + }, + /** + * @override + * @param {Object} state + * @param {string} state.subject + */ + setState: function (state) { + this._super.apply(this, arguments); + this.setSubject(state.subject); + }, + /** + * @param {string} subject + */ + setSubject: function (subject) { + this.$('.o_composer_subject input').val(subject); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @override + * @private + * @returns {$.Deferred} + */ + _preprocessMessage: function () { + var self = this; + return this._super().then(function (message) { + var subject = self._$subjectInput.val(); + self._$subjectInput.val(""); + message.subject = subject; + return message; + }); + }, + /** + * @override + * @private + */ + _shouldSend: function () { + return false; + }, +}); + +return ExtendedComposer; + +}); diff --git a/addons/mail/static/src/js/composers/mention_manager.js b/addons/mail/static/src/js/composers/mention_manager.js new file mode 100644 index 00000000000..fb8d9ddc065 --- /dev/null +++ b/addons/mail/static/src/js/composers/mention_manager.js @@ -0,0 +1,432 @@ +odoo.define('mail.composer.MentionManager', function (require) { +"use strict"; + +var core = require('web.core'); +var dom = require('web.dom'); +var session = require('web.session'); +var Widget = require('web.Widget'); + +var QWeb = core.qweb; + +var NON_BREAKING_SPACE = '\u00a0'; + +// The MentionManager allows the Composer to register listeners. For each +// listener, it detects if the user is currently typing a mention (starting by a +// given delimiter). If so, if fetches mention suggestions and renders them. On +// suggestion clicked, it updates the selection for the corresponding listener. +var MentionManager = Widget.extend({ + className: 'dropup o_composer_mention_dropdown', + events: { + 'click .o_mention_proposition': '_onClickMentionItem', + 'mouseover .o_mention_proposition': '_onHoverMentionProposition', + }, + init: function (parent) { + this._super.apply(this, arguments); + + this._composer = parent; + + this._open = false; + this._listeners = []; + this.set('mention_suggestions', []); + this.on('change:mention_suggestions', this, this._renderSuggestions); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Returns true if the mention suggestions dropdown is open, false otherwise + * + * @returns {boolean} + */ + isOpen: function () { + return this._open; + }, + /** + * Returns the mentions of the given listener that haven't been erased from + * the composer's input + * + * @returns {Array} + */ + getListenerSelection: function (delimiter) { + var listener = _.findWhere(this._listeners, { delimiter: delimiter }); + if (listener) { + var inputMentions = this._composer + .$input + .val() + .match(new RegExp(delimiter+'[^ ]+(?= | )', 'g')); + return this._validateSelection(listener.selection, inputMentions); + } + return []; + }, + /** + * @returns {Object} containing listener selections, grouped by delimiter + * as key of the object. + */ + getListenerSelections: function () { + var selections = {}; + _.each(this._listeners, function (listener) { + selections[listener.delimiter] = listener.selection; + }); + return selections; + }, + /** + * Detects if the user is currently typing a mention word + */ + detectDelimiter: function () { + var self = this; + var textVal = this._composer.$input.val(); + var cursorPosition = this._getSelectionPositions().start; + var leftString = textVal.substring(0, cursorPosition); + + function validateKeyword(delimiter, beginningOnly) { + // use position before delimiter because there should be whitespaces + // or line feed/carriage return before the delimiter + var beforeDelimiterPosition = leftString.lastIndexOf(delimiter) - 1; + if (beginningOnly && beforeDelimiterPosition > 0) { + return false; + } + var searchStr = textVal.substring(beforeDelimiterPosition, cursorPosition); + // regex string start with delimiter or whitespace then delimiter + var pattern = "^"+delimiter+"|^\\s"+delimiter; + var regexStart = new RegExp(pattern, 'g'); + // trim any left whitespaces or the left line feed/ carriage return + // at the beginning of the string + searchStr = searchStr.replace(/^\s\s*|^[\n\r]/g, ''); + if (regexStart.test(searchStr) && searchStr.length) { + searchStr = searchStr.replace(pattern, ''); + return searchStr.indexOf(' ') < 0 && !/[\r\n]/.test(searchStr) ? + searchStr.replace(delimiter, '') : + false; + } + return false; + } + + this._activeListener = undefined; + for (var i = 0; i < this._listeners.length; i++) { + var listener = this._listeners[i]; + this._mentionWord = validateKeyword(listener.delimiter, listener.beginningOnly); + + if (this._mentionWord !== false) { + this._activeListener = listener; + break; + } + } + + if (this._activeListener) { + var mentionWord = this._mentionWord; + $.when(this._activeListener.fetchCallback(mentionWord)) + .then(function (suggestions) { + if (mentionWord === self._mentionWord) { + // update suggestions only if mentionWord didn't change + // in the meantime + self.set('mention_suggestions', suggestions); + } + }); + } else { + this.set('mention_suggestions', []); // close the dropdown + } + }, + /** + * Replaces mentions appearing in the string 's' by html links with proper + * redirection + * + * @param {string} s + * @returns {string} + */ + generateLinks: function (s) { + var self = this; + var baseHREF = session.url('/web'); + var linkParams = "href='%s' class='%s' data-oe-id='%s' data-oe-model='%s' target='_blank'"; + var mentionLink = "
" + + "%s%s" + + ""; + _.each(this._listeners, function (listener) { + if (!listener.generateLinks) { + return; + } + var selection = listener.selection; + if (selection.length) { + var matches = self._getMatch(s, listener); + var substrings = []; + var startIndex = 0; + for (var i = 0; i < matches.length; i++) { + var match = matches[i]; + var endIndex = match.index + match[0].length; + var selectionID = self._getSelectionID(match, selection); + // put back white spaces instead of non-breaking spaces in + // mention's name + var matchName = match[0].substring(1) + .replace(new RegExp(NON_BREAKING_SPACE, 'g'), ' '); + var href = baseHREF + + _.str.sprintf("#model=%s&id=%s", listener.model, selectionID); + var processedText = _.str.sprintf(mentionLink, + href, + listener.redirectClassname, + selectionID, + listener.model, + listener.delimiter, + matchName); + var subtext = s.substring(startIndex, endIndex) + .replace(match[0], processedText); + substrings.push(subtext); + startIndex = endIndex; + } + substrings.push(s.substring(startIndex, s.length)); + s = substrings.join(''); + } + }); + return s; + }, + /** + * @param {integer} keycode + */ + propositionNavigation: function (keycode) { + var $active = this.$('.o_mention_proposition.active'); + if (keycode === $.ui.keyCode.ENTER) { + // selecting proposition + $active.click(); + } else { + // navigation in propositions + var $to; + if (keycode === $.ui.keyCode.DOWN) { + $to = $active.nextAll('.o_mention_proposition').first(); + } else if (keycode === $.ui.keyCode.UP) { + $to = $active.prevAll('.o_mention_proposition').first(); + } else if (keycode === $.ui.keyCode.TAB) { + $to = $active.nextAll('.o_mention_proposition').first(); + if (!$to.length) { + $to = $active.prevAll('.o_mention_proposition').last(); + } + } + if ($to && $to.length) { + $active.removeClass('active'); + $to.addClass('active'); + } + } + }, + /** + * Registers a new listener, described by an object containing the following + * keys + * + * @param {Object} listener + * @param {boolean} [listener.beginningOnly] true to enable autocomplete + * only at first position of input + * @param {char} [listener.delimiter] the mention delimiter + * @param {function} [listener.fetchCallback] the callback to fetch mention + * suggestions + * @param {boolean} [listener.generateLinks] true to wrap mentions in + * links + * @param {string} [listener.model=''] (optional) the model used for + * redirection + * @param {string} [listener.redirectClassname=''] (optional) the classname + * of the wrapping the mention + * @param {Array} [listener.selection=[]] (optional) initial mentions for + * each listener + * @param {string} [listener.suggestionTemplate] the template of + * suggestions' dropdown + */ + register: function (listener) { + this._listeners.push(_.defaults(listener, { + model: '', + redirectClassname: '', + selection: [], + })); + }, + resetSuggestions: function () { + this.set('mention_suggestions', []); + }, + resetSelections: function () { + _.each(this._listeners, function (listener) { + listener.selection = []; + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Returns the matches (as RexExp.exec does) for the mention in the input + * text + * + * @private + * @param {string} inputText: the text to search matches + * @param {Object} listener: the listener for which we want to find a match + * @returns {Object[]} matches in the same format as RexExp.exec() + */ + _getMatch: function (inputText, listener) { + // create the regex of all mention's names + var names = _.pluck(listener.selection, 'name'); + var escapedNames = _.map(names, function (str) { + return "("+_.str.escapeRegExp(listener.delimiter+str)+")"; + }); + var regexStr = escapedNames.join('|'); + // extract matches + var result = []; + if (regexStr.length) { + var myRegexp = new RegExp(regexStr, 'g'); + var match = myRegexp.exec(inputText); + while (match !== null) { + result.push(match); + match = myRegexp.exec(inputText); + } + } + return result; + }, + /** + * @private + * @param {string[]} match + * @param {Object} selection + * @returns {Object} + */ + _getSelectionID: function (match, selection) { + return _.findWhere(selection, { name: match[0].slice(1) }).id; + }, + /** + * Get cursor position and selection + * + * @private + * @returns a current cursor position + */ + _getSelectionPositions: function () { + var InputElement = this._composer.$input.get(0); + return InputElement ? dom.getSelectionRange(InputElement) : { start: 0, end: 0 }; + }, + /** + * @private + */ + _renderSuggestions: function () { + var suggestions = []; + if (_.find(this.get('mention_suggestions'), _.isArray)) { + // Array of arrays -> Flatten and insert dividers between groups + var insertDivider = false; + _.each(this.get('mention_suggestions'), function (suggestionGroup) { + if (suggestionGroup.length > 0) { + if (insertDivider) { + suggestions.push({ divider: true }); + } + suggestions = suggestions.concat(suggestionGroup); + insertDivider = true; + } + }); + } else { + suggestions = this.get('mention_suggestions'); + } + if (suggestions.length) { + this.$el.html(QWeb.render(this._activeListener.suggestionTemplate, { + suggestions: suggestions, + })); + this.$el + .addClass('open') + .find('ul') + .css('max-width', this._composer.$input.width()) + .find('.o_mention_proposition') + .first() + .addClass('active'); + this._open = true; + } else { + this.$el.removeClass('open'); + this.$el.empty(); + this._open = false; + } + }, + /* + * Set cursor position + * + * @private + * @param {integer} pos + */ + _setCursorPosition: function (pos) { + this._composer.$input.each(function (index, elem) { + dom.setSelectionRange(elem, { start: pos, end: pos }); + }); + }, + /** + * @private + * @param {Object} selection + * @param {Array} inputMentions + * @returns {Array} + */ + _validateSelection: function (selection, inputMentions) { + var validatedSelection = []; + _.each(inputMentions, function (mention) { + var validatedMention = _.findWhere(selection, { name: mention.slice(1) }); + if (validatedMention) { + validatedSelection.push(validatedMention); + } + }); + return validatedSelection; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {KeyboardEvent} ev - to get a current selected mention from list + * and eventlistener. + */ + _onClickMentionItem: function (ev) { + ev.preventDefault(); + + var textInput = this._composer.$input.val(); + var id = $(ev.currentTarget).data('id'); + var suggestions = _.flatten(this.get('mention_suggestions')); + var selectedSuggestion = _.find(suggestions, function (s) { + return s.id === id; + }); + var substitution = selectedSuggestion.substitution; + if (!substitution) { + // no substitution string given, so use the mention name instead + // replace white spaces with non-breaking spaces to facilitate + // mentions detection in text + selectedSuggestion.name = selectedSuggestion.name.replace(/ /g, NON_BREAKING_SPACE); + substitution = this._activeListener.delimiter + selectedSuggestion.name; + } + var getMentionIndex = function (matches, cursorPosition) { + for (var i = 0; i < matches.length; i++) { + if (cursorPosition <= matches[i].index) { + return i; + } + } + return i; + }; + + // add the selected suggestion to the list + if (this._activeListener.selection.length) { + // get mention matches (ordered by index in the text) + var matches = this._getMatch(textInput, this._activeListener); + var index = getMentionIndex(matches, this._getSelectionPositions().start); + this._activeListener.selection.splice(index, 0, selectedSuggestion); + } else { + this._activeListener.selection.push(selectedSuggestion); + } + + // update input text, and reset dropdown + var cursorPosition = this._getSelectionPositions().start; + var textLeft = textInput.substring(0, cursorPosition-(this._mentionWord.length+1)); + var textRight = textInput.substring(cursorPosition, textInput.length); + var textInputNew = textLeft + substitution + ' ' + textRight; + this._composer.$input.val(textInputNew); + this._setCursorPosition(textLeft.length+substitution.length+2); + this.set('mention_suggestions', []); + this._composer.focus(); // to trigger autoresize + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onHoverMentionProposition: function (ev) { + var $elem = $(ev.currentTarget); + this.$('.o_mention_proposition').removeClass('active'); + $elem.addClass('active'); + }, + +}); + +return MentionManager; + +}); diff --git a/addons/mail/static/src/js/discuss.js b/addons/mail/static/src/js/discuss.js index def90b01595..ba0ad31a849 100644 --- a/addons/mail/static/src/js/discuss.js +++ b/addons/mail/static/src/js/discuss.js @@ -1,8 +1,9 @@ -odoo.define('mail.chat_discuss', function (require) { +odoo.define('mail.Discuss', function (require) { "use strict"; -var ChatThread = require('mail.ChatThread'); -var composer = require('mail.composer'); +var BasicComposer = require('mail.composer.Basic'); +var ExtendedComposer = require('mail.composer.Extended'); +var ThreadWidget = require('mail.widget.Thread'); var AbstractAction = require('web.AbstractAction'); var config = require('web.config'); @@ -26,24 +27,24 @@ var _t = core._t; * validated. */ var PartnerInviteDialog = Dialog.extend({ - dialog_title: _t('Invite people'), - template: "mail.PartnerInviteDialog", + dialog_title: _t("Invite people"), + template: 'mail.PartnerInviteDialog', /** * @override * @param {integer|string} channelID id of the channel, - * a string for static channels (e.g. 'channel_inbox'). + * a string for static channels (e.g. 'mailbox_inbox'). */ init: function (parent, title, channelID) { - this.channelID = channelID; + this._channelID = channelID; this._super(parent, { title: title, - size: "medium", + size: 'medium', buttons: [{ text: _t("Invite"), close: true, - classes: "btn-primary", + classes: 'btn-primary', click: this._addChannel.bind(this), }], }); @@ -53,17 +54,17 @@ var PartnerInviteDialog = Dialog.extend({ */ start: function () { var self = this; - this.$input = this.$('.o_mail_chat_partner_invite_input'); + this.$input = this.$('.o_mail_discuss_partner_invite_input'); this.$input.select2({ width: '100%', allowClear: true, multiple: true, formatResult: function (item) { - var status = QWeb.render('mail.chat.UserStatus', {status: item.im_status}); + var status = QWeb.render('mail.UserStatus', { status: item.im_status }); return $('').text(item.text).prepend(status); }, query: function (query) { - self.call('chat_manager', 'searchPartner', query.term, 20) + self.call('mail_service', 'searchPartner', query.term, 20) .then(function (partners) { query.callback({ results: _.map(partners, function (partner) { @@ -91,16 +92,20 @@ var PartnerInviteDialog = Dialog.extend({ return this._rpc({ model: 'mail.channel', method: 'channel_invite', - args: [this.channelID], + args: [this._channelID], kwargs: {partner_ids: _.pluck(data, 'id')}, }).then(function () { var names = _.escape(_.pluck(data, 'text').join(', ')); - var notification = _.str.sprintf(_t('You added %s to the conversation.'), names); - self.do_notify(_t('New people'), notification); - // Clear the membersDeferred to fetch again the partner - // when getMentionPartnerSuggestions from the chatManager is triggered - var channel = self.call('chat_manager', 'getChannel', self.channelID); - delete channel.membersDeferred; + var notification = + _.str.sprintf( + _t("You added %s to the conversation."), + names + ); + self.do_notify(_t("New people"), notification); + // Update list of members with the invited user, so that + // we can mention this user in this channel right away. + var channel = self.call('mail_service', 'getChannel', self._channelID); + channel.forceFetchMembers(); }); } }, @@ -113,7 +118,7 @@ var PartnerInviteDialog = Dialog.extend({ * This let the moderator provide a reason for rejecting the messages. */ var ModeratorRejectMessageDialog = Dialog.extend({ - template: "mail.ModeratorRejectMessageDialog", + template: 'mail.ModeratorRejectMessageDialog', /** * @override @@ -135,12 +140,12 @@ var ModeratorRejectMessageDialog = Dialog.extend({ this._messageIDs = params.messageIDs; this._proceedReject = params.proceedReject; this._super(parent, { - title: _t('Send explanation to author'), - size: "medium", + title: _t("Send explanation to author"), + size: 'medium', buttons: [{ text: _t("Send"), close: true, - classes: "btn-primary", + classes: 'btn-primary', click: _.bind(this._onSendClicked, this), }], }); @@ -175,14 +180,14 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { update_moderation_buttons: '_onUpdateModerationButtons', }, events: { - 'blur .o_mail_add_channel input': '_onAddChannelBlur', + 'blur .o_mail_add_thread input': '_onAddThreadBlur', 'click .o_mail_annoying_notification_bar .fa-close': '_onCloseNotificationBar', - 'click .o_mail_chat_channel_item': '_onChannelClicked', + 'click .o_mail_discuss_item': '_onDiscussItemClicked', 'click .o_mail_open_channels': '_onPublicChannelsClick', 'click .o_mail_partner_unpin': '_onUnpinChannel', 'click .o_mail_channel_settings': '_onChannelSettingsClicked', 'click .o_mail_request_permission': '_onRequestNotificationPermission', - 'click .o_mail_sidebar_title .o_add': '_onAddChannel', + 'click .o_mail_sidebar_title .o_add': '_onAddThread', 'keydown': '_onKeydown', }, @@ -191,71 +196,92 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ init: function (parent, action, options) { this._super.apply(this, arguments); + + this.action = action; this.action_manager = parent; this.dataset = new data.DataSetSearch(this, 'mail.message'); + this.displayNotificationBar = ( + window.Notification && + window.Notification.permission === 'default' + ); this.domain = []; - this.action = action; this.options = options || {}; - this.channelsScrolltop = {}; - this.throttledUpdateChannels = _.throttle(this._updateChannels.bind(this), 100, { leading: false }); - this.notification_bar = (window.Notification && window.Notification.permission === "default"); - this.selected_message = null; - this.composerStates = {}; - this.defaultChannelID = this.options.active_id || - this.action.context.active_id || - this.action.params.default_active_id || - 'channel_inbox'; + + this._threadsScrolltop = {}; + this._composerStates = {}; + this._defaultThreadID = this.options.active_id || + this.action.context.active_id || + this.action.params.default_active_id || + 'mailbox_inbox'; + this._selectedMessage = null; + this._throttledUpdateThreads = _.throttle( + this._updateThreads.bind(this), 100, { leading: false }); }, /** * @override */ willStart: function () { var self = this; - var viewID = this.action && this.action.search_view_id && this.action.search_view_id[0]; + var viewID = this.action && + this.action.search_view_id && + this.action.search_view_id[0]; var def = this .loadFieldView(this.dataset, viewID, 'search') - .then(function (fields_view) { - self.fields_view = fields_view; + .then(function (fieldsView) { + self.fields_view = fieldsView; }); - return $.when(this._super(), this.call('chat_manager', 'isReady'), def); + return $.when(this._super(), this.call('mail_service', 'isReady'), def); }, /** * @override */ start: function () { var self = this; - var defaultChannel = this.call('chat_manager', 'getChannel', this.defaultChannelID) || - this.call('chat_manager', 'getChannel', 'channel_inbox'); - this.basicComposer = new composer.BasicComposer(this, {mention_partners_restricted: true}); - this.extendedComposer = new composer.ExtendedComposer(this, {mention_partners_restricted: true}); - this.basicComposer.on('post_message', this, this._onPostMessage); - this.basicComposer.on('input_focused', this, this._onComposerFocused); - this.extendedComposer.on('post_message', this, this._onPostMessage); - this.extendedComposer.on('input_focused', this, this._onComposerFocused); + this._basicComposer = new BasicComposer(this, + { mentionPartnersRestricted: true } + ); + this._extendedComposer = new ExtendedComposer(this, + { mentionPartnersRestricted: true } + ); + this._basicComposer + .on('post_message', this, this._onPostMessage) + .on('input_focused', this, this._onComposerFocused); + this._extendedComposer + .on('post_message', this, this._onPostMessage) + .on('input_focused', this, this._onComposerFocused); this._renderButtons(); var defs = []; - defs.push(this._renderThread()); - defs.push(this.basicComposer.appendTo(this.$('.o_mail_chat_content'))); - defs.push(this.extendedComposer.appendTo(this.$('.o_mail_chat_content'))); + defs.push( + this._renderThread() + ); + defs.push( + this._basicComposer.appendTo(this.$('.o_mail_discuss_content'))); + defs.push( + this._extendedComposer.appendTo(this.$('.o_mail_discuss_content'))); defs.push(this._renderSearchView()); return this.alive($.when.apply($, defs)) .then(function () { - return self.alive(self._setChannel(defaultChannel)); + return self.alive(self._setThread(self._defaultThreadID)); }) .then(function () { - self._updateChannels(); + self._updateThreads(); self._startListening(); - self.thread.$el.on("scroll", null, _.debounce(function () { - if (self.thread.get_scrolltop() < 20 && - !self.thread.$('.o_mail_no_content').length && - !self.call('chat_manager', 'isAllHistoryLoaded', self.channel, self.domain)) { + self._threadWidget.$el.on('scroll', null, _.debounce(function () { + if ( + self._threadWidget.getScrolltop() < 20 && + !self._threadWidget.$('.o_mail_no_content').length && + !self._thread.isAllHistoryLoaded(self.domain) + ) { self._loadMoreMessages(); } - if (self.thread.is_at_bottom()) { - self.call('chat_manager', 'markChannelAsSeen', self.channel); + if ( + self._threadWidget.isAtBottom() && + self._thread.getType() !== 'mailbox' + ) { + self._thread.markAsRead(); } }, 100)); }); @@ -268,7 +294,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { this._updateControlPanel(); this.action_manager.do_push_state({ action: this.action.id, - active_id: this.channel.id, + active_id: this._thread.getID(), }); }, /** @@ -284,9 +310,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @override */ on_attach_callback: function () { - this.call('chat_manager', 'getChatBus').trigger('discuss_open', true); - if (this.channel) { - this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]}); + this.call('mail_service', 'getMailBus').trigger('discuss_open', true); + if (this._thread) { + this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]); } this._loadEnoughMessages(); }, @@ -294,8 +320,8 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @override */ on_detach_callback: function () { - this.call('chat_manager', 'getChatBus').trigger('discuss_open', false); - this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); + this.call('mail_service', 'getMailBus').trigger('discuss_open', false); + this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop(); }, //-------------------------------------------------------------------------- @@ -312,9 +338,12 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _banAuthorsFromMessageIDs: function (messageIDs) { var self = this; var emailList = _.map(messageIDs, function (messageID) { - return self.call('chat_manager', 'getMessage', messageID).email_from; + return self.call('mail_service', 'getMessage', messageID).getEmailFrom(); }).join(", "); - var text = _.str.sprintf(_t("You are going to ban: %s. Do you confirm the action?"), emailList); + var text = _.str.sprintf( + _t("You are going to ban: %s. Do you confirm the action?"), + emailList + ); var options = { confirm_callback: function () { self._moderateMessages(messageIDs, 'ban'); @@ -334,7 +363,10 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { var num = messageIDs.length; var text; if (num > 1) { - text = _.str.sprintf(_t("You are going to discard %s messages. Do you confirm the action?"), num); + text = _.str.sprintf( + _t("You are going to discard %s messages. Do you confirm the action?"), + num + ); } else if (num === 1) { text = _t("You are going to discard 1 message. Do you confirm the action?"); } @@ -351,42 +383,51 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _fetchAndRenderThread: function () { var self = this; - return this.call('chat_manager', 'getMessages', { - channelID: this.channel.id, - domain: this.domain - }).then(function (messages) { - self.thread.render(messages, self._getThreadRenderingOptions(messages)); + return this._thread.getMessages(this.domain) + .then(function (messages) { + self._threadWidget.render( + messages, + self._getThreadRenderingOptions(messages) + ); self._updateButtonStatus(messages.length === 0); return self._loadEnoughMessages(); }); }, /** * @private - * @param {Object} messages + * @param {mail.model.Message} messages * @returns {Object} */ _getThreadRenderingOptions: function (messages) { - // Compute position of the 'New messages' separator, only once when joining - // a channel to keep it in the thread when new messages arrive - if (_.isUndefined(this.messages_separator_position)) { - if (!this.unread_counter) { - this.messages_separator_position = false; // no unread message -> don't display separator + // Compute position of the 'New messages' separator, only once when + // joining a channel to keep it in the thread when new messages arrive + if (_.isUndefined(this.messagesSeparatorPosition)) { + if (!this._unreadCounter) { + // no unread message -> don't display separator + this.messagesSeparatorPosition = false; } else { - var msg = this.call('chat_manager', 'getLastSeenMessage', this.channel); - this.messages_separator_position = msg ? msg.id : 'top'; + var messageID = this._thread.getLastSeenMessageID(); + this.messagesSeparatorPosition = messageID || 'top'; } } return { - channel_id: this.channel.id, - display_load_more: !this.call('chat_manager', 'isAllHistoryLoaded', this.channel, this.domain), - display_needactions: this.channel.display_needactions, - messages_separator_position: this.messages_separator_position, - squash_close_messages: this.channel.type !== 'static' && !this.channel.mass_mailing, - display_empty_channel: !messages.length && !this.domain.length, - display_no_match: !messages.length && this.domain.length, - display_subject: this.channel.mass_mailing || this.channel.id === "channel_inbox" || this.channel.id === "channel_moderation", - display_email_icon: false, - display_reply_icon: true, + threadID: this._thread.getID(), + displayLoadMore: !this._thread.isAllHistoryLoaded(this.domain), + displayMarkAsRead: this._thread.getID() === 'mailbox_inbox', + messagesSeparatorPosition: this.messagesSeparatorPosition, + squashCloseMessages: this._thread.getType() !== 'mailbox' && + !this._thread.isMassMailing(), + displayEmptyChannel: !messages.length && !this.domain.length, + displayNoMatchFound: !messages.length && this.domain.length, + displaySubjectOnMessages: + ( + this._thread.getType() !== 'mailbox' && + this._thread.isMassMailing() + ) || + this._thread.getID() === 'mailbox_inbox' || + this._thread.getID() === 'mailbox_moderation', + displayEmailIcons: false, + displayReplyIcons: true, }; }, /** @@ -423,11 +464,13 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * screen, or when there is no more message to fetch */ _loadEnoughMessages: function () { - var loadMoreMessages = this.thread.el.clientHeight && - this.thread.el.clientHeight === this.thread.el.scrollHeight && - !this.call('chat_manager', 'isAllHistoryLoaded', this.channel, this.domain); + var loadMoreMessages = + this._threadWidget.el.clientHeight && + (this._threadWidget.el.clientHeight === this._threadWidget.el.scrollHeight) && + !this._thread.isAllHistoryLoaded(this.domain); if (loadMoreMessages) { - return this._loadMoreMessages().then(this._loadEnoughMessages.bind(this)); + return this._loadMoreMessages() + .then(this._loadEnoughMessages.bind(this)); } }, /** @@ -438,21 +481,22 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _loadMoreMessages: function () { var self = this; - var oldestMsgID = this.$('.o_thread_message').first().data('messageId'); - var oldestMsgSelector = '.o_thread_message[data-message-id="' + oldestMsgID + '"]'; - var offset = -dom.getPosition(document.querySelector(oldestMsgSelector)).top; - return this.call('chat_manager', 'getMessages', { - channelID: this.channel.id, - domain: this.domain, - loadMore: true - }) + var oldestMessageID = this.$('.o_thread_message').first().data('messageId'); + var oldestMessageSelector = '.o_thread_message[data-message-id="' + oldestMessageID + '"]'; + var offset = -dom.getPosition(document.querySelector(oldestMessageSelector)).top; + return this._thread.getMessages(this.domain, true) .then(function (messages) { - if (self.messages_separator_position === 'top') { - self.messages_separator_position = undefined; // reset value to re-compute separator position + if (self.messagesSeparatorPosition === 'top') { + // reset value to re-compute separator position + self.messagesSeparatorPosition = undefined; } - self.thread.render(messages, self._getThreadRenderingOptions(messages)); - offset += dom.getPosition(document.querySelector(oldestMsgSelector)).top; - self.thread.scroll_to({offset: offset}); + self._threadWidget.render( + messages, + self._getThreadRenderingOptions(messages) + ); + offset += dom.getPosition(document.querySelector(oldestMessageSelector) + ).top; + self._threadWidget.scrollToPosition(offset); }); }, /** @@ -484,68 +528,71 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }, /** * Binds handlers on the given $input to make them autocomplete and/or - * create channels. + * create threads. * * @private * @param {JQuery} $input the input to prepare - * @param {string} type the type of channel to create ('dm', 'public' or - * 'private') + * @param {string} type the type of thread to create ('dm', 'public' or + * 'private' channel) */ - _prepareAddChannelInput: function ($input, type) { + _prepareAddThreadInput: function ($input, type) { var self = this; if (type === 'public') { $input.autocomplete({ source: function (request, response) { - self.last_search_val = _.escape(request.term); - self._searchChannel(self.last_search_val).done(function (result){ + self._lastSearchVal = _.escape(request.term); + self._searchChannel(self._lastSearchVal).done(function (result){ result.push({ - 'label': _.str.sprintf(''+_t("Create %s")+'', '"#'+self.last_search_val+'"'), + 'label': _.str.sprintf( + '' + _t("Create %s") + '', + '"#' + self._lastSearchVal + '"' + ), 'value': '_create', }); response(result); }); }, - select: function (event, ui) { - if (self.last_search_val) { + select: function (ev, ui) { + if (self._lastSearchVal) { if (ui.item.value === '_create') { - self.call('chat_manager', 'createChannel', self.last_search_val, "public"); + self.call('mail_service', 'createChannel', self._lastSearchVal, 'public'); } else { - self.call('chat_manager', 'joinChannel', ui.item.id); + self.call( 'mail_service', 'joinChannel', ui.item.id); } } }, - focus: function (event) { - event.preventDefault(); + focus: function (ev) { + ev.preventDefault(); }, html: true, }); } else if (type === 'private') { - $input.on('keyup', this, function (event) { - var name = _.escape($(event.target).val()); - if (event.which === $.ui.keyCode.ENTER && name) { - self.call('chat_manager', 'createChannel', name, "private"); + $input.on('keyup', this, function (ev) { + var name = _.escape($(ev.target).val()); + if (ev.which === $.ui.keyCode.ENTER && name) { + self.call('mail_service', 'createChannel', name, 'private'); } }); } else if (type === 'dm') { $input.autocomplete({ source: function (request, response) { - self.last_search_val = _.escape(request.term); - self.call('chat_manager', 'searchPartner', self.last_search_val, 10).done(response); + self._lastSearchVal = _.escape(request.term); + self.call('mail_service', 'searchPartner', self._lastSearchVal, 10).done(response); }, - select: function (event, ui) { - var partner_id = ui.item.id; - var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id); + select: function (ev, ui) { + var partnerID = ui.item.id; + var dm = self.call('mail_service', 'getDmFromPartnerID', partnerID); if (dm) { - self._setChannel(dm); + self._setThread(dm.getID()); } else { - self.call('chat_manager', 'createChannel', partner_id, "dm"); + self.call('mail_service', 'createChannel', partnerID, 'dm'); } // clear the input $(this).val(''); return false; }, - focus: function (event) { - event.preventDefault(); + focus: function (ev) { + ev.preventDefault(); }, }); } @@ -572,14 +619,15 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @private */ _renderButtons: function () { - this.$buttons = $(QWeb.render("mail.chat.ControlButtons", {debug: session.debug})); - this.$buttons.find('button').css({display:"inline-block"}); - this.$buttons.on('click', '.o_mail_chat_button_invite', this._onInviteButtonClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_mark_read', this._onMarkAllReadClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_unstar_all', this._onUnstarAllClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_moderate_all', this._onModerateAllClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_select_all', this._onSelectAllClicked.bind(this)); - this.$buttons.on('click', '.o_mail_chat_button_unselect_all', this._onUnselectAllClicked.bind(this)); + this.$buttons = $(QWeb.render('mail.discuss.ControlButtons', { debug: session.debug })); + this.$buttons.find('button').css({display:'inline-block'}); + this.$buttons + .on('click', '.o_mail_discuss_button_invite', this._onInviteButtonClicked.bind(this)) + .on('click', '.o_mail_discuss_button_mark_all_read', this._onMarkAllAsReadClicked.bind(this)) + .on('click', '.o_mail_discuss_button_unstar_all', this._onUnstarAllClicked.bind(this)) + .on('click', '.o_mail_discuss_button_moderate_all', this._onModerateAllClicked.bind(this)) + .on('click', '.o_mail_discuss_button_select_all', this._onSelectAllClicked.bind(this)) + .on('click', '.o_mail_discuss_button_unselect_all', this._onUnselectAllClicked.bind(this)); }, /** * @private @@ -588,25 +636,40 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _renderSearchView: function () { var self = this; var options = { - $buttons: $("
"), + $buttons: $('
'), action: this.action, disable_groupby: true, }; this.searchview = new SearchView(this, this.dataset, this.fields_view, options); - return this.alive(this.searchview.appendTo($("
"))).then(function () { - self.$searchview_buttons = self.searchview.$buttons.contents(); - // manually call do_search to generate the initial domain and filter - // the messages in the default channel - self.searchview.do_search(); - }); + return this.alive(this.searchview.appendTo($('
'))) + .then(function () { + self.$searchview_buttons = self.searchview.$buttons.contents(); + // manually call do_search to generate the initial domain and filter + // the messages in the default thread + self.searchview.do_search(); + }); }, /** + * Render the sidebar of discuss app + * * @private * @param {Object} options - * @returns {JQuery} + * @param {mail.model.Thread[]} [options.threads=[]] + * @returns {jQueryElement} */ _renderSidebar: function (options) { - return $(QWeb.render("mail.chat.Sidebar", options)); + var inbox = this.call('mail_service', 'getMailbox', 'inbox'); + var starred = this.call('mail_service', 'getMailbox', 'starred'); + var moderation = this.call('mail_service', 'getMailbox', 'moderation'); + var $sidebar = $(QWeb.render('mail.discuss.Sidebar', { + activeThreadID: this._thread ? this._thread.getID() : undefined, + threads: options.threads, + needactionCounter: inbox.getMailboxCounter(), + starredCounter: starred.getMailboxCounter(), + moderationCounter: moderation ? moderation.getMailboxCounter() : 0, + isModerator: this.call('mail_service', 'isModerator'), + })); + return $sidebar; }, /** * @private @@ -620,7 +683,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { } timeout = timeout || 20000; this.$snackbar = $(QWeb.render(template, context)); - this.$('.o_mail_chat_content').append(this.$snackbar); + this.$('.o_mail_discuss_content').append(this.$snackbar); // Hide snackbar after [timeout] milliseconds (by default, 20s) var $snackbar = this.$snackbar; setTimeout(function () { $snackbar.fadeOut(); }, timeout); @@ -632,52 +695,57 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @returns {Deferred} */ _renderThread: function () { - this.thread = new ChatThread(this, {display_help: true, loadMoreOnScroll: true}); + this._threadWidget = new ThreadWidget(this, { + displayHelp: true, + loadMoreOnScroll: true + }); - this.thread.on('redirect', this, function (resModel, resID) { - this.call('chat_manager', 'redirect', resModel, resID, this._setChannel.bind(this)); - }); - this.thread.on('redirect_to_channel', this, function (channelID) { - this.call('chat_manager', 'joinChannel', channelID).then(this._setChannel.bind(this)); - }); - this.thread.on('load_more_messages', this, this._loadMoreMessages); - this.thread.on('mark_as_read', this, function (messageID) { - this.call('chat_manager', 'markAsRead', [messageID]); - }); - this.thread.on('toggle_star_status', this, function (messageID) { - this.call('chat_manager', 'toggleStarStatus', messageID); - }); - this.thread.on('select_message', this, this._selectMessage); - this.thread.on('unselect_message', this, this._unselectMessage); - return this.thread.appendTo(this.$('.o_mail_chat_content')); + this._threadWidget + .on('redirect', this, function (resModel, resID) { + this.call('mail_service', 'redirect', resModel, resID, this._setThread.bind(this)); + }) + .on('redirect_to_channel', this, function (channelID) { + this.call('mail_service', 'joinChannel', channelID).then(this._setThread.bind(this)); + }) + .on('load_more_messages', this, this._loadMoreMessages) + .on('mark_as_read', this, function (messageID) { + this.call('mail_service', 'markMessagesAsRead', [messageID]); + }) + .on('toggle_star_status', this, function (messageID) { + var message = this.call('mail_service', 'getMessage', messageID); + message.toggleStarStatus(); + }) + .on('select_message', this, this._selectMessage) + .on('unselect_message', this, this._unselectMessage); + + return this._threadWidget.appendTo(this.$('.o_mail_discuss_content')); }, /** - * Restores the scroll position and composer state of the current channel + * @private + * @param {mail.model.Channel} channel + */ + _restoreComposerState: function (channel) { + var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer; + var composerState = this._composerStates[channel.getUUID()]; + if (composerState) { + composer.setState(composerState); + } + }, + /** + * Restores the scroll position and composer state of the current thread * * @private */ - _restoreChannelState: function () { + _restoreThreadState: function () { var $newMessagesSeparator = this.$('.o_thread_new_messages_separator'); if ($newMessagesSeparator.length) { - this.thread.$el.scrollTo($newMessagesSeparator); + this._threadWidget.$el.scrollTo($newMessagesSeparator); } else { - var newChannelScrolltop = this.channelsScrolltop[this.channel.id]; - this.thread.scroll_to({offset: newChannelScrolltop}); + var newThreadScrolltop = this._threadsScrolltop[this._thread.getID()]; + this._threadWidget.scrollToPosition(newThreadScrolltop); } - this._restoreComposerState(this.channel); - }, - /** - * @private - * @param {Object} channel - */ - _restoreComposerState: function (channel) { - if (channel.type === 'static') { - return; // no composer in static channels - } - var composer = channel.mass_mailing ? this.extendedComposer : this.basicComposer; - var composerState = this.composerStates[channel.uuid]; - if (composerState) { - composer.setState(composerState); + if (this._thread.getType() !== 'mailbox') { + this._restoreComposerState(this._thread); } }, /** @@ -694,10 +762,10 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { .then(function (result){ var values = []; _.each(result, function (channel){ - var escaped_name = _.escape(channel.name); + var escapedName = _.escape(channel.name); values.push(_.extend(channel, { - 'value': escaped_name, - 'label': escaped_name, + 'value': escapedName, + 'label': escapedName, })); }); return values; @@ -709,145 +777,134 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _selectMessage: function (messageID) { this.$el.addClass('o_mail_selection_mode'); - var message = this.call('chat_manager', 'getMessage', messageID);; - this.selected_message = message; - var subject = "Re: " + message.record_name; - this.extendedComposer.set_subject(subject); + var message = this.call('mail_service', 'getMessage', messageID); + this._selectedMessage = message; + var subject = "Re: " + message.getDocumentName(); + this._extendedComposer.setSubject(subject); - if (this.channel.type !== 'static') { - this.basicComposer.do_hide(); + if (this._thread.getType() !== 'mailbox') { + this._basicComposer.do_hide(); } - this.extendedComposer.do_show(); + this._extendedComposer.do_show(); - this.thread.scroll_to({id: messageID, duration: 200, only_if_necessary: true}); - this.extendedComposer.focus('body'); + this._threadWidget.scrollToMessage({ + msgID: messageID, + duration: 200, + onlyIfNecessary: true + }); + this._extendedComposer.focus('body'); }, /** + * Set the selected thread with ID `threadID`. + * If there is no thread with such ID, fallback on mailbox 'inbox'. + * * @private - * @param {Object} channel + * @param {integer} threadID a thread with such ID * @returns {$.Promise} */ - _setChannel: function (channel) { + _setThread: function (threadID) { var self = this; - // Store scroll position and composer state of the previous channel - this._storeChannelState(); + // Store scroll position and composer state of the previous thread + this._storeThreadState(); - this.channel = channel; - this.messages_separator_position = undefined; // reset value on channel change - this.unread_counter = this.channel.unread_counter; - this.last_seen_message_id = this.channel.last_seen_message_id; + this._thread = this.call('mail_service', 'getThread', threadID) || + this.call('mail_service', 'getMailbox', 'inbox'); + + // reset value on channel change + this.messagesSeparatorPosition = undefined; + this._unreadCounter = this._thread.getUnreadCounter(); if (this.$snackbar) { this.$snackbar.remove(); } - this.action.context.active_id = channel.id; - this.action.context.active_ids = [channel.id]; + this.action.context.active_id = this._thread.getID(); + this.action.context.active_ids = [this._thread.getID()]; return this._fetchAndRenderThread().then(function () { - // Mark channel's messages as read and clear needactions - if (channel.type !== 'static') { - self.call('chat_manager', 'markChannelAsSeen', channel); + // Mark thread's messages as read and clear needactions + if (self._thread.getType() !== 'mailbox') { + self._thread.markAsRead(); } - // Restore scroll position and composer of the new current channel - self._restoreChannelState(); + // Restore scroll position and composer of the new current thread + self._restoreThreadState(); - // Update control panel before focusing the composer, otherwise focus is on the searchview - self.set("title", '#' + channel.name); + // Update control panel before focusing the composer, otherwise + // focus is on the searchview + self.set("title", '#' + self._thread.getName()); self._updateControlPanel(); - self._updateControlPanelButtons(channel); + self._updateControlPanelButtons(self._thread); - // Display and focus the adequate composer, and unselect possibly selected message - // to prevent sending messages as reply to that message + // Display and focus the adequate composer, and unselect possibly + // selected message to prevent sending messages as reply to that + // message self._unselectMessage(); self.action_manager.do_push_state({ action: self.action.id, - active_id: self.channel.id, + active_id: self._thread.getID(), }); }); }, /** - * Binds handlers on chatManager events + * Binds handlers on mail bus events * * @private */ _startListening: function () { - var chatBus = this.call('chat_manager', 'getChatBus'); - chatBus.on('open_channel', this, this._setChannel); - chatBus.on('new_message', this, this._onNewMessage); - chatBus.on('update_message', this, this._onMessageUpdated); - chatBus.on('new_channel', this, this._onNewChannel); - chatBus.on('anyone_listening', this, function (channel, query) { - query.is_displayed = query.is_displayed || - (channel.id === this.channel.id && this.thread.is_at_bottom()); - }); - chatBus.on('unsubscribe_from_channel', this, this._onChannelLeft); - chatBus.on('update_needaction', this, this.throttledUpdateChannels); - chatBus.on('update_starred', this, this.throttledUpdateChannels); - chatBus.on('update_channel_unread_counter', this, this.throttledUpdateChannels); - chatBus.on('update_dm_presence', this, this.throttledUpdateChannels); - chatBus.on('activity_updated', this, this.throttledUpdateChannels); - chatBus.on('update_moderation_counter', this, this.throttledUpdateChannels); + this.call('mail_service', 'getMailBus') + .on('open_thread_in_discuss', this, this._onOpenThreadInDiscuss) + .on('new_message', this, this._onNewMessage) + .on('update_message', this, this._onMessageUpdated) + .on('new_channel', this, this._onNewChannel) + .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) + .on('unsubscribe_from_channel', this, this._onChannelLeft) + .on('update_needaction', this, this._throttledUpdateThreads) + .on('update_starred', this, this._throttledUpdateThreads) + .on('update_thread_unread_counter', this, this._throttledUpdateThreads) + .on('update_dm_presence', this, this._throttledUpdateThreads) + .on('activity_updated', this, this._throttledUpdateThreads) + .on('update_moderation_counter', this, this._throttledUpdateThreads); }, /** - * Stores the scroll position and composer state of the current channel + * @private + * @param {mail.model.Channel} channel + */ + _storeComposerState: function (channel) { + var composer = channel.isMassMailing() ? this._extendedComposer : this._basicComposer; + this._composerStates[channel.getUUID()] = composer.getState(); + composer.clearComposer(); + }, + /** + * Stores the scroll position of the current thread. + * For channels, also stores composer state. * * @private */ - _storeChannelState: function () { - if (this.channel) { - this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); - this._storeComposerState(this.channel); + _storeThreadState: function () { + if (this._thread) { + this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop(); + if (this._thread.getType() !== 'mailbox') { + this._storeComposerState(this._thread); + } } }, - /** - * @private - * @param {Object} channel - */ - _storeComposerState: function (channel) { - if (channel.type === 'static') { - return; // no composer in static channels - } - var composer = channel.mass_mailing ? this.extendedComposer : this.basicComposer; - this.composerStates[channel.uuid] = composer.getState(); - composer.clear_composer(); - }, /** * @private */ _unselectMessage: function () { - this.basicComposer.do_toggle(this.channel.type !== 'static' && !this.channel.mass_mailing); - this.extendedComposer.do_toggle(this.channel.type !== 'static' && !!this.channel.mass_mailing); + this._basicComposer.do_toggle(this._thread.getType() !== 'mailbox' && !this._thread.isMassMailing()); + this._extendedComposer.do_toggle(this._thread.getType() !== 'mailbox' && !!this._thread.isMassMailing()); if (!config.device.touch) { - var composer = this.channel.mass_mailing ? this.extendedComposer : this.basicComposer; + var composer = this._thread.getType() !== 'mailbox' && this._thread.isMassMailing() ? + this._extendedComposer : + this._basicComposer; composer.focus(); } this.$el.removeClass('o_mail_selection_mode'); - this.thread.unselect(); - this.selected_message = null; - }, - /** - * Renders the mainside bar with current channels - * - * @private - */ - _updateChannels: function () { - var self = this; - var $sidebar = this._renderSidebar({ - active_channel_id: this.channel ? this.channel.id: undefined, - channels: this.call('chat_manager', 'getChannels'), - needaction_counter: this.call('chat_manager', 'getNeedactionCounter'), - starred_counter: this.call('chat_manager', 'getStarredCounter'), - moderationCounter: this.call('chat_manager', 'getModerationCounter'), - isModerator: this.call('chat_manager', 'isModerator'), - }); - this.$(".o_mail_chat_sidebar").html($sidebar.contents()); - _.each(['dm', 'public', 'private'], function (type) { - var $input = self.$('.o_mail_add_channel[data-type=' + type + '] input'); - self._prepareAddChannelInput($input, type); - }); + this._threadWidget.unselectMessage(); + this._selectedMessage = null; }, /** * @private @@ -855,25 +912,31 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @param {string} type */ _updateButtonStatus: function (disabled, type) { - if (this.channel.id === "channel_inbox") { + if (this._thread.getID() === 'mailbox_inbox') { this.$buttons - .find('.o_mail_chat_button_mark_read') + .find('.o_mail_discuss_button_mark_all_read') .toggleClass('disabled', disabled); // Display Rainbowman when all inbox messages are read through // 'MARK ALL READ' or marking last inbox message as read if (disabled && type === 'mark_as_read') { this.trigger_up('show_effect', { - message: _t('Congratulations, your inbox is empty!'), + message: _t("Congratulations, your inbox is empty!"), type: 'rainbow_man', }); } } - if (this.channel.id === "channel_starred") { + if (this._thread.getID() === 'mailbox_starred') { this.$buttons - .find('.o_mail_chat_button_unstar_all') + .find('.o_mail_discuss_button_unstar_all') .toggleClass('disabled', disabled); } - if ((this.channel.isModerated && this.channel.isModerator) || this.channel.id === "channel_moderation") { + if ( + this._thread.getID() === 'mailbox_moderation' || + ( + this._thread.isModerated() && + this._thread.isModerator() + ) + ) { this._updateModerationButtons(); } }, @@ -891,36 +954,37 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }); }, /** - * Updates the control panel buttons visibility based on channel type + * Updates the control panel buttons visibility based on thread type * * @private - * @param {Object} channel + * @param {mail.model.Thread} thread */ - _updateControlPanelButtons: function (channel) { + _updateControlPanelButtons: function (thread) { // Hide 'unsubscribe' button in state channels and DM and channels with group-based subscription this.$buttons - .find('.o_mail_chat_button_invite, .o_mail_chat_button_settings') - .toggle(channel.type !== "dm" && channel.type !== 'static'); + .find('.o_mail_discuss_button_invite, .o_mail_discuss_button_settings') + .toggle(thread.getType() !== 'dm' && thread.getType() !== 'mailbox'); this.$buttons - .find('.o_mail_chat_button_mark_read') - .toggle(channel.id === "channel_inbox") - .removeClass("o_hidden"); + .find('.o_mail_discuss_button_mark_all_read') + .toggle(thread.getID() === 'mailbox_inbox') + .removeClass('o_hidden'); this.$buttons - .find('.o_mail_chat_button_unstar_all') - .toggle(channel.id === "channel_starred") - .removeClass("o_hidden"); + .find('.o_mail_discuss_button_unstar_all') + .toggle(thread.getID() === 'mailbox_starred') + .removeClass('o_hidden'); this.$buttons - .find('.o_mail_chat_button_select_all') - .toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation") - .removeClass("o_hidden"); + .find('.o_mail_discuss_button_select_all') + .toggle((thread.isModerated() && thread.isModerator()) || thread.getID() === 'mailbox_moderation') + .removeClass('o_hidden'); this.$buttons - .find('.o_mail_chat_button_unselect_all') - .toggle((channel.isModerated && channel.isModerator) || channel.id === "channel_moderation") - .removeClass("o_hidden"); - this.$buttons.find('.o_mail_chat_button_moderate_all').hide(); - this.$('.o_mail_chat_channel_item') + .find('.o_mail_discuss_button_unselect_all') + .toggle((thread.isModerated() && thread.isModerator()) || thread.getID() === 'mailbox_moderation') + .removeClass('o_hidden'); + this.$buttons.find('.o_mail_discuss_button_moderate_all').hide(); + + this.$('.o_mail_discuss_item') .removeClass('o_active') - .filter('[data-channel-id=' + channel.id + ']') + .filter('[data-thread-id=' + thread.getID() + ']') .removeClass('o_unread_message') .addClass('o_active'); }, @@ -942,20 +1006,20 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @private */ _updateModerationDecisionButton: function () { - if (this.thread.$('.moderation_checkbox:checked').length) { - this.$buttons.find('.o_mail_chat_button_moderate_all').show(); + if (this._threadWidget.$('.moderation_checkbox:checked').length) { + this.$buttons.find('.o_mail_discuss_button_moderate_all').show(); } else { - this.$buttons.find('.o_mail_chat_button_moderate_all').hide(); + this.$buttons.find('.o_mail_discuss_button_moderate_all').hide(); } }, /** * @private */ _updateSelectUnselectAllButtons: function () { - var buttonSelect = this.$buttons.find('.o_mail_chat_button_select_all'); - var buttonUnselect = this.$buttons.find('.o_mail_chat_button_unselect_all'); - var numCheckboxes = this.thread.$('.moderation_checkbox').length; - var numCheckboxesChecked = this.thread.$('.moderation_checkbox:checked').length; + var buttonSelect = this.$buttons.find('.o_mail_discuss_button_select_all'); + var buttonUnselect = this.$buttons.find('.o_mail_discuss_button_unselect_all'); + var numCheckboxes = this._threadWidget.$('.moderation_checkbox').length; + var numCheckboxesChecked = this._threadWidget.$('.moderation_checkbox:checked').length; if (numCheckboxes) { if (numCheckboxesChecked === numCheckboxes) { buttonSelect.toggleClass('disabled', true); @@ -972,6 +1036,26 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { buttonUnselect.toggleClass('disabled', true); } }, + /** + * Renders the mainside bar with current threads + * + * @private + */ + _updateThreads: function () { + var self = this; + + var $sidebar = this._renderSidebar({ + threads: _.filter(this.call('mail_service', 'getThreads'), function (thread) { + return thread.getType() !== 'document_thread'; + }), + }); + + this.$('.o_mail_discuss_sidebar').html($sidebar.contents()); + _.each(['dm', 'public', 'private'], function (type) { + var $input = self.$('.o_mail_add_thread[data-type=' + type + '] input'); + self._prepareAddThreadInput($input, type); + }); + }, //-------------------------------------------------------------------------- // Handlers @@ -979,103 +1063,123 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onAddChannel: function (event) { - event.preventDefault(); - var type = $(event.target).data("type"); - this.$('.o_mail_add_channel[data-type=' + type + ']') + _onAddThread: function (ev) { + ev.preventDefault(); + var type = $(ev.target).data('type'); + this.$('.o_mail_add_thread[data-type=' + type + ']') .show() - .find("input").focus(); + .find('input').focus(); }, /** * @private */ - _onAddChannelBlur: function () { - this.$('.o_mail_add_channel').hide(); - }, - /** - * @private - * @param {MouseEvent} event - */ - _onChannelClicked: function (event) { - event.preventDefault(); - var channelID = $(event.currentTarget).data('channel-id'); - var channel = this.call('chat_manager', 'getChannel', channelID); - this._setChannel(channel); + _onAddThreadBlur: function () { + this.$('.o_mail_add_thread').hide(); }, /** * @private * @param {integer|string} channelID */ _onChannelLeft: function (channelID) { - if (this.channel.id === channelID) { - var channel = this.call('chat_manager', 'getChannel', 'channel_inbox'); - this._setChannel(channel); + if (this._thread.getID() === channelID) { + this._setThread('mailbox_inbox'); } - this._updateChannels(); - delete this.channelsScrolltop[channelID]; + this._updateThreads(); + delete this._threadsScrolltop[channelID]; }, /** * @private */ _onCloseNotificationBar: function () { - this.$(".o_mail_annoying_notification_bar").slideUp(); + this.$('.o_mail_annoying_notification_bar').slideUp(); }, /** * @private */ _onComposerFocused: function () { - var composer = this.channel.mass_mailing ? this.extendedComposer : this.basicComposer; - var commands = this.call('chat_manager', 'getCommands', this.channel); - var partners = this.call('chat_manager', 'getMentionPartnerSuggestions', this.channel); - composer.mention_set_enabled_commands(commands); - composer.mention_set_prefetched_partners(partners); + var composer = this._thread.isMassMailing() ? this._extendedComposer : this._basicComposer; + var commands = this._thread.getCommands(); + var partners = this._thread.getMentionPartnerSuggestions(); + composer.mentionSetCommands(commands); + composer.mentionSetPrefetchedPartners(partners); }, /** + * When clicking on an item in the sidebar + * + * @private + * @param {MouseEvent} ev + */ + _onDiscussItemClicked: function (ev) { + ev.preventDefault(); + var threadID = $(ev.currentTarget).data('thread-id'); + this._setThread(threadID); + }, + /** + * Invite button is only for channels (not mailboxes) + * * @private */ _onInviteButtonClicked: function () { - var title = _.str.sprintf(_t('Invite people to #%s'), this.channel.name); - new PartnerInviteDialog(this, title, this.channel.id).open(); + var title = _.str.sprintf(_t("Invite people to #%s"), this._thread.getName()); + new PartnerInviteDialog(this, title, this._thread.getID()).open(); + }, + /** + * Called when someone asks discuss whether the bottom of `thread` is + * visible or not. An object `query` is provided in order to reponse on the + * key `isDisplayed`. + * + * @private + * @param {mail.model.Thread} thread + * @param {Object} query + * @param {boolean} query.isBottomVisible the response to provide on whether + * the thread is visible in discuss. + */ + _onIsThreadBottomVisible: function (thread, query) { + query.isBottomVisible = query.isBottomVisible || + ( + thread.getID() === this._thread.getID() && + this._threadWidget.isAtBottom() + ); }, /** * @private - * @param {KeyEvent} event + * @param {KeyEvent} ev */ - _onKeydown: function (event) { - if (event.which === $.ui.keyCode.ESCAPE && this.selected_message) { + _onKeydown: function (ev) { + if (ev.which === $.ui.keyCode.ESCAPE && this._selectedMessage) { this._unselectMessage(); } }, /** * @private */ - _onMarkAllReadClicked: function () { - this.call('chat_manager', 'markAllAsRead', this.channel, this.domain); + _onMarkAllAsReadClicked: function () { + this._thread.markAllMessagesAsRead(this.domain); }, /** * @private - * @param {Object} message + * @param {mail.model.Message} message * @param {string} type the channel type */ _onMessageUpdated: function (message, type) { var self = this; - var currentChannelID = this.channel.id; - if ((currentChannelID === "channel_starred" && !message.is_starred) || - (currentChannelID === "channel_moderation" && !message.needsModeration) || - (currentChannelID === "channel_inbox" && !message.is_needaction)) { - this.call('chat_manager', 'getMessages', { - channelID: this.channel.id, - domain: this.domain - }).then(function (messages) { + var currentThreadID = this._thread.getID(); + if ( + (currentThreadID === 'mailbox_starred' && !message.isStarred()) || + (currentThreadID === 'mailbox_inbox' && !message.isNeedaction()) || + (currentThreadID === 'mailbox_moderation' && !message.needsModeration()) + ) { + this._thread.getMessages(this.domain) + .then(function (messages) { var options = self._getThreadRenderingOptions(messages); - self.thread.remove_message_and_render(message.id, messages, options) + self._threadWidget.removeMessageAndRender(message.getID(), messages, options) .then(function () { self._updateButtonStatus(messages.length === 0, type); }); }); - } else if (_.contains(message.channel_ids, currentChannelID) || (message.res_id === currentChannelID)) { + } else if (_.contains(message.getThreadIDs(), currentThreadID)) { this._fetchAndRenderThread(); } }, @@ -1085,7 +1189,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _onModerateAllClicked: function (ev) { var decision = $(ev.target).data('decision'); - var messageIDs = this.thread.$('.moderation_checkbox:checked') + var messageIDs = this._threadWidget.$('.moderation_checkbox:checked') .map(function () { return $(this).data('message-id'); }) @@ -1105,55 +1209,69 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }, /** * @private - * @param {Object} channel + * @param {mail.model.Channel} channel */ _onNewChannel: function (channel) { - this._updateChannels(); - if (channel.autoswitch) { - this._setChannel(channel); + this._updateThreads(); + if (channel.isAutoswitch()) { + this._setThread(channel.getID()); } }, /** * @private - * @param {Object} message + * @param {mail.model.Message} message */ _onNewMessage: function (message) { var self = this; - if (_.contains(message.channel_ids, this.channel.id)) { - if (this.channel.type !== 'static' && this.thread.is_at_bottom()) { - this.call('chat_manager', 'markChannelAsSeen', this.channel); + if (_.contains(message.getThreadIDs(), this._thread.getID())) { + if (this._thread.getType() !== 'mailbox' && this._threadWidget.isAtBottom()) { + this._thread.markAsRead(); } - var should_scroll = this.thread.is_at_bottom(); + var shouldScroll = this._threadWidget.isAtBottom(); this._fetchAndRenderThread().then(function () { - if (should_scroll) { - self.thread.scroll_to({id: message.id}); + if (shouldScroll) { + self._threadWidget.scrollToMessage({ msgID: message.getID() }); } }); } - // Re-render sidebar to indicate that there is a new message in the corresponding channels - this._updateChannels(); - // Dump scroll position of channels in which the new message arrived - this.channelsScrolltop = _.omit(this.channelsScrolltop, message.channel_ids); + // Re-render sidebar to indicate that there is a new message in the corresponding threads + this._updateThreads(); + // Dump scroll position of threads in which the new message arrived + this._threadsScrolltop = _.omit(this._threadsScrolltop, message.getThreadIDs()); + }, + /** + * Called when opening a thread in discuss, due to discuss being open. + * All threads except document threads are valid for discuss app. + * + * @private + * @param {mail.model.Thread} + */ + _onOpenThreadInDiscuss: function (thread) { + if (thread.getType() !== 'document_thread') { + this._setThread(thread.getID()); + } }, /** * @private - * @param {Object} message + * @param {Object} messageData */ - _onPostMessage: function (message) { + _onPostMessage: function (messageData) { var self = this; - var options = this.selected_message ? {} : {channelID: this.channel.id}; - if (this.selected_message) { - message.subtype = this.selected_message.is_note ? 'mail.mt_note': 'mail.mt_comment'; - message.subtype_id = false; - message.message_type = 'comment'; + if (this._selectedMessage) { + messageData.subtype = this._selectedMessage.isNote() ? 'mail.mt_note': 'mail.mt_comment'; + messageData.subtype_id = false; + messageData.message_type = 'comment'; + messageData.content_subtype = 'html'; } - this.call('chat_manager', 'postMessage', message, options) + this._thread.postMessage(messageData) .then(function () { - if (self.selected_message) { - self._renderSnackbar('mail.chat.MessageSentSnackbar', {record_name: self.selected_message.record_name}, 5000); + if (self._selectedMessage) { + self._renderSnackbar('mail.discuss.MessageSentSnackbar', { + documentName: self._selectedMessage.getDocumentName() + }, 5000); self._unselectMessage(); } else { - self.thread.scroll_to(); + self._threadWidget.scrollToBottom(); } }) .fail(function () { @@ -1165,9 +1283,9 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { */ _onPublicChannelsClick: function () { this.do_action({ - name: _t('Public Channels'), + name: _t("Public Channels"), type: 'ir.actions.act_window', - res_model: "mail.channel", + res_model: 'mail.channel', views: [[false, 'kanban'], [false, 'form']], domain: [['public', '!=', 'private']], }, { @@ -1176,41 +1294,41 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { }, /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onRequestNotificationPermission: function (event) { + _onRequestNotificationPermission: function (ev) { var self = this; - event.preventDefault(); - this.$(".o_mail_annoying_notification_bar").slideUp(); + ev.preventDefault(); + this.$('.o_mail_annoying_notification_bar').slideUp(); var def = window.Notification && window.Notification.requestPermission(); if (def) { def.then(function (value) { if (value !== 'granted') { - self.call('bus_service', 'sendNotification', self, _t('Permission denied'), - _t('Odoo will not have the permission to send native notifications on this device.')); + self.call('bus_service', 'sendNotification', _t("Permission denied"), + _t("Odoo will not have the permission to send native notifications on this device.")); } else { - self.call('bus_service', 'sendNotification', self, _t('Permission granted'), - _t('Odoo has now the permission to send you native notifications on this device.')); + self.call('bus_service', 'sendNotification', _t("Permission granted"), + _t("Odoo has now the permission to send you native notifications on this device.")); } }); } }, /** * @private - * @param {OdooEvent} + * @param {OdooEvent} ev */ - _onSearch: function (event) { - event.stopPropagation(); + _onSearch: function (ev) { + ev.stopPropagation(); var session = this.getSession(); var result = pyeval.eval_domains_and_contexts({ - domains: event.data.domains, + domains: ev.data.domains, contexts: [session.user_context], }); this.domain = result.domain; - if (this.channel) { + if (this._thread) { // initially (when _onSearch is called manually), there is no - // channel set yet, so don't try to fetch and render the thread as - // this will be done as soon as the default channel is set + // thread set yet, so don't try to fetch and render the thread as + // this will be done as soon as the default thread is set this._fetchAndRenderThread(); } }, @@ -1221,33 +1339,33 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _onSelectAllClicked: function (ev) { var $button = $(ev.target); if (!$button.hasClass('disabled')) { - this.thread.toggleModerationCheckboxes(true); + this._threadWidget.toggleModerationCheckboxes(true); this._updateModerationButtons(); } }, /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onChannelSettingsClicked: function (event) { - var channelID = $(event.target).data("channel-id"); + _onChannelSettingsClicked: function (ev) { + var threadID = $(ev.target).data('thread-id'); this.do_action({ type: 'ir.actions.act_window', - res_model: "mail.channel", - res_id: channelID, + res_model: 'mail.channel', + res_id: threadID, views: [[false, 'form']], target: 'current' }); }, /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onUnpinChannel: function (event) { - event.stopPropagation(); - var channelID = $(event.target).data("channel-id"); - var channel = this.call('chat_manager', 'getChannel', channelID); - this.call('chat_manager', 'unsubscribe', channel); + _onUnpinChannel: function (ev) { + ev.stopPropagation(); + var channelID = $(ev.target).data('thread-id'); + var channel = this.call('mail_service', 'getChannel', channelID); + channel.unsubscribe(); }, /** * @private @@ -1256,7 +1374,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { _onUnselectAllClicked: function (ev) { var $button = $(ev.target); if (!$button.hasClass('disabled')) { - this.thread.toggleModerationCheckboxes(false); + this._threadWidget.toggleModerationCheckboxes(false); this._updateModerationButtons(); } }, @@ -1264,7 +1382,7 @@ var Discuss = AbstractAction.extend(ControlPanelMixin, { * @private */ _onUnstarAllClicked: function () { - this.call('chat_manager', 'unstarAll'); + this.call('mail_service', 'unstarAll'); }, /** * Update the moderation buttons. diff --git a/addons/mail/static/src/js/discuss_mobile.js b/addons/mail/static/src/js/discuss_mobile.js index 531553e6804..867af05152c 100644 --- a/addons/mail/static/src/js/discuss_mobile.js +++ b/addons/mail/static/src/js/discuss_mobile.js @@ -1,7 +1,7 @@ -odoo.define('mail.chat_discuss_mobile', function (require) { +odoo.define('mail.DiscussMobile', function (require) { "use strict"; -var Discuss = require('mail.chat_discuss'); +var Discuss = require('mail.Discuss'); var config = require('web.config'); var core = require('web.core'); @@ -12,12 +12,15 @@ if (!config.device.isMobile) { return; } +/** + * Overrides Discuss module in mobile + */ Discuss.include({ template: 'mail.discuss_mobile', events: _.extend(Discuss.prototype.events, { 'click .o_mail_mobile_tab': '_onMobileTabClicked', - 'click .o_channel_inbox_item': '_onMobileInboxButtonClicked', - 'click .o_mail_channel_preview': '_onMobileChannelClicked', + 'click .o_mailbox_inbox_item': '_onMobileInboxButtonClicked', + 'click .o_mail_preview': '_onMobileThreadClicked', }), /** @@ -25,13 +28,13 @@ Discuss.include({ */ init: function () { this._super.apply(this, arguments); - this.currentState = this.defaultChannelID; + this._currentState = this._defaultThreadID; }, /** * @override */ start: function () { - this.$mainContent = this.$('.o_mail_chat_content'); + this._$mainContent = this.$('.o_mail_discuss_content'); return this._super.apply(this, arguments) .then(this._updateControlPanel.bind(this)); }, @@ -39,8 +42,8 @@ Discuss.include({ * @override */ on_attach_callback: function () { - if (this.channel && this._isInInboxTab()) { - this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]}); + if (this._thread && this._isInInboxTab()) { + this._threadWidget.scrollToPosition(this._threadsScrolltop[this._thread.getID()]); } }, /** @@ -48,7 +51,7 @@ Discuss.include({ */ on_detach_callback: function () { if (this._isInInboxTab()) { - this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); + this._threadsScrolltop[this._thread.getID()] = this._threadWidget.getScrolltop(); } }, @@ -61,7 +64,7 @@ Discuss.include({ * @returns {Boolean} true iff we currently are in the Inbox tab */ _isInInboxTab: function () { - return _.contains(['channel_inbox', 'channel_starred'], this.currentState); + return _.contains(['mailbox_inbox', 'mailbox_starred'], this._currentState); }, /** * @override @@ -71,18 +74,18 @@ Discuss.include({ var self = this; this._super.apply(this, arguments); _.each(['dm', 'public', 'private'], function (type) { - var selector = '.o_mail_chat_button_' + type; - self.$buttons.on('click', selector, self._onAddChannel.bind(self)); + var selector = '.o_mail_discuss_button_' + type; + self.$buttons.on('click', selector, self._onAddThread.bind(self)); }); }, /** - * Overrides to only store the channel state if we are in the Inbox tab, as - * this is the only tab in which we actually have a displayed channel + * Overrides to only store the thread state if we are in the Inbox tab, as + * this is the only tab in which we actually have a displayed thread * * @override * @private */ - _restoreChannelState: function () { + _restoreThreadState: function () { if (this._isInInboxTab()) { this._super.apply(this, arguments); } @@ -100,29 +103,31 @@ Discuss.include({ /** * @override * @private - * @param {Object} channel */ - _setChannel: function (channel) { - if (channel.type !== 'static') { - return this.call('chat_manager', 'detachChannel', channel.id); + _setThread: function (threadID) { + var thread = this.call('mail_service', 'getThread', threadID); + if (thread.getType() !== 'mailbox') { + this.call('mail_service', 'openThreadWindow', threadID); + return $.when(); } else { return this._super.apply(this, arguments); } }, /** - * Overrides to only store the channel state if we are in the Inbox tab, as - * this is the only tab in which we actually have a displayed channel + * Overrides to only store the thread state if we are in the Inbox tab, as + * this is the only tab in which we actually have a displayed thread * * @override * @private */ - _storeChannelState: function () { - if (this.channel && this._isInInboxTab()) { + _storeThreadState: function () { + if (this._thread && this._isInInboxTab()) { this._super.apply(this, arguments); } }, /** - * Overrides to toggle the visibility of the tabs when a message is unselected + * Overrides to toggle the visibility of the tabs when a message is + * unselected * * @override * @private @@ -135,74 +140,86 @@ Discuss.include({ * @override * @private */ - _updateChannels: function () { - return this._updateContent(this.currentState); + _updateThreads: function () { + return this._updateContent(this._currentState); }, /** * Redraws the content of the client action according to its current state. * * @private - * @param {string} type the channel's type to display (e.g. 'channel_inbox', - * 'channel_starred', 'dm'...). + * @param {string} type the thread's type to display (e.g. 'mailbox_inbox', + * 'mailbox_starred', 'dm'...). */ _updateContent: function (type) { var self = this; - var inInbox = type === 'channel_inbox' || type === 'channel_starred'; - if (!inInbox && this._isInInboxTab()) { + var inMailbox = type === 'mailbox_inbox' || type === 'mailbox_starred'; + if (!inMailbox && this._isInInboxTab()) { // we're leaving the inbox, so store the thread scrolltop - this._storeChannelState(); + this._storeThreadState(); } var previouslyInInbox = this._isInInboxTab(); - this.currentState = type; + this._currentState = type; // fetch content to display var def; - if (inInbox) { + if (inMailbox) { def = this._fetchAndRenderThread(); } else { - var allChannels = this.call('chat_manager', 'getChannels'); - var channels = _.where(allChannels, {type: type}); - def = this.call('chat_manager', 'getChannelsPreview', channels); + var allChannels = this.call('mail_service', 'getChannels'); + var channels = _.filter(allChannels, function (channel) { + return channel.getType() === type; + }); + def = this.call('mail_service', 'getChannelPreviews', channels); } - return $.when(def).then(function (channelsPreview) { + return $.when(def).then(function (previews) { // update content - if (inInbox) { + if (inMailbox) { if (!previouslyInInbox) { - self.$('.o_mail_chat_tab_pane').remove(); - self.$mainContent.append(self.thread.$el); - self.$mainContent.append(self.extendedComposer.$el); + self.$('.o_mail_discuss_tab_pane').remove(); + self._$mainContent.append(self._threadWidget.$el); + self._$mainContent.append(self._extendedComposer.$el); } - self._restoreChannelState(); + self._restoreThreadState(); } else { - self.thread.$el.detach(); - self.extendedComposer.$el.detach(); - var $content = $(QWeb.render("mail.chat.MobileTabPane", { - channels: channelsPreview, + self._threadWidget.$el.detach(); + self._extendedComposer.$el.detach(); + var $content = $(QWeb.render('mail.discuss.MobileTabPane', { + previews: previews, type: type, })); - self._prepareAddChannelInput($content.find('.o_mail_add_channel input'), type); - self.$mainContent.html($content); + self._prepareAddThreadInput($content.find('.o_mail_add_thread input'), type); + self._$mainContent.html($content); } // update control panel - self.$buttons.find('button').addClass('o_hidden'); - self.$buttons.find('.o_mail_chat_button_' + type).removeClass('o_hidden'); - self.$buttons.find('.o_mail_chat_button_mark_read').toggleClass('o_hidden', type !== 'channel_inbox'); - self.$buttons.find('.o_mail_chat_button_unstar_all').toggleClass('o_hidden', type !== 'channel_starred'); + self.$buttons.find('button') + .addClass('o_hidden'); + self.$buttons.find('.o_mail_discuss_button_' + type) + .removeClass('o_hidden'); + self.$buttons.find('.o_mail_discuss_button_mark_all_read') + .toggleClass('o_hidden', type !== 'mailbox_inbox'); + self.$buttons.find('.o_mail_discuss_button_unstar_all') + .toggleClass('o_hidden', type !== 'mailbox_starred'); - // update Inbox page buttons - if (inInbox) { - self.$('.o_mail_chat_mobile_inbox_buttons').removeClass('o_hidden'); - self.$('.o_channel_inbox_item').removeClass('btn-primary').addClass('btn-default'); - self.$('.o_channel_inbox_item[data-type=' + type + ']').removeClass('btn-default').addClass('btn-primary'); + // update Mailbox page buttons + if (inMailbox) { + self.$('.o_mail_discuss_mobile_mailboxes_buttons') + .removeClass('o_hidden'); + self.$('.o_mailbox_inbox_item') + .removeClass('btn-primary') + .addClass('btn-default'); + self.$('.o_mailbox_inbox_item[data-type=' + type + ']') + .removeClass('btn-default') + .addClass('btn-primary'); } else { - self.$('.o_mail_chat_mobile_inbox_buttons').addClass('o_hidden'); + self.$('.o_mail_discuss_mobile_mailboxes_buttons') + .addClass('o_hidden'); } // update bottom buttons self.$('.o_mail_mobile_tab').removeClass('active'); - // channel_inbox and channel_starred share the same tab - type = type === 'channel_starred' ? 'channel_inbox' : type; + // mailbox_inbox and mailbox_starred share the same tab + type = type === 'mailbox_starred' ? 'mailbox_inbox' : type; self.$('.o_mail_mobile_tab[data-type=' + type + ']').addClass('active'); }); }, @@ -212,45 +229,46 @@ Discuss.include({ //-------------------------------------------------------------------------- /** + * @override * @private */ - _onAddChannel: function () { - this.$('.o_mail_add_channel').show().find('input').focus(); + _onAddThread: function () { + this.$('.o_mail_add_thread').show().find('input').focus(); }, /** - * Switches to the clicked channel in the Inbox page (Inbox or Starred). + * Switches to the clicked thread in the Inbox page (Inbox or Starred). * * @private - * @param {MouseEvent} + * @param {MouseEvent} ev */ - _onMobileInboxButtonClicked: function (event) { - var channel = this.call('chat_manager', 'getChannel', $(event.currentTarget).data('type')); - this._setChannel(channel); - this._updateContent(this.channel.id); + _onMobileInboxButtonClicked: function (ev) { + var mailboxID = $(ev.currentTarget).data('type'); + this._setThread(mailboxID); + this._updateContent(this._thread.getID()); }, /** * Switches to another tab. * * @private - * @param {MouseEvent} + * @param {MouseEvent} ev */ - _onMobileTabClicked: function (event) { - var type = $(event.currentTarget).data('type'); - if (type === 'channel_inbox') { - var channel = this.call('chat_manager', 'getChannel', 'channel_inbox'); - this._setChannel(channel); + _onMobileTabClicked: function (ev) { + var type = $(ev.currentTarget).data('type'); + if (type === 'mailbox') { + var inbox = this.call('mail_service', 'getMailbox', 'inbox'); + this._setThread(inbox); } this._updateContent(type); }, /** - * Opens a channel in a chat windown (full screen in mobile). + * Opens a thread in a chat window (full screen in mobile). * * @private - * @param {MouseEvent} + * @param {MouseEvent} ev */ - _onMobileChannelClicked: function (event) { - var channelID = $(event.currentTarget).data("channel_id"); - this.call('chat_manager', 'detachChannel', channelID); + _onMobileThreadClicked: function (ev) { + var threadID = $(ev.currentTarget).data('preview-id'); + this.call('mail_service', 'openThreadWindow', threadID); }, }); diff --git a/addons/mail/static/src/js/emojis.js b/addons/mail/static/src/js/emojis.js new file mode 100644 index 00000000000..4fedb5647a6 --- /dev/null +++ b/addons/mail/static/src/js/emojis.js @@ -0,0 +1,149 @@ +odoo.define('mail.emojis', function (require) { +"use strict"; + +/** + * This module exports the list of all available emojis on the client side. + * An emoji object has the following properties: + * + * - {string[]} sources: the character representations of the emoji + * - {string} unicode: the unicode representation of the emoji + * - {string} description: the description of the emoji + */ + +/** + * This data represent all the available emojis that are supported on the web + * client: + * + * - key: this is the source representation of an emoji, i.e. its "character" + * representation. This is a string that can be easily typed by the + * user and then translated to its unicode representation (see value) + * - value: this is the unicode representation of an emoji, i.e. its "true" + * representation in the system. + */ +var data = { + ":)": "😊", + ":-)": "😊", // alternative (alt.) + "=)": "😊", // alt. + ":]": "😊", // alt. + ":D": "😃", + ":-D": "😃", // alt. + "=D": "😃", // alt. + "xD": "😆", + "XD": "😆", // alt. + "x'D": "😂", + ";)": "😉", + ";-)": "😉", // alt. + "B)": "😎", + "8)": "😎", // alt. + "B-)": "😎", // alt. + "8-)": "😎", // alt. + ";p": "😜", + ";P": "😜", // alt. + ":p": "😋", + ":P": "😋", // alt. + ":-p": "😋", // alt. + ":-P": "😋", // alt. + "=P": "😋", // alt. + "xp": "😝", + "xP": "😝", // alt. + "o_o": "😳", + ":|": "😐", + ":-|": "😐", // alt. + ":/": "😕", // alt. + ":-/": "😕", // alt. + ":(": "😞", + ":@": "😱", + ":O": "😲", + ":-O": "😲", // alt. + ":o": "😲", // alt. + ":-o": "😲", // alt. + ":'o": "😨", + "3:(": "😠", + ">:(": "😠", // alt. + "3:": "😠", // alt. + "3:)": "😈", + ">:)": "😈", // alt. + ":*": "😘", + ":-*": "😘", // alt. + "o:)": "😇", + ":'(": "😢", + ":'-(": "😭", + ":\"(": "😭", // alt. + "<3": "❤️", + ":heart": "❤️", // alt. + ":heart_eyes": "😍", + ":turban": "👳", + ":+1": "👍", + ":-1": "👎", + ":ok": "👌", + ":poop": "💩", + ":no_see": "🙈", + ":no_hear": "🙉", + ":no_speak": "🙊", + ":bug": "🐞", + ":kitten": "😺", + ":bear": "🐻", + ":snail": "🐌", + ":boar": "🐗", + ":clover": "🍀", + ":sunflower": "🌹", + ":fire": "🔥", + ":sun": "☀️", + ":partly_sunny:": "⛅️", + ":rainbow": "🌈", + ":cloud": "☁️", + ":zap": "⚡️", + ":star": "⭐️", + ":cookie": "🍪", + ":pizza": "🍕", + ":hamburger": "🍔", + ":fries": "🍟", + ":cake": "🎂", + ":cake_part": "🍰", + ":coffee": "☕️", + ":banana": "🍌", + ":sushi": "🍣", + ":rice_ball": "🍙", + ":beer": "🍺", + ":wine": "🍷", + ":cocktail": "🍸", + ":tropical": "🍹", + ":beers": "🍻", + ":ghost": "👻", + ":skull": "💀", + ":et": "👽", + ":alien": "👽", // alt. + ":party": "🎉", + ":trophy": "🏆", + ":key": "🔑", + ":pin": "📌", + ":postal_horn": "📯", + ":music": "🎵", + ":trumpet": "🎺", + ":guitar": "🎸", + ":soccer": "⚽️", + ":football": "🏈", + ":8ball": "🎱", + ":clapper": "🎬", + ":microphone": "🎤" +}; + +// list of emojis in a dictionary, indexed by emoji unicode +var emojiDict = {}; +_.each(data, function (unicode, source) { + if (!emojiDict[unicode]) { + emojiDict[unicode] = { + sources: [source], + unicode: unicode, + description: source, + }; + } else { + emojiDict[unicode].sources.push(source); + } +}); + +var emojis = _.values(emojiDict); + +return emojis; + +}); diff --git a/addons/mail/static/src/js/extended_chat_window.js b/addons/mail/static/src/js/extended_chat_window.js deleted file mode 100644 index a85740ac5e1..00000000000 --- a/addons/mail/static/src/js/extended_chat_window.js +++ /dev/null @@ -1,63 +0,0 @@ -odoo.define('mail.ExtendedChatWindow', function (require) { -"use strict"; - -var ChatWindow = require('mail.ChatWindow'); -var composer = require('mail.composer'); - -return ChatWindow.extend({ - template: "mail.ExtendedChatWindow", - events: _.extend({}, ChatWindow.prototype.events, { - "click .o_chat_window_expand": "on_click_expand", - }), - start: function () { - var self = this; - var def; - if (self.options.thread_less) { - this.$el.addClass('o_thread_less'); - this.$('.o_chat_search_input input') - .autocomplete({ - source: function (request, response) { - self.call('chat_manager', 'searchPartner', request.term, 10).done(response); - }, - select: function (event, ui) { - self.trigger('open_dm_session', ui.item.id); - }, - }) - .focus(); - } else if (!self.options.input_less) { - var basicComposer = new composer.BasicComposer(self, {mention_partners_restricted: true, isMini: true}); - basicComposer.on('post_message', self, function (message) { - self.call('chat_manager', 'postMessage', message, { - channelID: self.channel_id, - }); - }); - basicComposer.once('input_focused', self, function () { - var channel = self.call('chat_manager', 'getChannel', self.channel_id); - var commands = self.call('chat_manager', 'getCommands', channel); - var partners = self.call('chat_manager', 'getMentionPartnerSuggestions', channel); - basicComposer.mention_set_enabled_commands(commands); - basicComposer.mention_set_prefetched_partners(partners); - }); - def = basicComposer.replace(self.$('.o_chat_composer')); - } - return $.when(this._super(), def); - }, - // Override on_keydown to only prevent jquery's blockUI to cancel event, but without sending - // the message on ENTER keydown as this is handled by the BasicComposer - on_keydown: function (event) { - event.stopPropagation(); - }, - on_reverse_breadcrumb: function () { - this.call('chat_manager', 'getChatBus').trigger('discuss_open', false); - }, - on_click_expand: _.debounce(function (event) { - event.preventDefault(); - this.do_action('mail.mail_channel_action_client_chat', { - clear_breadcrumbs: false, - active_id: this.channel_id, - on_reverse_breadcrumb: this.on_reverse_breadcrumb - }); - }, 1000, true), -}); - -}); diff --git a/addons/mail/static/src/js/followers.js b/addons/mail/static/src/js/followers.js index 651165dde30..a28f72edb1c 100644 --- a/addons/mail/static/src/js/followers.js +++ b/addons/mail/static/src/js/followers.js @@ -31,7 +31,7 @@ var Followers = AbstractField.extend({ supportedFieldTypes: ['one2many'], // inherited - init: function(parent, name, record, options) { + init: function (parent, name, record, options) { this._super.apply(this, arguments); this.image = this.attrs.image || 'image_small'; @@ -40,7 +40,7 @@ var Followers = AbstractField.extend({ this.followers = []; this.subtypes = []; this.data_subtype = {}; - this.is_follower = undefined; + this._isFollower = undefined; var session = this.getSession(); this.partnerID = session.partner_id; @@ -84,7 +84,7 @@ var Followers = AbstractField.extend({ // private _displayButtons: function () { - if (this.is_follower) { + if (this._isFollower) { this.$('button.o_followers_follow_button').removeClass('o_followers_notfollow').addClass('o_followers_following'); this.$('.o_subtypes_list > .dropdown-toggle').attr('disabled', false); this.$('.o_followers_actions .dropdown-toggle').addClass('o_followers_following'); @@ -100,7 +100,7 @@ var Followers = AbstractField.extend({ this.$('.o_followers_title_box > button').prop('disabled', true); this.$('.o_followers_count') .html(this.value.res_ids.length) - .parent().attr("title", this._formatFollowers(this.value.res_ids.length)); + .parent().attr('title', this._formatFollowers(this.value.res_ids.length)); }, _displayFollowers: function () { var self = this; @@ -118,7 +118,7 @@ var Followers = AbstractField.extend({ // On mouse-enter it will show the edit_subtype pencil. if (record.is_editable) { - $follower_li.on('mouseenter mouseleave', function(e) { + $follower_li.on('mouseenter mouseleave', function (e) { $(e.currentTarget).find('.o_edit_subtype').toggleClass('hide', e.type === 'mouseleave'); }); } @@ -129,7 +129,7 @@ var Followers = AbstractField.extend({ this.$('.o_followers_title_box > button').prop('disabled', !$followers_list.children().length); this.$('.o_followers_count') .html(this.value.res_ids.length) - .parent().attr("title", this._formatFollowers(this.value.res_ids.length)); + .parent().attr('title', this._formatFollowers(this.value.res_ids.length)); }, _displaySubtypes:function (data, dialog, display_warning) { var old_parent_model; @@ -167,7 +167,7 @@ var Followers = AbstractField.extend({ view_mode: 'form', view_type: 'form', views: [[false, 'form']], - name: _t('Invite Follower'), + name: _t("Invite Follower"), target: 'new', context: { 'default_res_model': this.model, @@ -182,11 +182,11 @@ var Followers = AbstractField.extend({ _formatFollowers: function (count){ var str = ''; if (count <= 0) { - str = _t('No follower'); + str = _t("No follower"); } else if (count === 1){ - str = _t('One follower'); + str = _t("One follower"); } else { - str = ''+count+' '+_t('followers'); + str = ''+count+' '+_t("followers"); } return str; }, @@ -210,7 +210,7 @@ var Followers = AbstractField.extend({ return _.contains(self.value.res_ids, follower.id); }); var user_follower = _.filter(self.followers, function (rec) { return rec.is_uid; }); - self.is_follower = user_follower.length >= 1; + self._isFollower = user_follower.length >= 1; }); }, _reload: function () { @@ -258,17 +258,17 @@ var Followers = AbstractField.extend({ }); return def; }, - _updateSubscription: function (event, follower_id, is_channel) { + _updateSubscription: function (event, followerID, isChannel) { var ids = {}; var subtypes; - if (follower_id !== undefined) { + if (followerID !== undefined) { // Subtypes edited from the modal subtypes = this.dialog.$('input[type="checkbox"]'); - if (is_channel) { - ids.channel_ids = [follower_id]; + if (isChannel) { + ids.channel_ids = [followerID]; } else { - ids.partner_ids = [follower_id]; + ids.partner_ids = [followerID]; } } else { subtypes = this.$('.o_followers_actions input[type="checkbox"]'); @@ -286,7 +286,7 @@ var Followers = AbstractField.extend({ // If no more subtype followed, unsubscribe the follower if (!checklist.length) { this._unfollow(ids).fail(function () { - $(event.target).prop("checked", true); + $(event.target).prop('checked', true); }); } else { var kwargs = _.extend({}, ids); @@ -302,21 +302,33 @@ var Followers = AbstractField.extend({ } }, - // handlers - _onAddFollower: function (event) { - event.preventDefault(); + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {MouseEvent} ev + */ + _onAddFollower: function (ev) { + ev.preventDefault(); this._inviteFollower(false); }, - _onAddChannel: function (event) { - event.preventDefault(); + /** + * @private + * @param {MouseEvent} ev + */ + _onAddChannel: function (ev) { + ev.preventDefault(); this._inviteFollower(true); }, /** - * @param {Event} event + * @private + * @param {MouseEvent} ev */ - _onEditSubtype: function (event) { + _onEditSubtype: function (ev) { var self = this; - var $currentTarget = $(event.currentTarget); + var $currentTarget = $(ev.currentTarget); var follower_id = $currentTarget.data('follower-id'); // id of model mail_follower this._rpc({ route: '/mail/read_subscription_data', @@ -327,13 +339,13 @@ var Followers = AbstractField.extend({ var is_channel = $currentTarget.data('oe-model') === 'mail.channel'; self.dialog = new Dialog(this, { size: 'medium', - title: _t('Edit Subscription of ') + $currentTarget.siblings('a').text(), + title: _t("Edit Subscription of ") + $currentTarget.siblings('a').text(), buttons: [ { text: _t("Apply"), classes: 'btn-primary', click: function () { - self._updateSubscription(event, res_id, is_channel); + self._updateSubscription(ev, res_id, is_channel); }, close: true }, @@ -349,16 +361,23 @@ var Followers = AbstractField.extend({ self.dialog.open(); }); }, + /** + * @private + */ _onFollowButtonClicked: function () { - if (!this.is_follower) { + if (!this._isFollower) { this._follow(); } else { this._unfollow({partner_ids: [this.partnerID]}); } }, - _onRedirect: function (event) { - event.preventDefault(); - var $target = $(event.target); + /** + * @private + * @param {MouseEvent} ev + */ + _onRedirect: function (ev) { + ev.preventDefault(); + var $target = $(ev.target); this.do_action({ type: 'ir.actions.act_window', view_type: 'form', @@ -368,18 +387,26 @@ var Followers = AbstractField.extend({ res_id: $target.data('oe-id'), }); }, - _onRemoveFollower: function (event) { - var res_model = $(event.target).parent().find('a').data('oe-model'); - var res_id = $(event.target).parent().find('a').data('oe-id'); - if (res_model === 'res.partner') { - return this._unfollow({partner_ids: [res_id]}); + /** + * @private + * @param {MouseEvent} ev + */ + _onRemoveFollower: function (ev) { + var resModel = $(ev.target).parent().find('a').data('oe-model'); + var resID = $(ev.target).parent().find('a').data('oe-id'); + if (resModel === 'res.partner') { + return this._unfollow({partner_ids: [resID]}); } else { - return this._unfollow({channel_ids: [res_id]}); + return this._unfollow({channel_ids: [resID]}); } }, - _onSubtypeClicked: function (event) { - event.stopPropagation(); - this._updateSubscription(event); + /** + * @private + * @param {MouseEvent} ev + */ + _onSubtypeClicked: function (ev) { + ev.stopPropagation(); + this._updateSubscription(ev); var $list = this.$('.o_subtypes_list'); if (!$list.hasClass('open')) { $list.addClass('open'); diff --git a/addons/mail/static/src/js/form_controller.js b/addons/mail/static/src/js/form_controller.js index b8cb5ab1c25..a165fe850ba 100644 --- a/addons/mail/static/src/js/form_controller.js +++ b/addons/mail/static/src/js/form_controller.js @@ -19,12 +19,12 @@ FormController.include({ /** * @private - * @param {OdooEvent} event + * @param {OdooEvent} ev * @param {string} event.data.id datapointID - * @param {integer[]} event.data.msgIDs list of message ids + * @param {integer[]} event.data.messageIDs list of message IDs */ - _onNewMessage: function (event) { - this.model.updateMessageIDs(event.data.id, event.data.msgIDs); + _onNewMessage: function (ev) { + this.model.updateMessageIDs(ev.data.id, ev.data.messageIDs); }, }); diff --git a/addons/mail/static/src/js/models/channel.js b/addons/mail/static/src/js/models/channel.js new file mode 100644 index 00000000000..e63cab2f907 --- /dev/null +++ b/addons/mail/static/src/js/models/channel.js @@ -0,0 +1,442 @@ +odoo.define('mail.model.Channel', function (require) { +"use strict"; + +var ThreadWithCache = require('mail.model.ThreadWithCache'); +var mailUtils = require('mail.utils'); + +var time = require('web.time'); + +/** + * This class represent channels in JS. In this context, the word channel + * has the same meaning of channel on the server, meaning that direct messages + * (DM) and livechats are also channels. + * + * Any piece of code in JS that make use of channels must ideally interact with + * such objects, instead of direct data from the server. + */ +var Channel = ThreadWithCache.extend({ + /** + * @override + * @param {string} [data.anonymous_name] + * @param {string} data.channel_type + * @param {boolean} data.group_based_subscription + * @param {boolean} [data.is_minimized=false] + * @param {boolean} data.is_moderator whether the current user is + * moderator of this channel. + * @param {string} [data.last_message_date] date in server-format + * @param {boolean} [data.mass_mailing] + * @param {integer} [data.message_unread_counter] + * @param {string} [data.public] either 'public' or 'private' + * @param {string} data.state + * @param {string} [data.uuid] + * @param {Object} options + * @param {boolean} [options.autoswitch=true] + * @param {Object[]} commands + */ + init: function (parent, data, options, commands) { + var self = this; + this._super.apply(this, arguments); + + // If set, autoswitch channel on joining this channel in discuss + // the default behaviour is to autoswitch on join. + // exception: receiving channel or chat session notifications + // + this._autoswitch = 'autoswitch' in options ? options.autoswitch : true; + this._chat = undefined; // FIXME: could be dropped when livechat and DM are moved out of this class + this._commands = undefined; + this._detached = data.is_minimized; + this._directPartnerID = undefined; + this._folded = data.state === 'folded'; + // if set: hide 'Leave channel' button + this._groupBasedSubscription = data.group_based_subscription; + this._isModerator = data.is_moderator; + this._lastMessageDate = undefined; + this._massMailing = data.mass_mailing; + // Deferred that is resolved on fetched members of this channel. + this._membersDef = undefined; + this._moderation = data.is_moderation; + // number of messages that are 'needaction', which is equivalent to the + // number of messages in this channel that are in inbox. + this._needactionCounter = data.message_needaction_counter || 0; + this._serverType = data.channel_type; + this._status = undefined; + this._throttleFetchSeen = _.throttle(this._fetchSeen.bind(this), 3000); + // unique identifier for this channel, which is required for some rpc + this._uuid = data.uuid; + + // * list of commands available for this channel + this._commands = _.filter(commands, function (command) { + return !command.channel_types || + _.contains(command.channel_types, self._serverType); + }); + if (this._type === 'channel') { + this._type = data.public !== 'private' ? 'public' : 'private'; + } + if ('anonymous_name' in data) { + this._name = data.anonymous_name; + } + if (data.last_message_date) { + this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date)); + } + this._chat = !this.getType().match(/^(public|private)$/); + if (data.message_unread_counter) { + this._unreadCounter = data.message_unread_counter; + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Close the channel + * + * This operation is executed server-side and the chat window will be + * folded in all potential tabs from all browsers. + * + * @override + */ + close: function () { + this._super.apply(this, arguments); + this._rpc({ + model: 'mail.channel', + method: 'channel_fold', + kwargs: { uuid: this.getUUID(), state: 'closed' }, + }, { shadow: true }); + }, + /** + * Decrement the needaction counter of the channel + * Floor value at 0. + * + * @param {integer} [num=1] the amount to decrement at most + */ + decrementNeedactionCounter: function (num) { + num = _.isNumber(num) ? num : 1; + this._needactionCounter = Math.max(this._needactionCounter - 1, 0); + }, + /** + * Open the chat window for this channel + * (in all potential tabs from all browsers) + * + * @pverride + */ + detach: function () { + this._super.apply(this, arguments); + this._rpc({ + model: 'mail.channel', + method: 'channel_minimize', + args: [this.getUUID(), true], + }, { + shadow: true, + }); + }, + /** + * Force fetch members of the channel + * + * @returns {$.Promise} resolved with list of channel listeners + */ + forceFetchMembers: function () { + this._membersDef = undefined; + return this.getMentionPartnerSuggestions(); + }, + /** + * Folds/Minimize the channel + * (in all potential tabs from all browsers) + * + * @override + * @param {boolean} folded + */ + fold: function (folded) { + this._super.apply(this, arguments); + var args = { + uuid: this.getUUID(), + }; + if (_.isBoolean(folded)) { + args.state = folded ? 'folded' : 'open'; + } + this._rpc({ + model: 'mail.channel', + method: 'channel_fold', + kwargs: args, + }, {shadow: true}); + }, + /** + * Show the list of available commands on this channel (e.g. '/help') + * + * @override + * @returns {Array} list of commands + */ + getCommands: function () { + return this._commands; + }, + /** + * @returns {mail.model.Message|undefined} last message of channel, if any + */ + getLastMessage: function () { + return _.last(this._cache['[]'].messages); + }, + /** + * Get listeners of a channel + * + * @returns {$.Promise} resolved with list of channel listeners + */ + getMentionPartnerSuggestions: function () { + if (!this._membersDef) { + this._membersDef = this._rpc({ + model: 'mail.channel', + method: 'channel_fetch_listeners', + args: [this.getUUID()], + }, { + shadow: true + }) + .then(function (members) { + return members; + }); + } + return this._membersDef; + }, + /** + * @returns {integer} + */ + getNeedactionCounter: function () { + return this._needactionCounter; + }, + /** + * @override + */ + getPreview: function () { + var result = this._super.apply(this, arguments); + if (!this.isChat()) { + result.imageSRC = '/web/image/mail.channel/' + this.getID() + '/image_small'; + } + var lastMessage = this.getLastMessage(); + return _.extend(result, { + author: lastMessage ? lastMessage.getDisplayedAuthor() : '', + body: lastMessage ? mailUtils.parseAndTransform(lastMessage.getBody(), mailUtils.inline) : '', + date: lastMessage ? lastMessage.getDate() : moment(), + isAuthor: this.hasMessages() && this.getLastMessage().isAuthor(), + }); + }, + /** + * Returns the title to display in thread window's headers. + * For channels, the title is prefixed with "#". + * + * @override + * @returns {string|Object} the name of the thread by default (see getName) + */ + getTitle: function () { + return "#" + this._super.apply(this, arguments); + }, + /** + * Get the UUID of the channel. + * This is a string that uniquely links this channel to a server channel. + * + * @returns {string} uuid of this channel + */ + getUUID: function () { + return this._uuid; + }, + /** + * Increment the needaction counter of this channel by 1 unit + */ + incrementNeedactionCounter: function () { + this._needactionCounter++; + }, + /** + * States whether the channel should be auto-selected on creation + * + * Note that this is not of the responsibility of the channel + * (see mail.model.Thread for more information) + * + * @override + * @returns {boolean} + */ + isAutoswitch: function () { + return this._autoswitch; + }, + /** + * All instances of this class are chanels + * + * @override + * @returns {boolean} + */ + isChannel: function () { + return true; + }, + /** + * States whether this channel is a chat or not. + * These types of channels are chat: + * - direct messages (DM) + * - livechat + * + * @override + * @returns {boolean} + */ + isChat: function () { + return this._chat; + }, + /** + * States whether the channel auto-subscribes some users in a group + * + * @returns {boolean} + */ + isGroupBasedSubscription: function () { + return this._groupBasedSubscription; + }, + /** + * States whether the channel is moderated or not. + * + * @override + * @returns {boolean} + */ + isModerated: function () { + return this._moderation; + }, + /** + * States whether the current user is moderator of this channel. + * + * @returns {boolean} + */ + isModerator: function () { + return this._isModerator; + }, + /** + * Marks this channel as read. + * The last seen message will be the last message. + * Resolved with the last seen message, only for non-mailbox channels + * + * @override + * @returns {$.Promise} resolved with last message ID + * seen in the channel, and when the channel has been marked as seen on + * the server. + */ + markAsRead: function () { + if (this._unreadCounter > 0) { + this.resetUnreadCounter(); + return this._throttleFetchSeen(); + } + return this._super.apply(this, arguments); + }, + /** + * Prepare and send a message to the server on this channel + * + * @override + * @param {Object} data data related to the new message + * @returns {$.Promise} resolved when the message has been sent to + * the server, with the object message that has been sent to the server. + */ + postMessage: function (data) { + var self = this; + return this._super.apply(this, arguments).then(function (messageData) { + _.extend(messageData, { + message_type: 'comment', + subtype: 'mail.mt_comment', + command: data.command, + }); + return self._rpc({ + model: 'mail.channel', + method: data.command ? 'execute_command' : 'message_post', + args: [self._id], + kwargs: messageData, + }).then(function () { + return messageData; + }); + }); + }, + /** + * Unsubscribes from channel + * + * @returns {$.Promise} resolve when unsubscribed + */ + unsubscribe: function () { + if (_.contains(['public', 'private'], this.getType())) { + // unfollow channel + return this._rpc({ + model: 'mail.channel', + method: 'action_unfollow', + args: [[this._id]], + }); + } else { + // unpin livechat + return this._rpc({ + model: 'mail.channel', + method: 'channel_pin', + args: [this.getUUID(), false], + }); + } + }, + /** + * Updates the internal state of the channel, and reflects the changes in + * the UI. + * + * Called by {mail.Manager.Notification} on receiving a channel window + * notification. A notification is received after a client-side action + * that changes the state of the channel window visually, so that cross tab + * channels are updated similarly. + * + * TODO: This method should be dropped if the state of windows are + * completely handled client-side, which should be the case in the near + * future. + * + * @param {Object} params + * @param {boolean} params.detached + * @param {boolean} params.folded + */ + updateWindowState: function (params) { + this._folded = 'folded' in params ? params.folded : this._folded; + this._detached = 'detached' in params ? params.detached : this._detached; + this._warnUpdatedWindowState(); + }, + /** + * Reset the needaction counter to 0. + */ + resetNeedactionCounter: function () { + this._needactionCounter = 0; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + * @returns {$.Promise} resolved with ID of last seen message + */ + _fetchSeen: function () { + var self = this; + return this._rpc({ + model: 'mail.channel', + method: 'channel_seen', + args: [[this._id]], + }, { + shadow: true + }).then(function (lastSeenMessageID) { + self._lastSeenMessageID = lastSeenMessageID; + return lastSeenMessageID; + }); + }, + /** + * Get the domain to fetch all the messages in the current channel + * + * Note that with moderation, even though some messages are not really + * linked to a channel ('channel_ids'), we should nonetheless display them. + * These messages are fetched from their associated document + * ('mail.channel' with provided channel ID), and messages that are pending + * moderation. + * + * @override + * @private + * @returns {Array} + */ + _getThreadDomain: function () { + return ['|', '&', '&', + ['model', '=', 'mail.channel'], + ['res_id', 'in', [this._id]], + ['need_moderation', '=', true], + ['channel_ids', 'in', [this._id]]]; + }, +}); + +return Channel; + +}); diff --git a/addons/mail/static/src/js/models/dm.js b/addons/mail/static/src/js/models/dm.js new file mode 100644 index 00000000000..cd08dd7526d --- /dev/null +++ b/addons/mail/static/src/js/models/dm.js @@ -0,0 +1,94 @@ +odoo.define('mail.model.DM', function (require) { +"use strict"; + +var Channel = require('mail.model.Channel'); + +/** + * + * Any piece of code in JS that make use of DMs must ideally interact with + * such objects, instead of direct data from the server. + */ +var DM = Channel.extend({ + /** + * @override + * @param {Object} data + * @param {Object} data.direct_partner + * @param {integer} data.direct_partner[0].id + * @param {string} data.direct_partner[0].im_status + * @param {string} data.direct_partner[0].name + */ + init: function (parent, data) { + this._super.apply(this, arguments); + + this._type = 'dm'; + this._name = data.direct_partner[0].name; + this._directPartnerID = data.direct_partner[0].id; + this._status = data.direct_partner[0].im_status; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the direct partner ID linked to the DM, i.e. the partner ID of the + * user at the other end of the DM conversation. All DM do have a direct + * partner iD. + * + * @returns {integer} + */ + getDirectPartnerID: function () { + return this._directPartnerID; + }, + /** + * @override + */ + getPreview: function () { + var result = this._super.apply(this, arguments); + result.imageSRC = '/web/image/res.partner/' + this.getDirectPartnerID() + '/image_small'; + return result; + }, + /** + * @returns {string} + */ + getStatus: function () { + return this._status; + }, + /** + * DM are chat + * + * @override + * @returns {boolean} + */ + isChat: function () { + return true; + }, + /** + * @param {string} newStatus + */ + setStatus: function (newStatus) { + this._status = newStatus; + }, + /** + * Unpin this DM + * + * @override + * @return {$.Promise} resolve when unpinned + */ + unsubscribe: function () { + return this._rpc({ + model: 'mail.channel', + method: 'channel_pin', + args: [this.getUUID(), false], + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + +}); + +return DM; + +}); diff --git a/addons/mail/static/src/js/models/document_thread.js b/addons/mail/static/src/js/models/document_thread.js new file mode 100644 index 00000000000..b9671107d9d --- /dev/null +++ b/addons/mail/static/src/js/models/document_thread.js @@ -0,0 +1,276 @@ +odoo.define('mail.model.DocumentThread', function (require) { +"use strict"; + +var Thread = require('mail.model.Thread'); + +var session = require('web.session'); + +/** + * These threads represent threads that are directly attached to documents. + * It is sometimes called the "chatter", but technically this is just the + * part of the chatter containing the thread, as the chatter also contains + * the activity and listener fields. + * + * Note that there are still some hacks in the handling of document threads, + * due to document threads are instantiated from fetched messages, either from + * mail.ThreadField or by fetching messages in Inbox. + * + * Also, because messages are not sent on the longpoll bus for this kind of + * threads, it stores the list of message IDs that it contains, which should + * be up-to-date whenever someone wants to get the messages in the document + * thread. + */ +var DocumentThread = Thread.extend({ + + /** + * @override + * @param {string} [data.messagesIDs] the list of message ids linked + * to the document (if not given, they will be fetched before fetching the + * messages) + * @param {string} data.model + * @param {integer} data.resID + */ + init: function (parent, data) { + data.id = data.resModel + '_' + data.resID; + data.type = 'document_thread'; + + this._super.apply(this, arguments); + + // ID of the related document + this._documentID = data.resID; + // model of the related document + this._documentModel = data.resModel; + // used to handle history of messages + this._messageIDs = data.messageIDs || []; + // list of loaded messages + this._messages = []; + + // if the messageIDs haven't been given, fetch them before fetching the + // messages + this._mustFetchMessageIDs = !_.isArray(data.messageIDs); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add this message to this document thread. + * + * @override + * @param {mail.model.Message} message + */ + addMessage: function (message) { + // update internal list of messages + this._messages.push(message); + this._messages = _.sortBy(this._messages, function (msg) { + return msg.getID(); + }); + // update message ids associated to this document thread + if (!_.contains(this._messageIDs, message.getID())) { + this._messageIDs.push(message.getID()); + } + }, + /** + * Get the model name of the document that is linked to this document thread + * + * @returns {string} + */ + getDocumentModel: function () { + return this._documentModel; + }, + /** + * Get the ID of the document that is linked to this document thread + * + * @returns {integer} + */ + getDocumentID: function () { + return this._documentID; + }, + /** + * Get he list of message IDs that this document threads contain. + * + * @returns {integer[]} + */ + getMessageIDs: function () { + return this._messageIDs; + }, + /** + * + * Get messages of the document thread + * + * @override + * @param {Object} options + * @param {boolean} [options.forceFetch] if true, fetch anyway, as user + * clicked on 'load more'. + * @returns {$.Promise} + */ + getMessages: function (options) { + var self = this; + return this._fetchMessages(options).then(function () { + self.call('mail_service', 'markMessagesAsRead', self._messageIDs); + return self._messages; + }); + }, + /** + * States whether the thread is linked to a document + * Document thread's are always linked to a document. + * + * @override + * @returns {boolean} + */ + isLinkedToDocument: function () { + return true; + }, + /** + * Overrides this method so that all the messages of this document thread + * are marked as read on the server. + * + * @override + * @returns {$.Promise} resolved when messages have been marked as read on + * the server. + */ + markAsRead: function () { + this._super.apply(this, arguments); + return this.call('mail_service', 'markMessagesAsRead', this._messageIDs); + }, + /** + * Post message for document thread + * + * @override + * @param {Object} data data related to the new message + * @return {$.Promise} resolved when the message has been sent to + * the server, with the object message that has been sent to the server. + */ + postMessage: function (data) { + var self = this; + var resModel = this.getDocumentModel(); + var resID = this.getDocumentID(); + return this._super.apply(this, arguments) + .then(function (messageData) { + _.extend(messageData, { + context: data.context, + message_type: data.message_type, + subtype: data.subtype, + subtype_id: data.subtype_id, + }); + return self._rpc({ + model: resModel, + method: 'message_post', + args: [resID], + kwargs: messageData, + }) + .then(function (messageID) { + return self._rpc({ + model: 'mail.message', + method: 'message_format', + args: [[messageID]], + }) + .then(function (messages) { + messages[0].model = resModel; + messages[0].res_id = resID; + self.call('mail_service', 'addMessage', messages[0]); + return messages[0]; + }); + }); + }); + }, + /** + * Set list of message IDs of this document thread + * + * Useful in order to handle message history of the document thread, + * in particular to fetch messages when necessary and/or display 'load more'. + * + * @param {integer[]} messageIDs + */ + setMessageIDs: function (messageIDs) { + this._mustFetchMessageIDs = false; + this._messageIDs = messageIDs; + }, + /** + * Set the name of this document thread + * + * This is useful if the name of the document related to the document thread + * has changed + * + * @param {string} newName + */ + setName: function (newName) { + this._name = newName; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get most up to date messageIDs + * + * @private + * @returns {$.Promise} resolved when message IDs have been fetched and set + * in the model + */ + _fetchMessageIDs: function () { + var self = this; + var resID = this.getDocumentID(); + if (!resID || !this._mustFetchMessageIDs) { + return $.when(); + } + return this._rpc({ + model: this.getDocumentModel(), + method: 'read', + args: [[resID], ['message_ids']], + }).then(function (result) { + self.setMessageIDs(result[0].message_ids); + }); + }, + /** + * @override + * @private + * @param {Object} options + * @param {boolean} [options.forceFetch] + * @returns {$.Promise} resolved when messages have been fetched + document + * thread has updated messages + */ + _fetchMessages: function (options) { + var self = this; + return this._fetchMessageIDs().then(function () { + var messageIDs = self._messageIDs; + var loadedMessages = _.filter(self._messages, function (message) { + return _.contains(messageIDs, message.getID()); + }); + var loadedMessageIDs = _.map(loadedMessages, function (message) { + return message.getID(); + }); + + options = options || {}; + var shouldFetch = _.difference( + messageIDs.slice(0, self._FETCH_LIMIT), + loadedMessageIDs + ).length > 0; + if (options.forceFetch || shouldFetch) { + var idsToLoad = _.difference(messageIDs, loadedMessageIDs) + .slice(0, self._FETCH_LIMIT); + return self._rpc({ + model: 'mail.message', + method: 'message_format', + args: [idsToLoad], + context: session.user_context, + }) + .then(function (messagesData) { + _.each(messagesData, function (messageData) { + self.call('mail_service', 'addMessage', messageData, { silent: true }); + }); + }); + } else { + return $.when(); + } + + }); + }, + +}); + +return DocumentThread; + +}); diff --git a/addons/mail/static/src/js/models/mail_failure.js b/addons/mail/static/src/js/models/mail_failure.js new file mode 100644 index 00000000000..fe6241ff032 --- /dev/null +++ b/addons/mail/static/src/js/models/mail_failure.js @@ -0,0 +1,102 @@ +odoo.define('mail.model.MailFailure', function (require) { +"use strict"; + +var Class = require('web.Class'); +var core = require('web.core'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); +var time = require('web.time'); + +var _t = core._t; + +var MailFailure = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @param {mail.Manager} parent + * @param {Object} data + * @param {string} data.last_message_date + * @param {integer} data.message_id + * @param {string} data.model + * @param {string} data.model_name + * @param {string} data.module_icon + * @param {Array} data.notifications + * @param {string} data.record_name + * @param {integer} data.res_id + * @param {string} data.uuid + */ + init: function (parent, data) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + + this._documentID = data.res_id; + this._documentModel = data.model; + this._lastMessageDate = moment(); // by default: current datetime + this._messageID = data.message_id; + this._modelName = data.model_name; + this._moduleIcon = data.module_icon; + this._notifications = data.notifications; + this._recordName = data.record_name; + this._uuid = data.uuid; + + if (data.last_message_date) { + this._lastMessageDate = moment(time.str_to_datetime(data.last_message_date)); + } + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the document model that this mail failure is linked to. + * + * @returns {string} + */ + getDocumentModel: function () { + return this._documentModel; + }, + /** + * Get the document ID that this mail failure is linked to. + * + * @returns {integer} + */ + getDocumentID: function () { + return this._documentID; + }, + /** + * Get the ID of the message that this mail failure is related to. + * + * @returns {integer} + */ + getMessageID: function () { + return this._messageID; + }, + /** + * Get a valid object for the 'mail.preview' template + * + * @returns {Object} + */ + getPreview: function () { + var preview = { + body: _t("An error occured when sending an email"), + date: this._lastMessageDate, + documentID: this._documentID, + documentModel: this._documentModel, + id: 'mail_failure', + imageSRC: this._moduleIcon, + title: this._recordName, + }; + return preview; + }, + /** + * Tell whether the mail failure comes from a message in a document thread. + * + * @returns {boolean} + */ + isLinkedToDocumentThread: function () { + return !!(this._documentModel !== 'mail.channel' && this._documentID); + }, +}); + +return MailFailure; + +}); diff --git a/addons/mail/static/src/js/models/mail_model.js b/addons/mail/static/src/js/models/mail_model.js new file mode 100644 index 00000000000..8f081eb3464 --- /dev/null +++ b/addons/mail/static/src/js/models/mail_model.js @@ -0,0 +1,20 @@ +odoo.define('mail.Model', function (require) { +"use strict"; + +var Class = require('web.Class'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); + +var MailModel = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @param {mail.Manager} parent + */ + init: function (parent) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + }, +}); + +return MailModel; + +}); diff --git a/addons/mail/static/src/js/models/mailbox.js b/addons/mail/static/src/js/models/mailbox.js new file mode 100644 index 00000000000..bcb5ae609b7 --- /dev/null +++ b/addons/mail/static/src/js/models/mailbox.js @@ -0,0 +1,213 @@ +odoo.define('mail.model.Mailbox', function (require) { +"use strict"; + +var ThreadWithCache = require('mail.model.ThreadWithCache'); + +var core = require('web.core'); + +var _t = core._t; + +/** + * Mailboxes, called "static channels" in earlier version of Odoo, are threads + * that are not represented on the server. We can see them as "threads that are + * not conversations". For instance, the well-known "Inbox" folder contains a + * list of messages, but the inbox does not represent a conversation: Inbox is + * modeled as a mailbox. + */ +var Mailbox = ThreadWithCache.extend({ + + /** + * @override + * @param {Object} data + * @param {string} data.id the ID of the mailbox, without the 'mailbox_' + * prefix. + * @param {integer} [data.mailboxCounter=0] the initial mailbox counter of + * this mailbox. + */ + init: function (parent, data) { + // ID or mailboxes are always prefixed with 'mailbox_' + data.id = 'mailbox_' + data.id; + data.type = 'mailbox'; + this._super.apply(this, arguments); + this._mailboxCounter = data.mailboxCounter || 0; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Decrement the counter of this mailbox. It floors to 0. + * + * @param {integer} [num=1] how many, at most, the counter is decremented + */ + decrementMailboxCounter: function (num) { + num = _.isNumber(num) ? num : 1; + this._mailboxCounter = Math.max(this._mailboxCounter - num, 0); + }, + /** + * Get the local messages of the mailbox (by local messages, we mean + * messages that have already been fetched from the server). + * + * It is possible to filter on local messages that are specific to a + * document with the `options` parameter. + * + * @param {Object} [options] + * @param {string} [options.documentModel] model of the document that the + * local messages of inbox must be linked to. + * @param {integer} [options.documentID] ID of the document that the local + * messages of inbox must be linked to. + */ + getLocalMessages: function (options) { + var localMessages = this._cache['[]'].messages; + if (!options) { + return localMessages; + } + if (options.documentModel && options.documentID) { + return _.filter(localMessages, function (localMessage) { + return localMessage.getDocumentModel() === options.documentModel && + localMessage.getDocumentID() === options.documentID; + }); + } + }, + /** + * Get the mailbox counter of this mailbox. + * + * @returns {integer} + */ + getMailboxCounter: function () { + return this._mailboxCounter; + }, + /** + * Get the preview of the messages from the inbox mailbox. + * If there are multiple messages associated to a similar document or + * channel, only keep the lastest message of this document/channel in the + * previews. + * + * @returns {$.Promise} + */ + getMessagePreviews: function () { + var self = this; + + return this.getMessages().then(function (messages) { + // pick only last message of chatter + // items = list of objects + // { unreadCounter: integer, message: mail.model.Message } + var items = []; + _.each(messages, function (message) { + var unreadCounter = 1; + var similarItem = _.find(items, function (item) { + return self._areMessagesFromSameDocumentThread(item.message, message) || + self._areMessagesFromSameChannel(item.message, message); + }); + if (similarItem) { + unreadCounter = similarItem.unreadCounter + 1; + var index = _.findIndex(items, similarItem); + items[index] = { + unreadCounter: unreadCounter, + message: message, + }; + } else { + items.push({ + unreadCounter: unreadCounter, + message: message, + }); + } + }); + return _.map(items, function (item) { + return _.extend(item.message.getPreview(), { + unreadCounter: item.unreadCounter, + }); + }); + }); + }, + /** + * Increment the counter of this mailbox + * + * @param {integer} [num=1] how many, the counter is incremented + */ + incrementMailboxCounter: function (num) { + num = _.isNumber(num) ? num : 1; + this._mailboxCounter = Math.max(this._mailboxCounter + num, 0); + }, + /** + * Marks all messages from the mailbox as read. At the moment, this method + * makes only sense for 'Inbox'. + * + * @param {Array} domain + * @return {$.Promise} resolved when all messages have been marked as read + * on the server + */ + markAllMessagesAsRead: function (domain) { + if (this._id === 'mailbox_inbox' && this.getMailboxCounter() > 0) { + return this._rpc({ + model: 'mail.message', + method: 'mark_all_as_read', + kwargs: { + channel_ids: [], + domain: domain, + }, + }); + } + return $.when(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Tell whether the two messages originate from the same channel. + * If any one of them does not originate from a channel, returns false. + * + * @private + * @param {mail.model.Message} message1 + * @param {mail.model.Message} message2 + * @returns {boolean} + */ + _areMessagesFromSameChannel: function (message1, message2) { + return message1.originatesFromChannel() && + message2.originatesFromChannel() && + message1.getOriginChannelID() === message2.getOriginChannelID(); + }, + /** + * Tell whether the two messages are linked to the same document thread. + * If any one of them is not linked to a document, returns false. + * + * @private + * @param {mail.model.Message} message1 + * @param {mail.model.Message} message2 + * @returns {boolean} + */ + _areMessagesFromSameDocumentThread: function (message1, message2) { + return message1.isLinkedToDocumentThread() && + message2.isLinkedToDocumentThread() && + message1.getDocumentModel() === message2.getDocumentModel() && + message1.getDocumentID() === message2.getDocumentID(); + }, + /** + * Get the domain to fetch all the messages in the current mailbox + * + * @override + * @private + * @returns {Array} + * @throws on missing domain for the provided mailbox (mailboxes should + * always have a domain on messages to fetch) + */ + _getThreadDomain: function () { + if (this._id === 'mailbox_inbox') { + return [['needaction', '=', true]]; + } else if (this._id === 'mailbox_starred') { + return [['starred', '=', true]]; + } else if (this._id === 'mailbox_moderation') { + return [['need_moderation', '=', true]]; + } else { + throw _t(_.str("Missing domain for mailbox with ID '%s'", this._id)); + } + }, + +}); + +return Mailbox; + +}); diff --git a/addons/mail/static/src/js/models/messages/abstract_message.js b/addons/mail/static/src/js/models/messages/abstract_message.js new file mode 100644 index 00000000000..4cdb2273008 --- /dev/null +++ b/addons/mail/static/src/js/models/messages/abstract_message.js @@ -0,0 +1,409 @@ +odoo.define('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; + // if set, the message should display the author of the message + this._displayAuthor = false; + this._serverAuthorID = data.author_id; + this._type = data.message_type || undefined; + + this._processAttachmentURL(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Set whether the message should display the author or not + * + * @param {boolean} bool if set, display the author of this message + */ + displayAuthor: function (bool) { + this._displayAuthor = bool; + }, + /** + * 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]; + }, + /** + * Get the relative url of the avatar to display next to the message + * + * @abstract + * @return {string} + */ + getAvatarSource: function () {}, + /** + * 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()); + }, + /** + * 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 contains some customer email data + * By default, messages do not have any customer email data + * + * @return {boolean} + */ + hasCustomerEmailData: function () { + return false; + }, + /** + * 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'); + }); + }, + /** + * 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; + }, + /** + * 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 + * By default, messages are not linked to a document. + * + * @return {boolean} + */ + isLinkedToDocument: 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; + }, + /** + * State whether this message should display the author + * + * @return {boolean} + */ + shouldDisplayAuthor: function () { + return this._displayAuthor; + }, + /** + * 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._isAuthor(); + }, + + //-------------------------------------------------------------------------- + // 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} + */ + _isAuthor: 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; + +}); diff --git a/addons/mail/static/src/js/models/messages/message.js b/addons/mail/static/src/js/models/messages/message.js new file mode 100644 index 00000000000..06b776ea580 --- /dev/null +++ b/addons/mail/static/src/js/models/messages/message.js @@ -0,0 +1,780 @@ +odoo.define('mail.model.Message', function (require) { +"use strict"; + +var emojis = require('mail.emojis'); +var AbstractMessage = require('mail.model.AbstractMessage'); +var mailUtils = require('mail.utils'); + +var core = require('web.core'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); +var session = require('web.session'); + +var _t = core._t; + +/** + * This is the main class for the modeling messages in JS. + * Such messages are stored in the mail manager, and any piece of JS code whose + * logic relies on threads must ideally interact with such objects. + */ +var Message = AbstractMessage.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @override + * @param {web.Manager} parent + * @param {Object} data + * @param {string} [data.body = ""] + * @param {(string|integer)[]} [data.channel_ids] + * @param {Object[]} [data.customer_email_data] + * @param {string} [data.customer_email_status] + * @param {string} [data.email_from] + * @param {string} [data.info] + * @param {string} [data.model] + * @param {string} [data.moderation_status='accepted'] + * @param {string} [data.module_icon] + * @param {Array} [data.needaction_partner_ids = []] + * @param {string} [data.record_name] + * @param {integer} [data.res_id] + * @param {Array} [data.starred_partner_ids = []] + * @param {string} [data.subject] + * @param {string} [data.subtype_description] + * @param {Object[]} [data.tracking_value_ids] + * @param {Object[]} emojis + */ + init: function (parent, data, emojis) { + this._super.apply(this, arguments); + + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + + this._customerEmailData = data.customer_email_data || []; + this._customerEmailStatus = data.customer_email_status; + this._documentModel = data.model; + this._documentName = data.record_name; + this._documentID = data.res_id; + this._emailFrom = data.email_from; + this._info = data.info; + this._moduleIcon = data.module_icon; + this._needactionPartnerIDs = data.needaction_partner_ids || []; + this._starredPartnerIDs = data.starred_partner_ids || []; + this._subject = data.subject; + this._subtypeDescription = data.subtype_description; + this._threadIDs = data.channel_ids || []; + this._trackingValueIDs = data.tracking_value_ids; + + this._moderationStatus = data.moderation_status || 'accepted'; + + this._processBody(emojis); + this._processMailboxes(); + this._processModeration(); + this._processDocumentThread(); + this._processTrackingValues(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @param {Object} data + */ + addCustomerEmailData: function (data) { + this._customerEmailData.push(data); + }, + /** + * Get the name of the author of this message + * If there are no author, return "". + * + * @return {string} + */ + getAuthorName: function () { + return this._getAuthorName(); + }, + /** + * Get the relative url of the avatar to display next to the message + * + * @override + * @return {string} + */ + getAvatarSource: function () { + if (this._isOdoobotAuthor()) { + return '/mail/static/src/img/odoo_o.png'; + } else if (this.hasAuthor()) { + return '/web/image/res.partner/' + this.getAuthorID() + '/image_small'; + } else if (this.getType() === 'email') { + return '/mail/static/src/img/email_icon.png'; + } + return '/mail/static/src/img/smiley/avatar.jpg'; + }, + /** + * Get the customer email data of this email, if any. + * If this message has no such data, returns 'undefined' + * + * @return {Object[]|undefined} + */ + getCustomerEmailData: function () { + if (!this.hasCustomerEmailData()) { + return undefined; + } + return this._customerEmailData; + }, + /** + * Get the customer email status of this email, if any. + * If this message has no such data, returns 'undefined' + * + * @return {string|undefined} + */ + getCustomerEmailStatus: function () { + if (!this.hasCustomerEmailData()) { + return undefined; + } + return this._customerEmailStatus; + }, + /** + * Get the text to display for the author of the message + * + * Rule of precedence for the displayed author: + * + * author name > sender email > "anonymous" + * + * @override + * @return {string} + */ + getDisplayedAuthor: function () { + var res = this._super.apply(this, arguments); + if (res) { + return res; + } else { + return this.hasEmailFrom() ? this.getEmailFrom() : + _t("Anonymous"); + } + }, + /** + * Get the ID of the document that this message is linked. + * + * @override + * @return {integer} + */ + getDocumentID: function () { + return this._documentID; + }, + /** + * Get the model of the document that this message is linked. + * If this message is not linked to a document, returns "". + * + * @override + * @return {string} + */ + getDocumentModel: function () { + return this._documentModel; + }, + /** + * Get the name of the document that this message is linked. + * If this message is not linked to a document, returns "". + * + * It uses the name of an existing document thread, in order to have the + * updated document name. If there is no document thread yet, use the + * currently aware document name. + * + * As a result, this method should ideally be called internally, in order + * to avoid having different names for messages linked to a same modified + * document. + * + * @override + * @return {string} + */ + getDocumentName: function () { + if (!this.isLinkedToDocumentThread()) { + return ""; + } + var model = this.getDocumentModel(); + var id = this.getDocumentID(); + var documentThread = this.call('mail_service', 'getDocumentThread', model, id); + if (documentThread) { + this._documentName = documentThread.getName(); + } + return this._documentName; + }, + /** + * Get the email of the sender of this message. + * If this email has no sender email, returns "". + * + * @return {string} + */ + getEmailFrom: function () { + if (!this.hasEmailFrom()) { + return ""; + } + return this._emailFrom; + }, + /** + * Get the ID of the channel that this message originates from. + * If this message does not originate from a channel, returns `-1`. + * + * @override + * @return {integer} + */ + getOriginChannelID: function () { + if (!this.originatesFromChannel()) { + return -1; + } + return this._documentID; + }, + /** + * Get the name of the channel that this message originates from. + * If this message does not originate from a channel, returns "". + * + * @override + * @returns {string} + */ + getOriginChannelName: function () { + if (!this.originatesFromChannel()) { + return ""; + } + var originChannelID = this.getOriginChannelID(); + var channel = originChannelID && this.call( + 'mail_service', + 'getChannel', + originChannelID); + if (!channel) { + return ""; + } + return channel.getName(); + }, + /** + * Returns the information required to render the preview of this channel. + * + * @return {Object} + */ + getPreview: function () { + var id, title; + if (this.isLinkedToDocumentThread()) { + id = this.getDocumentModel() + '_' + this.getDocumentID(); + title = this.getDocumentName(); + } else { + id = 'mailbox_inbox'; + title = this.hasSubject() ? this.getSubject() : this.getDisplayedAuthor(); + } + return { + author: this.getDisplayedAuthor(), + body: mailUtils.parseAndTransform(this.getBody(), mailUtils.inline), + date: this.getDate(), + documentModel: this.getDocumentModel(), + documentID: this.getDocumentID(), + id: id, + imageSRC: this._getModuleIcon() || this.getAvatarSource(), + status: this.status, + title: title, + }; + }, + /** + * Get the subject of this message + * If this message has no subject, returns "". + * + * @override + * @return {string} + */ + getSubject: function () { + if (!this.hasSubject()) { + return ""; + } + return this._subject; + }, + /** + * @override + * @return {string} + */ + getSubtypeDescription: function () { + return this._subtypeDescription; + }, + /** + * Get the list of thread IDs that this message is linked to + * If this message is not linked to a thread, returns 'undefined' + * + * @return {string[]|undefined} list of thread IDs, if any + */ + getThreadIDs: function () { + return this._threadIDs; + }, + /** + * Get the tracking values of this message + * If this message has no tracking values, returns 'undefined' + * + * @override + * @return {Object[]|undefined} + */ + getTrackingValues: function () { + if (!this.hasTrackingValues()) { + return undefined; + } + return this._trackingValueIDs; + }, + /** + * @override + * @return {string} + */ + getURL: function () { + return session.url('/mail/view?message_id=' + this._id); + }, + /** + * State whether this message contains some customer email data + * + * @override + * @return {boolean} + */ + hasCustomerEmailData: function () { + return !!(this._customerEmailData && (this._customerEmailData.length > 0)); + }, + /** + * State whether this message has an email of its sender. + * + * @override + * @return {string} + */ + hasEmailFrom: function () { + return !!(this._emailFrom); + }, + /** + * State whether this message has a subject + * + * @return {boolean} + */ + hasSubject: function () { + return !!(this._subject); + }, + /** + * @override + * @return {boolean} + */ + hasSubtypeDescription: function () { + return !!(this._subtypeDescription); + }, + /** + * State whether this message contains some tracking values + * + * @override + * @return {boolean} + */ + hasTrackingValues: function () { + return !!(this._trackingValueIDs && (this._trackingValueIDs.length > 0)); + }, + /** + * State whether the current user is the author of this message + * + * @return {boolean} + */ + isAuthor: function () { + return this._isAuthor(); + }, + /** + * State whether this message is linked to a document thread (not channel) + * + * Usually, if this is true, then this message comes from a document thread, + * but the document model could be a channel. In that case, the document + * resID tells the channel that this message originally comes from. + * + * To detect whether the message comes from a channel, see the method + * 'originatesFromChannel'. + * + * @override + * @return {boolean} + */ + isLinkedToDocumentThread: function () { + return !!(this._documentModel !== 'mail.channel' && this._documentID); + }, + /** + * States whether the current message needs moderation in general. + * + * @override + * @returns {boolean} + */ + needsModeration: function () { + return this._moderationStatus === 'pending_moderation'; + }, + /** + * States whether the current message needs moderation by the current user. + * Such a message should be in the moderation mailbox. + * + * @returns {boolean} + */ + needsModerationByUser: function () { + return _.contains(this._threadIDs, 'mailbox_moderation'); + }, + /** + * State whether this message is needaction + * + * @override + * @returns {boolean} + */ + isNeedaction: function () { + return _.contains(this._threadIDs, 'mailbox_inbox'); + }, + /** + * State whether this message is starred + * + * @override + * @returns {boolean} + */ + isStarred: function () { + return _.contains(this._threadIDs, 'mailbox_starred'); + }, + /** + * State whether this message is a system notification + * + * @override + * @returns {boolean} + */ + isSystemNotification: function () { + return ( + this.getType() === 'notification' && + this._documentModel === 'mail.channel' + ) || this._isTransient(); + }, + /** + * States whether the message originates from a channel or not + * + * @returns {boolean} + */ + originatesFromChannel: function () { + return this._documentModel === 'mail.channel'; + }, + /** + * Unregister thread with ID `threadID` from this message + * + * @param {string|integer} threadID ID of thread + */ + removeThread: function (threadID) { + this._threadIDs = _.without(this._threadIDs, threadID); + }, + /** + * Update the moderation status of the message, so that it is now accepted + * or rejected. When the message is accepted, it may be linked to more + * threads, which is the case for relay channels on moderated channels. + * + * @param {string} newModerationStatus ['accepted', 'rejected'] + * @param {Object} [options] + * @param {Object} [options.additionalThreadIDs] contains additional thread + * IDs to be registered on the message. + */ + setModerationStatus: function (newModerationStatus, options) { + var self = this; + this._moderationStatus = newModerationStatus; + if (newModerationStatus === 'accepted' && options) { + _.each(options.additionalThreadIDs, function (threadID) { + self._addThread(threadID); + }); + } + this._warnMessageModerated(); + }, + /** + * Set whether the message is starred or not. + * If it is starred, the message is moved to the "Starred" mailbox. + * Note that this function only applies it locally, the server is not aware + * + * @param {boolean} starred if set, the message is starred + */ + setStarred: function (starred) { + if (starred) { + this._addThread('mailbox_starred'); + } else { + this.removeThread('mailbox_starred'); + } + }, + /** + * State whether this message should display the subject + * + * @return {boolean} + */ + shouldDisplaySubject: function () { + return this.hasSubject() && + this.getType() !== 'notification' && + !this.originatesFromChannel(); + }, + /** + * 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 or Odoobot messages + * (note: Odoobot is the default author of transient messages) + * + * @override + * @return {boolean} + */ + shouldRedirectToAuthor: function () { + return this._super.apply(this, arguments) && !this._isOdoobotAuthor(); + }, + /** + * Toggle the star status of the message + * + * It relies on the star status of the message from the date of the server. + * The star status is updated from a 'toggle_star' notification on the + * longpoll bus + * + * @see {mail.Manager.Notification} for the receipt of 'toggle_star' + * notification after this rpc. + * + * @return {$.Promise} + */ + toggleStarStatus: function () { + return this._rpc({ + model: 'mail.message', + method: 'toggle_message_starred', + args: [[this._id]], + }); + }, + /** + * Update the customer email status + * + * @param {string} newCustomerEmailStatus + */ + updateCustomerEmailStatus: function (newCustomerEmailStatus) { + this._customerEmailStatus = newCustomerEmailStatus; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Register a thread to the message + * Useful when you mark a message as 'to do'. + * This message will be available in 'Starred' mailbox. + * + * @private + * @param {string|integer} threadID + */ + _addThread: function (threadID) { + if (!this._threadIDs) { + this._threadIDs = []; + } + this._threadIDs.push(threadID); + this._threadIDs = _.uniq(this._threadIDs); + }, + /** + * Get the name of the author of this message. + * If Odoobot is the author (default author for transient messages), + * returns 'Odoobot'. + * + * @override + * @private + * @returns {string} + */ + _getAuthorName: function () { + if (this._isOdoobotAuthor()) { + return "Odoobot"; + } + return this._super.apply(this, arguments); + }, + /** + * @private + * @return {string} + */ + _getModuleIcon: function () { + return this._moduleIcon; + }, + /** + * State if the author of this message is Odoobot + * This is the default author for transient messages. + * + * @private + * @return {boolean} + */ + _isOdoobotAuthor: function () { + return this._serverAuthorID === this.call('mail_service', 'getOdoobotID'); + }, + /** + * State whether the message is transient or not + * + * @private + * @return {boolean} + */ + _isTransient: function () { + return this._info === 'transient_message'; + }, + /** + * Convert the server-format body of the message to the client-format. + * Basically, it processes emojis and url. + * + * @private + */ + _processBody: function () { + var self = this; + + _.each(emojis, function (emoji) { + var unicode = emoji.unicode; + var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|)", 'g'); + var originalBody = self.body; + self._body = self._body.replace(regexp, + ' ' + unicode + ' '); + // Idiot-proof limit. If the user had the amazing idea of + // copy-pasting thousands of emojis, the image rendering can lead + // to memory overflow errors on some browsers (e.g. Chrome). Set an + // arbitrary limit to 200 from which we simply don't replace them + // (anyway, they are already replaced by the unicode counterpart). + if (_.str.count(self._body, 'o_mail_emoji') > 200) { + self._body = originalBody; + } + }); + + // add anchor tags to urls + self._body = mailUtils.parseAndTransform(self._body, mailUtils.addLink); + }, + /** + * If the message is linked to a document, link this message to the related + * document thread. If there is no document thread yet, create it + */ + _processDocumentThread: function () { + if (!this.isLinkedToDocumentThread()) { + return; + } + var resModel = this.getDocumentModel(); + var resID = this.getDocumentID(); + var documentThread = this.call('mail_service', 'getOrAddDocumentThread', { + resModel: resModel, + resID: resID, + name: this.getDocumentName(), + }); + this._threadIDs.push(documentThread.getID()); + }, + /** + * Set the appropriate mailboxes to this message based on server data + * + * @private + */ + _processMailboxes: function () { + if (_.contains(this._needactionPartnerIDs, session.partner_id)) { + this._setNeedaction(true); + } + if (_.contains(this._starredPartnerIDs, session.partner_id)) { + this.setStarred(true); + } + if ( + this.originatesFromChannel() && + _.contains( + this.call('mail_service', 'getModeratedChannelIDs'), + this.getOriginChannelID() + ) && + this.needsModeration() + ) { + this._setModeratedByUser(true); + } + }, + /** + * Do some extra processing at message init, related to the + * moderated status of the message. + * + * If the message needs moderation, it is not yet linked to the + * moderated channel server-side. Therefore, it is not registered + * in the list of threadIDs, as it is built on server-side information + * at message initialisation (using data.channel_ids). + * + * Since the web client uses the list of thread IDs to show visually the + * message in a thread, we should hack the response of the server so that + * it assumes the message really belongs to this thread. + * + * @private + */ + _processModeration: function () { + if (this.needsModeration()) { + // the message is not linked to the moderated channel on the + // server, therefore this message has not this channel in + // channel_ids. Here, just to show this message in the channel + // visually, it links this message to the channel + this._threadIDs.push(this.getOriginChannelID()); + } + }, + /** + * Process the tracking values on message creation, which + * basically format date to the local only once by message + * + * Cannot be done in preprocess, since it alter the original value + * + * @private + */ + _processTrackingValues: function () { + if (this.hasTrackingValues()) { + _.each(this.getTrackingValues(), function (trackingValue) { + if (trackingValue.field_type === 'datetime') { + if (trackingValue.old_value) { + trackingValue.old_value = + moment + .utc(trackingValue.old_value) + .local() + .format('LLL'); + } + if (trackingValue.new_value) { + trackingValue.new_value = + moment + .utc(trackingValue.new_value) + .local() + .format('LLL'); + } + } else if (trackingValue.field_type === 'date') { + if (trackingValue.old_value) { + trackingValue.old_value = + moment(trackingValue.old_value) + .local() + .format('LL'); + } + if (trackingValue.new_value) { + trackingValue.new_value = + moment(trackingValue.new_value) + .local() + .format('LL'); + } + } + }); + } + }, + /** + * Set whether the message is moderated by current user or not. + * If it is moderated by the current user, the message is moved to the + * "Moderation" mailbox. Note that this function only applies it locally, + * the server is not aware + * + * @private + * @param {boolean} moderated if set, the message is moderated by user + */ + _setModeratedByUser: function (moderated) { + if (moderated) { + this._addThread('mailbox_moderation'); + } else { + this.removeThread('mailbox_moderation'); + } + }, + /** + * Set whether the message is needaction or not. + * If it is needaction, the message is moved to the "Inbox" mailbox. + * Note that this function only applies it locally, the server is not aware + * + * @private + * @param {boolean} needaction if set, the message is needaction + */ + _setNeedaction: function (needaction) { + if (needaction) { + this._addThread('mailbox_inbox'); + } else { + this.removeThread('mailbox_inbox'); + } + }, + /** + * @private + */ + _warnMessageModerated: function () { + var mailBus = this.call('mail_service', 'getMailBus'); + if (this.needsModerationByUser()) { + var moderationBox = this.call('mail_service', 'getMailbox', 'moderation'); + moderationBox.decrementMailboxCounter(); + moderationBox.removeMessage(this.getID()); + mailBus.trigger('update_moderation_counter'); + } + if (this._moderationStatus !== 'accepted') { + this.call('mail_service', 'removeMessageFromThreads', this); + } + mailBus.trigger('update_message', this); + }, + +}); + +return Message; + +}); diff --git a/addons/mail/static/src/js/models/thread.js b/addons/mail/static/src/js/models/thread.js new file mode 100644 index 00000000000..de2c6628d67 --- /dev/null +++ b/addons/mail/static/src/js/models/thread.js @@ -0,0 +1,446 @@ +odoo.define('mail.model.Thread', function (require) { +"use strict"; + +var emojis = require('mail.emojis'); +var mailUtils = require('mail.utils'); + +var Class = require('web.Class'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); + +/** + * This is the super class modeling threads in JS. + * Such threads are stored in the mail manager, and any piece of JS code whose + * logic relies on threads must ideally interact with such objects. + * + * In particular, channels and mailboxes are two different kinds of threads. + */ +var Thread = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { + /** + * @param {mail.Manager} parent + * @param {Object} data + * @param {string} [data.channel_type] + * @param {integer|string} data.id + * @param {string} data.name + * @param {string} [data.type] + */ + init: function (parent, data) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + + this._id = data.id; + + // threads are not detached by default + this._detached = false; + // threads are unfolded by default + this._folded = false; + // if this._massMailing is set, display subject on messages, use + // extended composer and show "Send by messages by email" on discuss + // sidebar + this._massMailing = false; + this._name = data.name; + // on 1st request to getPreview, fetch data if incomplete. Otherwise it + // means that there is no message in this channel. + this._previewed = false; + this._type = data.type || data.channel_type; + // any new message that has not been read yet + this._unreadCounter = 0; + // max number of fetched messages from the server + this._FETCH_LIMIT = 30; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a message to this thread. + * + * @abstract + * @param {mail.model.Message} message + */ + addMessage: function (message) {}, + /** + * Updates the _detached state of the thread. Must be overriden to reflect + * the new state in the interface. + */ + close: function () { + this._detached = false; + this._folded = false; + this._warnUpdatedWindowState(); + }, + /** + * Updates the _detached state of the thread. Must be overriden to reflect + * the new state in the interface. + * + * @param {Object} [options={}] + * @param {boolean} [options.keepFoldState=false] if set, keep the fold state + * of this thread. Otherwise unfold it while detaching it. + * @param {boolean} [options.passively=false] if set, if the thread window + * will be created passively. + */ + detach: function (options) { + options = options || {}; + this._detached = true; + this._folded = options.keepFoldState ? this._folded : false; + this._warnUpdatedWindowState({ + passively: options.passively, + }); + }, + /** + * Updates the _folded state of the thread. Must be overriden to reflect + * the new state in the interface. + */ + fold: function (folded) { + this._detached = true; + this._folded = folded; + this._warnUpdatedWindowState(); + }, + /** + * Get the list of available commands for the thread. + * By default, threads do not have any available command. + * + * @returns {Array} + */ + getCommands: function () { + return []; + }, + /** + * Get the ID of the thread + * + * @returns {integer|string} string for mailboxes (e.g. 'mailbox_inbox') + */ + getID: function () { + return this._id; + }, + /** + * Get the listeners of the thread. + * By default, a thread has not listener. + * + * @abstract + * @returns {$.Promise} + */ + getMentionPartnerSuggestions: function () { + return $.when([]); + }, + /** + * Get the list of messages in this thread. + * By default, a thread has no messages. + * + * Note that this method only returns some messages, as we do not want to + * fetch all messages of a thread at once, just to read the last message. + * + * As a result, this method fetches only some messages of the thread, + * starting from the last message in the thread. At most, it fetches + * `this.LIMIT` number of messages at a time. + * + * @abstract + * @returns {$.Promise} + */ + getMessages: function () { + return $.when([]); + }, + /** + * Get the name of this thread + * + * @returns {string|Object} object for lazy translated names with _lt + */ + getName: function () { + return this._name; + }, + /** + * Returns the information required to render the preview of this channel. + * + * @returns {Object} a valid object for the rendering of previews + * (@see mail.Preview template) + */ + getPreview: function () { + return { + id: this.getID(), + imageSRC: '/mail/static/src/img/smiley/avatar.jpg', + isChat: this.isChat(), + status: this.getStatus(), + title: this.getName(), + unreadCounter: this.getUnreadCounter(), + }; + }, + /** + * 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|Object} the name of the thread by default (see @getName) + */ + getTitle: function () { + return this.getName(); + }, + /** + * @returns {string} + */ + getType: function () { + return this._type; + }, + /** + * @returns {integer} + */ + getUnreadCounter: function () { + return this._unreadCounter; + }, + /** + * State whether this channel has been previewed + * + * A channel that has been previewed means that it had the necessary data + * to display its preview format. A channel needs its meta data and the + * last message in order to build its preview format. + * + * This is useful in order to not fetch preview info on this channel more + * than once on channels that have no message at all. + * + * Any received message updates the last_message, so a channel should + * always have all the necessary information to display its preview after + * the 1st time. + * + * @returns {boolean} + */ + hasBeenPreviewed: function () { + return this._previewed; + }, + /** + * State whether there are unread messages in this thread + * + * @returns {boolean} + */ + hasUnreadMessages: function () { + return this._unreadCounter !== 0; + }, + /** + * Increments the needaction counter of this thread + * FIXME: this method makes only sense for channels. Not sure, but I think + * this method is necessary at this level because of the model + * im_support.SupportChannel + * + * @abstract + */ + incrementNeedactionCounter: function () {}, + /** + * Increment the unread counter of this thread by 1 unit + */ + incrementUnreadCounter: function () { + this._unreadCounter++; + this._warnNewUnreadCounter(); + }, + /** + * States whether the thread should be auto-selected on creation + * + * Note that this is not of the responsibility of the thread: it only + * provides guidance for the object that uses threads (e.g. mail.Discuss + * must listen on threads and auto-select the thread if autoswitch is set). + * + * By default, threads are not in autoswitch mode. + * + * @returns {boolean} + */ + isAutoswitch: function () { + return false; + }, + /** + * States whether this thread is a channel or not. A thread is a channel if + * it is an instance of mail.model.Channel (direct or indirect). + * By default, any thread is not a channel + * + * @returns {boolean} + */ + isChannel: function () { + return false; + }, + /** + * States whether this thread is a chat or not. In particular, public and + * private channels are not chat, but DMs and Livechats are chats. Chats + * are threads used for communication between two users. + * By default, any thread is not a chat + * + * @returns {boolean} + */ + isChat: function () { + return false; + }, + /** + * States whether this thread is detached or not. + * A thread that is detached must have a thread window linked to itself. + * + * @return {boolean} + */ + isDetached: function () { + return this._detached; + }, + /** + * States whether this thread is folded or not. + * + * @return {boolean} + */ + isFolded: function () { + return this._folded; + }, + /** + * States whether the thread is linked to a document + * By default, threads are not linked to a document. + * + * @returns {boolean} + */ + isLinkedToDocument: function () { + return false; + }, + /** + * States whether this thread has the mass mailing setting active or not. + * This is a server-side setting, that determine the type of composer that + * is used (basic or extended composer). + * + * @return {boolean} + */ + isMassMailing: function () { + return this._massMailing; + }, + /** + + * States whether the thread is moderated or not. + * By default, threads are not moderated. + * + * @returns {boolean} + */ + isModerated: function () { + return false; + }, + /** + * States whether the current user is moderator of this thread. + * By default, the current user is not moderator of this thread. + * + * @returns {boolean} + */ + isModerator: function () { + return false; + }, + /** + * Mark this channel as previewed + * + * This is useful in order to not fetch preview info on this channel + * is the server has no preview in the first place. + * + * Note: preview fetch is useful only when the channel contains messages + * that have not been fetched at all. After that, this channel instance + * is updated regularly so that the most up-to-date info are available + * to make the preview of this channel. + */ + markAsPreviewed: function () { + this._previewed = true; + }, + /** + * Mark the thread as read, which resets the unread counter to 0. + * + * @returns {$.Promise} resolved + */ + markAsRead: function () { + if (this._unreadCounter > 0) { + this._unreadCounter = 0; + this._warnNewUnreadCounter(); + } + return $.when(); + }, + /** + * Post a message on the thread. + * + * This method must be completed by concrete threads, + * As it currently only pre-process the messages at the moment. + * + * @abstract + * @param {Object} data + * @returns {$.Promise} resolved with the message object to be sent + * to the server + */ + postMessage: function (data) { + // This message will be received from the mail composer as html content + // subtype but the urls will not be linkified. If the mail composer + // takes the responsibility to linkify the urls we end up with double + // linkification a bit everywhere. Ideally we want to keep the content + // as text internally and only make html enrichment at display time but + // the current design makes this quite hard to do. + var body = mailUtils.parseAndTransform(_.str.trim(data.content), mailUtils.addLink); + body = this._generateEmojis(body); + var messageData = { + partner_ids: data.partner_ids, + body: body, + attachment_ids: data.attachment_ids, + }; + if ('subject' in data) { + messageData.subject = data.subject; + } + return $.when(messageData); + }, + /** + * @private + */ + resetUnreadCounter: function () { + this._unreadCounter = 0; + this._warnNewUnreadCounter(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @abstract + * @private + * @return {$.Promise} + */ + _fetchMessages: function () { + return $.when(); + }, + /** + * Replace character representations of emojis by their unicode + * representation in the provided HTML string. Note that the provided html + * string is altered by this function. + * + * @param {string} htmlString + * @returns {string} + */ + _generateEmojis: function (htmlString) { + _.each(emojis, function (emoji) { + _.each(emoji.sources, function (source) { + var escapedSource = String(_.escape(source)).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1'); + var regexp = new RegExp("(\\s|^)(" + escapedSource + ")(?=\\s|$)", 'g'); + htmlString = htmlString.replace(regexp, '$1' + emoji.unicode); + }); + }); + return htmlString; + }, + /** + * Warn other mail components that the window state of this thread has + * changed (it has become closed, folded, unfolded, detached, etc.) + * + * @private + * @param {Object} [options={}] + */ + _warnUpdatedWindowState: function (options) { + options = options || {}; + this.call('mail_service', 'updateThreadWindow', this.getID(), options); + }, + /** + * Warn on the chat bus that the unread counter has been updated + * + * @private + */ + _warnNewUnreadCounter: function () { + this.call('mail_service', 'getMailBus') + .trigger('update_thread_unread_counter', this); + }, +}); + +return Thread; + +}); diff --git a/addons/mail/static/src/js/models/thread_with_cache.js b/addons/mail/static/src/js/models/thread_with_cache.js new file mode 100644 index 00000000000..f25a19005f2 --- /dev/null +++ b/addons/mail/static/src/js/models/thread_with_cache.js @@ -0,0 +1,204 @@ +odoo.define('mail.model.ThreadWithCache', function (require) { +"use strict"; + +var Thread = require('mail.model.Thread'); + +var session = require('web.session'); + +/** + * This is a version of a thread that is handling a cache. + * Basically, this cache is an object indexed by stringified indexes. + * These indexes represent the domain of messages that match this domain. + * + * Any threads that are instances of this Class can be used with a search view, + * in order to make searches on messages in a thread. + */ +var ThreadWithCache = Thread.extend({ + /** + * @override + * @param {integer} [data.seen_message_id=undefined] + */ + init: function (parent, data) { + this._super.apply(this, arguments); + + this._cache = { + '[]': { + allHistoryLoaded: false, + loaded: false, + messages: [], + } + }; + this._lastSeenMessageID = data.seen_message_id; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add the message to this thread + * + * @override + * @private + * @param {mail.model.Message} message + * @param {Array} domain + */ + addMessage: function (message, domain) { + var cache = this._getCache(domain); + var index = _.sortedIndex(cache.messages, message, function (msg) { + return msg.getID(); + }); + if (cache.messages[index] !== message) { + cache.messages.splice(index, 0, message); + } + }, + /** + * Get the last seen message for a given thread + * + * @return {integer|null} last seen message ID (if any) + */ + getLastSeenMessageID: function () { + return this._lastSeenMessageID || null; + }, + /** + * @override + * @param {Array} [domain] + * @param {boolean} [loadMore=false] + */ + getMessages: function (domain, loadMore) { + if (loadMore) { + return this._fetchMessages(domain, true); + } + var cache = this._getCache(domain); + if (cache.loaded) { + return $.when(cache.messages); + } else { + return this._fetchMessages(domain); + } + }, + /** + * @private + * @return {boolean} true if there is at least a message, false otherwise + */ + hasMessages: function () { + return !!(_.last(this._cache['[]'].messages)); + }, + /** + * Invalidate the caches of the thread + */ + invalidateCaches: function () { + this._cache = _.pick(this._cache, '[]'); + }, + /** + * State whether all messages have been loaded or not + * + * @param {Array} domain + * @return {boolean} + */ + isAllHistoryLoaded: function (domain) { + return this._getCache(domain).allHistoryLoaded; + }, + /** + * Remove message with ID `messageID` from this thread + * + * This is equivalent to removing this message from all the caches + * + * @param {integer} messageID + */ + removeMessage: function (messageID) { + _.each(this._cache, function (cache) { + cache.messages = _.reject(cache.messages, function (message) { + return message.getID() === messageID; + }); + }); + }, + /** + * Set 'messageID' as the ID of the last message seen in this thread + * + * @param {integer} messageID + */ + setLastSeenMessageID: function (messageID) { + this._lastSeenMessageID = messageID; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Gets messages from thread + * + * @override + * @private + * @param {Array} [pDomain] filter on the messages of the channel + * @param {boolean} [loadMore] Whether it should load more message + * @return {$.Promise} resolved with list of messages + */ + _fetchMessages: function (pDomain, loadMore) { + var self = this; + + var domain = this._getThreadDomain(); + + var cache = this._getCache(pDomain); + if (pDomain) { + domain = domain.concat(pDomain || []); + } + if (loadMore) { + var minMessageID = cache.messages[0].getID(); + domain = [['id', '<', minMessageID]].concat(domain); + } + return this._rpc({ + model: 'mail.message', + method: 'message_fetch', + args: [domain], + kwargs: { + limit: this._FETCH_LIMIT, + context: session.user_context + }, + }).then(function (messages) { + if (!cache.allHistoryLoaded) { + cache.allHistoryLoaded = messages.length < self._FETCH_LIMIT; + } + cache.loaded = true; + _.each(messages, function (message) { + self.call('mail_service', 'addMessage', message, { + silent: true, + domain: pDomain, + }); + }); + cache = self._getCache(pDomain || []); + return cache.messages; + }); + }, + /** + * Get thread content from the cache + * Useful to get cached messages. + * + * @private + * @param {Array} domain + * @return {Object|undefined} + */ + _getCache: function (domain) { + var stringifiedDomain = JSON.stringify(domain || []); + if (!this._cache[stringifiedDomain]) { + this._cache[stringifiedDomain] = { + allHistoryLoaded: false, + loaded: false, + messages: [], + }; + } + return this._cache[stringifiedDomain]; + }, + /** + * Get the domain to fetch all the messages in the current thread + * + * @abstract + * @private + * @returns {Array} + */ + _getThreadDomain: function () {}, +}); + +return ThreadWithCache; + +}); diff --git a/addons/mail/static/src/js/services/chat_manager.js b/addons/mail/static/src/js/services/chat_manager.js deleted file mode 100644 index 8a90fc57482..00000000000 --- a/addons/mail/static/src/js/services/chat_manager.js +++ /dev/null @@ -1,2016 +0,0 @@ -odoo.define('mail.ChatManager', function (require) { -"use strict"; - -var utils = require('mail.utils'); - -var AbstractService = require('web.AbstractService'); -var Bus = require('web.Bus'); -var config = require('web.config'); -var core = require('web.core'); -var session = require('web.session'); -var time = require('web.time'); -var web_client = require('web.web_client'); - -var _t = core._t; -var _lt = core._lt; - -var LIMIT = 30; // max number of fetched messages from the server -var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers -var ODOOBOT_ID = "ODOOBOT"; // default author_id for messages - -var emojiUnicodes={ - ":)":"😊", ":-)":"😊","=)":"😊", ":]":"😊", - ":D":"😃",":-D":"😃","=D":"😃", - "xD":"😆","XD":"😆", - "x'D":"😂", - ";)":"😉",";-)":"😉", - "B)":"😎","8)":"😎","B-)":"😎","8-)":"😎", - ";p":"😜",";P":"😜", - ":p":"😋",":P":"😋",":-p":"😋",":-P":"😋","=P":"😋", - "xp":"😝","xP":"😝", - "o_o":"😳", - ":|":"😐",":-|":"😐", - ":/":"😕",":-/":"😕", - ":(":"😞", - ":@":"😱", - ":O":"😲",":-O":"😲",":o":"😲",":-o":"😲", - ":'o":"😨", - "3:(":"😠",">:(":"😠","3:":"😠", - "3:)":"😈",">:)":"😈", - ":*":"😘",":-*":"😘", - "o:)":"😇", - ":'(":"😢", - ":'-(":"😭",":\"(":"😭", - "<3":"❤️",":heart":"❤️", - ":heart_eyes":"😍", - ":turban":"👳", - ":+1":"👍", - ":-1":"👎", - ":ok":"👌", - ":poop":"💩", - ":no_see":"🙈", - ":no_hear":"🙉", - ":no_speak":"🙊", - ":bug":"🐞", - ":kitten":"😺", - ":bear":"🐻", - ":snail":"🐌", - ":boar":"🐗", - ":clover":"🍀", - ":sunflower":"🌹", - ":fire":"🔥", - ":sun":"☀️", - ":partly_sunny:":"⛅️", - ":rainbow":"🌈", - ":cloud":"☁️", - ":zap":"⚡️", - ":star":"⭐️", - ":cookie":"🍪", - ":pizza":"🍕", - ":hamburger":"🍔", - ":fries":"🍟", - ":cake":"🎂", - ":cake_part":"🍰", - ":coffee":"☕️", - ":banana":"🍌", - ":sushi":"🍣", - ":rice_ball":"🍙", - ":beer":"🍺", - ":wine":"🍷", - ":cocktail":"🍸", - ":tropical":"🍹", - ":beers":"🍻", - ":ghost":"👻", - ":skull":"💀", - ":et":"👽",":alien":"👽", - ":party":"🎉", - ":trophy":"🏆", - ":key":"🔑", - ":pin":"📌", - ":postal_horn":"📯", - ":music":"🎵", - ":trumpet":"🎺", - ":guitar":"🎸", - ":soccer":"⚽️", - ":football":"🏈", - ":8ball":"🎱", - ":clapper":"🎬", - ":microphone":"🎤" - }; - -/** - * This service handles everything about chat channels and messages. - * - * There are basically two points of entry: - * - * 1. Calling a public method by means of 'this.call' - * 2. Receiving events on busBus (e.g. 'notification') - */ -var ChatManager = AbstractService.extend({ - name: 'chat_manager', - dependencies: ['ajax', 'bus_service'], - CHANNEL_SEEN_THROTTLE: 3000, - /** - * @override - */ - init: function () { - var self = this; - this._super.apply(this, arguments); - - this.messages = []; - this.channels = []; - this.messageFailures = []; - this.channelsPreviewDef; - this.channelDefs = {}; - this.unreadConversationCounter = 0; - this.emojis = []; - this.needactionCounter = 0; - this.starredCounter = 0; - this.moderationCounter = 0; - this.moderatedChannelIDs = []; - this.mentionPartnerSuggestions = []; - this.cannedResponses = []; - this.commands = []; - this.discussMenuID; - this.globalUnreadCounter = 0; - this.pinnedDmPartners = []; // partner_ids we have a pinned DM with - this.discussOpen = false; - - this.chatBus = new Bus(this); - this.busBus = this.call('bus_service', 'getBus'); - - this.chatBus.on('discuss_open', null, function (open) { - self.discussOpen = open; - }); - - this.busBus.on('notification', this, this._onNotification); - this.busBus.on('window_focus', this, this._onWindowFocus); - - // TODO create private fonction on prototype - this.channelSeen = _.throttle(function (channel) { - return self._rpc({ - model: 'mail.channel', - method: 'channel_seen', - args: [[channel.id]], - }, { - shadow: true - }); - }, self.CHANNEL_SEEN_THROTTLE); - - this._isReady = this._initMessaging(); - - // Add static channels - this._addChannel({ - id: "channel_inbox", - name: _lt("Inbox"), - type: "static", - }, { displayNeedactions: true }); - this._addChannel({ - id: "channel_starred", - name: _lt("Starred"), - type: "static" - }); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Closes the chat window of a corresponding channel. - * This operation is executed server-side and the chat window will be - * folded in all potential tabs from all browsers. - * - * @param {integer|string} channelID - * @return {$.Promise} - */ - closeChatSession: function (channelID) { - var channel = this.getChannel(channelID); - return this._rpc({ - model: 'mail.channel', - method: 'channel_fold', - kwargs: {uuid : channel.uuid, state : 'closed'}, - }, {shadow: true}); - }, - /** - * Creates a channel - * - * @param {integer|string} name id of partner (in case of dm) or name - * @param {string} type ['dm', 'public', 'private'] - * @return {$.Promise} - */ - createChannel: function (name, type) { - var method = type === "dm" ? "channel_get" : "channel_create"; - var args = type === "dm" ? [[name]] : [name, type]; - var context = _.extend({isMobile: config.device.isMobile}, session.user_context); - return this._rpc({ - model: 'mail.channel', - method: method, - args: args, - kwargs: {context: context}, - }) - .then(this._addChannel.bind(this)); - }, - /** - * Open the chat window for a given channel - * (in all potential tabs from all browsers) - * - * @param {integer} channelID - * @return {$.Promise} - */ - detachChannel: function (channelID) { - var channel = this.getChannel(channelID); - return this._rpc({ - model: 'mail.channel', - method: 'channel_minimize', - args: [channel.uuid, true], - }, { - shadow: true, - }); - }, - /** - * Folds/Minimize the chat window - * (in all potential tabs from all browsers) - * - * @param {integer} channelID - * @param {boolean} folded - * @return {$.Promise} - */ - foldChannel: function (channelID, folded) { - var args = { - uuid: this.getChannel(channelID).uuid, - }; - if (_.isBoolean(folded)) { - args.state = folded ? 'folded' : 'open'; - } - return this._rpc({ - model: 'mail.channel', - method: 'channel_fold', - kwargs: args, - }, {shadow: true}); - }, - /** - * Returns the list of canned responses - * A canned response is a pre-formatted text that is triggered with - * some keystrokes, such as with ':'. - * - * @return {Array} array of Objects (mail.shortcode) - */ - getCannedResponses: function () { - return this.cannedResponses; - }, - /** - * Returns a channel corresponding to the given id. - * - * @param {string|integer} id e.g. 'channel_inbox', 'channel_starred' - * @return {Object|undefined} the channel, if it exists - */ - getChannel: function (id) { - return _.findWhere(this.channels, {id: id}); - }, - /** - * Returns a list of channels - * - * @return {Object[]} list of channels - */ - getChannels: function () { - return _.clone(this.channels); - }, - /** - * Returns the content that will be shown in the mail navbar dropdown - * - * @param {Object[]} channels - * @return {$.Promise} resolved with array of preview msgs - */ - getChannelsPreview: function (channels) { - var self = this; - var channelsPreview = _.map(channels, function (channel) { - var info; - if ((channel.channel_ids && _.contains(channel.channel_ids, "channel_inbox")) || channel.id === "mail_failure") { - // this is a hack to preview messages and mail failure - // map inbox(mail_message) data with existing channel/chat template - info = _.pick(channel, - 'id', 'body', 'avatar_src', 'res_id', 'model', 'module_icon', - 'subject','date', 'record_name', 'status', 'displayed_author', - 'email_from', 'unread_counter'); - info.last_message = { - body: info.body, - date: info.date, - displayed_author: info.displayed_author || info.email_from, - }; - info.name = info.record_name || info.subject || info.displayed_author; - info.image_src = info.module_icon || info.avatar_src; - info.message_id = info.id; - if (channel.id !== "mail_failure") { - info.id = 'channel_inbox'; - } - return info; - } - info = _.pick(channel, 'id', 'is_chat', 'name', 'status', 'unread_counter'); - info.last_message = channel.last_message || _.last(channel.cache['[]'].messages); - if (!info.is_chat) { - info.image_src = '/web/image/mail.channel/'+channel.id+'/image_small'; - } else if (channel.direct_partner_id) { - info.image_src = '/web/image/res.partner/'+channel.direct_partner_id+'/image_small'; - } else { - info.image_src = '/mail/static/src/img/smiley/avatar.jpg'; - } - return info; - }); - var missingChannels = _.where(channelsPreview, {last_message: undefined}); - if (!this.channelsPreviewDef) { - if (missingChannels.length) { - var missingChannelIDs = _.pluck(missingChannels, 'id'); - this.channelsPreviewDef = this._rpc({ - model: 'mail.channel', - method: 'channel_fetch_preview', - args: [missingChannelIDs], - }, { - shadow: true, - }); - } else { - this.channelsPreviewDef = $.when(); - } - } - return this.channelsPreviewDef.then(function (channels) { - _.each(missingChannels, function (channelPreview) { - var channel = _.findWhere(channels, {id: channelPreview.id}); - if (channel) { - channelPreview.last_message = self._addMessage(channel.last_message); - } - }); - // sort channels: 1. unread, 2. chat, 3. date of last msg - channelsPreview.sort(function (c1, c2) { - return Math.min(1, c2.unread_counter) - Math.min(1, c1.unread_counter) || - c2.is_chat - c1.is_chat || - !!c2.last_message - !!c1.last_message || - (c2.last_message && c2.last_message.date.diff(c1.last_message.date)); - }); - - // generate last message preview (inline message body and compute date to display) - _.each(channelsPreview, function (channel) { - if (channel.last_message) { - channel.last_message_preview = utils.parse_and_transform(channel.last_message.body, utils.inline); - channel.last_message_date = channel.last_message.date.fromNow(); - } - }); - return channelsPreview; - }); - }, - /** - * @return {web.Bus} the chat bus - */ - getChatBus: function () { - return this.chatBus; - }, - /** - * Show the list of available commands next to a message (e.g. star) - * - * @param {Object} channel - * @return {Array} list of commands - */ - getCommands: function (channel) { - var commands = _.filter(this.commands, function (command) { - return !command.channel_types || _.contains(command.channel_types, channel.server_type); - }); - return commands; - }, - /** - * Returns the record id of ir.ui.menu for Discuss - * - * @return {integer} record id - */ - getDiscussMenuID: function () { - return this.discussMenuID; - }, - /** - * Gets direct message channel - * - * @param {integer} partnerID - * @return {Object|undefined} channel - */ - getDmFromPartnerID: function (partnerID) { - return _.findWhere(this.channels, {direct_partner_id: partnerID}); - }, - /** - * Returns list of emojis Objects - * - * @return {Object[]} list of emojis - * ['id', 'source', 'unicode_source', 'substitution', 'description'] - */ - getEmojis: function () { - return this.emojis; - }, - /** - * Get the last seen message for a given channel - * - * @param {Object} channel - * @return {Object|undefined} last seen Message Object (if any) - */ - getLastSeenMessage: function (channel) { - var result; - if (channel.last_seen_message_id) { - var messages = channel.cache['[]'].messages; - var msg = _.findWhere(messages, {id: channel.last_seen_message_id}); - if (msg) { - var i = _.sortedIndex(messages, msg, 'id') + 1; - while (i < messages.length && - (messages[i].is_author || messages[i].is_system_notification)) { - msg = messages[i]; - i++; - } - result = msg; - } - } - return result; - }, - /** - * Returns a list of mail failures - * - * @return {Object[]} list of channels - */ - getMessageFailures: function () { - return this.messageFailures; - }, - /** - * Get all listeners of a channel. - * - * @param {Object} channel - * @return {$.Promise>|Array} Two cases: - * - * 1. 'Channel' provided - * => Promise resolved with a list containing a list of members - * (list is cached in channel.membersDeferred) - * 2. No 'channel' provided - * => list containing a list of members (cached by chat_manager) - */ - getMentionPartnerSuggestions: function (channel) { - if (!channel) { - return this.mentionPartnerSuggestions; - } - if (!channel.membersDeferred) { - channel.membersDeferred = this._rpc({ - model: 'mail.channel', - method: 'channel_fetch_listeners', - args: [channel.uuid], - }, { - shadow: true - }) - .then(function (members) { - return [members]; - }); - } - return channel.membersDeferred; - }, - /** - * Gets message from its id - * - * @param {integer} msgID - * @return {Object|undefined} Message Object (if any) - */ - getMessage: function (msgID) { - return _.findWhere(this.messages, {id: msgID}); - }, - /** - * Gets messages from channel or ids or record (model and res_id): - * - * 1. From channel if we have 'channelID' in options - * 2. From ids if we have 'ids' in options - * 3. From model if we have 'model' and 'res_id' in options - * - * Rule of precedence: - * - * 'channelID' < 'ids' < 'model' and 'res_id' - * - * If we have none of the cases above, we return an empty list. - * - * @param {Object} [options] - * @param {integer|string} [options.channelID] - * @param {Array} [options.domain] - * @param {integer[]} [options.ids] - * @param {boolean} [options.loadMore] - * @param {string} [options.model] - * @param {integer} [options.res_id] - * @return {$.Promise} list of messages - */ - getMessages: function (options) { - var channel; - var self = this; - - if ('channelID' in options && options.loadMore) { - // get channel messages, force load more - channel = this.getChannel(options.channelID); - return this._fetchFromChannel(channel, {domain: options.domain || {}, loadMore: true}); - } - if ('channelID' in options) { - // channel message, check in cache first - channel = this.getChannel(options.channelID); - var channelCache = this._getChannelCache(channel, options.domain); - if (channelCache.loaded) { - return $.when(channelCache.messages); - } else { - return this._fetchFromChannel(channel, {domain: options.domain}); - } - } - if ('ids' in options) { - // get messages from their ids (chatter is the main use case) - return this._fetchDocumentMessages(options.ids, options).then(function (result) { - self.markAsRead(options.ids); - return result; - }); - } - if ('model' in options && 'res_id' in options) { - // get messages for a chatter, when it doesn't know the ids (use - // case is when using the full composer) - var domain = [['model', '=', options.model], ['res_id', '=', options.res_id]]; - return this._rpc({ - model: 'mail.message', - method: 'message_fetch', - args: [domain], - kwargs: {limit: LIMIT}, - }) - .then(function (msgs) { - return _.map(msgs, self._addMessage.bind(self)); - }); - } - return $.when([]); - }, - /** - * Returns the number of messages that the user needs to moderate - * - * @return {integer} - */ - getModerationCounter: function () { - return this.moderationCounter; - }, - /** - * Returns the number of messages received from followed channels - * + all messages where the current user is notified. - * - * @return {integer} needaction counter - */ - getNeedactionCounter: function () { - return this.needactionCounter; - }, - /** - * Gets the number of starred message - * - * @return {integer} starred counter - */ - getStarredCounter: function () { - return this.starredCounter; - }, - /** - * Gets the number of conversation which contains unread messages - * - * @return {integer} unread conversation counter - */ - getUnreadConversationCounter: function () { - return this.unreadConversationCounter; - }, - /** - * States whether all messages have been loaded or not - * - * @param {Object} channel - * @param {Array} domain - * @return {boolean} - */ - isAllHistoryLoaded: function (channel, domain) { - return this._getChannelCache(channel, domain).all_history_loaded; - }, - /** - * @return {boolean} - */ - isModerator: function () { - return this._isModerator; - }, - /** - * @return {$.Promise} - */ - isReady: function () { - return this._isReady; - }, - /** - * join an existing channel - * See @createChannel to join a new channel - * - * @param {integer} channelID - * @param {Object} [options] - * @return {$.Promise} resolved with channel object - */ - joinChannel: function (channelID, options) { - var self = this; - if (channelID in this.channelDefs) { - // prevents concurrent calls to channel_join_and_get_info - return this.channelDefs[channelID]; - } - var channel = this.getChannel(channelID); - if (channel) { - // channel already joined - this.channelDefs[channelID] = $.when(channel); - } else { - this.channelDefs[channelID] = this._rpc({ - model: 'mail.channel', - method: 'channel_join_and_get_info', - args: [[channelID]], - }) - .then(function (result) { - return self._addChannel(result, options); - }); - } - return this.channelDefs[channelID]; - }, - /** - * Marks all messages from a channel as read - * - * @param {Object} channel - * @param {Array} domain - * @return {$.Promise} - */ - markAllAsRead: function (channel, domain) { - if ((channel.id === "channel_inbox" && this.needactionCounter) || - (channel && channel.needaction_counter)) { - return this._rpc({ - model: 'mail.message', - method: 'mark_all_as_read', - kwargs: { - channel_ids: channel.id !== "channel_inbox" ? [channel.id] : [], - domain: domain, - }, - }); - } - return $.when(); - }, - /** - * Mark messages as read - * - * @param {Array} msgIDs list of messages ids - * @return {$.Promise} - */ - markAsRead: function (msgIDs) { - var self = this; - var ids = _.filter(msgIDs, function (id) { - var message = _.findWhere(self.messages, {id: id}); - // If too many messages, not all are fetched, and some might not be found - return !message || message.is_needaction; - }); - if (ids.length) { - return this._rpc({ - model: 'mail.message', - method: 'set_message_done', - args: [ids], - }); - } else { - return $.when(); - } - }, - /** - * Marks a channel as seen. - * The seen message will be the last message. - * Resolved with the last seen message, only for non-static channels - * - * @param {Object} channel - * @return {$.Promise} last message id seen in the channel - */ - markChannelAsSeen: function (channel) { - if (channel.unread_counter > 0 && channel.type !== 'static') { - this._updateChannelUnreadCounter(channel, 0); - return this.channelSeen(channel); - } - return $.when(); - }, - /** - * Opens the chat window in discuss. - * - * @param {integer} partnerID - * @return {$.Promise} resolved with the dm channel - */ - openAndDetachDm: function (partnerID) { - return this._rpc({ - model: 'mail.channel', - method: 'channel_get_and_minimize', - args: [[partnerID]], - }) - .then(this._addChannel.bind(this)); - }, - /** - * Open the channel: - * - * 1. If discuss is opened, asks discuss to open the channel - * 2. Otherwise, asks the chat_window_manager to detach the channel - * - * @param {Object} channel - */ - openChannel: function (channel) { - this.chatBus.trigger(this.discussOpen ? 'open_channel' : 'detach_channel', channel); - }, - /** - * Prepares and sends a message to the server: - * - * 1. Either the message is posted on a channel - * 2. Or the message is posted in a model's record (chatter) - * - * Rule of precedence: - * - * 'channelID' < 'model' & 'res_id' - * - * If options as none of these parameters, do nothing and return - * a promise no resolved item. - * - * @param {Object} data data related to the new message - * @param {Object} options - * @param {string|integer} [options.channelID] - * @param {string} [options.model] - * @param {integer} [options.res_id] - * @return {$.Promise} - */ - postMessage: function (data, options) { - var self = this; - options = options || {}; - - // This message will be received from the mail composer as html content subtype - // but the urls will not be linkified. If the mail composer takes the responsibility - // to linkify the urls we end up with double linkification a bit everywhere. - // Ideally we want to keep the content as text internally and only make html - // enrichment at display time but the current design makes this quite hard to do. - var body = utils.parse_and_transform(_.str.trim(data.content), utils.add_link); - - var msg = { - partner_ids: data.partner_ids, - body: body, - attachment_ids: data.attachment_ids, - }; - - // Replace emojis by their unicode character - _.each(emojiUnicodes, function (unicode, key) { - var escapedKey = String(key).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1'); - var regexp = new RegExp("(\\s|^)(" + escapedKey + ")(?=\\s|$)", "g"); - msg.body = msg.body.replace(regexp, "$1" + unicode); - }); - if ('subject' in data) { - msg.subject = data.subject; - } - if ('channelID' in options) { - // post a message in a channel or execute a command - return this._rpc({ - model: 'mail.channel', - method: data.command ? 'execute_command' : 'message_post', - args: [options.channelID], - kwargs: _.extend(msg, { - message_type: 'comment', - subtype: 'mail.mt_comment', - command: data.command, - }), - }); - } - if ('model' in options && 'res_id' in options) { - // post a message in a chatter - _.extend(msg, { - context: data.context, - message_type: data.message_type, - subtype: data.subtype, - subtype_id: data.subtype_id, - }); - - return this._rpc({ - model: options.model, - method: 'message_post', - args: [options.res_id], - kwargs: msg, - }) - .then(function (msgID) { - return self._rpc({ - model: 'mail.message', - method: 'message_format', - args: [[msgID]], - }) - .then(function (msgs) { - msgs[0].model = options.model; - msgs[0].res_id = options.res_id; - self._addMessage(msgs[0]); - }); - }); - } - return $.when(); - }, - /** - * Special redirection handling for given model and id - * - * If the model is res.partner, and there is a user associated with this - * partner which isn't the current user, open the DM with this user. - * Otherwhise, open the record's form view, if this is not the current user's. - * - * @param {string} resModel model to open - * @param {integer} resID record to open - * @param {function} [dmRedirectionCallback] only used if 'res.partner' - */ - redirect: function (resModel, resID, dmRedirectionCallback) { - var self = this; - var redirectToDocument = function (resModel, resID, viewID) { - web_client.do_action({ - type:'ir.actions.act_window', - view_type: 'form', - view_mode: 'form', - res_model: resModel, - views: [[viewID || false, 'form']], - res_id: resID, - }); - }; - if (resModel === 'res.partner') { - var domain = [['partner_id', '=', resID]]; - this._rpc({ - model: 'res.users', - method: 'search', - args: [domain], - }) - .then(function (userIDs) { - if (userIDs.length && userIDs[0] !== session.uid && dmRedirectionCallback) { - self.createChannel(resID, 'dm').then(dmRedirectionCallback); - } else { - redirectToDocument(resModel, resID); - } - }); - } else { - this._rpc({ - model: resModel, - method: 'get_formview_id', - args: [[resID], session.user_context], - }) - .then(function (viewID) { - redirectToDocument(resModel, resID, viewID); - }); - } - }, - /** - * Removes all messages from the current model except 'needaction'. - * We want to keep it in inbox. - * - * @param {string} model - */ - removeChatterMessages: function (model) { - this.messages = _.reject(this.messages, function (message) { - return (!message.channel_ids || message.channel_ids.length === 0) && message.model === model; - }); - }, - /** - * Search among prefetched partners, using the string 'searchVal' - * - * @param {string} searchVal - * @param {integer} limit max number of found partners in the response - * @return {$.Promise} list of found partners (matching 'searchVal') - */ - searchPartner: function (searchVal, limit) { - var def = $.Deferred(); - var values = []; - // search among prefetched partners - var searchRegexp = new RegExp(_.str.escapeRegExp(utils.unaccent(searchVal)), 'i'); - _.each(this.mentionPartnerSuggestions, function (partners) { - if (values.length < limit) { - values = values.concat(_.filter(partners, function (partner) { - return session.partner_id !== partner.id && searchRegexp.test(partner.name); - })).splice(0, limit); - } - }); - if (!values.length) { - // extend the research to all users - def = this._rpc({ - model: 'res.partner', - method: 'im_search', - args: [searchVal, limit || 20], - }, { - shadow: true, - }); - } else { - def = $.when(values); - } - return def.then(function (values) { - var autocompleteData = _.map(values, function (value) { - return { id: value.id, value: value.name, label: value.name }; - }); - return _.sortBy(autocompleteData, 'label'); - }); - }, - /** - * Stars or unstars message - * - * @param {integer} msgID - * @return {$.Promise} - */ - toggleStarStatus: function (msgID) { - return this._rpc({ - model: 'mail.message', - method: 'toggle_message_starred', - args: [[msgID]], - }); - }, - /** - * Unstars all messages from all channels - * - * @return {$.Promise} - */ - unstarAll: function () { - return this._rpc({ - model: 'mail.message', - method: 'unstar_all', - args: [[]] - }); - }, - /** - * Unsubscribes from channel - * - * @param {Object} channel - * @param {integer|string} [channel.id] mandatory if channel is of type 'public' or 'private' - * @param {string} [channel.type] - * @param {string} [channel.uuid] mandatory if channel is not of type 'public' or 'private' - * @return {$.Promise} - */ - unsubscribe: function (channel) { - if (_.contains(['public', 'private'], channel.type)) { - return this._rpc({ - model: 'mail.channel', - method: 'action_unfollow', - args: [[channel.id]], - }); - } else { - return this._rpc({ - model: 'mail.channel', - method: 'channel_pin', - args: [channel.uuid, false], - }); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Adds a channel and returns it. - * Simply returns the channel if already exists. - * - * @private - * @param {Object} data - * @param {string|integer} data.id id of channel or 'channel_inbox', 'channel_starred', ... - * @param {string|Object} data.name name of channel, e.g. 'general' - * @param {string} data.type type of channel, e.g. 'static' - * @param {string} [data.state] e.g. 'open', 'folded' - * @param {Object|integer} [options=undefined] - * @param {boolean} [options.silent] - * @return {Object} the newly or already existing channel - */ - _addChannel: function (data, options) { - options = typeof options === "object" ? options : {}; - var channel = this.getChannel(data.id); - if (channel) { - if (channel.is_folded !== (data.state === "folded")) { - channel.is_folded = (data.state === "folded"); - this.call('chat_window_manager', 'toggleFoldChat', channel); - } - } else { - channel = this._makeChannel(data, options); - this.channels.push(channel); - if (data.last_message) { - channel.last_message = this._addMessage(data.last_message); - } - // In case of a static channel (Inbox, Starred), the name is translated thanks to _lt - // (lazy translate). In this case, channel.name is an object, not a string. - this.channels = _.sortBy(this.channels, function (channel) { - return _.isString(channel.name) ? channel.name.toLowerCase() : ''; - }); - if (!options.silent) { - this.chatBus.trigger("new_channel", channel); - } - if (channel.is_detached) { - this.call('chat_window_manager', 'openChat', channel); - } - } - return channel; - }, - /** - * Adds a channel to a message - * Usefull when you mark a message as 'to do'. - * The message will be available in 'Starred' channel. - * - * @private - * @param {Object} message - * @param {string} channelID - */ - _addChannelToMessage: function (message, channelID) { - if (!message.channel_ids) { - message.channel_ids = []; - } - message.channel_ids.push(channelID); - message.channel_ids = _.uniq(message.channel_ids); - }, - /** - * Creates a new message - * - * @private - * @param {Object} data message data - * @param {integer} data.id - * @param {Object} [options] - * @param {Array} [options.domain] - * @param {boolean} [options.increment_unread] whether we should increment - * the unread_counter of channel. - * @param {boolean} [options.silent] whether it should inform in the chatBus - * of the newly created message. - * @return {Object} message object - */ - _addMessage: function (data, options) { - var self = this; - options = options || {}; - var msg = _.findWhere(this.messages, { id: data.id }); - - if (!msg) { - msg = this._makeMessage(data); - // Keep the array ordered by id when inserting the new message - this.messages.splice(_.sortedIndex(this.messages, msg, 'id'), 0, msg); - _.each(msg.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - // update the channel's last message (displayed in the channel - // preview, in mobile) - if (!channel.last_message || msg.id > channel.last_message.id) { - channel.last_message = msg; - } - self._addToCache(msg, []); - if (options.domain && options.domain !== []) { - self._addToCache(msg, options.domain); - } - if (channel.hidden) { - channel.hidden = false; - self.chatBus.trigger('new_channel', channel); - } - if (channel.type !== 'static' && !msg.is_author && !msg.is_system_notification) { - if (options.increment_unread) { - self._updateChannelUnreadCounter(channel, channel.unread_counter+1); - } - if (channel.is_chat && options.show_notification) { - if (!self.discussOpen && !config.device.isMobile) { - // automatically open chat window - self.call('chat_window_manager', 'openChat', channel, { passively: true }); - } - var query = {is_displayed: false}; - self.chatBus.trigger('anyone_listening', channel, query); - self._notifyIncomingMessage(msg, query); - } - } - } - }); - if (!options.silent) { - this.chatBus.trigger('new_message', msg); - } - } else if (options.domain && options.domain !== []) { - this._addToCache(msg, options.domain); - } else if (data.moderation_status === 'accepted') { - msg.channel_ids = data.channel_ids; - msg.needsModeration = false; - if (msg.isModerator) { - this.moderationCounter--; - this._removeMessageFromChannel("channel_moderation", msg); - this.chatBus.trigger('update_moderation_counter'); - } - this.chatBus.trigger('update_message', msg); - } - return msg; - }, - /** - * Stores message to channelCache. - * - * @private - * @param {Object} message - * @param {Array} domain - */ - _addToCache: function (message, domain) { - var self = this; - _.each(message.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - var channelCache = self._getChannelCache(channel, domain); - var index = _.sortedIndex(channelCache.messages, message, 'id'); - if (channelCache.messages[index] !== message) { - channelCache.messages.splice(index, 0, message); - } - } - }); - }, - /** - * Gets messages from their ids - * This method is used when the chatter linked to a record need to be loaded. - * - * @private - * @param {Array} msgIDs message ids to load - * @param {Object} options - * @return {$.Promise} resolved with fetched messages - */ - _fetchDocumentMessages : function (msgIDs, options) { - var self = this; - var loadedMsgs = _.filter(this.messages, function (message) { - return _.contains(msgIDs, message.id); - }); - var loadedMsgIDs = _.pluck(loadedMsgs, 'id'); - - options = options || {}; - if (options.forceFetch || _.difference(msgIDs.slice(0, LIMIT), loadedMsgIDs).length) { - var idsToLoad = _.difference(msgIDs, loadedMsgIDs).slice(0, LIMIT); - return this._rpc({ - model: 'mail.message', - method: 'message_format', - args: [idsToLoad], - context: session.user_context, - }) - .then(function (msgs) { - var processedMsgs = []; - _.each(msgs, function (msg) { - processedMsgs.push(self._addMessage(msg, {silent: true})); - }); - return _.sortBy(loadedMsgs.concat(processedMsgs), function (msg) { - return msg.id; - }); - }); - } else { - return $.when(loadedMsgs); - } - }, - /** - * Gets messages from channel - * - * @private - * @param {Object} channel - * @param {integer|string} channel.id string for static channels, e.g. 'channel_inbox' - * @param {Object} [option={}] - * @param {Array} [options.domain] filter on the messages of the channel - * @param {boolean} [options.loadMore] Whether it should load more message - * @return {$.Promise} resolved with list of messages - */ - _fetchFromChannel: function (channel, options) { - var self = this; - options = options || {}; - var domain = - (channel.id === 'channel_inbox') ? [['needaction', '=', true]] : - (channel.id === 'channel_starred') ? [['starred', '=', true]] : - (channel.id === 'channel_moderation') ? [['need_moderation', '=', true]] : - ['|', - '&', '&', - ['model', '=', 'mail.channel'], - ['res_id', 'in', [channel.id]], - ['need_moderation', '=', true], - ['channel_ids', 'in', [channel.id]] - ]; - var cache = this._getChannelCache(channel, options.domain); - - if (options.domain) { - domain = domain.concat(options.domain || []); - } - if (options.loadMore) { - var minMessageID = cache.messages[0].id; - domain = [['id', '<', minMessageID]].concat(domain); - } - - return this._rpc({ - model: 'mail.message', - method: 'message_fetch', - args: [domain], - kwargs: {limit: LIMIT, context: session.user_context}, - }) - .then(function (msgs) { - if (!cache.all_history_loaded) { - cache.all_history_loaded = msgs.length < LIMIT; - } - cache.loaded = true; - - _.each(msgs, function (msg) { - self._addMessage(msg, { - channel_id: channel.id, - silent: true, - domain: options.domain, - }); - }); - var channelCache = self._getChannelCache(channel, options.domain || []); - return channelCache.messages; - }); - }, - /** - * Gets channel content from the cache. - * Usefull to get cached messages from a channel. - * - * @private - * @param {Object} channel - * @param {Object[]} channel.cache - * @param {Array} domain - * @return {Object|undefined} - */ - _getChannelCache: function (channel, domain) { - var stringifiedDomain = JSON.stringify(domain || []); - if (!channel.cache[stringifiedDomain]) { - channel.cache[stringifiedDomain] = { - all_history_loaded: false, - loaded: false, - messages: [], - }; - } - return channel.cache[stringifiedDomain]; - }, - /** - * @private - * @returns {$.Promise} - */ - _initMessaging: function () { - var self = this; - return session.is_bound.then(function (){ - var context = _.extend({isMobile: config.device.isMobile}, session.user_context); - return self._rpc({ - route: '/mail/init_messaging', - params: {context: context}, - }); - }).then(function (result) { - _.each(result.channel_slots, function (channels) { - _.each(channels, self._addChannel.bind(self)); - }); - self.messageFailures = result.mail_failures; - self.needactionCounter = result.needaction_inbox_counter || 0; - self.starredCounter = result.starred_counter || 0; - self.moderationCounter = result.moderation_counter; - self.moderatedChannelIDs = result.moderation_channel_ids; - self._isModerator = result.is_moderator; - - //if user is moderator then add moderation channel - if (self._isModerator) { - self._addChannel({ - id: "channel_moderation", - name: _lt("Moderate Messages"), - type: "static" - }); - } - self.commands = _.map(result.commands, function (command) { - return _.extend({ id: command.name }, command); - }); - self.mentionPartnerSuggestions = result.mention_partner_suggestions; - self.discussMenuID = result.menu_id; - - // Shortcodes: canned responses - _.each(result.shortcodes, function (s) { - self.cannedResponses.push(_.pick(s, ['id', 'source', 'substitution'])); - }); - // emojis - - var lastAdded = null; - _.each(emojiUnicodes, function (unicode, key) { - if (lastAdded != unicode){ - lastAdded = unicode; - self.emojis.push({source:key, unicode_source:unicode, description:key}); - } - }); - - self.busBus.start_polling(); - }); - }, - /** - * Clear cache of a channel - * - * @private - * @param {Array} channelIDs must be int or string - */ - _invalidateCaches: function (channelIDs) { - var self = this; - _.each(channelIDs, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - channel.cache = { '[]': channel.cache['[]']}; - } - }); - }, - /** - * Creates channel object - * - * @private - * @param {Object} data - * @param {string} [data.anonymous_name] - * @param {string} data.channel_type - * @param {Object[]} [data.direct_partner] - * @param {boolean} [data.group_based_subscription] - * @param {integer|string} data.id - * @param {boolean} data.is_moderator - * @param {boolean} data.is_minimized - * @param {string} [data.last_message_date] - * @param {boolean} data.mass_mailing - * @param {integer} [data.message_needaction_counter] - * @param {integer} [data.message_unread_counter] - * @param {boolean} data.moderation whether this channel is moderated or not - * @param {string} data.name - * @param {string} [data.public] - * @param {integer} data.seen_message_id - * @param {string} [data.state] - * @param {string} [data.type] - * @param {string} data.uuid - * @param {Object} options - * @param {boolean} [options.autoswitch] - * @param {boolean} options.displayNeedactions - * @param {boolean} [options.hidden] - * @return {Object} channel - */ - _makeChannel: function (data, options) { - var channel = { - id: data.id, - name: data.name, - server_type: data.channel_type, - type: data.type || data.channel_type, - all_history_loaded: false, - uuid: data.uuid, - is_detached: data.is_minimized, - is_folded: data.state === "folded", - autoswitch: 'autoswitch' in options ? options.autoswitch : true, - hidden: options.hidden, - display_needactions: options.displayNeedactions, - mass_mailing: data.mass_mailing, - isModerated: data.moderation, - isModerator: data.is_moderator, - group_based_subscription: data.group_based_subscription, - needaction_counter: data.message_needaction_counter || 0, - unread_counter: 0, - last_seen_message_id: data.seen_message_id, - cache: {'[]': { - all_history_loaded: false, - loaded: false, - messages: [], - }}, - }; - if (channel.type === "channel") { - channel.type = data.public !== "private" ? "public" : "private"; - } - if (_.size(data.direct_partner) > 0) { - channel.type = "dm"; - channel.name = data.direct_partner[0].name; - channel.direct_partner_id = data.direct_partner[0].id; - channel.status = data.direct_partner[0].im_status; - this.pinnedDmPartners.push(channel.direct_partner_id); - this.busBus.update_option('bus_presence_partner_ids', this.pinnedDmPartners); - } else if ('anonymous_name' in data) { - channel.name = data.anonymous_name; - } - if (data.last_message_date) { - channel.last_message_date = moment(time.str_to_datetime(data.last_message_date)); - } - channel.is_chat = !channel.type.match(/^(public|private|static)$/); - if (data.message_unread_counter) { - this._updateChannelUnreadCounter(channel, data.message_unread_counter); - } - return channel; - }, - /** - * Creates message object - * - * @private - * @param {Object} data - * @param {integer[]} [data.attachment_ids=[]] - * @param {integer[]} [data.author_id] - * @param {string} [data.body=""] - * @param {Array} data.channel_ids contains integers and strings - * @param {string} data.customer_email_status - * @param {string} data.date - * @param {string} data.email_from - * @param {integer} data.id - * @param {string} [data.info] - * @param {boolean} data.is_discussion - * @param {boolean} data.is_note - * @param {boolean} data.is_notification - * @param {string} data.message_type - * @param {string} [data.moderation_status] - * @param {string} [data.model] - * @param {boolean} data.module_icon src url of the module icon - * @param {string} data.record_name - * @param {integer} data.res_id - * @param {string} data.subject - * @param {string} data.subtype_description - * @param {integer[]} data.tracking_value_ids - * @return {Object} message - */ - _makeMessage: function (data) { - var self = this; - var msg = { - id: data.id, - author_id: data.author_id, - body: data.body || "", - date: moment(time.str_to_datetime(data.date)), - message_type: data.message_type, - subtype_description: data.subtype_description, - is_author: data.author_id && data.author_id[0] === session.partner_id, - isModerator: data.model === 'mail.channel' && - _.contains(this.moderatedChannelIDs, data.res_id) && - data.moderation_status === 'pending_moderation', - is_note: data.is_note, - is_discussion: data.is_discussion, - is_notification: data.is_notification, - is_system_notification: (data.message_type === 'notification' && data.model === 'mail.channel') - || data.info === 'transient_message', - attachment_ids: data.attachment_ids || [], - subject: data.subject, - email_from: data.email_from, - customer_email_status: data.customer_email_status, - customer_email_data: data.customer_email_data, - record_name: data.record_name, - tracking_value_ids: data.tracking_value_ids, - channel_ids: data.channel_ids, - model: data.model, - res_id: data.res_id, - url: session.url("/mail/view?message_id=" + data.id), - module_icon:data.module_icon, - }; - _.each(emojiUnicodes, function (value, key) { - //add o_mail_emoji class on each unicode to manage size and font - var unicode = String(value); - var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|)", "g"); - var msg_bak = msg.body; - msg.body = msg.body.replace(regexp, ' '+unicode+' '); - // Idiot-proof limit. If the user had the amazing idea of copy-pasting thousands of emojis, - // the image rendering can lead to memory overflow errors on some browsers (e.g. Chrome). - // Set an arbitrary limit to 200 from which we simply don't replace them (anyway, they are - // already replaced by the unicode counterpart). - if (_.str.count(msg.body, 'o_mail_emoji') > 200) { - msg.body = msg_bak; - } - }); - function propertyDescr(channel) { - return { - enumerable: true, - get: function () { - return _.contains(msg.channel_ids, channel); - }, - set: function (bool) { - if (bool) { - self._addChannelToMessage(msg, channel); - } else { - msg.channel_ids = _.without(msg.channel_ids, channel); - } - } - }; - } - - Object.defineProperties(msg, { - is_starred: propertyDescr("channel_starred"), - is_needaction: propertyDescr("channel_inbox"), - needsModerationByUser: propertyDescr("channel_moderation"), - }); - - if (_.contains(data.needaction_partner_ids, session.partner_id)) { - msg.is_needaction = true; - } - if (_.contains(data.starred_partner_ids, session.partner_id)) { - msg.is_starred = true; - } - if (data.moderation_status === 'pending_moderation') { - msg.needsModeration = true; - msg.needsModerationByUser = msg.isModerator; - // the message is not linked to the moderated channel on the - // server, therefore this message has not this channel in - // channel_ids. Here, just to show this message in the channel - //visually, it links this message to the channel - msg.channel_ids.push(msg.res_id); - } - if (msg.model === 'mail.channel') { - var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred', 'channel_moderation'); - var origin = realChannels.length === 1 ? realChannels[0] : undefined; - var channel = origin && this.getChannel(origin); - if (channel) { - msg.origin_id = origin; - msg.origin_name = channel.name; - } - } - - // Compute displayed author name or email - if ((!msg.author_id || !msg.author_id[0]) && msg.email_from) { - msg.mailto = msg.email_from; - } else { - msg.displayed_author = (msg.author_id === ODOOBOT_ID) && "OdooBot" || - msg.author_id && msg.author_id[1] || - msg.email_from || _t('Anonymous'); - } - - // Don't redirect on author clicked of self-posted or OdooBot messages - msg.author_redirect = !msg.is_author && msg.author_id !== ODOOBOT_ID; - - // Compute the avatar_url - if (msg.author_id === ODOOBOT_ID) { - msg.avatar_src = "/mail/static/src/img/odoo_o.png"; - } else if (msg.author_id && msg.author_id[0]) { - msg.avatar_src = "/web/image/res.partner/" + msg.author_id[0] + "/image_small"; - } else if (msg.message_type === 'email') { - msg.avatar_src = "/mail/static/src/img/email_icon.png"; - } else { - msg.avatar_src = "/mail/static/src/img/smiley/avatar.jpg"; - } - - // add anchor tags to urls - msg.body = utils.parse_and_transform(msg.body, utils.add_link); - - // Compute url of attachments - _.each(msg.attachment_ids, function (a) { - a.url = '/web/content/' + a.id + '?download=true'; - }); - - // format date to the local only once by message - // can not be done in preprocess, since it alter the original value - if (msg.tracking_value_ids && msg.tracking_value_ids.length) { - var format; - _.each(msg.tracking_value_ids, function (f) { - if (f.field_type === 'datetime') { - format = 'LLL'; - if (f.old_value) { - f.old_value = moment.utc(f.old_value).local().format(format); - } - if (f.new_value) { - f.new_value = moment.utc(f.new_value).local().format(format); - } - } else if (f.field_type === 'date') { - format = 'LL'; - if (f.old_value) { - f.old_value = moment(f.old_value).local().format(format); - } - if (f.new_value) { - f.new_value = moment(f.new_value).local().format(format); - } - } - }); - } - - return msg; - }, - /** - * @private - * @param {Object} data key, value to decide activity created or deleted - */ - _manageActivityUpdateNotification: function (data) { - this.chatBus.trigger('activity_updated', data); - }, - /** - * @private - * @param {Object} data - */ - _manageAuthorNotification: function (data) { - this._addMessage(data.message); - }, - /** - * @private - * @param {Object} message - * @param {Array} message.channel_ids list of integers and strings, - * where strings for static channels, e.g. 'channel_inbox'. - */ - _manageChannelNotification: function (message) { - var self = this; - var def; - var channelAlreadyInCache = true; - if (message.channel_ids.length === 1) { - channelAlreadyInCache = !!this.getChannel(message.channel_ids[0]); - def = this.joinChannel(message.channel_ids[0], {autoswitch: false}); - } else { - def = $.when(); - } - def.then(function () { - // don't increment unread if channel wasn't in cache yet as - // its unread counter has just been fetched - self._addMessage(message, { - show_notification: true, - increment_unread: channelAlreadyInCache - }); - self._invalidateCaches(message.channel_ids); - }); - }, - /** - * @private - * @param {Object} data - * @param {integer|string} data.id string for static channels, e.g. 'channel_inbox' - * @param {integer} [data.last_message_id] mandatory if 'id' refers to an - * existing channel. - */ - _manageChannelSeenNotification: function (data) { - var channel = this.getChannel(data.id); - if (channel) { - channel.last_seen_message_id = data.last_message_id; - if (channel.unread_counter) { - this._updateChannelUnreadCounter(channel, 0); - } - } - }, - /** - * Controls the session of the chat window - * - * @private - * @param {Object} chatSession - * @param {string} chatSession.channel_type - * @param {string|integer} chatSession.id string for static channels, e.g. 'channel_inbox' - * @param {string} chatSession.info - * @param {boolean} chatSession.is_minimized - * @param {string} chatSession.name - * @param {string} chatSession.state - */ - _manageChatSessionNotification: function (chatSession) { - var channel; - if ((chatSession.channel_type === "channel") && (chatSession.state === "open")) { - this._addChannel(chatSession, {autoswitch: false}); - if (!chatSession.is_minimized && chatSession.info !== 'creation') { - web_client.do_notify(_t("Invitation"), _t("You have been invited to: ") + chatSession.name); - } - } - // partner specific change (open a detached window for example) - if ((chatSession.state === "open") || (chatSession.state === "folded")) { - channel = chatSession.is_minimized && this.getChannel(chatSession.id); - if (channel) { - channel.is_detached = true; - channel.is_folded = (chatSession.state === "folded"); - this.call('chat_window_manager', 'openChat', channel); - } - } else if (chatSession.state === "closed") { - channel = this.getChannel(chatSession.id); - if (channel) { - channel.is_detached = false; - this.call('chat_window_manager', 'closeChat', channel, {keep_open_if_unread: true}); - } - } - }, - /** - * @private - * @param {Object} data - * @param {Object[]} [data.message_ids] - */ - _manageDeletionNotification: function (data) { - var self = this; - _.each(data.message_ids, function (msgID) { - var message = _.findWhere(self.messages, { id: msgID }); - if (message) { - if (message.isModerator) { - self._removeMessageFromChannel("channel_moderation", message); - self.moderationCounter--; - } - message.needsModeration = false; - self._removeMessageFromChannel(message.res_id, message); - self.chatBus.trigger('update_message', message); - } - }); - this.chatBus.trigger('update_moderation_counter'); - }, - /** - * Add or remove failure when receiving a failure update message - * @private - * @param {Object} data - */ - _manageMessageFailureNotification: function (data) { - var self = this; - _.each(data.elements, function (updateMessage) { - - var isAddFailure = _.some(updateMessage.notifications, function(notif) { - return notif[0] === 'exception' || notif[0] === 'bounce'; - }); - var res = _.find(self.messageFailures, {'message_id': updateMessage.message_id}); - if (res) { - var index = _.findIndex(self.messageFailures, res); - if (isAddFailure) { - self.messageFailures[index] = updateMessage; - } else { - self.messageFailures.splice(index, 1); - } - } else if (isAddFailure) { - self.messageFailures.push(updateMessage); - } - var message = _.findWhere(self.messages, { id: updateMessage.message_id }); - if (message) { - if (isAddFailure) { - message.customer_email_status = "exception"; - } else{ - message.customer_email_status = "sent"; - } - self._updateMessageNotificationStatus(updateMessage, message); - self.chatBus.trigger('update_message', message); - } - }); - this.chatBus.trigger('update_needaction', this.needactionCounter); - }, - /** - * Updates channel_inbox when a message has marked as read. - * - * @private - * @param {Object} data - * @param {integer[]} [data.channel_ids] - * @param {integer[]} [data.message_ids] - * @param {string} [data.type] - */ - _manageMarkAsReadNotification: function (data) { - var self = this; - _.each(data.message_ids, function (msgID) { - var message = _.findWhere(self.messages, { id: msgID }); - if (message) { - self._invalidateCaches(message.channel_ids); - self._removeMessageFromChannel("channel_inbox", message); - self.chatBus.trigger('update_message', message, data.type); - } - }); - if (data.channel_ids) { - _.each(data.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - channel.needaction_counter = Math.max(channel.needaction_counter - data.message_ids.length, 0); - } - }); - } else { // if no channel_ids specified, this is a 'mark all read' in the inbox - _.each(this.channels, function (channel) { - channel.needaction_counter = 0; - }); - } - this.needactionCounter = Math.max(this.needactionCounter - data.message_ids.length, 0); - this.chatBus.trigger('update_needaction', this.needactionCounter); - }, - /** - * @private - * @param {Object} data notification data - * @param {Object} data.message - */ - _manageModeratorNotification: function (data) { - this.moderationCounter++; - this._addMessage(data.message); - this.chatBus.trigger('update_moderation_counter'); - }, - /** - * @private - * @param {Object} message - * @param {integer[]} message.channel_ids - */ - _manageNeedactionNotification: function (message) { - var self = this; - message = this._addMessage(message, { - channel_id: 'channel_inbox', - increment_unread: true, - show_notification: true, - }); - this._invalidateCaches(message.channel_ids); - if (message.channel_ids.length !== 0) { - this.needactionCounter++; - } - _.each(message.channel_ids, function (channelID) { - var channel = self.getChannel(channelID); - if (channel) { - channel.needaction_counter++; - } - }); - this.chatBus.trigger('update_needaction', this.needactionCounter); - }, - /** - * @private - * @param {Object} data structure depending on the type - * @param {integer} data.id - */ - _managePartnerNotification: function (data) { - if (data.info === "unsubscribe") { - var channel = this.getChannel(data.id); - if (channel) { - var msg; - if (_.contains(['public', 'private'], channel.type)) { - msg = _.str.sprintf(_t('You unsubscribed from %s.'), channel.name); - } else { - msg = _.str.sprintf(_t('You unpinned your conversation with %s.'), channel.name); - } - this._removeChannel(channel); - this.chatBus.trigger("unsubscribe_from_channel", data.id); - web_client.do_notify(_t("Unsubscribed"), msg); - } - } else if (data.type === 'toggle_star') { - this._manageToggleStarNotification(data); - } else if (data.type === 'mark_as_read') { - this._manageMarkAsReadNotification(data); - } else if (data.type === 'moderator') { - this._manageModeratorNotification(data); - } else if (data.type === 'author') { - this._manageAuthorNotification(data); - } else if (data.type === 'deletion') { - this._manageDeletionNotification(data); - } else if (data.info === 'channel_seen') { - this._manageChannelSeenNotification(data); - } else if (data.info === 'transient_message') { - this._manageTransientMessageNotification(data); - } else if (data.type === 'activity_updated') { - this._manageActivityUpdateNotification(data); - } else if (data.type === 'mail_failure') { - this._manageMessageFailureNotification(data); - } else { - this._manageChatSessionNotification(data); - } - }, - /** - * @private - * @param {Object} data partner infos - * @param {integer} data.id - * @param {string} data.im_status - */ - _managePresenceNotification: function (data) { - var dm = this.getDmFromPartnerID(data.id); - if (dm) { - dm.status = data.im_status; - this.chatBus.trigger('update_dm_presence', dm); - } - }, - /** - * @private - * @param {Object} data - * @param {integer[]} data.message_ids - * @param {boolean} data.starred - * @param {string} data.type - */ - _manageToggleStarNotification: function (data) { - var self = this; - _.each(data.message_ids, function (msgID) { - var message = _.findWhere(self.messages, { id: msgID }); - if (message) { - self._invalidateCaches(message.channel_ids); - message.is_starred = data.starred; - if (!message.is_starred) { - self._removeMessageFromChannel("channel_starred", message); - } else { - self._addToCache(message, []); - var channelStarred = self.getChannel('channel_starred'); - channelStarred.cache = _.pick(channelStarred.cache, "[]"); - } - self.chatBus.trigger('update_message', message); - } - }); - - if (data.starred) { // increase starred counter if message is marked as star - this.starredCounter += data.message_ids.length; - } else { // decrease starred counter if message is remove from star if unstar_all then it will set to 0. - this.starredCounter -= data.message_ids.length; - } - - this.chatBus.trigger('update_starred', this.starredCounter); - }, - /** - * @private - * @param {Object} data - * @param {string} data.author_id - */ - _manageTransientMessageNotification: function (data) { - var lastMessage = _.last(this.messages); - data.id = (lastMessage ? lastMessage.id : 0) + 0.01; - data.author_id = data.author_id || ODOOBOT_ID; - this._addMessage(data); - }, - /** - * shows a popup to notify a new received message. - * This will also rename the odoo tab browser if - * the user is not in it. - * - * @private - * @param {Object} msg message received - * @param {Array} msg.author_id contains [integer, string] - * @param {string} msg.body - * @param {Object} options - * @param {boolean} options.is_displayed - */ - _notifyIncomingMessage: function (msg, options) { - if (this.busBus.is_odoo_focused() && options.is_displayed) { - // no need to notify - return; - } - var title = _t('New message'); - if (msg.author_id[1]) { - title = _.escape(msg.author_id[1]); - } - var content = utils.parse_and_transform(msg.body, utils.strip_html) - .substr(0, PREVIEW_MSG_MAX_SIZE); - - if (!this.busBus.is_odoo_focused()) { - this.globalUnreadCounter++; - var tabTitle = _.str.sprintf(_t("%d Messages"), this.globalUnreadCounter); - web_client.set_title_part("_chat", tabTitle); - } - - this.call('bus_service', 'sendNotification', web_client, title, content); - }, - /** - * Removes channel - * - * @private - * @param {Object} [channel] - * @param {integer} [channel.direct_partner_id] mandatory if type is 'dm' - * @param {integer|string} channel.id string for static channels, e.g. 'channel_inbox' - * @param {string} [channel.type] - */ - _removeChannel: function (channel) { - if (!channel) { return; } - if (channel.type === 'dm') { - var index = this.pinnedDmPartners.indexOf(channel.direct_partner_id); - if (index > -1) { - this.pinnedDmPartners.splice(index, 1); - this.busBus.update_option('bus_presence_partner_ids', this.pinnedDmPartners); - } - } - this.channels = _.without(this.channels, channel); - delete this.channelDefs[channel.id]; - }, - /** - * Removes a message from a channel. - * it will also remove cached message from the channel - * - * @private - * @param {integer|string} channelID string for static channels, e.g. 'channel_inbox' - * @param {Object} message - */ - _removeMessageFromChannel: function (channelID, message) { - message.channel_ids = _.without(message.channel_ids, channelID); - var channel = _.findWhere(this.channels, { id: channelID }); - _.each(channel.cache, function (cache) { - cache.messages = _.without(cache.messages, message); - }); - }, - /** - * Increments or decrements unreadConversationCounter - * - * @private - * @param {Object} channel - * @param {integer} counter - */ - _updateChannelUnreadCounter: function (channel, counter) { - if (channel.unread_counter > 0 && counter === 0) { - this.unreadConversationCounter = Math.max(0, this.unreadConversationCounter-1); - } else if (channel.unread_counter === 0 && counter > 0) { - this.unreadConversationCounter++; - } - channel.unread_counter = counter; - this.chatBus.trigger("update_channel_unread_counter", channel); - }, - /** - * Update the message notification status of message based on update_message - * - * @private - * @param {Object} updateMessage - * @param {Object[]} updateMessage.notifications - */ - _updateMessageNotificationStatus: function (updateMessage, message) { - _.each(updateMessage.notifications, function (notif, id, list) { - var partnerName = notif[1]; - var notifStatus = notif[0]; - var res = _.find(message.customer_email_data, function(entry){ - return entry[0] === parseInt(id); - }); - if (res) { - res[2] = notifStatus; - } else { - message.customer_email_data.push([parseInt(id), partnerName, notifStatus]); - } - }); - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Notification handlers - * Sometimes, the web client receives unsubscribe notification and an extra - * notification on that channel. This is then followed by an attempt to - * rejoin the channel that we just left. The next few lines remove the - * extra notification to prevent that situation to occur. - * - * @private - * @param {Array} notifications - */ - _onNotification: function (notifications) { - var self = this; - var unsubscribedNotif = _.find(notifications, function (notif) { - return notif[1].info === "unsubscribe"; - }); - if (unsubscribedNotif) { - notifications = _.reject(notifications, function (notif) { - return notif[0][1] === "mail.channel" && notif[0][2] === unsubscribedNotif[1].id; - }); - } - _.each(notifications, function (notification) { - var model = notification[0][1]; - if (model === 'ir.needaction') { - // new message in the inbox - self._manageNeedactionNotification(notification[1]); - } else if (model === 'mail.channel') { - // new message in a channel - self._manageChannelNotification(notification[1]); - } else if (model === 'res.partner') { - // channel joined/left, message marked as read/(un)starred, chat open/closed - self._managePartnerNotification(notification[1]); - } else if (model === 'bus.presence') { - // update presence of users - self._managePresenceNotification(notification[1]); - } - }); - }, - /** - * Global unread counter and notifications - * - * @private - */ - _onWindowFocus: function () { - this.globalUnreadCounter = 0; - web_client.set_title_part("_chat"); - }, - -}); - -core.serviceRegistry.add('chat_manager', ChatManager); - -return ChatManager; - -}); diff --git a/addons/mail/static/src/js/services/chat_window_manager.js b/addons/mail/static/src/js/services/chat_window_manager.js deleted file mode 100644 index fd45b336c23..00000000000 --- a/addons/mail/static/src/js/services/chat_window_manager.js +++ /dev/null @@ -1,534 +0,0 @@ -odoo.define('mail.ChatWindowManager', function (require) { -"use strict"; - -var ExtendedChatWindow = require('mail.ExtendedChatWindow'); - -var AbstractService = require('web.AbstractService'); -var config = require('web.config'); -var core = require('web.core'); -var utils = require('web.utils'); -var web_client = require('web.web_client'); - -var _t = core._t; -var QWeb = core.qweb; - -//---------------------------------------------------------------- -var CHAT_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows - -/** - * This service handles chat channels that are displayed in "chat window" mode - */ -var ChatWindowManager = AbstractService.extend({ - name: 'chat_window_manager', - dependencies: ['chat_manager'], - - /** - * @override - */ - init: function () { - var self = this; - - this._super.apply(this, arguments); - - this.chatSessions = []; - this.newChatSession; - this.displayState = { - chatWindowsHidden: false, // chat windows aren't displayed when the client action is open - hiddenSessions: [], - hiddenUnreadCounter: 0, // total number of unread msgs in hidden chat windows - nbSlots: 0, - spaceLeft: 0, - windowsDropdownIsOpen: false, // used to keep dropdown open when closing chat windows - }; - this.call('chat_manager', 'isReady').then(function () { - var channels = self.call('chat_manager', 'getChannels'); - _.each(channels, function (channel) { - if (channel.is_detached) { - self.openChat(channel); - } - }); - }); - - var chatBus = this.call('chat_manager', 'getChatBus'); - chatBus.on('update_message', this, this._onUpdateMessage); - chatBus.on('new_message', this, this._onNewMessage); - chatBus.on('anyone_listening', this, this._onAnyoneListening); - chatBus.on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel); - chatBus.on('update_channel_unread_counter', this, this._onUpdateChannelUnreadCounter); - chatBus.on('update_dm_presence', this, this._onUpdateDmPresence); - chatBus.on('detach_channel', this, this._onDetachChannel); - chatBus.on('discuss_open', this, this._onDiscussOpen); - - core.bus.on('resize', this, _.debounce(this._repositionWindows.bind(this), 100)); - }, - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * @param {Object} chatSession - * @param {integer} chatSession.id - * @param {Object} options - */ - closeChat: function (chatSession, options) { - var session = _.find(this.chatSessions, {id: chatSession.id}); - if (session) { - this._closeChat(session, options); - } - }, - /** - * @param {Object} session - * @param {integer} session.id - * @param {boolean} [session.is_chat] true for dm? => server_type="chat" & type="dm"... - * @param {boolean} [session.mass_mailing] - * @param {string} [session.status] e.g. 'offline' - * @param {Object} [options] - * @param {boolean} [options.passively] if set to true, open the chat window - * without focusing the input and marking messages as read if it is not - * open yet, and do nothing otherwise - */ - openChat: function (session, options) { - var self = this; - if (!session) { - this._openChatWithoutSession(); - return; - } - options = options || {}; - var chatSession = _.findWhere(this.chatSessions, {id: session.id}); - if (!chatSession) { - var prefix = !session.is_chat ? "#" : ""; - var windowOptions = { - autofocus: !options.passively, - input_less: session.mass_mailing, - status: session.status, - }; - chatSession = { - id: session.id, - uuid: session.uuid, - name: session.name, - keep_unread: options.passively, // don't automatically mark unread messages as seen - window: new ExtendedChatWindow(this, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions), - }; - chatSession.window.on("close_chat_session", null, function () { - self._closeChat(chatSession); - self.call('chat_manager', 'closeChatSession', chatSession.id); - }); - chatSession.window.on("toggle_star_status", null, function (messageID) { - self.call('chat_manager', 'toggleStarStatus', messageID); - }); - - chatSession.window.on("fold_channel", null, function (channelID, folded) { - self.call('chat_manager', 'foldChannel', channelID, folded); - }); - - chatSession.window.on("post_message", null, function (message, channelID) { - self.call('chat_manager', 'postMessage', message, { - channelID: channelID - }).then(function () { - chatSession.window.thread.scroll_to(); - }); - }); - chatSession.window.on("redirect", null, function (resModel, resID) { - self.call('chat_manager', 'redirect', resModel, resID, self.openChat.bind(self)); - }); - chatSession.window.on("redirect_to_channel", null, function (channelID) { - var session = _.findWhere(self.chatSessions, {id: channelID}); - if (!session) { - self.call('chat_manager', 'joinChannel', channelID).then(function (channel) { - self.call('chat_manager', 'detachChannel', channelID); - }); - } else { - session.window.toggle_fold(false); - } - }); - - var removeNewChat = false; - if (options.passively) { - this.chatSessions.push(chatSession); // simply insert the window to the left - } else if (this.newChatSession && this.newChatSession.partner_id && this.newChatSession.partner_id === session.direct_partner_id) { - // the window takes the place of the 'newChatSession' window - this.chatSessions[_.indexOf(this.chatSessions, this.newChatSession)] = chatSession; - removeNewChat = true; - } else { - this._addChatSession(chatSession); // add session such that window is visible - } - - chatSession.window.appendTo($('body')) - .then(function () { - self._repositionWindows({remove_new_chat: removeNewChat}); - return self.call('chat_manager', 'getMessages', {channelID: chatSession.id}); - }).then(function (messages) { - chatSession.window.render(messages); - chatSession.window.thread.scroll_to(); - setTimeout(function () { - chatSession.window.thread.$el.on("scroll", null, _.debounce(function () { - if (!chatSession.keep_unread && chatSession.window.thread.is_at_bottom()) { - self.call('chat_manager', 'markChannelAsSeen', session); - } - }, 100)); - }, 0); // setTimeout to prevent to execute handler on first scroll_to, which is asynchronous - if (options.passively) { - // mark first unread messages as seen when focusing the window, then on scroll to bottom as usual - chatSession.window.$('.o_mail_thread, .o_chat_composer').one('click', function () { - self.call('chat_manager', 'markChannelAsSeen', session); - }); - } else if (!self.displayState.chatWindowsHidden && !session.is_folded) { - self.call('chat_manager', 'markChannelAsSeen', session); - } - }); - } else if (!options.passively) { - if (chatSession.window.is_hidden) { - this._makeSessionVisible(chatSession); - } else if (session.is_folded !== chatSession.window.folded) { - chatSession.window.toggle_fold(session.is_folded); - } - } - }, - /** - * Called when unfolding the chat window - * - * @param {Object} channel - * @param {integer} channel.id - * @param {boolean} [channel.is_folded] - */ - toggleFoldChat: function (channel) { - var session = _.find(this.chatSessions, {id: channel.id}); - if (session) { - session.window.toggle_fold(channel.is_folded); - } - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Add chatSession such that it will be the left-most visible window - * - * @private - * @param {Object} chatSession - */ - _addChatSession: function (chatSession) { - this._computeAvailableSlots(this.chatSessions.length+1); - this.chatSessions.splice(this.displayState.nbSlots-1, 0, chatSession); - }, - /** - * @private - * @param {Object} chatSession - * @param {boolean} [chatSession.keep_unread] - * @param {Object} chatSession.window - * @param {Object} [options] - * @param {boolean} [options.keep_open_if_unread] - */ - _closeChat: function (chatSession, options) { - if (options && options.keep_open_if_unread && chatSession.keep_unread) { - return; - } - this.chatSessions = _.without(this.chatSessions, chatSession); - chatSession.window.destroy(); - this._repositionWindows(); - }, - /** - * @private - */ - _closeNewChat: function () { - this.chatSessions = _.without(this.chatSessions, this.newChatSession); - this._repositionWindows({remove_new_chat: true}); - }, - /** - * @private - * @param {integer} nbWindows - */ - _computeAvailableSlots: function (nbWindows) { - if (config.device.isMobile) { - this.displayState.nbSlots = 1; // one chat window full screen in mobile - return; - } - var width = window.innerWidth; - var nbSlots = Math.floor(width/CHAT_WINDOW_WIDTH); - var spaceLeft = width - (Math.min(nbSlots, nbWindows)*CHAT_WINDOW_WIDTH); - if (nbSlots < nbWindows && spaceLeft < 50) { - nbSlots--; // leave at least 50px for the hidden windows dropdown button - spaceLeft += CHAT_WINDOW_WIDTH; - } - this.displayState.nbSlots = nbSlots; - this.displayState.spaceLeft = spaceLeft; - }, - /** - * @private - */ - _destroyNewChat: function () { - this.newChatSession.window.destroy(); - this.newChatSession = undefined; - }, - /** - * @private - * @param {Object} session - * @param {Widget} session.window - */ - _makeSessionVisible: function (session) { - utils.swap(this.chatSessions, session, this.chatSessions[this.displayState.nbSlots-1]); - this._repositionWindows(); - session.window.toggle_fold(false); - }, - /** - * @private - */ - _openChatWithoutSession: function () { - var self = this; - if (!this.newChatSession) { - this.newChatSession = { - id: '_open', - window: new ExtendedChatWindow(web_client, undefined, _t('New message'), false, 0, {thread_less: true}), - }; - this.newChatSession.window.on("close_chat_session", null, this._closeNewChat.bind(this)); - this.newChatSession.window.on('open_dm_session', null, function (partner_id) { - self.newChatSession.partner_id = partner_id; - var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id); - if (!dm) { - self.call('chat_manager', 'openAndDetachDm', partner_id); - } else { - var dmSession = _.findWhere(self.chatSessions, {id: dm.id}); - if (!dmSession) { - self.call('chat_manager', 'detachChannel', dm.id); - } else { - self._closeChat(dmSession); - dm.is_folded = false; - self.openChat(dm); - } - } - }); - this._addChatSession(this.newChatSession); - this.newChatSession.window.appendTo($('body')).then(this._repositionWindows.bind(this)); - } else { - if (this.newChatSession.window.is_hidden) { - this._makeSessionVisible(this.newChatSession); - } else if (this.newChatSession.window.folded) { - this.newChatSession.window.toggle_fold(false); - } - } - }, - /** - * @private - * @param {Object} message - * @param {integer[]} message.channel_ids - * @param {boolean} scrollBottom - */ - _updateSessions: function (message, scrollBottom) { - var self = this; - _.each(this.chatSessions, function (session) { - if (_.contains(message.channel_ids, session.id)) { - var messageVisible = !self.displayState.chatWindowsHidden && !session.window.folded && - !session.window.is_hidden && session.window.thread.is_at_bottom(); - if (messageVisible && !session.keep_unread) { - var channel = self.call('chat_manager', 'getChannel', session.id); - self.call('chat_manager', 'markChannelAsSeen', channel); - } - self.call('chat_manager', 'getMessages', {channelID: session.id}) - .then(function (messages) { - session.window.render(messages); - if (scrollBottom && messageVisible) { - session.window.thread.scroll_to(); - } - }); - } - }); - }, - /** - * @private - */ - _renderHiddenSessionsDropdown: function () { - var $dropdown = $(QWeb.render("mail.ChatWindowsDropdown", { - sessions: this.displayState.hiddenSessions, - open: this.displayState.windowsDropdownIsOpen, - unread_counter: this.displayState.hiddenUnreadCounter, - widget: {isMobile: config.device.isMobile}, - })); - return $dropdown; - }, - /** - * @private - */ - _repositionHiddenSessionsDropdown: function () { - // Unfold dropdown to the left if there is enough place - var $dropdownUL = this.displayState.$hiddenWindowsDropdown.children('ul'); - if (this.displayState.spaceLeft > $dropdownUL.width() + 10) { - $dropdownUL.addClass('dropdown-menu-right'); - } - }, - /** - * @private - * @param {Object} [options] - * @param {boolean} [options.remove_new_chat] - */ - _repositionWindows: function (options) { - var self = this; - if (options && options.remove_new_chat) { - this._destroyNewChat(); - } - if (this.displayState.chatWindowsHidden) { - return; - } - this._computeAvailableSlots(this.chatSessions.length); - var hiddenSessions = []; - var hiddenUnreadCounter = 0; - var nbSlots = this.displayState.nbSlots; - _.each(this.chatSessions, function (session, index) { - if (index < nbSlots) { - session.window.$el.css({right: CHAT_WINDOW_WIDTH*index, bottom: 0}); - session.window.do_show(); - } else { - hiddenSessions.push(session); - hiddenUnreadCounter += session.window.unread_msgs; - session.window.do_hide(); - } - }); - this.displayState.hiddenSessions = hiddenSessions; - this.displayState.hiddenUnreadCounter = hiddenUnreadCounter; - - if (this.displayState.$hiddenWindowsDropdown) { - this.displayState.$hiddenWindowsDropdown.remove(); - } - if (hiddenSessions.length) { - this.displayState.$hiddenWindowsDropdown = this._renderHiddenSessionsDropdown(); - var $hiddenWindowsDropdown = this.displayState.$hiddenWindowsDropdown; - $hiddenWindowsDropdown.css({right: CHAT_WINDOW_WIDTH * nbSlots, bottom: 0}) - .appendTo($('body')); - this._repositionHiddenSessionsDropdown(); - this.displayState.windowsDropdownIsOpen = false; - - $hiddenWindowsDropdown.on('click', '.o_chat_header', function (event) { - var sessionID = $(event.currentTarget).data('session-id'); - var session = _.findWhere(hiddenSessions, {id: sessionID}); - if (session) { - self._makeSessionVisible(session); - } - }); - $hiddenWindowsDropdown.on('click', '.o_chat_window_close', function (event) { - var sessionID = $(event.currentTarget).closest('.o_chat_header').data('session-id'); - var session = _.findWhere(hiddenSessions, {id: sessionID}); - if (session) { - session.window.on_click_close(event); - self.displayState.windowsDropdownIsOpen = true; // keep the dropdown open - } - }); - } - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - * @param {Object} channel - * @param {integer|string} channel.id - * @param {Object} query - */ - _onAnyoneListening: function (channel, query) { - _.each(this.chatSessions, function (session) { - if (channel.id === session.id && session.window.thread.is_at_bottom() && !session.window.is_hidden) { - query.is_displayed = true; - } - }); - }, - /** - * @private - * @param {Object} channel - * @param {integer} channel.id - */ - _onDetachChannel: function (channel) { - var chatSession = _.findWhere(this.chatSessions, {id: channel.id}); - if (!chatSession || chatSession.window.folded) { - this.call('chat_manager', 'detachChannel', channel.id); - } else if (chatSession.window.is_hidden) { - this._makeSessionVisible(chatSession); - } else { - chatSession.window.focus_input(); - } - }, - /** - * @private - * @param {boolean} open - */ - _onDiscussOpen: function (open) { - this.displayState.chatWindowsHidden = open; - if (open) { - $('body').addClass('o_no_chat_window'); - } else { - $('body').removeClass('o_no_chat_window'); - this._repositionWindows(); - } - }, - /** - * @private - * @param {Object} message - */ - _onNewMessage: function (message) { - this._updateSessions(message, true); - }, - /** - * @private - * @param {Object} channel - * @param {integer} channel.unread_counter - */ - _onUpdateChannelUnreadCounter: function (channel) { - var self = this; - this.displayState.hiddenUnreadCounter = 0; - _.each(self.chatSessions, function (session) { - if (channel.id === session.id) { - session.window.update_unread(channel.unread_counter); - if (channel.unread_counter === 0) { - session.keep_unread = false; - } - } - if (session.window.is_hidden) { - self.displayState.hiddenUnreadCounter += session.window.unread_msgs; - } - }); - if (self.displayState.$hiddenWindowsDropdown) { - self.displayState.$hiddenWindowsDropdown.html(self._renderHiddenSessionsDropdown().html()); - self._repositionHiddenSessionsDropdown(); - } - }, - /** - * @private - * @param {Object} channel - * @param {string|integer} channel.id - * @param {string} channel.status e.g. 'offline' - */ - _onUpdateDmPresence: function (channel) { - _.each(this.chatSessions, function (session) { - if (channel.id === session.id) { - session.window.update_status(channel.status); - } - }); - }, - /** - * @private - * @param {Object} message - */ - _onUpdateMessage: function (message) { - this._updateSessions(message, false); - }, - /** - * @private - * @param {string|integer} channelID - */ - _onUnsubscribeFromChannel: function (channelID) { - var self = this; - _.each(self.chatSessions, function (session) { - if (channelID === session.id) { - self._closeChat(session); - } - }); - }, - -}); - -core.serviceRegistry.add('chat_window_manager', ChatWindowManager); - -return ChatWindowManager; - -}); diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js new file mode 100644 index 00000000000..841c2c5cbfd --- /dev/null +++ b/addons/mail/static/src/js/services/mail_manager.js @@ -0,0 +1,1246 @@ +odoo.define('mail.Manager', function (require) { +"use strict"; + +/** + * This service is responsible for anything related to mail in JS. + * In particular, it stores all threads and messages that have been fetched + * from the server. + * + * As this service does quite a lot of stuffs, it is split into many different + * files. For instance, the handling of notification in the longpoll is in + * mail_notification_manager.js. This file contains the core functionnality + * of the mail service. + * + * The main point of entry of this service is to call public methods by mean + * of a service call. + * + * @example + * + * To get all threads that have been fetched from the server: + * + * this.call('mail_service', 'getThreads'); + * + * To get a particular thread with server ID 1: + * + * this.call('mail_service', 'getThread', 1); + */ +var AbstractService = require('web.AbstractService'); + +var Channel = require('mail.model.Channel'); +var DM = require('mail.model.DM'); +var Mailbox = require('mail.model.Mailbox'); +var MailFailure = require('mail.model.MailFailure'); +var Message = require('mail.model.Message'); +var mailUtils = require('mail.utils'); + +var Bus = require('web.Bus'); +var config = require('web.config'); +var core = require('web.core'); +var session = require('web.session'); + +var _t = core._t; +var _lt = core._lt; + +var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers + +var MailManager = AbstractService.extend({ + name: 'mail_service', + dependencies: ['ajax', 'bus_service', 'local_storage'], + _ODOOBOT_ID: "ODOOBOT", // default authorID for transient messages + + /** + * @override + */ + start: function () { + this._busBus = this.call('bus_service', 'getBus'); + this._cannedResponses = []; + this._mailBus = new Bus(this); + this._commands = []; + this._discussMenuID = undefined; + this._discussOpen = false; + this._isModerator = false; + this._mailFailures = []; + // list of employees for chatter mentions + this._mentionPartnerSuggestions = []; + this._messages = []; + this._moderatedChannelIDs = []; + // # of message received when odoo is out of focus + this._outOfFocusUnreadMessageCounter = 0; + // partner_ids we have a pinned DM with + this._pinnedDmPartners = []; + // all threads, including channels, DM, mailboxes, document threads, ... + this._threads = []; + + // listen on buses + this._mailBus + .on('discuss_open', this, this._onDiscussOpen) + .on('window_focus', this, this._onWindowFocus); + + this._initializeFromServer(); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Add a message + * + * @param {Object} data message data + * @param {integer} data.id + * @param {string} [data.moderation_status] + * @param {Object} [options] + * @param {Array} [options.domain] + * @param {boolean} [options.incrementUnread] whether we should increment + * the unread_counter of channel. + * @param {boolean} [options.silent] whether it should inform in the mailBus + * of the newly created message. + * @returns {mail.model.Message} message object + */ + addMessage: function (data, options) { + options = options || {}; + var message = this.getMessage(data.id); + if (!message) { + message = this._makeMessage(data); + // Keep the array ordered by ID when inserting the new message + var index = _.sortedIndex(this._messages, message, function (msg) { + return msg.getID(); + }); + this._messages.splice(index, 0, message); + this._addNewMessagePostprocessThread(message, options); + this._addMessageToThreads(message, []); + if (!options.silent) { + this._mailBus.trigger('new_message', message); + } + } + if (data.moderation_status === 'accepted') { + message.setModerationStatus('accepted', { + additionalThreadIDs: data.channel_ids + }); + } + if (options.domain && options.domain !== []) { + this._addMessageToThreads(message, options.domain); + } + return message; + }, + /** + * Creates a channel, can be either a true channel or a DM based on `type` + * + * @param {integer|string} name id of partner (in case of dm) or name + * @param {string} type ['dm', 'public', 'private'] + * @returns {$.Promise} resolved with ID of the newly created + * channel + */ + createChannel: function (name, type) { + if (type === 'dm') { + return this._createDM(name); + } else { + return this._createChannel(name, type); + } + }, + /** + * Returns the list of canned responses + * A canned response is a pre-formatted text that is triggered with + * some keystrokes, such as with ':'. + * + * @returns {Array} array of Objects (mail.shortcode) + */ + getCannedResponses: function () { + return this._cannedResponses; + }, + /** + * Returns a channel corresponding to the given id. + * + * @param {integer} threadID + * @returns {mail.model.Channel|undefined} the channel, if it exists + */ + getChannel: function (threadID) { + return _.find(this._threads, function (thread) { + return thread.isChannel() && (thread.getID() === threadID); + }); + }, + /** + * Returns a list of channels + * + * @returns {mail.model.Channel[]} list of channels + */ + getChannels: function () { + return _.filter(this._threads, function (thread) { + return thread.isChannel(); + }); + }, + /** + * Returns the content that will be shown in the mail navbar dropdown + * + * @param {mail.model.Channel[]} channels + * @returns {$.Promise} resolved with list of channel previews + */ + getChannelPreviews: function (channels) { + var self = this; + return this._getChannelPreviews(channels).then(function (previews) { + var sortedPreviews = self._sortPreviews(previews); + return sortedPreviews; + }); + }, + /** + * @returns {web.Bus} the mail bus + */ + getMailBus: function () { + return this._mailBus; + }, + /** + * Returns the record id of ir.ui.menu for Discuss + * + * @returns {integer} record id + */ + getDiscussMenuID: function () { + return this._discussMenuID; + }, + /** + * Gets direct message channel + * + * @param {integer} partnerID + * @returns {Object|undefined} channel + */ + getDmFromPartnerID: function (partnerID) { + return _.find(this._threads, function (thread) { + return thread.getType() === 'dm' && + thread.getDirectPartnerID() === partnerID; + }); + }, + /** + * @param {string} mailboxID + * @returns {mail.model.Mailbox} the mailbox, if any + */ + getMailbox: function (mailboxID) { + return _.find(this._threads, function (thread) { + return thread.getID() === 'mailbox_' + mailboxID; + }); + }, + /** + * Returns a list of mail failures + * + * @returns {mail.model.MailFailure[]} list of mail failures + */ + getMailFailures: function () { + return this._mailFailures; + }, + /** + * Get partners as mentions from a chatter + * Typically all employees as partner suggestions. + * + * @returns {Array} + */ + getMentionPartnerSuggestions: function () { + return this._mentionPartnerSuggestions; + }, + /** + * Gets message from its ID + * + * @param {integer} messageID + * @returns {mail.model.Message|undefined} the matched message (if any) + */ + getMessage: function (messageID) { + return _.find(this._messages, function (message) { + return message.getID() === messageID; + }); + }, + /** + * Returns the previews to display in the systray's messaging menu. + * + * @param {string} [filter=undefined] specify 'chat' or 'channels' to only + * get previews for that type of threads + * @returns {$.Promise} resolved with list of objects that have a + * valid format for rendering a messaging menu preview items. + */ + getSystrayPreviews: function (filter) { + var self = this; + var channelDef = this._getSystrayChannelPreviews(filter); + var inboxDef = this._getSystrayInboxPreviews(filter); + var failureDef = this._getSystrayMailFailurePreviews(filter); + + return $.when(channelDef, inboxDef, failureDef) + .then(function (previewsChannel, previewsInbox, previewsFailure) { + // order: failures > inbox > channel, each group must be sorted + previewsChannel = self._sortPreviews(previewsChannel); + previewsInbox = self._sortPreviews(previewsInbox); + previewsFailure = self._sortPreviews(previewsFailure); + return _.union(previewsFailure, previewsInbox, previewsChannel); + }); + }, + /** + * Get the list of channel IDs where the current user is a moderator + * + * @returns {integer[]} + */ + getModeratedChannelIDs: function () { + return this._moderatedChannelIDs || []; + }, + /** + * Get the Odoobot ID, which is the default authorID for transient messages + * + * @returns {string} + */ + getOdoobotID: function () { + return this._ODOOBOT_ID; + }, + /** + * Returns thread matching provided ID, if any + * + * @param {string|integer} threadID + * @returns {mail.model.Thread|undefined} the thread, if it exists + */ + getThread: function (threadID) { + return _.find(this._threads, function (thread) { + return thread.getID() === threadID; + }); + }, + /** + * Returns a list of threads + * + * @returns {mail.model.Thread[]} list of threads + */ + getThreads: function () { + return this._threads; + }, + /** + * States whether the current user is a moderator or not + * + * @returns {boolean} + */ + isModerator: function () { + return this._isModerator; + }, + /** + * States whether the mail manager is ready or not + * This is the case when it has fetched the initial state from the server, + * by means of the route 'mail/init_messaging' + * + * @returns {$.Promise} + */ + isReady: function () { + return this._isReady; + }, + /** + * Join an existing channel + * See @createChannel to join a new channel + * + * @param {integer} channelID + * @param {Object} [options] options to be passed on channel add + * @returns {$.Promise} resolved with channelID joined + */ + joinChannel: function (channelID, options) { + var def; + var channel = this.getChannel(channelID); + if (channel) { + // channel already joined + def = $.when(channelID); + } else { + def = this._joinAndAddChannel(channelID, options); + } + return def; + }, + /** + * Mark messages as read + * + * @param {Array} messageIDs list of messages IDs + * @returns {$.Promise} resolved when messages have been marked as read on + * the server. + */ + markMessagesAsRead: function (messageIDs) { + var self = this; + var ids = _.filter(messageIDs, function (id) { + var message = self.getMessage(id); + // If too many messages, not all are fetched, + // and some might not be found + return !message || message.isNeedaction(); + }); + if (ids.length) { + return this._rpc({ + model: 'mail.message', + method: 'set_message_done', + args: [ids], + }); + } else { + return $.when(); + } + }, + /** + * @param {integer|string} threadID + */ + openThread: function (threadID) { + this._openThreadInDiscuss(threadID); + }, + /** + * Special redirection handling for given model and id + * + * If the model is res.partner, and there is a user associated with this + * partner which isn't the current user, open the DM with this user. + * Otherwhise, open the record's form view (if not current user's). + * + * @param {string} resModel model to open + * @param {integer} resID record to open + * @param {function} [dmRedirectionCallback] only used if 'res.partner', + * a function that has a threadID as input + */ + redirect: function (resModel, resID, dmRedirectionCallback) { + if (resModel === 'res.partner') { + this._redirectPartner(resModel, resID, dmRedirectionCallback); + } else { + this._redirectDefault(resModel, resID); + } + }, + /** + * Remove the message from all of its threads + * + * @param {mail.model.Message} message + */ + removeMessageFromThreads: function (message) { + var self = this; + _.each(message.getThreadIDs(), function (threadID) { + var thread = self.getThread(threadID); + if (thread) { + thread.removeMessage(message.getID()); + } + }); + }, + /** + * Search among prefetched partners, using the string 'searchVal' + * + * @param {string} searchVal + * @param {integer} limit max number of found partners in the response + * @returns {$.Promise} list of found partners (matching + * 'searchVal') + */ + searchPartner: function (searchVal, limit) { + var def = $.Deferred(); + var partners = this._searchPartnerPrefetch(searchVal, limit); + + if (!partners.length) { + def = this._searchPartnerFetch(searchVal, limit); + } else { + def = $.when(partners); + } + return def.then(function (partners) { + var suggestions = _.map(partners, function (partner) { + return { + id: partner.id, + value: partner.name, + label: partner.name + }; + }); + return _.sortBy(suggestions, 'label'); + }); + }, + /** + * Unstars all messages from all channels + * + * @returns {$.Promise} + */ + unstarAll: function () { + return this._rpc({ + model: 'mail.message', + method: 'unstar_all', + args: [[]] + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a channel and returns it. + * Simply returns the channel if already exists. + * + * @private + * @param {Object} data + * @param {integer} data.id id of channel + * @param {string|Object} data.name name of channel, e.g. 'general' + * @param {string} data.type type of channel, e.g. 'public' + * @param {string} [data.state] e.g. 'open', 'folded' + * @param {Object|integer} [options=undefined] + * @param {boolean} [options.silent=false] + * @returns {integer} the ID of the newly or already existing channel + */ + _addChannel: function (data, options) { + options = typeof options === 'object' ? options : {}; + var channel = this.getChannel(data.id); + if (!channel) { + channel = this._makeChannel(data, options); + if (channel.getType() === 'dm') { + this._pinnedDmPartners.push(channel.getDirectPartnerID()); + this._busBus.update_option( + 'bus_presence_partner_ids', + this._pinnedDmPartners + ); + } + this._threads.push(channel); + if (data.last_message) { + // channel_info in mobile, necessary for showing channel + // preview in mobile + this.addMessage(data.last_message); + } + this._sortThreads(); + if (!options.silent) { + this._mailBus.trigger('new_channel', channel); + } + } + return channel.getID(); + }, + /** + * Add a new mailbox + * + * @private + * @param {Object} data + * @param {Object} options + * @returns {mail.model.Mailbox} + */ + _addMailbox: function (data, options) { + options = typeof options === 'object' ? options : {}; + var mailbox = new Mailbox(this, data, options, this._commands); + this._threads.push(mailbox); + this._sortThreads(); + return mailbox; + }, + /** + * Stores a message to the cache `domain` of all of its threads + * + * @private + * @param {mail.model.Message} message + * @param {Array} domain + */ + _addMessageToThreads: function (message, domain) { + var self = this; + _.each(message.getThreadIDs(), function (threadID) { + var thread = self.getThread(threadID); + if (thread) { + thread.addMessage(message, domain); + } + }); + }, + /** + * For newly added message, postprocess threads linked to this message + * + * @private + * @param {mail.model.Message} message + * @param {Object} options + * @param {Array} [options.domain] + * @param {boolean} [options.incrementUnread] + * @param {boolean} [options.showNotification] + */ + _addNewMessagePostprocessThread: function (message, options) { + var self = this; + _.each(message.getThreadIDs(), function (threadID) { + var thread = self.getThread(threadID); + if (thread) { + if ( + thread.getType() !== 'mailbox' && + !message.isAuthor() && + !message.isSystemNotification() + ) { + if (options.incrementUnread) { + thread.incrementUnreadCounter(); + } + if (thread.isChat() && options.showNotification) { + if (!self._isDiscussOpen() && !config.device.isMobile) { + // automatically open thread window + // while keeping it unread + thread.detach({ passively: true }); + } + var query = { isBottomVisible: false }; + self._mailBus.trigger('is_thread_bottom_visible', thread, query); + if (!query.isBottomVisible) { + self._notifyIncomingMessage(message, query); + } + } + } + } + }); + }, + /** + * Create a Channel (other than a DM) + * + * @private + * @param {string} name + * @param {string} type + * @returns {$.Promise} ID of the created channel + */ + _createChannel: function (name, type) { + var context = _.extend({ isMobile: config.device.isMobile }, session.user_context); + return this._rpc({ + model: 'mail.channel', + method: 'channel_create', + args: [name, type], + kwargs: { context: context }, + }) + .then(this._addChannel.bind(this)); + }, + /** + * Create a Direct Messages Chat + * + * @private + * @param {string} name + * @returns {$.Promise} ID of the created channel + */ + _createDM: function (name) { + var context = _.extend({ isMobile: config.device.isMobile }, session.user_context); + return this._rpc({ + model: 'mail.channel', + method: 'channel_get', + args: [[name]], + kwargs: { context: context }, + }) + .then(this._addChannel.bind(this)); + }, + /** + * @private + * @param {integer[]} channelIDs + * @returns {$.Promise} resolved with list of channel preview + */ + _fetchChannelPreviews: function (channelIDs) { + return this._rpc({ + model: 'mail.channel', + method: 'channel_fetch_preview', + args: [channelIDs], + }, { shadow: true }); + }, + /** + * Get previews of the channels + * + * The preview of a channel is built from the channel information and its + * lastest message. A channel without a last message is incomplete, so it + * must fetch this message. + * + * Note that a channel could have no message at all, so it fetches for the + * last message only once per channel. This is correct to not fetch more + * than once in this case, because any later received message updates + * automatically the last message of a channel. + * + * @private + * @param {mail.model.Channel[]} channels + * @returns {$.Deferred} resolved with list of channel previews + */ + _getChannelPreviews: function (channels) { + var self = this; + + var unpreviewedChannels = _.reject(channels, function (channel) { + return channel.hasBeenPreviewed(); + }); + var fetchDef; + if (unpreviewedChannels.length) { + var ids = _.map(unpreviewedChannels, function (channel) { + return channel.getID(); + }); + fetchDef = this._fetchChannelPreviews(ids); + } else { + fetchDef = $.when(); + } + return fetchDef.then(function (fetchedPreviews) { + // add fetched messages + _.each(fetchedPreviews, function (fetchedPreview) { + if (fetchedPreview.last_message) { + self.addMessage(fetchedPreview.last_message); + } + // mark the channel as previewed, so that we do not need to + // fetch preview again + var channel = self.getChannel(fetchedPreview.id); + channel.markAsPreviewed(); + }); + return _.map(channels, function (channel) { + return channel.getPreview(); + }); + }); + }, + /** + * Get the previews of the mail failures + * Mail failures of a same model are grouped together, so that there are few + * preview items on the messaging menu in the systray. + * + * To determine whether this is a model preview or a document preview review + * the documentID is omitted for a model preview, whereas it is set for a + * preview of a failure related to a document. + * + * @returns {$.Promise} resolved with previews of mail failures + */ + _getMailFailurePreviews: function () { + // items = list of objects: + // item = { + // unreadCounter: {integer}, + // failure: {mail.model.MailFailure}, + // isSameDocument: {boolean} + // } + var items = []; + _.each(this._mailFailures, function (failure) { + var unreadCounter = 1; + var isSameDocument = true; + var sameModelItem = _.find(items, function (item) { + if ( + item.failure.isLinkedToDocumentThread() && + (item.failure.getDocumentModel() === failure.getDocumentModel()) + ) { + isSameDocument = item.failure.getDocumentID() === failure.getDocumentID(); + return true; + } + return false; + }); + + if (failure.isLinkedToDocumentThread() && sameModelItem) { + unreadCounter = sameModelItem.unreadCounter + 1; + isSameDocument = sameModelItem.isSameDocument && isSameDocument; + var index = _.findIndex(items, sameModelItem); + items[index] = { + unreadCounter: unreadCounter, + failure: failure, + isSameDocument: isSameDocument, + }; + } else { + items.push({ + unreadCounter: unreadCounter, + failure: failure, + isSameDocument: true, + }); + } + }); + return _.map(items, function (item) { + // return preview with correct unread counter + // also unset documentID if the grouped mail failures are from + // same model but different document + var preview = {}; + _.extend(preview, item.failure.getPreview(), { + unreadCounter: item.unreadCounter, + }); + if (!item.failure.isSameDocument) { + preview.documentID = undefined; + } + return preview; + }); + }, + /** + * @private + * @param {string|undefined} [filter] the filter to apply on channel + * previews in systray messaging menu + * @returns {$.Promise} resolved with object valid for template + * mail.Preview on channel previews, based on selected filter + */ + _getSystrayChannelPreviews: function (filter) { + var filteredThreads = _.filter(this._threads, function (thread) { + if (!thread.isChannel()) { + return false; + } + if (filter === 'chat') { + return thread.isChat(); + } else if (filter === 'channels') { + return !thread.isChat(); + } + return true; + }); + return this.getChannelPreviews(filteredThreads); + }, + /** + * Get the previews of needaction messages, i.e. messages in Inbox mailbox, + * based on selected filter. + * + * FIXME: it will use the current fetched messages in inbox + perform + * another message fetch on inbox. In other words, it means that some thread + * previews may be missing in the systray messaging menu. For example: + * + * - Each message fetch on Inbox fetches at most 30 messages. + * - There are 31 messages on Inbox. + * - No message on Inbox has been fetched at the moment + * - the 1st message on Inbox is linked to a document, and no other + * message in Inbox is linked to this document + * + * When clicking on the messaging menu, it will fetch all messages of Inbox, + * except the 1st message, in order to show the previews. The preview of + * the document thread of the 1st message will not be shown in the message + * menu + * + * This is a limitation, which should be solved by making changes on the + * server logic (e.g. provide list of all document threads in inbox on + * the rpc mail/init_messaging). + * + * @private + * @param {string|undefined} [filter] + * @returns {$.Deferred} resolved with valid objects for template + * mail.Preview on inbox message previews + */ + _getSystrayInboxPreviews: function (filter) { + // 'All' filter, show messages preview from inbox and mail failures + // inbox previews + if (filter === 'mailbox_inbox' || !filter) { + var inbox = this.getMailbox('inbox'); + return inbox.getMessagePreviews(); + } else { + return $.when([]); + } + }, + /** + * Get the previews of mail failure, based on selected filter. + * + * @private + * @param {string|undefined} [filter] + */ + _getSystrayMailFailurePreviews: function (filter) { + // mail failure previews + if (filter === 'mailbox_inbox' || !filter) { + return this._getMailFailurePreviews(); + } else { + return $.when([]); + } + }, + /** + * Initialize the canned responses from the server data + * + * @private + * @param {Object} data + * @param {Object[]} [data.shortcodes] + * @param {integer} data.shortcodes[i].id + * @param {string} data.shortcodes[i].source + * @param {string} data.shortcodes[i].substitution + */ + _initializeCannedResponses: function (data) { + var self = this; + _.each(data.shortcodes, function (s) { + var cannedResponse = _.pick(s, ['id', 'source', 'substitution']); + self._cannedResponses.push(cannedResponse); + }); + }, + /** + * Initialize the channels from the server, including public, private, DM, + * livechat, etc. + * + * @private + * @param {Object} data + * @param {Object} [data.channel_slots] contains the data of channels to + * initialize, which are grouped by channel type by key of the object + * (e.g. list of public channel data are stored in 'channel_channel') + * @param {Object[]} [data.channel_slots[i] list of data of channel of type + * `i` + */ + _initializeChannels: function (data) { + var self = this; + _.each(data.channel_slots, function (channels) { + _.each(channels, self._addChannel.bind(self)); + }); + }, + /** + * Initialize commands from the server + * + * @private + * @param {Object} data + * @param {Object[]} data.commands list of command data from the server + */ + _initializeCommands: function (data) { + this._commands = _.map(data.commands, function (command) { + return _.extend({ id: command.name }, command); + }); + }, + /** + * @private + * @returns {$.Promise} + */ + _initializeFromServer: function () { + var self = this; + this._isReady = session.is_bound.then(function () { + var context = _.extend( + { isMobile: config.device.isMobile }, + session.user_context + ); + return self._rpc({ + route: '/mail/init_messaging', + params: { context: context }, + }); + }).then(function (result) { + self._updateFromServer(result); + self._busBus.start_polling(); + }); + }, + /** + * Initialize the mailboxes, namely 'Inbox', 'Starred', + * and 'Moderation Queue' if the user is a moderator of a channel + * + * @private + * @param {Object} data + * @param {boolean} [data.is_moderator=false] states whether the user is + * moderator of a channel + * @param {integer} [data.moderation_counter=0] states the mailbox counter + * to set to 'Moderation Queue' + * @param {integer} [data.needaction_inbox_counter=0] states the mailbox + * counter to set to 'Inbox' + * @param {integer} [data.starred_counter=0] states the mailbox counter to + * set to 'Starred' + */ + _initializeMailboxes: function (data) { + this._addMailbox({ + id: 'inbox', + name: _lt("Inbox"), + mailboxCounter: data.needaction_inbox_counter || 0, + }); + this._addMailbox({ + id: 'starred', + name: _lt("Starred"), + mailboxCounter: data.starred_counter || 0, + }); + + if (data.is_moderator) { + this._addMailbox({ + id: 'moderation', + name: _lt("Moderate Messages"), + mailboxCounter: data.moderation_counter || 0, + }); + } + }, + /** + * Initialize mail failures from the server data + * + * @private + * @param {Object} data + * @param {Object[]} data.mail_failures data to initialize mail failures + * locally + */ + _initializeMailFailures: function (data) { + var self = this; + this._mailFailures = _.map(data.mail_failures, function (mailFailureData) { + return new MailFailure(self, mailFailureData); + }); + }, + /** + * Initialize moderation settings from the server data + * + * @private + * @param {Object} data + * @param {boolean} [data.is_moderator=false] + * @param {integer[]} [data.moderation_channel_ids] + */ + _initializeModerationSettings: function (data) { + this._moderatedChannelIDs = data.moderation_channel_ids; + this._isModerator = data.is_moderator; + }, + /** + * State whether discuss app is open or not + * + * @private + * @returns {boolean} + */ + _isDiscussOpen: function () { + return this._discussOpen; + }, + /** + * Join the channel, and add it locally afterwards + * + * @private + * @param {integer|string} channelID + * @param {Object} options options passed on channel add + * @returns {$.Promise} channelID of joined and added channel + */ + _joinAndAddChannel: function (channelID, options) { + var self = this; + return this._rpc({ + model: 'mail.channel', + method: 'channel_join_and_get_info', + args: [[channelID]], + }) + .then(function (result) { + return self._addChannel(result, options); + }); + }, + /** + * Creates a new instance of Channel with the given data and options. + * + * @private + * @param {Object} data + * @param {Array} [data.direct_partner] if set and is an non-empty array, + * the channel is a DM + * @param {Object} [options] + * @returns {mail.model.Channel} + */ + _makeChannel: function (data, options) { + if (_.size(data.direct_partner) > 0) { + return new DM(this, data, options, this._commands); + } + return new Channel(this, data, options, this._commands); + }, + /** + * Creates a new instance of Message with the given data. + * + * @private + * @param {Object} data + * @returns {mail.model.Message} + */ + _makeMessage: function (data) { + return new Message(this, data); + }, + /** + * shows a popup to notify a new received message. + * This will also rename the odoo tab browser if + * the user has no focus on it. + * + * @private + * @param {mail.model.Message} message message received + * @param {Object} options + * @param {boolean} options.isDisplayed + */ + _notifyIncomingMessage: function (message, options) { + if (this._busBus.is_odoo_focused() && options.isDisplayed) { + // no need to notify + return; + } + var title = _t("New message"); + if (message.hasAuthor()) { + title = _.escape(message.getAuthorName()); + } + var content = mailUtils.parseAndTransform(message.getBody(), mailUtils.stripHTML) + .substr(0, PREVIEW_MSG_MAX_SIZE); + + if (!this._busBus.is_odoo_focused()) { + this._outOfFocusUnreadMessageCounter++; + var tabTitle = _.str.sprintf( + _t("%d Messages"), + this._outOfFocusUnreadMessageCounter + ); + this.trigger_up('set_title_part', { + part: '_chat', + title: tabTitle + }); + } + + this.call('bus_service', 'sendNotification', title, content); + }, + /** + * Open the thread in the Discuss app + * + * @private + * @param {integer|string} threadID a valid threadID + */ + _openThreadInDiscuss: function (threadID) { + var thread = this.getThread(threadID); + this._mailBus.trigger('open_thread_in_discuss', thread); + }, + /** + * @private + * @param {string} resModel + * @param {integer} resID + */ + _redirectDefault: function (resModel, resID) { + var self = this; + this._rpc({ + model: resModel, + method: 'get_formview_id', + args: [[resID], session.user_context], + }) + .then(function (viewID) { + self._redirectToDocument(resModel, resID, viewID); + }); + }, + /** + * @private + * @param {Object} message + * @param {Array} message.channel_ids list of integers and strings, + * where strings for static channels, e.g. 'mailbox_inbox'. + * @param {string} resModel + * @param {integer} resID + * @param {string} viewID + */ + _redirectToDocument: function (resModel, resID, viewID) { + this.do_action({ + type: 'ir.actions.act_window', + view_type: 'form', + view_mode: 'form', + res_model: resModel, + views: [[viewID || false, 'form']], + res_id: resID, + }); + }, + /** + * @private + * @param {string} resModel 'res.partner' + * @param {integer} resID + * @param {function} dmRedirection a function callback that has + * threadID as input + */ + _redirectPartner: function (resModel, resID, dmRedirection) { + var self = this; + var domain = [['partner_id', '=', resID]]; + this._rpc({ + model: 'res.users', + method: 'search', + args: [domain], + }) + .then(function (userIDs) { + if ( + userIDs.length && + userIDs[0] !== session.uid && + dmRedirection + ) { + self.createChannel(resID, 'dm').then(dmRedirection); + } else { + self._redirectToDocument(resModel, resID); + } + }); + }, + /** + * Remove channel + * + * This is only called by the mail_notification_manager + * + * @private + * @param {mail.model.Channel} [channel] + */ + _removeChannel: function (channel) { + if (!channel) { return; } + if (channel.getType() === 'dm') { + var index = this._pinnedDmPartners.indexOf(channel.getDirectPartnerID()); + if (index > -1) { + this._pinnedDmPartners.splice(index, 1); + this._busBus.update_option( + 'bus_presence_partner_ids', + this._pinnedDmPartners + ); + } + } + this._threads = _.without(this._threads, channel); + }, + /** + * Removes a message from a thread. + * + * @private + * @param {string} threadID + * @param {mail.model.Message} message + */ + _removeMessageFromThread: function (threadID, message) { + message.removeThread(threadID); + var thread = _.find(this._threads, function (thread) { + return thread.getID() === threadID; + }); + thread.removeMessage(message.getID()); + }, + /** + * @private + */ + _resetOutOfFocusUnreadMessageCounter: function () { + this._outOfFocusUnreadMessageCounter = 0; + }, + /** + * Extend the research to all users + * + * @private + * @param {string} searchVal + * @param {integer} limit + * @returns {$.Promise} fetched partners matching 'searchVal' + */ + _searchPartnerFetch: function (searchVal, limit) { + return this._rpc({ + model: 'res.partner', + method: 'im_search', + args: [searchVal, limit || 20], + }, { shadow: true }); + }, + /** + * Search among prefetched partners + * + * @private + * @param {string} searchVal + * @param {integer} limit + * @returns {string[]} partner suggestions that match searchVal + * (max limit, exclude session partner) + */ + _searchPartnerPrefetch: function (searchVal, limit) { + var values = []; + var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(searchVal)), 'i'); + _.each(this._mentionPartnerSuggestions, function (partners) { + if (values.length < limit) { + values = values.concat(_.filter(partners, function (partner) { + return (session.partner_id !== partner.id) && + searchRegexp.test(partner.name); + })).splice(0, limit); + } + }); + return values; + }, + /** + * Sort previews + * + * 1. unread, + * 2. chat, + * 3. date, + * + * @private + * @param {Object[]} previews + * @returns {Object[]} sorted list of previews + */ + _sortPreviews: function (previews) { + var res = previews.sort(function (p1, p2) { + var unreadDiff = Math.min(1, p2.unreadCounter) - Math.min(1, p1.unreadCounter); + var isChatDiff = p2.isChat - p1.isChat; + var dateDiff = (!!p2.date - !!p1.date) || + ( + p2.date && + p2.date.diff(p1.date) + ); + return unreadDiff || isChatDiff || dateDiff; + }); + return res; + }, + /** + * Sort threads + * + * In case of mailboxes (Inbox, Starred), the name is translated + * thanks to _lt (lazy translate). In this case, channel.getName() is an + * object, not a string. + * + * @private + */ + _sortThreads: function () { + this._threads = _.sortBy(this._threads, function (thread) { + var name = thread.getName(); + return _.isString(name) ? name.toLowerCase() : ''; + }); + }, + /** + * Update internal state from server data (mail/init_messaging rpc result) + * + * @private + * @param {Object} result data from server on mail/init_messaging rpc + * @param {Object[]} result.mention_partner_suggestions list of suggestions + * with all the employees + * @param {integer} result.menu_id the menu ID of discuss app + */ + _updateFromServer: function (result) { + // commands are needed for channel instantiation + this._initializeCommands(result); + this._initializeChannels(result); + this._initializeModerationSettings(result); + this._initializeMailboxes(result); + this._initializeMailFailures(result); + this._initializeCannedResponses(result); + + this._mentionPartnerSuggestions = result.mention_partner_suggestions; + this._discussMenuID = result.menu_id; + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {boolean} open + */ + _onDiscussOpen: function (open) { + this._discussOpen = open; + }, + /** + * Reset out of focus unread message counter + tab title + * + * @private + */ + _onWindowFocus: function () { + this._resetOutOfFocusUnreadMessageCounter(); + this.trigger_up('set_title_part', { part: '_chat' }); + }, +}); + +return MailManager; + +}); diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js new file mode 100644 index 00000000000..cd0ff9223a0 --- /dev/null +++ b/addons/mail/static/src/js/services/mail_notification_manager.js @@ -0,0 +1,484 @@ +odoo.define('mail.Manager.Notification', function (require) { +"use strict"; + +/** + * Mail Notification Manager + * + * This part of the mail manager is responsible for receiving notifications on + * the longpoll bus, which are data received from the server. + */ +var MailManager = require('mail.Manager'); +var MailFailure = require('mail.model.MailFailure'); + +var core = require('web.core'); + +var _t = core._t; + +MailManager.include({ + + start: function () { + this._super.apply(this, arguments); + this._busBus.on('notification', this, this._onNotification); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Remove channel notifications if there is an unsubscribe notification + * on this channel. + * + * @private + * @param {Object[]} notifications + * @returns {Object[]} notifications filtered of channel notifications + * matching unsubscribe notifs + */ + _filterNotificationsOnUnsubscribe: function (notifications) { + var unsubscribedNotif = _.find(notifications, function (notif) { + return notif[1].info === 'unsubscribe'; + }); + if (unsubscribedNotif) { + notifications = _.reject(notifications, function (notif) { + return notif[0][1] === 'mail.channel' && + notif[0][2] === unsubscribedNotif[1].id; + }); + } + return notifications; + }, + /** + * Called when a new or updated message is received on a channel + * + * @private + * @param {Object} messageData + * @param {Array} messageData.channel_ids list of integers and strings, + * where strings for static channels, e.g. 'mailbox_inbox'. + */ + _handleChannelNotification: function (messageData) { + var self = this; + var def; + var channelAlreadyInCache = true; + if (messageData.channel_ids.length === 1) { + channelAlreadyInCache = !!this.getChannel(messageData.channel_ids[0]); + def = this.joinChannel(messageData.channel_ids[0], { autoswitch: false }); + } else { + def = $.when(); + } + def.then(function () { + // don't increment unread if channel wasn't in cache yet as + // its unread counter has just been fetched + self.addMessage(messageData, { + showNotification: true, + incrementUnread: channelAlreadyInCache + }); + }); + }, + /** + * On message becoming a need action (pinned to inbox) + * + * @private + * @param {Object} messageData + * @param {integer[]} messageData.channel_ids + */ + _handleNeedactionNotification: function (messageData) { + var self = this; + var inbox = this.getMailbox('inbox'); + var message = this.addMessage(messageData, { + incrementUnread: true, + showNotification: true, + }); + inbox.incrementMailboxCounter(); + _.each(message.getThreadIDs(), function (threadID) { + var channel = self.getChannel(threadID); + if (channel) { + channel.incrementNeedactionCounter(); + } + }); + this._mailBus.trigger('update_needaction', inbox.getMailboxCounter()); + }, + /** + * Called when an activity record has been updated on the server + * + * @private + * @param {Object} data key, value to decide activity created or deleted + */ + _handlePartnerActivityUpdateNotification: function (data) { + this._mailBus.trigger('activity_updated', data); + }, + /** + * Called when a channel has been seen, and the server responses with the + * last message seen. Useful in order to track last message seen. + * + * @private + * @param {Object} data + * @param {integer} data.id ID of a channel + * @param {integer} [data.last_message_id] mandatory if 'id' refers to an + * existing channel. + */ + _handlePartnerChannelSeenNotification: function (data) { + var channel = this.getChannel(data.id); + if (channel) { + channel.setLastSeenMessageID(data.last_message_id); + if (channel.hasUnreadMessages()) { + channel.resetUnreadCounter(); + } + } + }, + /** + * Called when receiving a channel state as a partner notification: + * + * - if it is a new channel, it means we have been invited to this channel + * - if it is an existing channel, it means the window state of the channel + * may have changed (as this state is stored server-side) + * + * @private + * @param {Object} channelData + * @param {integer} channelData.id + * @param {boolean} channelData.is_minimized + * @param {string} channelData.state + */ + _handlePartnerChannelNotification: function (channelData) { + if ( + (channelData.channel_type === 'channel') && + (channelData.state === 'open') + ) { + // invited to a new channel + this._addChannel(channelData, { autoswitch: false }); + if ( + !channelData.is_minimized && + channelData.info !== 'creation' + ) { + this.do_notify( + _t("Invitation"), + _t("You have been invited to: ") + channelData.name); + } + } + var channel = this.getChannel(channelData.id); + if (channel) { + channel.updateWindowState({ + folded: channelData.state === 'folded' ? true : false, + detached: channelData.is_minimized, + }); + } + }, + /** + * Add or remove failure when receiving a failure update message + * + * @private + * @param {Object} data + * @param {Object[]} data.elements list of mail failure data + * @param {string} data.elements[].message_id ID of related message that + * has a mail failure. + * @param {Array} data.elements[].notifications list of notifications + * that is related to a mail failure. + * @param {string} data.elements[].notifications[0] sending state of a mail + * failure (e.g. 'exception'). + */ + _handlePartnerMailFailureNotification: function (datas) { + var self = this; + _.each(datas.elements, function (data) { + var isNewFailure = _.some(data.notifications, function (notif) { + return notif[0] === 'exception' || notif[0] === 'bounce'; + }); + var matchedFailure = _.find(self._mailFailures, function (failure) { + return failure.getMessageID() === data.message_id; + }); + if (matchedFailure) { + var index = _.findIndex(self._mailFailures, matchedFailure); + if (isNewFailure) { + self._mailFailures[index] = new MailFailure(self, data); + } else { + self._mailFailures.splice(index, 1); + } + } else if (isNewFailure) { + self._mailFailures.push(new MailFailure(self, data)); + } + var message = _.find(self._messages, function (msg) { + return msg.getID() === data.message_id; + }); + if (message) { + if (isNewFailure) { + message.updateCustomerEmailStatus('exception'); + } else { + message.updateCustomerEmailStatus('sent'); + } + self._updateMessageNotificationStatus(data, message); + self._mailBus.trigger('update_message', message); + } + }); + this._mailBus.trigger('update_needaction', this.needactionCounter); + }, + /** + * Updates mailbox_inbox when a message has marked as read. + * + * @private + * @param {Object} data + * @param {integer[]} [data.channel_ids] + * @param {integer[]} [data.message_ids] + * @param {string} [data.type] + */ + _handlePartnerMarkAsReadNotification: function (data) { + var self = this; + _.each(data.message_ids, function (messageID) { + var message = _.find(self._messages, function (msg) { + return msg.getID() === messageID; + }); + if (message) { + self._removeMessageFromThread('mailbox_inbox', message); + self._mailBus.trigger('update_message', message, data.type); + } + }); + if (data.channel_ids) { + _.each(data.channel_ids, function (channelID) { + var channel = self.getChannel(channelID); + if (channel) { + channel.decrementNeedactionCounter(data.message_ids.length, 0); + } + }); + } else { + // if no channel_ids specified, this is a 'mark all read' in inbox + _.each(this._threads, function (thread) { + thread.resetNeedactionCounter(); + }); + } + var inbox = this.getMailbox('inbox'); + inbox.decrementMailboxCounter(data.message_ids.length); + this._mailBus.trigger('update_needaction', inbox.getMailboxCounter()); + }, + /** + * On receiving a message made by the current user, on a moderated channel, + * which is pending moderation. + * + * @private + * @param {Object} data + * @param {Object} data.message server-side data of the message + */ + _handlePartnerMessageAuthorNotification: function (data) { + this.addMessage(data.message); + }, + /** + * Notification to delete several messages locally + * Useful when a pending moderation message has been rejected, so that + * this message should not be displayed anymore. + * + * @private + * @param {Object} data + * @param {Object[]} [data.message_ids] IDs of messages to delete locally. + */ + _handlePartnerMessageDeletionNotification: function (data) { + var self = this; + _.each(data.message_ids, function (messageID) { + var message = self.getMessage(messageID); + if (message) { + message.setModerationStatus('rejected'); + } + }); + }, + /** + * On receiving a message pending moderation, and current user is moderator + * of such message. + * + * @param {Object} data notification data + * @param {Object} data.message data of message + */ + _handlePartnerMessageModeratorNotification: function (data) { + this.addMessage(data.message); + this._mailBus.trigger('update_moderation_counter'); + }, + /** + * On receiving a notification that is specific to a user + * + * @private + * @param {Object} data structure depending on the type + * @param {integer} data.id + */ + _handlePartnerNotification: function (data) { + if (data.info === 'unsubscribe') { + this._handlePartnerUnsubscribeNotification(data); + } else if (data.type === 'toggle_star') { + this._handlePartnerToggleStarNotification(data); + } else if (data.type === 'mark_as_read') { + this._handlePartnerMarkAsReadNotification(data); + } else if (data.type === 'moderator') { + this._handlePartnerMessageModeratorNotification(data); + } else if (data.type === 'author') { + this._handlePartnerMessageAuthorNotification(data); + } else if (data.type === 'deletion') { + this._handlePartnerMessageDeletionNotification(data); + } else if (data.info === 'channel_seen') { + this._handlePartnerChannelSeenNotification(data); + } else if (data.info === 'transient_message') { + this._handlePartnerTransientMessageNotification(data); + } else if (data.type === 'activity_updated') { + this._handlePartnerActivityUpdateNotification(data); + } else if (data.type === 'mail_failure') { + this._handlePartnerMailFailureNotification(data); + } else { + this._handlePartnerChannelNotification(data); + } + }, + /** + * On toggling on or off the star status of one or several messages. + * As the information is stored server-side, the web client must adapt + * itself from server's data on the messages. + * + * @private + * @param {Object} data + * @param {integer[]} data.message_ids IDs of messages that have a change + * of their starred status. + * @param {boolean} data.starred states whether the messages with id in + * `data.message_ids` have become either starred or unstarred + */ + _handlePartnerToggleStarNotification: function (data) { + var self = this; + var starred = this.getMailbox('starred'); + _.each(data.message_ids, function (messageID) { + var message = _.find(self._messages, function (msg) { + return msg.getID() === messageID; + }); + if (message) { + message.setStarred(data.starred); + if (!message.isStarred()) { + self._removeMessageFromThread('mailbox_starred', message); + } else { + self._addMessageToThreads(message, []); + var channelStarred = self.getMailbox('starred'); + channelStarred.invalidateCaches(); + } + self._mailBus.trigger('update_message', message); + } + }); + + if (data.starred) { + // increase starred counter if message is marked as star + starred.incrementMailboxCounter(data.message_ids.length); + } else { + // decrease starred counter if message is remove from starred + // if unstar_all then it will set to 0. + starred.decrementMailboxCounter(data.message_ids.length); + } + + this._mailBus.trigger('update_starred', starred.getMailboxCounter()); + }, + /** + * On receiving a transient message, i.e. a message which does not come from + * a member of the channel. Usually a log message, such as one generated + * from a command with ('/'). + * + * @private + * @param {Object} data + * @param {string} data.author_id + */ + _handlePartnerTransientMessageNotification: function (data) { + var lastMessage = _.last(this._messages); + data.id = (lastMessage ? lastMessage.getID() : 0) + 0.01; + data.author_id = data.author_id || this.getOdoobotID(); + this.addMessage(data); + }, + /** + * On receiving a unsubscribe from channel notification, confirm + * unsubscription from channel and adapt screen accordingly. + * + * @private + * @param {Object} data + * @param {Object} data.id ID of the unsubscribed channel + */ + _handlePartnerUnsubscribeNotification: function (data) { + var channel = this.getChannel(data.id); + if (channel) { + var message; + if (_.contains(['public', 'private'], channel.getType())) { + message = _.str.sprintf( + _t("You unsubscribed from %s."), + channel.getName() + ); + } else { + message = _.str.sprintf( + _t("You unpinned your conversation with %s."), + channel.getName() + ); + } + this._removeChannel(channel); + this._mailBus.trigger('unsubscribe_from_channel', data.id); + this.do_notify(_("Unsubscribed"), message); + } + }, + /** + * On receiving an update on user status (e.g. becoming 'online', 'offline', + * 'idle', etc.). + * + * @private + * @param {Object} data partner infos + * @param {integer} data.id + * @param {string} data.im_status + */ + _handlePresenceNotification: function (data) { + var dm = this.getDmFromPartnerID(data.id); + if (dm) { + dm.setStatus(data.im_status); + this._mailBus.trigger('update_dm_presence', dm); + } + }, + /** + * Update the message notification status of message based on update_message + * + * @private + * @param {Object} data + * @param {Object[]} data.notifications + * @param {mail.model.Message} message + */ + _updateMessageNotificationStatus: function (data, message) { + _.each(data.notifications, function (notif, id) { + var partnerName = notif[1]; + var notifStatus = notif[0]; + var res = _.find(message.getCustomerEmailData(), function (entry) { + return entry[0] === parseInt(id); + }); + if (res) { + res[2] = notifStatus; + } else { + message.addCustomerEmailData([parseInt(id), partnerName, notifStatus]); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Notification handlers + * Sometimes, the web client receives unsubscribe notification and an extra + * notification on that channel. This is then followed by an attempt to + * rejoin the channel that we just left. The next few lines remove the + * extra notification to prevent that situation to occur. + * + * @private + * @param {Array} notifs + */ + _onNotification: function (notifs) { + var self = this; + notifs = this._filterNotificationsOnUnsubscribe(notifs); + _.each(notifs, function (notif) { + var model = notif[0][1]; + if (model === 'ir.needaction') { + // new message in the inbox + self._handleNeedactionNotification(notif[1]); + } else if (model === 'mail.channel') { + // new message in a channel + self._handleChannelNotification(notif[1]); + } else if (model === 'res.partner') { + // channel joined/left, message marked as read/(un)starred, + // chat open/closed, moderation specific, etc. + self._handlePartnerNotification(notif[1]); + } else if (model === 'bus.presence') { + // update presence of users + self._handlePresenceNotification(notif[1]); + } + }); + }, +}); + +return MailManager; + +}); diff --git a/addons/mail/static/src/js/services/mail_service.js b/addons/mail/static/src/js/services/mail_service.js new file mode 100644 index 00000000000..9815b23b6de --- /dev/null +++ b/addons/mail/static/src/js/services/mail_service.js @@ -0,0 +1,15 @@ +odoo.define('mail.Service', function (require) { +"use strict"; + +var core = require('web.core'); + +var MailManager = require('mail.Manager'); + +require('mail.Manager.Notification'); +require('mail.Manager.Window'); + +core.serviceRegistry.add('mail_service', MailManager); + +return MailManager; + +}); diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js new file mode 100644 index 00000000000..c72d6418f08 --- /dev/null +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -0,0 +1,684 @@ +odoo.define('mail.Manager.Window', function (require) { +"use strict"; + +var MailManager = require('mail.Manager'); +var ThreadWindow = require('mail.ThreadWindow'); + +var config = require('web.config'); +var core = require('web.core'); +var utils = require('web.utils'); + +var QWeb = core.qweb; + +var THREAD_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows + +/** + * Mail Window Manager + * + * This part of the mail manager is responsible for the management of thread + * windows. + */ +MailManager.include({ + + // tell where to append thread window + THREAD_WINDOW_APPENDTO: 'body', + + start: function () { + this._super.apply(this, arguments); + + this._availableSlotsForThreadWindows = 0; + this._hiddenThreadWindows = []; + // used to keep dropdown open when closing thread windows + this._keepHiddenThreadWindowsDropdownOpen = false; + this._spaceLeftForThreadWindows = 0; + this._threadWindows = []; + // jQuery element for the dropdown of hidden thread windows + // see _renderHiddenThreadWindowsDropdown + this._$hiddenThreadWindowsDropdown = null; + + this._mailBus + .on('update_message', this, this._onUpdateMessage) + .on('new_message', this, this._onNewMessage) + .on('new_channel', this, this._onNewChannel) + .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible) + .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel) + .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter) + .on('update_dm_presence', this, this._onUpdateDmPresence); + + core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100)); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Open the blank thread window (i.e. the thread window without any thread + * linked to it). Make it if there is no blank thread window yet. + */ + openBlankThreadWindow: function () { + var blankThreadWindow = this._getBlankThreadWindow(); + if (!blankThreadWindow) { + blankThreadWindow = new ThreadWindow(this, null); + this._addThreadWindow(blankThreadWindow); + blankThreadWindow.appendTo(this.THREAD_WINDOW_APPENDTO) + .then(this._repositionThreadWindows.bind(this)); + } else { + if (blankThreadWindow.isHidden()) { + this._makeThreadWindowVisible(blankThreadWindow); + } else if (blankThreadWindow.isFolded()) { + blankThreadWindow.toggleFold(false); + } + } + }, + /** + * Open a DM in a thread window. This is useful when selecting a DM in the + * blank thread window, so that it replaces it with the DM window. + * + * @param {integer} partnerID + */ + openDmWindow: function (partnerID) { + var dm = this.getDmFromPartnerID(partnerID); + if (!dm) { + this._openAndDetachDm(partnerID); + } else { + this.openThreadWindow(dm.getID()); + } + }, + /** + * Open the thread window if discuss is not opened + * + * @override + * @param {integer|string} threadID + */ + openThread: function (threadID) { + if (!this._isDiscussOpen()) { + var thread = this.getThread(threadID); + if (thread) { + thread.detach(); + } + } else { + this._super.apply(this, arguments); + } + }, + /** + * Open a thread in a thread window + * + * @param {integer} threadID a valid thread ID + * @param {Object} [options] + * @param {boolean} [options.passively] if set to true, open the thread + * window without focusing the input and marking messages as read if it + * is not open yet, and do nothing otherwise. + * @param {boolean} [options.keepFoldState=false] if set to true, keep the + * fold state of the thread + */ + openThreadWindow: function (threadID, options) { + var self = this; + options = options || {}; + // valid threadID, therefore no check + var thread = this.getThread(threadID); + var threadWindow = this._getThreadWindow(threadID); + if (!threadWindow) { + threadWindow = this._makeNewThreadWindow(thread, options); + this._placeNewThreadWindow(threadWindow, options.passively); + + threadWindow.appendTo($(this.THREAD_WINDOW_APPENDTO)) + .then(function () { + self._repositionThreadWindows(); + return thread.getMessages(); + }).then(function (messages) { + threadWindow.render(messages); + threadWindow.threadWidget.scrollToBottom(); + // setTimeout to prevent to execute handler on first + // scrollTo, which is asynchronous + setTimeout(function () { + threadWindow.threadWidget.$el.on('scroll', null, _.debounce(function () { + if ( + !threadWindow.isPassive() && + threadWindow.threadWidget.isAtBottom() + ) { + thread.markAsRead(); + } + }, 100)); + }, 0); + if (options.passively) { + // mark first unread messages as seen when focusing the + // window, then on scroll to bottom as usual + threadWindow.$('.o_mail_thread, .o_thread_composer') + .one('click', function () { + thread.markAsRead(); + }); + } else if ( + !self._areThreadWindowsHidden() && + !thread.isFolded() + ) { + thread.markAsRead(); + } + }); + } else if (!options.passively) { + if (threadWindow.isHidden()) { + this._makeThreadWindowVisible(threadWindow); + } + } + threadWindow.updateVisualFoldState(); + }, + /** + * Called when a thread has its window state that has been changed, so its + * thread window view should be changed to match the model. + * + * @param {integer|string} threadID + * @param {Object} options option to be applied on opening thread window, if + * the thread is detached + */ + updateThreadWindow: function (threadID, options) { + var thread = this.getThread(threadID); + if (thread) { + if (thread.isDetached()) { + _.extend(options, { keepFoldState: true }); + this.openThreadWindow(threadID, options); + } else { + this._closeThreadWindow(threadID); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Add the thread window such that it will be the left-most visible window + * + * @private + * @param {mail.ThreadWindow} threadWindow + */ + _addThreadWindow: function (threadWindow) { + this._computeAvailableSlotsForThreadWindows(this._threadWindows.length+1); + this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow); + }, + /** + * States whether the thread windows are hidden or not. + * When discuss is open, the thread windows are hidden. + * + * @private + * @returns {boolean} + */ + _areThreadWindowsHidden: function () { + return this._isDiscussOpen(); + }, + /** + * Close the thread window linked to the thread with ID `threadID`. + * If there is no window linked to this thread, do nothing. + * + * @private + * @param {integer|string} threadID + */ + _closeThreadWindow: function (threadID) { + var threadWindow = _.find(this._threadWindows, function (threadWindow) { + return threadWindow.getID() === threadID; + }); + if (threadWindow) { + this._threadWindows = _.without(this._threadWindows, threadWindow); + this._repositionThreadWindows(); + threadWindow.destroy(); + } + }, + /** + * Compute the number of available slots to display thread windows on the + * screen. This is based on the width of the screen, and the width of a + * single thread window. + * + * The available slots attributes are updated as a consequence of this + * method call. + * + * @private + * @param {integer} nbWindows + */ + _computeAvailableSlotsForThreadWindows: function (nbWindows) { + if (config.device.isMobile) { + // one thread window full screen in mobile + this._availableSlotsForThreadWindows = 1; + return; + } + var width = window.innerWidth; + var availableSlots = Math.floor(width/THREAD_WINDOW_WIDTH); + var spaceLeft = width - (Math.min(availableSlots, nbWindows)*THREAD_WINDOW_WIDTH); + if (availableSlots < nbWindows && spaceLeft < 50) { + // leave at least 50px for the hidden windows dropdown button + availableSlots--; + spaceLeft += THREAD_WINDOW_WIDTH; + } + this._availableSlotsForThreadWindows = availableSlots; + this._spaceLeftForThreadWindows = spaceLeft; + }, + /** + * Get the blank thread window, which is the special thread window that has + * no thread linked to it. + * + * This is useful in case a DM window may replace the blank thread window, + * when we want to open a DM from the blank thread window. + * + * @private + * @returns {mail.ThreadWindow|undefined} the "blank thread" window, + * if it exists, otherwise undefined + */ + _getBlankThreadWindow: function () { + return _.find(this._threadWindows, function (threadWindow) { + return threadWindow.getID() === '_blank'; + }); + }, + /** + * Get thread window in the hidden windows matching ID `threadID`. + * + * Note: hidden windows are open windows that cannot be displayed + * due to having more thread windows open than available slots for thread + * windows on the screen. These thread windows are displayed in the hidden + * thread window dropdown menu. + * + * @private + * @param {integer|string} threadID + * @returns {mail.ThreadWindow|undefined} the hidden thread window, + * if exists + */ + _getHiddenThreadWindow: function (threadID) { + return _.find(this._hiddenThreadWindows, function (threadWindow) { + return threadWindow.getID() === threadID; + }); + }, + /** + * Get thread window matching ID `threadID` + * + * @private + * @param {integer} threadID + * @returns {mail.ThreadWindow|undefined} the thread window, if exists + */ + _getThreadWindow: function (threadID) { + return _.find(this._threadWindows, function (threadWindow) { + return threadWindow.getID() === threadID; + }); + }, + /** + * Make the hidden thread window dropdown menu, that is render it and set + * event listener on this dropdown menu DOM element. + * + * @private + */ + _makeHiddenThreadWindowsDropdown: function () { + var self = this; + if (this._$hiddenThreadWindowsDropdown) { + this._$hiddenThreadWindowsDropdown.remove(); + } + if (this._hiddenThreadWindows.length) { + this._$hiddenThreadWindowsDropdown = this._renderHiddenThreadWindowsDropdown(); + var $hiddenWindowsDropdown = this._$hiddenThreadWindowsDropdown; + $hiddenWindowsDropdown.css({right: THREAD_WINDOW_WIDTH * this._availableSlotsForThreadWindows, bottom: 0 }) + .appendTo(this.THREAD_WINDOW_APPENDTO); + this._repositionHiddenWindowsDropdown(); + this._keepHiddenThreadWindowsDropdownOpen = false; + + $hiddenWindowsDropdown + .on('click', '.o_thread_window_header', function (ev) { + var threadID = $(ev.currentTarget).data('thread-id'); + var threadWindow = self._getHiddenThreadWindow(threadID); + if (threadWindow) { + self._makeThreadWindowVisible(threadWindow); + } + }) + .on('click', '.o_thread_window_close', function (ev) { + var threadID = $(ev.currentTarget).closest('.o_thread_window_header') + .data('thread-id'); + var threadWindow = self._getHiddenThreadWindow(threadID); + if (threadWindow) { + threadWindow.close(); + // keep the dropdown open + self._keepHiddenThreadWindowsDropdownOpen = true; + } + }); + } + }, + /** + * Make a new thread window linked to a thread. + * + * @private + * @param {mail.model.Thread} thread + * @param {Object} options + * @param {boolean} [options.passively=false] + */ + _makeNewThreadWindow: function (thread, options) { + return new ThreadWindow(this, thread, _.extend(options, { + autofocus: !options.passively, + })); + }, + /** + * Make an open thread window fully visible on screen. + * + * This method assumes that the thread window is hidden (i.e. in the hidden + * dropdown menu). To make it visible, it swap the position of this thread + * window with the last thread window that is visible (i.e. the left-most + * visible thread window). + * + * @private + * @param {mail.ThreadWindow} threadWindow + */ + _makeThreadWindowVisible: function (threadWindow) { + utils.swap( + this._threadWindows, + threadWindow, + this._threadWindows[this._availableSlotsForThreadWindows-1] + ); + this._repositionThreadWindows(); + threadWindow.toggleFold(false); + }, + /** + * Open and detach the DM in a thread window. + * + * This method assumes that no such DM exists locally, so it is kind of a + * "create DM and open DM window" operation + * + * @private + * @param {integer} partnerID + * @returns {$.Promise} resolved with ID of the dm channel + */ + _openAndDetachDm: function (partnerID) { + return this._rpc({ + model: 'mail.channel', + method: 'channel_get_and_minimize', + args: [[partnerID]], + }) + .then(this._addChannel.bind(this)); + }, + /** + * On opening a new thread window, place it with other thread windows: + * + * - if it has been open with the blank thread window, replace the blank + * thread window with this one + * - if it has been open passively, simply but it after all windows + * - otherwise, make it the left-most visible thread window + * + * @param {mail.ThreadWindow} threadWindow a thread window that is linked + * to a thread (this must not be the blank thread window) + * @param {boolean} [passively=false] if set, if the thread window does not + * replace the blank thread window, it is add at the tail of the list of + * thread windows, which might be put in the thread window hidden dropdown + * menu if there are not enough space on the screen. + */ + _placeNewThreadWindow: function (threadWindow, passively) { + var thread = this.getThread(threadWindow.getID()); + // replace the blank thread window? + // the thread window should be a DM + var blankThreadWindow = this._getBlankThreadWindow(); + if ( + blankThreadWindow && + thread.getType() === 'dm' && + thread.getDirectPartnerID() === blankThreadWindow.directPartnerID + ) { + // the window takes the place of the 'blank' thread window + var index = _.indexOf(this._threadWindows, blankThreadWindow); + this._threadWindows[index] = threadWindow; + blankThreadWindow.destroy(); + } else if (passively) { + // simply insert the window to the left + this._threadWindows.push(threadWindow); + } else { + // add window such that it is visible + this._addThreadWindow(threadWindow); + } + }, + /** + * Unfold dropdown to the left if there is enough space on the screen. + * + * @private + */ + _repositionHiddenWindowsDropdown: function () { + var $dropdownUL = this._$hiddenThreadWindowsDropdown.children('ul'); + if (this._spaceLeftForThreadWindows > $dropdownUL.width() + 10) { + $dropdownUL.addClass('dropdown-menu-right'); + } + }, + /** + * Load the template of the hidden thread window dropdown + * + * @private + * @returns {jQuery.Element} + */ + _renderHiddenThreadWindowsDropdown: function () { + var $dropdown = $(QWeb.render('mail.HiddenThreadWindowsDropdown', { + threadWindows: this._hiddenThreadWindows, + open: this._keepHiddenThreadWindowsDropdownOpen, + unreadCounter: this._hiddenThreadWindowsUnreadCounter, + widget: { + isMobile: function () { + return config.device.isMobile; + }, + }, + })); + return $dropdown; + }, + /** + * Reposition the thread windows that should be hidden on the screen. + * Thread windows that have an index equal or greater than `index` in the + * attribute `threadWindows` should be hidden. Those thread windows are put + * in the hidden thread window dropdown menu. + * + * @private + * @param {integer} startIndex the index of the first thread window to hide, + * in increasing order of the thread windows in the `threadWindows` + * attribute + */ + _repositionHiddenThreadWindows: function (startIndex) { + var hiddenWindows = []; + var hiddenUnreadCounter = 0; + var index = startIndex; + while (index < this._threadWindows.length) { + var threadWindow = this._threadWindows[index]; + hiddenWindows.push(threadWindow); + hiddenUnreadCounter += threadWindow.getUnreadCounter(); + threadWindow.do_hide(); + index++; + } + this._hiddenThreadWindows = hiddenWindows; + this._hiddenThreadWindowsUnreadCounter = hiddenUnreadCounter; + + this._makeHiddenThreadWindowsDropdown(); + }, + /** + * Reposition the thread windows, based on the size of the screen: + * + * - display thread windows by increasing order of index in + * `_threadWindows` attribute, from right to left on the screen + * - if there is no enough space to show all windows at once, display + * a dropdown menu for hidden windows. + * + * This method should be called whenever there is a change of state of a + * thread in a window. Also, when this method is called, all the windows + * are visible and stacked in the top-left corner of the screen. + * + * @private + */ + _repositionThreadWindows: function () { + if (this._areThreadWindowsHidden()) { + return; + } + this._computeAvailableSlotsForThreadWindows(this._threadWindows.length); + var availableSlots = this._availableSlotsForThreadWindows; + + this._repositionVisibleThreadWindows(availableSlots-1); + this._repositionHiddenThreadWindows(availableSlots); + }, + /** + * Reposition the thread windows that should be visible on the screen. + * + * @private + * @param {integer} count how many thread windows can should be visible, + * which are picked in the attribute `_threadWindows` in increasing index + * order of appearance in the array. constraint: + * 0 <= count < this._threadWindows.length + */ + _repositionVisibleThreadWindows: function (count) { + var index = 0; + while (index < count && index < this._threadWindows.length) { + var threadWindow = this._threadWindows[index]; + threadWindow.$el.css({ right: THREAD_WINDOW_WIDTH*index, bottom: 0 }); + threadWindow.do_show(); + index++; + } + }, + /** + * Update thread windows state of threads that have `message`. + * This is either a new message or an updated message. + * + * @private + * @param {mail.model.Message} message + * @param {boolean} [scrollBottom=false] if set, thread windows with this + * message should scroll to the bottom if the message is visible + */ + _updateThreadWindows: function (message, scrollBottom) { + var self = this; + _.each(this._threadWindows, function (threadWindow) { + if (_.contains(message.getThreadIDs(), threadWindow.getID())) { + var thread = self.getThread(threadWindow.getID()); + var messageVisible = !self._areThreadWindowsHidden() && + !threadWindow.isFolded() && + !threadWindow.isHidden() && + threadWindow.threadWidget.isAtBottom(); + if (messageVisible && !threadWindow.isPassive()) { + thread.markAsRead(); + } + thread.getMessages() + .then(function (messages) { + threadWindow.render(messages); + if (scrollBottom && messageVisible) { + threadWindow.threadWidget.scrollToBottom(); + } + }); + } + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {boolean} open + */ + _onDiscussOpen: function (open) { + this._super.apply(this, arguments); + + if (open) { + $(this.THREAD_WINDOW_APPENDTO).addClass('o_no_thread_window'); + } else { + $(this.THREAD_WINDOW_APPENDTO).removeClass('o_no_thread_window'); + this._repositionThreadWindows(); + } + }, + /** + * Called when someone asks window manager whether the bottom of `thread` is + * visible or not. An object `query` is provided in order to reponse on the + * key `isDisplayed`. + * + * @private + * @param {mail.model.Thread} thread + * @param {Object} query + * @param {boolean} query.isBottomVisible write on it + */ + _onIsThreadBottomVisible: function (thread, query) { + _.each(this._threadWindows, function (threadWindow) { + if ( + thread.getID() === threadWindow.getID() && + threadWindow.threadWidget.isAtBottom() && + !threadWindow.isHidden() + ) { + query.isBottomVisible = true; + } + }); + }, + /** + * Show or hide window of this channel when a new channel is added. + * + * @private + * @param {mail.model.Channel} channel + */ + _onNewChannel: function (channel) { + if (channel.isDetached()) { + this.openThreadWindow(channel.getID(), { keepFoldState: true }); + } else { + this._closeThreadWindow(channel.getID()); + } + }, + /** + * Update thread window containing this message when a new message is added. + * + * @private + * @param {Object} message + */ + _onNewMessage: function (message) { + this._updateThreadWindows(message, true); + }, + /** + * Close the thread window when unsusbscribe from a channel. + * + * @private + * @param {integer} channelID + */ + _onUnsubscribeFromChannel: function (channelID) { + this._closeThreadWindow(channelID); + }, + /** + * Called when a thread has its unread counter that has changed. + * The unread counter on the thread windows should be updated. + * + * @private + * @param {mail.model.Thread} thread + */ + _onUpdateThreadUnreadCounter: function (thread) { + var self = this; + this._hiddenThreadWindowsUnreadCounter = 0; + _.each(this._threadWindows, function (threadWindow) { + if (thread.getID() === threadWindow.getID()) { + threadWindow.updateHeader(); + if (thread.getUnreadCounter() === 0) { + threadWindow.removePassive(); + } + } + if (threadWindow.isHidden()) { + self._hiddenThreadWindowsUnreadCounter += threadWindow.getUnreadCounter(); + } + }); + if (this._$hiddenThreadWindowsDropdown) { + this._$hiddenThreadWindowsDropdown.html( + this._renderHiddenThreadWindowsDropdown().html()); + this._repositionHiddenWindowsDropdown(); + } + }, + /** + * Called when there is a change of the im status of the user linked to + * DMs. The header of the thread window should be updated accordingly, + * in order to display the correct new im status of this users. + * + * @private + * @param {mail.model.Thread} thread + */ + _onUpdateDmPresence: function (thread) { + _.each(this._threadWindows, function (threadWindow) { + if (thread.getID() === threadWindow.getID()) { + threadWindow.updateHeader(); + } + }); + }, + /** + * Called when a message has been updated. + * + * @private + * @param {Object} message + */ + _onUpdateMessage: function (message) { + this._updateThreadWindows(message, false); + }, + +}); + +return MailManager; + +}); diff --git a/addons/mail/static/src/js/systray.js b/addons/mail/static/src/js/systray.js deleted file mode 100644 index 18e9511cf25..00000000000 --- a/addons/mail/static/src/js/systray.js +++ /dev/null @@ -1,353 +0,0 @@ -odoo.define('mail.systray', function (require) { -"use strict"; - -var config = require('web.config'); -var core = require('web.core'); -var session = require('web.session'); -var SystrayMenu = require('web.SystrayMenu'); -var time = require('web.time'); -var Widget = require('web.Widget'); -var QWeb = core.qweb; - -/** - * Menu item appended in the systray part of the navbar - * - * The menu item indicates the counter of needactions + unread messages in chat channels. When - * clicking on it, it toggles a dropdown containing a preview of each pinned channels (except - * static and mass mailing channels) with a quick link to open them in chat windows. It also - * contains a direct link to the Inbox in Discuss. - **/ -var MessagingMenu = Widget.extend({ - template:'mail.chat.MessagingMenu', - events: { - "click": "on_click", - "click .o_filter_button": "on_click_filter_button", - "click .o_new_message": "on_click_new_message", - "click .o_mail_channel_preview": "_onClickChannel", - "click .o_mail_channel_mark_read":"_onClickMarkRead", - }, - init: function () { - this._super.apply(this, arguments); - this.isMobile = config.device.isMobile; // used by the template - }, - start: function () { - this.$filter_buttons = this.$('.o_filter_button'); - this.$channels_preview = this.$('.o_mail_navbar_dropdown_channels'); - this.filter = false; - var self = this; - this.call('chat_manager', 'isReady').then(function () { - self.update_counter(); - var chatBus = self.call('chat_manager', 'getChatBus'); - chatBus.on('update_needaction', self, self.update_counter); - chatBus.on('update_channel_unread_counter', self, self.update_counter); - }); - return this._super(); - }, - is_open: function () { - return this.$el.hasClass('open'); - }, - update_counter: function () { - var messageFailures = this.call('chat_manager', 'getMessageFailures'); - var needactionCounter = this.call('chat_manager', 'getNeedactionCounter'); - var unreadConversationCounter = this.call('chat_manager', 'getUnreadConversationCounter'); - var counter = needactionCounter + unreadConversationCounter + messageFailures.length; - this.$('.o_notification_counter').text(counter); - this.$el.toggleClass('o_no_notification', !counter); - if (this.is_open()) { - this.update_channels_preview(); - } - }, - update_channels_preview: function () { - var self = this; - // Display spinner while waiting for channels preview - this.$channels_preview.html(QWeb.render('Spinner')); - this.call('chat_manager', 'isReady').then(function () { - var allChannels = self.call('chat_manager', 'getChannels'); - var channels = _.filter(allChannels, function (channel) { - if (self.filter === 'chat') { - return channel.is_chat; - } else if (self.filter === 'channels') { - return !channel.is_chat && channel.type !== 'static'; - } else { - return channel.type !== 'static'; - } - }); - var formatedFailures = []; - var messageFailures = self.call('chat_manager', 'getMessageFailures'); - _.each(messageFailures, function (messageFailure) { - var model = messageFailure.model; - var existingFailure = _.findWhere(formatedFailures, { model: model }); - if (existingFailure) { - if (existingFailure.res_id !== messageFailure.res_id) { - existingFailure.res_id = null; - existingFailure.record_name = messageFailure.model_name;//for display, will be used as subject - } - existingFailure.unread_counter ++; - } else { - messageFailure = _.clone(messageFailure); - messageFailure.unread_counter = 1; - messageFailure.id = "mail_failure"; - messageFailure.body = "An error occured sending an email"; - messageFailure.date = moment(time.str_to_datetime(messageFailure.last_message_date)); - messageFailure.displayed_author = ""; - formatedFailures.push(messageFailure); - } - }); - channels = _.union(channels, formatedFailures); - self.call('chat_manager', 'getMessages', {channelID: 'channel_inbox'}) - .then(function (messages) { - var res = []; - _.each(messages, function (message) { - message.unread_counter = 1; - var duplicatedMessage = _.findWhere(res, { - model: message.model, - 'res_id': message.res_id - }); - if (message.model && message.res_id && duplicatedMessage) { - message.unread_counter = duplicatedMessage.unread_counter + 1; - res[_.findIndex(res, duplicatedMessage)] = message; - } else { - res.push(message); - } - }); - if (self.filter === 'channel_inbox' || !self.filter) { - channels = _.union(channels, res); - } - self.call('chat_manager', 'getChannelsPreview', channels) - .then(self._render_channels_preview.bind(self)); - }); - }); - }, - _render_channels_preview: function (channels_preview) { - this.$channels_preview.html(QWeb.render('mail.chat.ChannelsPreview', { - channels: channels_preview, - })); - }, - on_click: function () { - if (!this.is_open()) { - this.update_channels_preview(); // we are opening the dropdown so update its content - } - }, - on_click_filter_button: function (event) { - event.stopPropagation(); - this.$filter_buttons.removeClass('active'); - var $target = $(event.currentTarget); - $target.addClass('active'); - this.filter = $target.data('filter'); - this.update_channels_preview(); - }, - on_click_new_message: function () { - this.call('chat_window_manager', 'openChat'); - }, - - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - /** - * When a channel is clicked on, we want to open chat/channel window - * - * @private - * @param {MouseEvent} event - */ - _onClickChannel: function (event) { - var self = this; - var channelID = $(event.currentTarget).data('channel_id'); - if (channelID === 'channel_inbox' || channelID === 'mail_failure') { - var resID = $(event.currentTarget).data('res_id'); - var resModel = $(event.currentTarget).data('res_model'); - if (resModel && resModel !== 'mail.channel' && resID) { - this.do_action({ - type: 'ir.actions.act_window', - res_model: resModel, - views: [[false, 'form']], - res_id: resID - }); - } else if (resModel && resModel !== 'mail.channel' && !resID) { - this.do_action({ - name: "Mail failures", - type: 'ir.actions.act_window', - view_mode: 'kanban,list,form', - views: [[false, 'kanban'], [false, 'list'], [false, 'form']], - target: 'current', - res_model: resModel, - domain: [['message_has_error', '=', true]], - }); - } else { - var clientChatOptions = {clear_breadcrumbs: true}; - if (resModel && resModel === 'mail.channel' && resID) { - clientChatOptions.active_id = resID; - } - this.do_action('mail.mail_channel_action_client_chat', clientChatOptions) - .then(function () { - self.trigger_up('hide_home_menu'); // we cannot 'go back to previous page' otherwise - core.bus.trigger('change_menu_section', self.call('chat_manager', 'getDiscussMenuID')); - }); - } - } else { - var channel = this.call('chat_manager', 'getChannel', channelID); - if (channel) { - this.call('chat_manager', 'openChannel', channel); - } - } - }, - /** - * When a channel Mark As Read button is clicked on, we want mark message as read - * - * @private - * @param {MouseEvent} event - */ - _onClickMarkRead: function (ev) { - ev.stopPropagation(); - var channelID = $(ev.currentTarget).data('channel_id'), - channel = this.call('chat_manager', 'getChannel', channelID); - //Mark only static channel's messages as read and clear notification - if (channelID === 'channel_inbox') { - var resID = $(ev.currentTarget).data('res_id'); - var model = $(ev.currentTarget).data('model'); - var domain = [['model', '=', model], ['res_id', '=', resID]]; - this.call('chat_manager', 'markAllAsRead', channel, domain); - } else { - this.call('chat_manager', 'markChannelAsSeen', channel); - } - }, -}); - -/** - * Menu item appended in the systray part of the navbar, redirects to the next activities of all app - */ -var ActivityMenu = Widget.extend({ - template:'mail.chat.ActivityMenu', - events: { - "click": "_onActivityMenuClick", - "click .o_mail_channel_preview": "_onActivityFilterClick", - }, - start: function () { - this.$activities_preview = this.$('.o_mail_navbar_dropdown_channels'); - this.call('chat_manager', 'getChatBus').on("activity_updated", this, this._updateCounter); - this.call('chat_manager', 'isReady').then(this._updateCounter.bind(this)); - this._updateActivityPreview(); - return this._super(); - }, - //-------------------------------------------------- - // Private - //-------------------------------------------------- - /** - * Make RPC and get current user's activity details - * @private - */ - _getActivityData: function () { - var self = this; - - return self._rpc({ - model: 'res.users', - method: 'systray_get_activities', - args: [], - kwargs: {context: session.user_context}, - }).then(function (data) { - self.activities = data; - self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0); - self.$('.o_notification_counter').text(self.activityCounter); - self.$el.toggleClass('o_no_notification', !self.activityCounter); - }); - }, - /** - * Get particular model view to redirect on click of activity scheduled on that model. - * @private - * @param {string} model - */ - _getActivityModelViewID: function (model) { - return this._rpc({ - model: model, - method: 'get_activity_view_id' - }); - }, - /** - * Check wether activity systray dropdown is open or not - * @private - * @returns {boolean} - */ - _isOpen: function () { - return this.$el.hasClass('open'); - }, - /** - * Update(render) activity system tray view on activity updation. - * @private - */ - _updateActivityPreview: function () { - var self = this; - self._getActivityData().then(function (){ - self.$activities_preview.html(QWeb.render('mail.chat.ActivityMenuPreview', { - activities : self.activities - })); - }); - }, - /** - * update counter based on activity status(created or Done) - * @private - * @param {Object} [data] key, value to decide activity created or deleted - * @param {String} [data.type] notification type - * @param {Boolean} [data.activity_deleted] when activity deleted - * @param {Boolean} [data.activity_created] when activity created - */ - _updateCounter: function (data) { - if (data) { - if (data.activity_created) { - this.activityCounter ++; - } - if (data.activity_deleted && this.activityCounter > 0) { - this.activityCounter --; - } - this.$('.o_notification_counter').text(this.activityCounter); - this.$el.toggleClass('o_no_notification', !this.activityCounter); - } - }, - //------------------------------------------------------------ - // Handlers - //----------------------------------------------------------- - /** - * Redirect to particular model view - * @private - * @param {MouseEvent} event - */ - _onActivityFilterClick: function (event) { - // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_channel_preview). - var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data()); - var context = {}; - if (data.filter === 'my') { - context['search_default_activities_overdue'] = 1; - context['search_default_activities_today'] = 1; - } else { - context['search_default_activities_' + data.filter] = 1; - } - this.do_action({ - type: 'ir.actions.act_window', - name: data.model_name, - res_model: data.res_model, - views: [[false, 'kanban'], [false, 'form']], - search_view_id: [false], - domain: [['activity_user_id', '=', session.uid]], - context:context, - }); - }, - /** - * When menu clicked update activity preview if counter updated - * @private - * @param {MouseEvent} event - */ - _onActivityMenuClick: function () { - if (!this._isOpen()) { - this._updateActivityPreview(); - } - }, -}); - -SystrayMenu.Items.push(MessagingMenu); -SystrayMenu.Items.push(ActivityMenu); - -// to test activity and messaging menus in qunit test cases we need it -return { - ActivityMenu: ActivityMenu, - MessagingMenu: MessagingMenu, -}; -}); diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js new file mode 100644 index 00000000000..d65bb7ff5db --- /dev/null +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -0,0 +1,150 @@ +odoo.define('mail.systray.ActivityMenu', function (require) { +"use strict"; + +var core = require('web.core'); +var session = require('web.session'); +var SystrayMenu = require('web.SystrayMenu'); +var Widget = require('web.Widget'); +var QWeb = core.qweb; + +/** + * Menu item appended in the systray part of the navbar, redirects to the next + * activities of all app + */ +var ActivityMenu = Widget.extend({ + name: 'activity_menu', + template:'mail.systray.ActivityMenu', + events: { + 'click': '_onActivityMenuClick', + 'click .o_mail_preview': '_onActivityFilterClick', + }, + willStart: function () { + return $.when(this.call('mail_service', 'isReady')); + }, + start: function () { + this._$activitiesPreview = this.$('.o_mail_systray_dropdown_items'); + this.call('mail_service', 'getMailBus').on('activity_updated', this, this._updateCounter); + this._updateCounter(); + this._updateActivityPreview(); + return this._super(); + }, + //-------------------------------------------------- + // Private + //-------------------------------------------------- + /** + * Make RPC and get current user's activity details + * @private + */ + _getActivityData: function () { + var self = this; + + return self._rpc({ + model: 'res.users', + method: 'systray_get_activities', + args: [], + kwargs: {context: session.user_context}, + }).then(function (data) { + self._activities = data; + self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0); + self.$('.o_notification_counter').text(self.activityCounter); + self.$el.toggleClass('o_no_notification', !self.activityCounter); + }); + }, + /** + * Get particular model view to redirect on click of activity scheduled on that model. + * @private + * @param {string} model + */ + _getActivityModelViewID: function (model) { + return this._rpc({ + model: model, + method: 'get_activity_view_id' + }); + }, + /** + * Check wether activity systray dropdown is open or not + * @private + * @returns {boolean} + */ + _isOpen: function () { + return this.$el.hasClass('open'); + }, + /** + * Update(render) activity system tray view on activity updation. + * @private + */ + _updateActivityPreview: function () { + var self = this; + self._getActivityData().then(function (){ + self._$activitiesPreview.html(QWeb.render('mail.systray.ActivityMenu.Previews', { + activities : self._activities + })); + }); + }, + /** + * update counter based on activity status(created or Done) + * @private + * @param {Object} [data] key, value to decide activity created or deleted + * @param {String} [data.type] notification type + * @param {Boolean} [data.activity_deleted] when activity deleted + * @param {Boolean} [data.activity_created] when activity created + */ + _updateCounter: function (data) { + if (data) { + if (data.activity_created) { + this.activityCounter ++; + } + if (data.activity_deleted && this.activityCounter > 0) { + this.activityCounter --; + } + this.$('.o_notification_counter').text(this.activityCounter); + this.$el.toggleClass('o_no_notification', !this.activityCounter); + } + }, + + //------------------------------------------------------------ + // Handlers + //------------------------------------------------------------ + + /** + * Redirect to particular model view + * @private + * @param {MouseEvent} event + */ + _onActivityFilterClick: function (event) { + // fetch the data from the button otherwise fetch the ones from the parent (.o_mail_preview). + var data = _.extend({}, $(event.currentTarget).data(), $(event.target).data()); + var context = {}; + if (data.filter === 'my') { + context['search_default_activities_overdue'] = 1; + context['search_default_activities_today'] = 1; + } else { + context['search_default_activities_' + data.filter] = 1; + } + this.do_action({ + type: 'ir.actions.act_window', + name: data.model_name, + res_model: data.res_model, + views: [[false, 'kanban'], [false, 'form']], + search_view_id: [false], + domain: [['activity_user_id', '=', session.uid]], + context:context, + }); + }, + /** + * When menu clicked update activity preview if counter updated + * @private + * @param {MouseEvent} event + */ + _onActivityMenuClick: function () { + if (!this._isOpen()) { + this._updateActivityPreview(); + } + }, +}); + +SystrayMenu.Items.push(ActivityMenu); + +return ActivityMenu; + +}); diff --git a/addons/mail/static/src/js/systray/systray_messaging_menu.js b/addons/mail/static/src/js/systray/systray_messaging_menu.js new file mode 100644 index 00000000000..7f8a565b6c7 --- /dev/null +++ b/addons/mail/static/src/js/systray/systray_messaging_menu.js @@ -0,0 +1,329 @@ +odoo.define('mail.systray.MessagingMenu', function (require) { +"use strict"; + +var config = require('web.config'); +var core = require('web.core'); +var SystrayMenu = require('web.SystrayMenu'); +var Widget = require('web.Widget'); +var QWeb = core.qweb; + +/** + * Menu item appended in the systray part of the navbar + * + * The menu item indicates the counter of needactions + unread messages in chat + * channels. When clicking on it, it toggles a dropdown containing a preview of + * each pinned channels (except mailbox and mass mailing channels) with a quick + * link to open them in chat windows. It also contains a direct link to the + * Inbox in Discuss. + **/ +var MessagingMenu = Widget.extend({ + name: 'messaging_menu', + template:'mail.systray.MessagingMenu', + events: { + 'click': '_onClick', + 'click .o_mail_preview': '_onClickPreview', + 'click .o_filter_button': '_onClickFilterButton', + 'click .o_new_message': '_onClickNewMessage', + 'click .o_mail_preview_mark_as_read': '_onClickPreviewMarkAsRead', + }, + /** + * @override + */ + willStart: function () { + return $.when(this._super.apply(this, arguments), this.call('mail_service', 'isReady')); + }, + /** + * @override + */ + start: function () { + this._$filterButtons = this.$('.o_filter_button'); + this._$previews = this.$('.o_mail_systray_dropdown_items'); + this._filter = false; + this._updateCounter(); + var mailBus = this.call('mail_service', 'getMailBus'); + mailBus.on('update_needaction', this, this._updateCounter); + mailBus.on('new_channel', this, this._updateCounter); + mailBus.on('update_thread_unread_counter', this, this._updateCounter); + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * States whether the widget is in mobile mode or not. + * This is used by the template. + * + * @returns {boolean} + */ + isMobile: function () { + return config.device.isMobile; + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when clicking on a preview related to a mail failure + * + * @private + * @param {$.Element} $target DOM of preview element clicked + */ + _clickMailFailurePreview: function ($target) { + var documentID = $target.data('document-id'); + var documentModel = $target.data('document-model'); + if (documentModel && documentID) { + this._openDocument(documentModel, documentID); + } else if (documentModel !== 'mail.channel') { + // preview of mail failures grouped to different document of same model + this.do_action({ + name: "Mail failures", + type: 'ir.actions.act_window', + view_mode: 'kanban,list,form', + views: [[false, 'kanban'], [false, 'list'], [false, 'form']], + target: 'current', + res_model: documentModel, + domain: [['message_has_error', '=', true]], + }); + } + }, + /** + * @private + * @return {boolean} whether the messaging menu is open or not. + */ + _isOpen: function () { + return this.$el.hasClass('open'); + }, + /** + * Open discuss + * + * @private + * @param {integer} [channelID] if set, auto-select this channel when + * opening the discuss app. + */ + _openDiscuss: function (channelID) { + var self = this; + var discussOptions = { clear_breadcrumbs: true }; + + if (channelID) { + discussOptions.active_id = channelID; + } + + this.do_action('mail.mail_channel_action_client_chat', discussOptions) + .then(function () { + // we cannot 'go back to previous page' otherwise + self.trigger_up('hide_home_menu'); + core.bus.trigger('change_menu_section', + self.call('mail_service', 'getDiscussMenuID')); + }); + }, + /** + * Open the document + * + * @private + * @param {string} documentModel the model of the document + * @param {integer} documentID + */ + _openDocument: function (documentModel, documentID) { + if (documentModel === 'mail.channel') { + this._openDiscuss(documentID); + } else { + this.do_action({ + type: 'ir.actions.act_window', + res_model: documentModel, + views: [[false, 'form']], + res_id: documentID + }); + } + }, + /** + * Render the list of conversation previews + * + * @private + * @param {Object} previews list of valid objects for preview rendering + * (see mail.Preview template) + */ + _renderPreviews: function (previews) { + this._$previews.html(QWeb.render('mail.systray.MessagingMenu.Previews', { + previews: previews, + })); + }, + /** + * Get and render list of previews, based on the selected filter + * + * preview shows the last message of a channel with inline format. + * There is a hack where filter "All" also shows preview of chatter + * messages (which are not channels). + * + * List of filters: + * + * 1. All + * - filter: undefined + * - previews: last messages of all non-mailbox channels, in addition + * to last messages of chatter (get from inbox) + * + * 2. Channel + * - filter: "Channels" + * - previews: last messages of all non-mailbox and non-DM channels + * + * 3. Chat + * - filter: "Chat" + * - previews: last messages of all DM channels + * + * @private + */ + _updatePreviews: function () { + // Display spinner while waiting for conversations preview + this._$previews.html(QWeb.render('Spinner')); + this.call('mail_service', 'getSystrayPreviews', this._filter) + .then(this._renderPreviews.bind(this)); + }, + /** + * Update the counter on the systray messaging menu icon. + * The counter display the number of unread messages in channels (DM included), the number of + * messages in Inbox mailbox, and the number of mail failures. + * Also updates the previews if the messaging menu is open. + * + * Note that the number of unread messages in document thread are ignored, because they are + * already considered in the number of messages in Inbox with the current design. + * Also, some unread messages in channel can also be in inbox, so they are considered twice in + * the counter. This is intended, as the number of needaction messages in a channel are + * separately considered in the messaging menu from the unread messages, even though a message + * can be both unread and needaction (such a message increments the counter twice). + * + * The global counter of the messaging menu should match the counter next to each of the preview + * item when the messaging menu is open. + * + * @private + */ + _updateCounter: function () { + var counter; + + var channels = this.call('mail_service', 'getChannels'); + var channelUnreadCounters = _.map(channels, function (channel) { + return channel.getUnreadCounter(); + }); + var unreadChannelCounter = _.reduce(channelUnreadCounters, function (c1, c2) { + return c1 + c2; + }, 0); + var inboxCounter = this.call('mail_service', 'getMailbox', 'inbox').getMailboxCounter(); + var mailFailureCounter = this.call('mail_service', 'getMailFailures').length; + + counter = unreadChannelCounter + inboxCounter + mailFailureCounter; + this.$('.o_notification_counter').text(counter); + this.$el.toggleClass('o_no_notification', !counter); + if (this._isOpen()) { + this._updatePreviews(); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onClick: function () { + if (!this._isOpen()) { + // we are opening the dropdown so update its content + this._updatePreviews(); + } + }, + /** + * @private + * @param {MouseEvent} ev + */ + _onClickFilterButton: function (ev) { + ev.stopPropagation(); + this._$filterButtons.removeClass('active'); + var $target = $(ev.currentTarget); + $target.addClass('active'); + this._filter = $target.data('filter'); + this._updatePreviews(); + }, + /** + * @private + */ + _onClickNewMessage: function () { + this.call('mail_service', 'openBlankThreadWindow'); + }, + /** + * When a preview is clicked on, we want to open the related object + * (thread, mail failure, etc.) + * + * @private + * @param {MouseEvent} ev + */ + _onClickPreview: function (ev) { + var $target = $(ev.currentTarget); + var previewID = $target.data('preview-id'); + + if (previewID === 'mail_failure') { + this._clickMailFailurePreview($target); + } else if (previewID === 'mailbox_inbox') { + // inbox preview for non-document thread, + // e.g. needaction message of channel + var documentID = $target.data('document-id'); + var documentModel = $target.data('document-model'); + this._openDocument(documentModel, documentID); + } else { + // preview of thread + this.call('mail_service', 'openThread', previewID); + } + }, + /** + * When a preview "Mark as read" button is clicked on, we want mark message + * as read + * + * @private + * @param {MouseEvent} event + */ + _onClickPreviewMarkAsRead: function (ev) { + ev.stopPropagation(); + var thread; + var $preview = $(ev.currentTarget).closest('.o_mail_preview'); + var previewID = $preview.data('preview-id'); + if (previewID === 'mailbox_inbox') { + var documentModel = $preview.data('document-model'); + var documentID = $preview.data('document-id'); + var inbox = this.call('mail_service', 'getMailbox', 'inbox'); + var messages = inbox.getLocalMessages({ + documentModel: documentModel, + documentID: documentID, + }); + var messageIDs = _.map(messages, function (message) { + return message.getID(); + }); + this.call('mail_service', 'markMessagesAsRead', messageIDs); + } else if (previewID === 'mail_failure') { + // AKU: ignored for the moment, this should be fixed when task + // 1860054 is merged + } else { + // this is mark as read on a thread + thread = this.call('mail_service', 'getThread', previewID); + if (thread) { + thread.markAsRead(); + } + } + }, +}); + +// Systray menu items display order matches order in the list +// lower index comes first, and display is from right to left. +// For messagin menu, it should come before activity menu, if any +// otherwise, it is the next systray item. +var activityMenuIndex = _.findIndex(SystrayMenu.Items, function (SystrayMenuItem) { + return SystrayMenuItem.prototype.name === 'activity_menu'; +}); +if (activityMenuIndex > 0) { + SystrayMenu.Items.splice(activityMenuIndex, 0, MessagingMenu); +} else { + SystrayMenu.Items.push(MessagingMenu); +} + +return MessagingMenu; + +}); diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js index 54a36e169e4..7d4833e81cd 100644 --- a/addons/mail/static/src/js/thread.js +++ b/addons/mail/static/src/js/thread.js @@ -1,9 +1,11 @@ -odoo.define('mail.ChatThread', function (require) { +odoo.define('mail.widget.Thread', function (require) { "use strict"; +var DocumentViewer = require('mail.DocumentViewer'); +var mailUtils = require('mail.utils'); + var core = require('web.core'); var time = require('web.time'); -var DocumentViewer = require('mail.DocumentViewer'); var Widget = require('web.Widget'); var QWeb = core.qweb; @@ -14,60 +16,31 @@ var ORDER = { DESC: -1, }; -var read_more = _t('read more'); -var read_less = _t('read less'); +var READ_MORE = _t("read more"); +var READ_LESS = _t("read less"); -function time_from_now(date) { - if (moment().diff(date, 'seconds') < 45) { - return _t("now"); - } - return date.fromNow(); -} - -var Thread = Widget.extend({ +/** + * This is a generic widget to render a chat thread + */ +var ThreadWidget = Widget.extend({ className: 'o_mail_thread', events: { - "click a": "on_click_redirect", - "click img": "on_click_redirect", - "click strong": "on_click_redirect", - "click .o_thread_show_more": "on_click_show_more", - "click .o_attachment_download": "_onAttachmentDownload", - "click .o_attachment_view": "_onAttachmentView", - "click .o_thread_message_needaction": function (event) { - var messageId = $(event.currentTarget).data('message-id'); - this.trigger("mark_as_read", messageId); - }, - "click .o_thread_message_moderation": "_onClickMessageModeration", - "change .moderation_checkbox": "_onChangeModerationCheckbox", - "click .o_thread_message_star": function (event) { - var messageId = $(event.currentTarget).data('message-id'); - this.trigger("toggle_star_status", messageId); - }, - "click .o_thread_message_email_exception": "_onClickMailException", - "click .o_thread_message_reply": function (event) { - this.selected_id = $(event.currentTarget).data('message-id'); - this.$('.o_thread_message').removeClass('o_thread_selected_message'); - this.$('.o_thread_message[data-message-id="' + this.selected_id + '"]') - .addClass('o_thread_selected_message'); - this.trigger('select_message', this.selected_id); - event.stopPropagation(); - }, - "click .oe_mail_expand": function (event) { - event.preventDefault(); - var $message = $(event.currentTarget).parents('.o_thread_message'); - $message.addClass('o_message_expanded'); - this.expanded_msg_ids.push($message.data('message-id')); - }, - "click .o_thread_message": function (event) { - $(event.currentTarget).toggleClass('o_thread_selected_message'); - }, - "click": function () { - if (this.selected_id) { - this.unselect(); - this.trigger('unselect_message'); - } - }, + '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_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_email_exception': '_onClickEmailException', + 'click .o_thread_message_moderation': '_onClickMessageModeration', + 'change .moderation_checkbox': '_onChangeModerationCheckbox', }, /** @@ -77,243 +50,211 @@ var Thread = Widget.extend({ */ init: function (parent, options) { this._super.apply(this, arguments); - this.enabledOptions = _.defaults(options || {}, { - display_order: ORDER.ASC, - display_needactions: true, - display_stars: true, - display_document_link: true, - display_avatar: true, - squash_close_messages: true, - display_email_icon: true, - display_reply_icon: false, + // options when the thread is enabled (e.g. can send message, + // interact on messages, etc.) + this._enabledOptions = _.defaults(options || {}, { + displayOrder: ORDER.ASC, + displayMarkAsRead: true, + displayStars: true, + displayDocumentLinks: true, + displayAvatars: true, + squashCloseMessages: true, + displayEmailIcons: true, + displayReplyIcons: false, loadMoreOnScroll: false, }); - this.disabledOptions = { - display_order: this.enabledOptions.display_order, - display_needactions: false, - display_stars: false, - display_document_link: false, - display_avatar: this.enabledOptions.display_avatar, - squash_close_messages: false, - display_email_icon: false, - display_reply_icon: false, - loadMoreOnScroll: this.enabledOptions.loadMoreOnScroll, + // options when the thread is disabled + this._disabledOptions = { + displayOrder: this._enabledOptions.displayOrder, + displayMarkAsRead: false, + displayStars: false, + displayDocumentLinks: false, + displayAvatars: this._enabledOptions.displayAvatars, + squashCloseMessages: false, + displayEmailIcons: false, + displayReplyIcons: false, + loadMoreOnScroll: this._enabledOptions.loadMoreOnScroll, }; - this.expanded_msg_ids = []; - this.selected_id = null; + this._selectedMessageID = null; }, /** - * @param {Object[]} messages + * @override + */ + destroy: function () { + clearInterval(this._updateTimestampsInterval); + }, + + /** + * @param {mail.model.AbstractMessage[]} messages list of messages of the + * thread, ordered by increasing IDs (a higher ID means a more recent + * message). * @param {Object} [options] - * @param {integer} [options.display_order=ORDER.ASC] - * @param {boolean} [options.display_load_more] + * @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 {boolean} [options.isCreateMode] - * @param {boolean} [options.squash_close_messages] + * @param {boolean} [options.squashCloseMessages] */ render: function (messages, options) { var self = this; - var msgs = _.map(messages, this._preprocessMessage.bind(this)); - var modeOptions = options.isCreateMode ? this.disabledOptions : this.enabledOptions; - if (modeOptions.display_order === ORDER.DESC) { - msgs.reverse(); + // copy so that reverse do not alter order in the thread object + messages = _.clone(messages); + + 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(); + }))); + + if (modeOptions.displayOrder === ORDER.DESC) { + messages.reverse(); } - options = _.extend({}, modeOptions, options); + options = _.extend({}, modeOptions, options, { + selectedMessageID: this._selectedMessageID, + }); // 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 prev_msg; - _.each(msgs, function (msg) { - if (!prev_msg || (Math.abs(msg.date.diff(prev_msg.date)) > 60000) || - prev_msg.message_type !== 'comment' || msg.message_type !== 'comment' || - (prev_msg.author_id[0] !== msg.author_id[0]) || prev_msg.model !== msg.model || - prev_msg.res_id !== msg.res_id) { - msg.display_author = true; + 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() + ) + ) + ) { + message.displayAuthor(true); } else { - msg.display_author = !options.squash_close_messages; + message.displayAuthor(!options.squashCloseMessages); } - if (msg.attachment_ids) { - var sortedAttachments = _.partition(msg.attachment_ids, function (att) { - return att.mimetype && att.mimetype.split('/')[0] === 'image'; - }); - msg.images = sortedAttachments[0]; - msg.others = sortedAttachments[1]; - } - prev_msg = msg; + prevMessage = message; }); - this.$el.html(QWeb.render('mail.ChatThread', { - messages: msgs, + this.$el.html(QWeb.render('mail.widget.Thread', { + messages: messages, options: options, ORDER: ORDER, - date_format: time.getLangDatetimeFormat(), + dateFormat: time.getLangDatetimeFormat(), })); - this.attachments = _.uniq(_.flatten(_.map(messages, 'attachment_ids'))); + _.each(messages, function (message) { + var $message = self.$('.o_thread_message[data-message-id="'+ message.getID() +'"]'); + $message.find('.o_mail_timestamp').data('date', message.getDate()); - _.each(msgs, function (msg) { - var $msg = self.$('.o_thread_message[data-message-id="'+ msg.id +'"]'); - $msg.find('.o_mail_timestamp').data('date', msg.date); - - self.insert_read_more($msg); + self._insertReadMore($message); }); - if (!this.update_timestamps_interval) { - this.update_timestamps_interval = setInterval(function () { - self.update_timestamps(); + if (!this._updateTimestampsInterval) { + this.updateTimestampsInterval = setInterval(function () { + self._updateTimestamps(); }, 1000*60); } }, - /** - * 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'. - */ - insert_read_more: function ($element) { - var self = this; + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- - var groups = []; - var read_more_nodes; - - // 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 = $('', { - 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 (!read_more_nodes) { - read_more_nodes = []; - groups.push(read_more_nodes); - } - $child.hide(); - read_more_nodes.push($child); - } else { - read_more_nodes = undefined; - self.insert_read_more($child); - } - }); - - _.each(groups, function (group) { - // Insert link just before the first node - var $read_more = $('', { - class: "o_mail_read_more", - href: "#", - text: read_more, - }).insertBefore(group[0]); - - // Toggle All next nodes - var is_read_more = true; - $read_more.click(function (e) { - e.preventDefault(); - is_read_more = !is_read_more; - _.each(group, function ($child) { - $child.hide(); - $child.toggle(!is_read_more); - }); - $read_more.text(is_read_more ? read_more : read_less); - }); - }); - }, - update_timestamps: function () { - var isAtBottom = this.is_at_bottom(); - this.$('.o_mail_timestamp').each(function () { - var date = $(this).data('date'); - $(this).html(time_from_now(date)); - }); - if (isAtBottom && !this.is_at_bottom()) { - this.scroll_to(); - } - }, - on_click_redirect: function (event) { - // ignore inherited branding - if ($(event.target).data('oe-field') !== undefined) { - return; - } - var id = $(event.target).data('oe-id'); - if (id) { - event.preventDefault(); - var model = $(event.target).data('oe-model'); - var options = model && (model !== 'mail.channel') ? {model: model, id: id} : {channel_id: id}; - this._redirect(options); - } - }, - - on_click_show_more: function () { - this.trigger('load_more_messages'); - }, - - /** - * Removes a message and re-renders the thread - * @param {int} [message_id] the id of the removed message - * @param {array} [messages] the list of messages to display, without the removed one - * @param {object} [options] options for the thread rendering - */ - remove_message_and_render: function (message_id, messages, options) { - var self = this; - var done = $.Deferred(); - this.$('.o_thread_message[data-message-id="' + message_id + '"]').fadeOut({ - done: function () { self.render(messages, options); done.resolve();}, - duration: 200, - }); - return done; - }, - - /** - * Scrolls the thread to a given message or offset if any, to bottom otherwise - * @param {int} [options.id] optional: the id of the message to scroll to - * @param {int} [options.offset] optional: the number of pixels to scroll - */ - scroll_to: function (options) { - options = options || {}; - if (options.id !== undefined) { - var $target = this.$('.o_thread_message[data-message-id="' + options.id + '"]'); - if (options.only_if_necessary) { - 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); - } else if ($target.length) { - this.$el.scrollTo($target); - } - } else if (options.offset !== undefined) { - this.$el.scrollTop(options.offset); - } else { - this.$el.scrollTop(this.el.scrollHeight); - } - }, - get_scrolltop: function () { + getScrolltop: function () { return this.$el.scrollTop(); }, - is_at_bottom: function () { - return this.el.scrollHeight - this.$el.scrollTop() - this.$el.outerHeight() < 5; + /** + * 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; }, - unselect: function () { - this.$('.o_thread_message').removeClass('o_thread_selected_message'); - this.selected_id = null; + /** + * Removes a message and re-renders the thread + * + * @param {integer} [messageID] the id of the removed message + * @param {mail.model.AbstractMessage[]} [messages] the list of messages to + * display, without the removed one + * @param {Object} [options] options for the thread rendering + */ + removeMessageAndRender: function (messageID, messages, options) { + var self = this; + var done = $.Deferred(); + this.$('.o_thread_message[data-message-id="' + messageID + '"]') + .fadeOut({ + done: function () { + self.render(messages, options); + done.resolve(); + }, + duration: 200, + }); + return done; }, - destroy: function () { - clearInterval(this.update_timestamps_interval); + /** + * 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.messageID 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 @@ -324,56 +265,132 @@ var Thread = Widget.extend({ 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 + //-------------------------------------------------------------------------- + + /** + * 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 = $('', { + 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 = $('', { + 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 //-------------------------------------------------------------------------- - /** - * @private - * @param {Object} message - * @param {integer} [message.id] - * @param {string} [message.model] - * @param {string} [message.subject] - * @param {string} [message.message_type] - */ - _preprocessMessage: function (message) { - var msg = _.extend({}, message); - - msg.date = moment.min(msg.date, moment()); - msg.hour = time_from_now(msg.date); - - var date = msg.date.format('YYYY-MM-DD'); - if (date === moment().format('YYYY-MM-DD')) { - msg.day = _t("Today"); - } else if (date === moment().subtract(1, 'days').format('YYYY-MM-DD')) { - msg.day = _t("Yesterday"); - } else { - msg.day = msg.date.format('LL'); - } - - if (_.contains(this.expanded_msg_ids, message.id)) { - msg.expanded = true; - } - - msg.display_subject = message.subject && message.message_type !== 'notification' && !(message.model && (message.model !== 'mail.channel')); - msg.is_selected = msg.id === this.selected_id; - return msg; - }, /** * @private * @param {Object} options - * @param {integer} [options.channel_id] + * @param {integer} [options.channelID] * @param {string} options.model * @param {integer} options.id */ _redirect: _.debounce(function (options) { - if ('channel_id' in options) { - this.trigger('redirect_to_channel', options.channel_id); + if ('channelID' in options) { + this.trigger('redirect_to_channel', options.channelID); } else { this.trigger('redirect', options.model, options.id); } }, 500, true), + /** + * @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 @@ -407,30 +424,115 @@ var Thread = Widget.extend({ }, /** * @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 }); + _onClick: function () { + if (this._selectedMessageID) { + this.unselectMessage(); + this.trigger('unselect_message'); + } }, /** * @private - * @param {MouseEvent} event + * @param {MouseEvent} ev */ - _onClickMailException: function (event) { - var messageId = $(event.currentTarget).data('message-id'); + _onClickEmailException: function (ev) { + var messageID = $(ev.currentTarget).data('message-id'); this.do_action('mail.mail_resend_message_action', { - additional_context: { - mail_message_to_resend: messageId + additional_context: { + mail_message_to_resend: messageID } }); }, + /** + * @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 + */ + _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'); + }, }); -Thread.ORDER = ORDER; +ThreadWidget.ORDER = ORDER; -return Thread; +return ThreadWidget; }); diff --git a/addons/mail/static/src/js/thread_field.js b/addons/mail/static/src/js/thread_field.js index 6d036208eb2..d3021ca1ce5 100644 --- a/addons/mail/static/src/js/thread_field.js +++ b/addons/mail/static/src/js/thread_field.js @@ -1,7 +1,8 @@ odoo.define('mail.ThreadField', function (require) { "use strict"; -var ChatThread = require('mail.ChatThread'); +var Message = require('mail.model.Message'); +var ThreadWidget = require('mail.widget.Thread'); var AbstractField = require('web.AbstractField'); var core = require('web.core'); @@ -11,69 +12,69 @@ var session = require('web.session'); var _t = core._t; -// ----------------------------------------------------------------------------- -// 'mail_thread' widget: displays the thread of messages -// ----------------------------------------------------------------------------- +/** + * 'mail_thread' widget: displays the thread of messages + */ var ThreadField = AbstractField.extend({ - // inherited + /** + * @override + */ init: function () { this._super.apply(this, arguments); - this.msgIDs = this.value.res_ids; + this._setDocumentThread(); }, + /** + * @override + */ willStart: function () { - return this.alive(this.call('chat_manager', 'isReady')); + return this.alive(this.call('mail_service', 'isReady')); }, + /** + * @override + */ start: function () { var self = this; this.dp = new concurrency.DropPrevious(); - this.thread = new ChatThread(this, { - display_order: ChatThread.ORDER.DESC, - display_document_link: false, - display_needactions: false, - squash_close_messages: false, + this._threadWidget = new ThreadWidget(this, { + displayOrder: ThreadWidget.ORDER.DESC, + displayDocumentLinks: false, + displayMarkAsRead: false, + squashCloseMessages: false, }); - this.thread.on('load_more_messages', this, this._onLoadMoreMessages); - this.thread.on('redirect', this, this._onRedirect); - this.thread.on('redirect_to_channel', this, this._onRedirectToChannel); - this.thread.on('toggle_star_status', this, function (messageID) { - self.call('chat_manager', 'toggleStarStatus', messageID); + this._threadWidget.on('load_more_messages', this, this._onLoadMoreMessages); + this._threadWidget.on('redirect', this, this._onRedirect); + this._threadWidget.on('redirect_to_channel', this, this._onRedirectToChannel); + this._threadWidget.on('toggle_star_status', this, function (messageID) { + var message = self.call('mail_service', 'getMessage', messageID); + message.toggleStarStatus(); }); - var def1 = this.thread.appendTo(this.$el); + var def1 = this._threadWidget.appendTo(this.$el); var def2 = this._super.apply(this, arguments); return this.alive($.when(def1, def2)).then(function () { // unwrap the thread to remove an unnecessary level on div - self.setElement(self.thread.$el); - var chatBus = self.call('chat_manager', 'getChatBus'); - chatBus.on('new_message', self, self._onNewMessage); - chatBus.on('update_message', self, self._onUpdateMessage); + self.setElement(self._threadWidget.$el); + var mailBus = self.call('mail_service', 'getMailBus'); + mailBus.on('new_message', self, self._onNewMessage); + mailBus.on('update_message', self, self._onUpdateMessage); }); }, - _render: function () { - return this._fetchAndRenderThread(this.msgIDs); - }, - isSet: function () { - return true; - }, - destroy: function () { - this.call('chat_manager', 'removeChatterMessages', this.model); - this._super.apply(this, arguments); - }, - _reset: function (record) { - this._super.apply(this, arguments); - this.msgIDs = this.value.res_ids; - // the mail widgets being persistent, one need to update the res_id on reset - this.res_id = record.res_id; - }, //-------------------------------------------------------------------------- // Public //-------------------------------------------------------------------------- + /** + * @override + * @return {boolean} + */ + isSet: function () { + return true; + }, /** * @param {Object} message * @param {integer[]} message.partner_ids @@ -81,15 +82,14 @@ var ThreadField = AbstractField.extend({ */ postMessage: function (message) { var self = this; - var options = {model: this.model, res_id: this.res_id}; - return this.call('chat_manager', 'postMessage', message, options) + return this._documentThread.postMessage(message) .then(function () { if (message.partner_ids.length) { - self.trigger_up('reload_mail_fields', {followers: true}); + self.trigger_up('reload_mail_fields', { followers: true }); } }) .fail(function () { - self.do_notify(_t('Sending Error'), _t('Your message has not been sent.')); + self.do_notify(_t("Sending Error"), _t("Your message has not been sent.")); }); }, @@ -99,43 +99,80 @@ var ThreadField = AbstractField.extend({ /** * @private - * @returns {Object[]} an array containing a single message 'Creating a new record...' - */ - _forgeCreationModeMessages: function () { - return [{ - id: 0, - body: "

Creating a new record...

", - date: moment(), - author_id: [session.partner_id, session.partner_display_name], - displayed_author: session.partner_display_name, - avatar_src: "/web/image/res.partner/" + session.partner_id + "/image_small", - attachment_ids: [], - customer_email_data: [], - tracking_value_ids: [], - }]; - }, - /** - * @private - * @param {integer[]} ids * @param {Object} [options] + * @param {boolean} [options.forceFetch] + * @return {$.Deferred} */ - _fetchAndRenderThread: function (ids, options) { + _fetchAndRenderThread: function (options) { var self = this; options = options || {}; - options.ids = ids; - var fetch_def = this.dp.add(this.call('chat_manager', 'getMessages', options)); - return fetch_def.then(function (raw_messages) { + options.ids = this._messageIDs; + var fetchDef = this.dp.add(this._documentThread.getMessages(options)); + return fetchDef.then(function (rawMessages) { var isCreateMode = false; if (!self.res_id) { - raw_messages = self._forgeCreationModeMessages(); + rawMessages = self._forgeCreateMessages(); isCreateMode = true; } - self.thread.render(raw_messages, { - display_load_more: raw_messages.length < ids.length, + self._threadWidget.render(rawMessages, { + displayLoadMore: rawMessages.length < self._documentThread.getMessageIDs().length, isCreateMode: isCreateMode, }); + return self._documentThread.markAsRead(); }); }, + /** + * Make a fake list of messages to render in create mode. + * Instead of rendering no messages at all, it displays a single message + * with body content "Creating a new record". + * + * @private + * @returns {mail.model.Message[]} an array containing a single message 'Creating a new record...' + */ + _forgeCreateMessages: function () { + var createMessage = new Message(this, { + id: 0, + body: _t("

Creating a new record...

"), + author_id: [session.partner_id, session.partner_display_name], + }); + return [createMessage]; + }, + /** + * @override + * @private + * @returns {$.Deferred} + */ + _render: function () { + return this._fetchAndRenderThread(); + }, + /** + * The mail widget being persistent, one needs to update the res_id and + * to set the correct DocumentThread on reset. + * + * @override + * @private + * @param {any} record + */ + _reset: function (record) { + this._super.apply(this, arguments); + this.res_id = record.res_id; + this._setDocumentThread(); + }, + /** + * Sets this._documentThread, the DocumentThread associated with the current + * model and resID. + * + * @returns {mail.model.DocumentThread} + */ + _setDocumentThread: function () { + var params = { + messageIDs: this.value.res_ids, + name: this.recordData.display_name, + resID: this.res_id, + resModel: this.model, + }; + this._documentThread = this.call('mail_service', 'getOrAddDocumentThread', params); + }, //-------------------------------------------------------------------------- // Handlers @@ -143,38 +180,62 @@ var ThreadField = AbstractField.extend({ /** * When a new message arrives, fetch its data to render it - * @param {Number} message_id : the identifier of the new message - * @returns {Deferred} + * + * @private */ _onLoadMoreMessages: function () { - this._fetchAndRenderThread(this.msgIDs, {forceFetch: true}); + this._fetchAndRenderThread({ forceFetch: true }); }, + /** + * @private + * @param {mail.model.Message} + */ _onNewMessage: function (message) { - if (message.model === this.model && message.res_id === this.res_id) { - this.msgIDs.unshift(message.id); + if ( + message.isLinkedToDocumentThread() && + message.getDocumentModel() === this.model && + message.getDocumentID() === this.res_id + ) { this.trigger_up('new_message', { id: this.value.id, - msgIDs: this.msgIDs, + messageIDs: this._documentThread.getMessageIDs(), }); - this._fetchAndRenderThread(this.msgIDs); + this._fetchAndRenderThread(); } }, + /** + * @private + * @param {integer} channelID + */ _onRedirectToChannel: function (channelID) { var self = this; - this.call('chat_manager', 'joinChannel', channelID).then(function () { - // Execute Discuss client action with 'channel' as default channel - self.do_action('mail.mail_channel_action_client_chat', {active_id: channelID}); + this.call('mail_service', 'joinChannel', channelID).then(function () { + // Execute Discuss with 'channel' as default channel + self.do_action('mail.mail_channel_action_client_chat', { active_id: channelID }); }); }, - _onRedirect: function (res_model, res_id) { + /** + * @private + * @param {string} resModel + * @param {integer} resID + */ + _onRedirect: function (resModel, resID) { this.trigger_up('redirect', { - res_id: res_id, - res_model: res_model, + res_id: resID, + res_model: resModel, }); }, + /** + * @private + * @param {mail.model.Message} + */ _onUpdateMessage: function (message) { - if (message.model === this.model && message.res_id === this.res_id) { - this._fetchAndRenderThread(this.msgIDs); + if ( + message.isLinkedToDocumentThread() && + message.getDocumentModel() === this.model && + message.getDocumentID() === this.res_id + ) { + this._fetchAndRenderThread(); } }, }); diff --git a/addons/mail/static/src/js/thread_windows/abstract_thread_window.js b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js new file mode 100644 index 00000000000..1d49d89eeb7 --- /dev/null +++ b/addons/mail/static/src/js/thread_windows/abstract_thread_window.js @@ -0,0 +1,390 @@ +odoo.define('mail.AbstractThreadWindow', function (require) { +"use strict"; + +var ThreadWidget = require('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; + +var HEIGHT_OPEN = '400px'; +var HEIGHT_FOLDED = '34px'; + +/** + * This is an abstract widget for rendering thread windows. + * + * It contains logic that are shared between mail.ThreadWindow and + * mail.LivechatWindow. + * + * The reason for having two different implementation of thread windows is + * that mail.ThreadWindow makes use of mail.Manager, which is used in the + * backend, while mail.LivechatWindow must work without this mail service. + */ +var AbstractThreadWindow = Widget.extend({ + template: 'mail.AbstractThreadWindow', + custom_events: { + escape_pressed: '_onEscapePressed', + document_viewer_closed: '_onDocumentViewerClose', + }, + events: { + 'click .o_thread_window_close': '_onClickClose', + 'click .o_thread_window_title': '_onClickFold', + 'click .o_thread_composer': '_onComposerClick', + 'click .o_mail_thread': '_onThreadWindowClicked', + 'keydown .o_thread_composer': '_onKeydown', + 'keypress .o_thread_composer': '_onKeypress', + }, + /** + * 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 {Object} thread + * @param {Object} [options={}] + */ + init: function (parent, thread, options) { + this._super(parent); + + this.options = _.defaults(options || {}, { + autofocus: true, + displayStars: true, + displayReplyIcons: false, + displayEmailIcons: false, + placeholder: _t("Say something"), + }); + + this._hidden = false; + }, + start: function () { + this.$input = this.$('.o_composer_text_field'); + this.$header = this.$('.o_thread_window_header'); + + this.threadWidget = new ThreadWidget(this, { + threadID: this._getThreadID(), + displayDocumentLinks: false, + displayMarkAsRead: false, + displayStars: this.options.displayStars, + }); + + if (this.isFolded()) { + this.$el.css('height', HEIGHT_FOLDED); + } else if (this.options.autofocus) { + this._focusInput(); + } + if (!config.device.isMobile) { + this.$el.css('margin-right', $.position.scrollbarWidth()); + } + var def = this.threadWidget.replace(this.$('.o_chat_content')); + return $.when(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 status of the thread, such as the im status of a DM. + * + * @abstract + * @returns {string} + */ + getStatus: function () {}, + /** + * Get the title of the thread window, which usually contains the name of + * the thread. + * + * @abstract + * @returns {string} + */ + getTitle: function () {}, + /** + * Get the unread counter of the thread linked to this thread window. + * + * @abstract + * @returns {integer} + */ + getUnreadCounter: function () {}, + /** + * 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. + * + * @abstract + * @returns {boolean} + */ + hasThread: function () {}, + /** + * States whether this thread is folded or not. This information is stored + * on the thread model. + * + * @abstract + * @returns {boolean} + */ + isFolded: function () {}, + /** + * 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; + }, + /** + * Render the thread window, using the provided list of messages + * + * TODO: use messages in thread, instead of providing list of messages + * + * @param {mail.model.AbstractMessage[]} messages + */ + render: function (messages) { + this._renderHeader(); + this.threadWidget.render(messages, { displayLoadMore: false }); + }, + /** + * Toggle the fold state of this thread window. Also update the fold state + * of the thread model. + * + * @param {boolean} folded + */ + 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(); + this._focusInput(); + } + this._animateFold(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Called when there is a change of the fold state of the thread window. + * This method animates the change of fold state of this thread window. + * + * @private + */ + _animateFold: function () { + this.$el.animate({ + height: this.isFolded() ? HEIGHT_FOLDED : HEIGHT_OPEN + }, 200); + }, + /** + * 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.getStatus(), + title: this.getTitle(), + unreadCounter: this.getUnreadCounter(), + widget: this, + }; + }, + /** + * Get the ID of the related thread. + * + * @abstract + * @private + * @return {integer} + */ + _getThreadID: function () {}, + /** + * Post a message on this thread window + * + * @abstract + * @private + * @param {Object} messageData + */ + _postMessage: function (messageData) {}, + /** + * Update the fold state of the related thread. + * This function is called when toggling the fold state of this window + * + * @abstract + * @private + * @param {boolean} folded + */ + _updateThreadFoldState: function (folded) {}, + /** + * Render the header of this thread window + * + * @private + */ + _renderHeader: function () { + var options = this._getHeaderRenderingOptions(); + this.$header.html( + QWeb.render('mail.AbstractThreadWindowHeaderContent', options)); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Close the thread + * + * @private + * @param {MouseEvent} ev + */ + _onClickClose: function (ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.close(); + }, + /** + * Fold/unfold the thread window + * + * @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(); + }, + /** + * @private + */ + _onEscapePressed: function () { + if (!this.isFolded()) { + this.close(); + } + }, + /** + * 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 + }, + /** + * 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; + +}); diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js new file mode 100644 index 00000000000..c0d7a22ea33 --- /dev/null +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -0,0 +1,428 @@ +odoo.define('mail.ThreadWindow', function (require) { +"use strict"; + +var AbstractThreadWindow = require('mail.AbstractThreadWindow'); +var BasicComposer = require('mail.composer.Basic'); + +var core = require('web.core'); + +var _t = core._t; + +/** + * This is the main widget for rendering small windows for mail.model.Thread. + * Almost all instances of this class are linked to a thread. The sole + * exception is the "blank" thread window. This window let us open another + * thread window, using this "blank" thread window. + */ +var ThreadWindow = AbstractThreadWindow.extend({ + template: 'mail.ThreadWindow', + events: _.extend({}, AbstractThreadWindow.prototype.events, { + 'click .o_thread_window_expand': '_onClickExpand', + }), + /** + * Version of thread window that supports {mail.model.Thread} + * + * @override + * @param {mail.Manager} parent + * @param {mail.model.Thread} [thread = null] if not set, this is a "blank" + * thread window. It lets us open a DM by providing the name of a chat. + * @param {Object} [options={}] + * @param {boolean} [options.passively=false] + */ + init: function (parent, thread, options) { + this._super.apply(this, arguments); + + // don't automatically mark unread messages as seen when at the bottom + // of the thread + this._passive = this.options.passively; + this._thread = thread || null; + + if (!this.hasThread()) { + // internal fold state of thread window without any thread + this._folded = false; + // remembered partner ID of "blank" thread window in order to be + // replaced with newly opened DM window + this.directPartnerID = null; + } + }, + + /** + * @override + */ + start: function () { + var self = this; + + var superDef = this._super().then(this._listenThreadWidget.bind(this)); + + var composerDef; + if (!this.hasThread()) { + this._startWithoutThread(); + } else if (this._needsComposer()) { + var basicComposer = new BasicComposer(this, { + mentionPartnersRestricted: this._thread.getType() !== 'document_thread', + isMini: true + }); + basicComposer.on('post_message', this, this._postMessage); + basicComposer.once('input_focused', this, function () { + var commands = this._thread.getCommands(); + var partners = this._thread.getMentionPartnerSuggestions(); + basicComposer.mentionSetCommands(commands); + basicComposer.mentionSetPrefetchedPartners(partners); + }); + composerDef = basicComposer.replace(this.$('.o_thread_composer')); + composerDef.then(function () { + self.$input = self.$('.o_composer_text_field'); + }); + } + + return $.when(superDef, composerDef); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * @override + */ + close: function () { + if (this._hasThread()) { + this._thread.close(); + } + }, + /** + * Set the focus on the input of this thread window + */ + focusInput: function () { + this._focusInput(); + }, + /** + * 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(); + }, + /** + * Get the status of the thread. If this window has no thread, returns + * 'undefined' + * + * @override + * @returns {string|undefined} + */ + getStatus: function () { + if (!this.hasThread()) { + return undefined; + } + return this._thread.getStatus(); + }, + /** + * Get the title of the thread window, which is equivalent to the name of + * related thread, prefixed with "#" if this is a chat (i.e. DM, backend + * livechat). + * + * If there is no thread linked to this thread window, display title of + * "blank" thread window. + * + * @override + * @returns {string} + */ + getTitle: function () { + if (!this.hasThread()) { + return _t("New message"); + } + return this._thread.getTitle(); + }, + /** + * Get the unread counter of the related thread. If there are no thread + * linked to this window, returns 0. + * + * @override + * @returns {integer} + */ + getUnreadCounter: function () { + if (!this.hasThread()) { + return 0; + } + return this._thread.getUnreadCounter(); + }, + /** + * @override + * @returns {boolean} + */ + hasThread: function () { + return !!this._thread; + }, + /** + * 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. + * + * @override + * @returns {boolean} + */ + isFolded: function () { + if (!this.hasThread()) { + return this._folded; + } + return this._thread.isFolded(); + }, + /** + * Tell whether the thread window is passive or not. A passive thread window + * does not auto-mark the thread as read when scrolling at the bottom. + * + * @returns {boolean} + */ + isPassive: function () { + return this._passive; + }, + /** + * Turn the thread window in active mode, so that when the bottom of the + * thread is visible, it is automatically marked as read. + */ + removePassive: function () { + this._passive = false; + }, + /** + * Update 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. + */ + updateHeader: function () { + this._renderHeader(); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Get the ID of the thread window, which is equivalent to 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. + * + * @override + * @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(); + }, + /** + * @override + * @private + */ + _getHeaderRenderingOptions: function () { + var options = this._super.apply(this, arguments); + if (this._thread.getType() === 'document_thread') { + options.expandTitle = _t("Open document"); + } else { + options.expandTitle = _t("Open in Discuss"); + } + return options; + }, + /** + * States whether the window is linked to a thread or not + * + * Useful to detect whether this is the "blank" thread window or not. + * + * @private + * @returns {boolean} + */ + _hasThread: function () { + return !!this._thread; + }, + /** + * Listen on thread widget events + * + * @private + */ + _listenThreadWidget: function () { + this.threadWidget + .on('redirect', this, this._onRedirect) + .on('redirect_to_channel', this, this._onRedirectToChannel) + .on('toggle_star_status', this, this._onToggleStarStatus); + }, + /** + * States whether the input of the thread window should be displayed or not. + * This is based on the type of the thread: + * + * Do not display the input in the following cases: + * + * - no thread related to this window + * - window of a mailbox (temp: let us have mailboxes in window mode) + * - window of a thread with mass mailing + * + * Any other threads show the input in the window. + * + * @private + * @returns {boolean} + */ + _needsComposer: function () { + return this.hasThread() && + (this._thread.getType() !== 'mailbox') && + !this._thread.isMassMailing(); + }, + /** + * Open this thread window. + * This private method exists only for the purpose of providing a callback + * function on redirect to a partner. + * + * @private + */ + _open: function () { + this.call('mail_service', 'openThreadWindow', this.getID()); + }, + /** + * Post a message in the thread related to this window. + * If this window has no thread, do nothing + * + * @override + * @private + * @param {Object} messageData + */ + _postMessage: function (messageData) { + var self = this; + if (!this._hasThread()) { + return; + } + this._thread.postMessage(messageData) + .then(function () { + self.threadWidget.scrollToBottom(); + }); + }, + /** + * @private + */ + _startWithoutThread: function () { + var self = this; + this.$el.addClass('o_thread_less'); + this.$('.o_thread_search_input input') + .autocomplete({ + source: function (request, response) { + self.call('mail_service', 'searchPartner', request.term, 10) + .done(response); + }, + select: function (event, ui) { + // remember partner ID so that we can replace this window + // with new DM window + var partnerID = ui.item.id; + self.directPartnerID = partnerID; + self.call('mail_service', 'openDmWindow', partnerID); + }, + }) + .focus(); + }, + /** + * Update the fold state of the thread + * If there is no thread linked to this window, it means this is the + * "blank" thread window, therefore we use the internal state 'folded' + * + * @override + * @private + * @param {boolean} folded + */ + _updateThreadFoldState: function (folded) { + if (this.hasThread()) { + this._thread.fold(folded); + } else { + this._folded = folded; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Expand the thread, which will open the thread in discuss. + * + * If there is no thread linked to this window, it means this is the "blank" + * thread window, therefore it opens discuss with the default thread (which + * should be the mailbox 'inbox') + * + * @private + * @param {MouseEvent} ev + */ + _onClickExpand: _.debounce(function (ev) { + var self = this; + ev.preventDefault(); + if (this._thread.getType() === 'document_thread') { + this.do_action({ + type: 'ir.actions.act_window', + res_model: this._thread.getDocumentModel(), + views: [[false, 'form']], + res_id: this._thread.getDocumentID(), + }); + } else { + this.do_action('mail.mail_channel_action_client_chat', { + clear_breadcrumbs: false, + active_id: this.hasThread() ? this._getThreadID() : undefined, + on_reverse_breadcrumb: function () { + self.call('mail_service', 'getMailBus') + .trigger('discuss_open', false); + }, + }); + } + }, 1000, true), + /** + * @override + * @private + * @param {KeyboardEvent} ev + * + * Override _onKeydown to only prevent jquery's blockUI to cancel event, + * but without sending the message on ENTER keydown as this is handled by + * the BasicComposer + */ + _onKeydown: function (ev) { + ev.stopPropagation(); + }, + /** + * callback function receives the threadID linked to resModel and resID + * Note: this callback function is only called for 'res.partner' + * + * @private + * @param {string} resModel + * @param {integer} resID + */ + _onRedirect: function (resModel, resID) { + var callback = this._open.bind(this); + this.call('mail_service', 'redirect', resModel, resID, callback); + }, + /** + * @private + * @param {integer} channelID + */ + _onRedirectToChannel: function (channelID) { + var thread = this.call('mail_service', 'getThread', channelID); + if (!thread) { + this.call('mail_service', 'joinChannel', channelID) + .then(function (channel) { + channel.detach(); + }); + } else { + this.toggleFold(false); + } + }, + /** + * @private + * @param {integer} messageID + */ + _onToggleStarStatus: function (messageID) { + var message = this.call('mail_service', 'getMessage', messageID); + message.toggleStarStatus(); + }, + +}); + +return ThreadWindow; + +}); diff --git a/addons/mail/static/src/js/tour.js b/addons/mail/static/src/js/tour.js index f8fd387eb9f..ffbe69de525 100644 --- a/addons/mail/static/src/js/tour.js +++ b/addons/mail/static/src/js/tour.js @@ -1,4 +1,4 @@ -odoo.define('mail.tour', function(require) { +odoo.define('mail.tour', function (require) { "use strict"; var core = require('web.core'); @@ -7,24 +7,24 @@ var tour = require('web_tour.tour'); var _t = core._t; tour.register('mail_tour', [{ - trigger: '.o_mail_chat .o_mail_chat_sidebar .o_add[data-type="public"]', + trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="public"]', content: _t("

Channels make it easy to organize information across different topics and groups.

Try to create your first channel (e.g. sales, marketing, product XYZ, after work party, etc).

"), position: 'bottom', }, { - trigger: '.o_mail_chat .o_composer_text_field', - content: _t("

Write a message to the members of the channel here.

You can notify someone with \'@\' or link another channel with \'#\'. Start your message with \'/\' to get the list of possible commands.

"), + trigger: '.o_mail_discuss .o_composer_text_field', + content: _t("

Write a message to the members of the channel here.

You can notify someone with '@' or link another channel with '#'. Start your message with '/' to get the list of possible commands.

"), position: "top", width: 350, }, { - trigger: ".o_mail_chat .o_mail_thread .o_thread_message_star", + trigger: ".o_mail_discuss .o_mail_thread .o_thread_message_star", content: _t("Messages can be starred to remind you to check back later."), position: "right", }, { - trigger: '.o_mail_chat .o_mail_chat_channel_item[data-channel-id="channel_starred"]', + trigger: '.o_mail_discuss .o_mail_item_item[data-thread-id="mailbox_starred"]', content: _t("Once a message has been starred, you can come back and review it at any time here."), position: "bottom", }, { - trigger: '.o_mail_chat .o_mail_chat_sidebar .o_add[data-type="dm"]', + trigger: '.o_mail_discuss .o_mail_discuss_sidebar .o_add[data-type="dm"]', content: _t("

Chat with coworkers in real-time using direct messages.

You might need to invite users from the Settings app first.

"), position: 'bottom', }]); diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index 16507035982..4749ba11b20 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -1,24 +1,28 @@ odoo.define('mail.utils', function (require) { "use strict"; -function parse_and_transform(html_string, transform_function) { - var open_token = "OPEN" + Date.now(); - var string = html_string.replace(/</g, open_token); +var core = require('web.core'); + +var _t = core._t; + +function parseAndTransform(htmlString, transformFunction) { + var openToken = "OPEN" + Date.now(); + var string = htmlString.replace(/</g, openToken); var children = $('
').html(string).contents(); - return _parse_and_transform(children, transform_function) - .replace(new RegExp(open_token, "g"), "<"); + return _parseAndTransform(children, transformFunction) + .replace(new RegExp(openToken, "g"), "<"); } -function _parse_and_transform(nodes, transform_function) { +function _parseAndTransform(nodes, transformFunction) { return _.map(nodes, function (node) { - return transform_function(node, function () { - return _parse_and_transform(node.childNodes, transform_function); + return transformFunction(node, function () { + return _parseAndTransform(node.childNodes, transformFunction); }); }).join(""); } // Suggested URL Javascript regex of http://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url // Adapted to make http(s):// not required if (and only if) www. is given. So `should.notmatch` does not match. -var url_regexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._\+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_\+.~#?&'$//=]*)/gi; +var urlRegexp = /\b(?:https?:\/\/\d{1,3}(?:\.\d{1,3}){3}|(?:https?:\/\/|(?:www\.))[-a-z0-9@:%._+~#=]{2,256}\.[a-z]{2,13})\b(?:[-a-z0-9@:%_+.~#?&'$//=]*)/gi; function linkify(text, attrs) { attrs = attrs || {}; if (attrs.target === undefined) { @@ -27,28 +31,28 @@ function linkify(text, attrs) { attrs = _.map(attrs, function (value, key) { return key + '="' + _.escape(value) + '"'; }).join(' '); - return text.replace(url_regexp, function (url) { + return text.replace(urlRegexp, function (url) { var href = (!/^https?:\/\//i.test(url)) ? "http://" + url : url; return '' + url + ''; }); } -function add_link (node, transform_children) { +function addLink(node, transformChildren) { if (node.nodeType === 3) { // text node return linkify(node.data); } if (node.tagName === "A") return node.outerHTML; - node.innerHTML = transform_children(); + node.innerHTML = transformChildren(); return node.outerHTML; } -function strip_html (node, transform_children) { +function stripHTML(node, transformChildren) { if (node.nodeType === 3) return node.data; // text node if (node.tagName === "BR") return "\n"; - return transform_children(); + return transformChildren(); } -function inline (node, transform_children) { +function inline(node, transform_children) { if (node.nodeType === 3) return node.data; if (node.nodeType === 8) return ""; if (node.tagName === "BR") return " "; @@ -58,7 +62,7 @@ function inline (node, transform_children) { } // Parses text to find email: Tagada -> [Tagada, address@mail.fr] or False -function parse_email (text) { +function parseEmail(text) { if (text){ var result = text.match(/(.*)<(.*@.*)>/); if (result) { @@ -74,13 +78,13 @@ function parse_email (text) { // Replaces textarea text into html text (add

, ) // TDE note : should be done server-side, in Python -> use mail.compose.message ? -function get_text2html (text) { +function getTextToHTML(text) { return text .replace(/((?:https?|ftp):\/\/[\S]+)/g,'$1 ') .replace(/[\n\r]/g,'
'); } -var accented_letters_mapping = { +var accentedLettersMapping = { 'a': '[àáâãäå]', 'ae': 'æ', 'c': 'ç', @@ -92,21 +96,29 @@ var accented_letters_mapping = { 'u': '[ùúûűü]', 'y': '[ýÿ]', }; -function unaccent (str) { - _.each(accented_letters_mapping, function (value, key) { +function unaccent(str) { + _.each(accentedLettersMapping, function (value, key) { str = str.replace(new RegExp(value, 'g'), key); }); return str; } +function timeFromNow(date) { + if (moment().diff(date, 'seconds') < 45) { + return _t("now"); + } + return date.fromNow(); +} + return { - parse_and_transform: parse_and_transform, - add_link: add_link, - linkify: linkify, - strip_html: strip_html, + addLink: addLink, + getTextToHTML: getTextToHTML, inline: inline, - parse_email: parse_email, - get_text2html: get_text2html, + linkify: linkify, + parseAndTransform: parseAndTransform, + parseEmail: parseEmail, + stripHTML: stripHTML, + timeFromNow: timeFromNow, unaccent: unaccent, }; diff --git a/addons/mail/static/src/scss/chat_window.scss b/addons/mail/static/src/scss/abstract_thread_window.scss similarity index 72% rename from addons/mail/static/src/scss/chat_window.scss rename to addons/mail/static/src/scss/abstract_thread_window.scss index 93ab8248b6b..c55662f4bb3 100644 --- a/addons/mail/static/src/scss/chat_window.scss +++ b/addons/mail/static/src/scss/abstract_thread_window.scss @@ -1,17 +1,17 @@ -$o-mail-chat-window-zindex: $zindex-modal + 1 !default; +$o-mail-thread-window-zindex: $zindex-modal + 1 !default; -.o_chat_window { +.o_thread_window { @include o-flex-display; @include o-flex-flow(column, nowrap); position: fixed; - width: $o-mail-chat-window-width; + width: $o-mail-thread-window-width; max-width: 100%; height: 400px; max-height: 100%; font-size: 12px; - background-color: $o-mail-chat-window-bg; + background-color: $o-mail-thread-window-bg; border-radius: 6px 6px 0 0; - z-index: $o-mail-chat-window-zindex; + z-index: $o-mail-thread-window-zindex; box-shadow: -5px -5px 10px rgba(black, 0.18); @media (max-width: $screen-xs-max) { @@ -27,7 +27,7 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; display: none; } - .o_chat_header { + .o_thread_window_header { @include o-flex-display; @include o-flex(0, 0, auto); color: white; @@ -41,26 +41,26 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; height: $o-mail-chat-header-height; padding-top: 12px; border-radius: 0px; - .o_chat_title { + .o_thread_window_title { font-size: 16px; margin-left: 10px; } - .o_chat_window_close { + .o_thread_window_close { font-size: 17px; color: white; } } - .o_chat_title { + .o_thread_window_title { cursor: pointer; @include o-flex(1, 1, auto); @include o-text-overflow; } - .o_chat_window_buttons { + .o_thread_window_buttons { @include o-flex(0, 0, auto); - .o_chat_window_close, .o_chat_window_expand { + .o_thread_window_close, .o_thread_window_expand { color: white; padding: 3px; margin-left: 5px; @@ -77,7 +77,7 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; .o_thread_date_separator { margin: 0px 0px 15px 0px; .o_thread_date { - background-color: $o-mail-chat-window-bg; + background-color: $o-mail-thread-window-bg; } } .o_thread_message { @@ -88,12 +88,12 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; } } - .o_chat_composer input { + .o_thread_composer input { width: 100%; } } -.o_chat_window_dropdown { +.o_thread_window_dropdown { width: auto; height: 28px; color: white; @@ -105,11 +105,11 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; display: none; } - .o_chat_header { + .o_thread_window_header { border-radius: 0; } - .o_chat_window_dropdown_toggler { + .o_thread_window_dropdown_toggler { padding: 5px; .o_total_unread_counter { @@ -119,18 +119,18 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; font-size: smaller; } } - &.open .o_chat_window_dropdown_toggler .o_total_unread_counter, .o_chat_window_expand { + &.open .o_thread_window_dropdown_toggler .o_total_unread_counter, .o_thread_window_expand { display: none; } > ul { - max-width: $o-mail-chat-window-width; + max-width: $o-mail-thread-window-width; padding: 0; - > li.o_chat_header { + > li.o_thread_window_header { font-size: 12px; padding: 3px 5px; - &~li.o_chat_header { + &~li.o_thread_window_header { border-top: 1px solid white; } &:hover { @@ -140,17 +140,17 @@ $o-mail-chat-window-zindex: $zindex-modal + 1 !default; } } -.o_ui_blocked .o_chat_window { - // We cannot put the z-index of chat windows directly to be greater than +.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 chat windows (and ui-autocomplete is used to choose the + // 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 chat windows but this is not really an issue as + // 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_chat_window .o_chat_window { +.o_no_thread_window .o_thread_window { display: none; } diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index fed625ace35..58bf5596cd3 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -12,7 +12,7 @@ local(Symbola); } -.o_chat_composer { +.o_thread_composer { position: relative; @include o-flex(0, 0, auto); @include o-flex-display; @@ -244,8 +244,8 @@ //font-size:150%; font-family: emojifont; } -.o_mail_channel_preview .o_mail_emoji{ - font-size:100%; +.o_mail_preview .o_mail_emoji { + font-size: 100%; } // Attachment Icons (common for chat thread and chat composer) @@ -428,7 +428,7 @@ $o-mail-attachment-margin: 5px; } } .o_modal_fullscreen { - z-index: $o-mail-chat-window-zindex + 1; + z-index: $o-mail-thread-window-zindex + 1; .o_viewer_content { position: relative; diff --git a/addons/mail/static/src/scss/discuss.scss b/addons/mail/static/src/scss/discuss.scss index d6cd8dbaaa9..2a65cc6e183 100644 --- a/addons/mail/static/src/scss/discuss.scss +++ b/addons/mail/static/src/scss/discuss.scss @@ -2,7 +2,7 @@ // Layout // ------------------------------------------------------------------ -.o_mail_chat { +.o_mail_discuss { @include o-position-absolute(0, 0, 0, 0); height: 100%; overflow: hidden; @@ -36,7 +36,7 @@ } } - .o_mail_chat_sidebar { + .o_mail_discuss_sidebar { width: $o-mail-chat-sidebar-width; height: 100%; float: left; @@ -63,12 +63,12 @@ cursor: pointer; } } - .o_mail_chat_channel_item { + .o_mail_discuss_item { @include o-flex-display; width: 100%; padding: 2px $o-horizontal-padding 2px ($o-horizontal-padding + 12px); // compensate icon - .o_channel_name { + .o_thread_name { @include o-flex(1, 1, auto); @include o-text-overflow; } @@ -85,17 +85,17 @@ margin-left: 3px; } - &.o_mail_chat_title_main { + &.o_mail_discuss_title_main { padding: $padding-base-vertical $o-horizontal-padding; margin-bottom: 2px; } } - .o_mail_chat_channel_item + .o_mail_sidebar_title { + .o_mail_discuss_item + .o_mail_sidebar_title { margin-top: $padding-base-vertical; } - .o_mail_add_channel { + .o_mail_add_thread { width: 100%; padding: 5px ($o-horizontal-padding + 23px); // compensate icon position: relative; @@ -110,7 +110,7 @@ } } - .o_mail_chat_content { + .o_mail_discuss_content { @include o-flex-display; @include o-flex-flow(column, nowrap); height: 100%; @@ -151,12 +151,12 @@ // Style // ------------------------------------------------------------------ -.o_mail_chat { - .o_mail_chat_sidebar { +.o_mail_discuss { + .o_mail_discuss_sidebar { background-color: $gray-dark; color: $gray-lighter-dark; - .o_mail_chat_channel_item { + .o_mail_discuss_item { cursor: pointer; &:hover { @@ -192,10 +192,10 @@ height: 18px; } - &.o_mail_chat_title_main { + &.o_mail_discuss_title_main { font-size: $font-size-base; - &.o_mail_chat_title_starred > .badge { + &.o_mail_mailbox_title_starred > .badge { background-color: $gray-light; border-color: $gray-light; } @@ -206,7 +206,7 @@ border-color: $gray; } - .o_mail_add_channel { + .o_mail_add_thread { display: none; > input { color: black; @@ -234,7 +234,7 @@ } } - .o_mail_chat_content { + .o_mail_discuss_content { background-color: white; } @@ -266,18 +266,18 @@ // Mobile // ------------------------------------------------------------------ @media (max-width: $screen-xs-max) { - .o_mail_chat { + .o_mail_discuss { @include o-flex-display; @include o-flex-flow(column, nowrap); background: white; - .o_mail_chat_mobile_inbox_buttons { + .o_mail_discuss_mobile_mailboxes_buttons { @include o-flex-display; @include o-flex(0, 0, auto); z-index: 2; box-shadow: 0 2px 4px $gray-lighter-darker; - .o_channel_inbox_item { + .o_mailbox_inbox_item { @include o-flex(1, 1, auto); width: 50%; padding: 2%; @@ -288,16 +288,16 @@ } } - .o_mail_chat_content { + .o_mail_discuss_content { @include o-flex(1, 0, 0); margin-left: 10px; margin-right: 10px; background-color: transparent; } - .o_mail_chat_tab_pane { + .o_mail_discuss_tab_pane { overflow-x: scroll; - .o_mail_add_channel { + .o_mail_add_thread { display: none; position: relative; padding: 5px 0px; @@ -321,10 +321,10 @@ } // ------------------------------------------------------------------ -// Channel preview: shared between discuss (mobile) and systray +// Thread preview: shared between discuss (mobile) and systray // ------------------------------------------------------------------ -.o_mail_channel_preview { +.o_mail_preview { @include o-flex-display; background-color: $gray-lighter; color: $o-main-text-color; @@ -333,7 +333,7 @@ position: relative; &:hover { background-color: $gray-lighter-dark; - .o_channel_name { + .o_preview_name { color: $headings-color; } .o_discuss_icon { @@ -343,30 +343,30 @@ &:not(:last-child) { border-bottom: 1px solid $gray-lighter-darker; } - .o_mail_channel_image { + .o_mail_preview_image { @include o-flex(0, 0, auto); position: relative; > img { @include square(100%); border-radius: 50%; } - &.o_mail_channel_app > img { + &.o_mail_preview_app > img { border-radius: 2px; } .o_mail_user_status { @include o-position-absolute($bottom: 0px, $right: 0px); } } - .o_channel_info { + .o_preview_info { @include o-flex(1, 1, 100%); overflow: hidden; - .o_channel_title { + .o_preview_title { @include o-flex-display; - .o_channel_name { + .o_preview_name { @include o-flex(0, 1, auto); @include o-text-overflow; } - .o_channel_counter { + .o_preview_counter { @include o-flex(1, 1, auto); } .o_last_message_date { @@ -385,14 +385,14 @@ .o_discuss_icon { opacity: 0; } - &.o_channel_unread { + &.o_preview_unread { background-color: transparent; &:hover { background-color: $gray-lighter; } - .o_channel_info { - .o_channel_title { - .o_channel_name, .o_channel_counter { + .o_preview_info { + .o_preview_title { + .o_preview_name, .o_preview_counter { font-weight: 700; } .o_last_message_date { @@ -409,18 +409,18 @@ border: none; } - .o_mail_channel_preview { + .o_mail_preview { padding: $o-mail-chatter-mobile-gap; - .o_mail_channel_image:not(img) { + .o_mail_preview_image:not(img) { width: $o-mail-chatter-mobile-gap*6; } - .o_channel_info, .o_last_message_date { + .o_preview_info, .o_last_message_date { margin-left: $o-mail-chatter-mobile-gap; } - .o_channel_name { + .o_preview_name { font-size: 1.1em; } diff --git a/addons/mail/static/src/scss/systray.scss b/addons/mail/static/src/scss/systray.scss index b0ed61e140f..9937608e040 100644 --- a/addons/mail/static/src/scss/systray.scss +++ b/addons/mail/static/src/scss/systray.scss @@ -1,5 +1,5 @@ -// Navbar icon and dropdown -.o_mail_navbar_item { +// Systray icon and dropdown +.o_mail_systray_item { > a { opacity: 1; > i { @@ -15,7 +15,7 @@ display: none; } } - &.open .o_mail_navbar_dropdown { + &.open .o_mail_systray_dropdown { @include o-flex-display; @include o-flex-flow(column, nowrap); } @@ -26,7 +26,7 @@ padding: 0em 0.3em; font-size: 0.7em; } - .o_mail_navbar_dropdown { + .o_mail_systray_dropdown { width: 350px; padding: 0; @@ -38,7 +38,7 @@ height: 50px; } - .o_mail_navbar_dropdown_top { + .o_mail_systray_dropdown_top { @include o-flex-display; @include o-flex(0, 0, auto); justify-content: space-between; @@ -59,23 +59,23 @@ } } - .o_mail_navbar_dropdown_channels { + .o_mail_systray_dropdown_items { @include o-flex(0, 1, auto); max-height: 400px; min-height: 50px; overflow-y: auto; @media (min-width: $screen-sm-min) { - .o_mail_channel_preview { + .o_mail_preview { min-height: 50px; padding: 5px; - .o_mail_channel_image { + .o_mail_preview_image { width: 40px; height: 40px; } - .o_channel_info { + .o_preview_info { margin-left: 10px; - .o_channel_title { + .o_preview_title { .o_last_message_date { padding-top: 2px; font-size: x-small; @@ -99,29 +99,29 @@ } } -.o_no_chat_window .o_mail_navbar_dropdown .o_new_message { +.o_no_thread_window .o_mail_systray_dropdown .o_new_message { display: none; // hide 'new message' button if chat windows are disabled } // Mobile rules // Goal: mock the design of Discuss in mobile @media (max-width: $screen-xs-max) { - .o_mail_navbar_item { + .o_mail_systray_item { .o_notification_counter { top: 10%; } - .o_mail_navbar_dropdown { + .o_mail_systray_dropdown { position: relative; - .o_mail_navbar_dropdown_top { + .o_mail_systray_dropdown_top { padding: 5px; } - .o_mail_navbar_mobile_header { + .o_mail_systray_mobile_header { padding: 5px; height: 44px; border-bottom: 1px solid #ebebeb; box-shadow: 0 0 2px $gray-lighter-darker; } - .o_mail_navbar_dropdown_channels { + .o_mail_systray_dropdown_items { max-height: none; padding-bottom: 52px; // leave space for tabs } diff --git a/addons/mail/static/src/scss/extended_chat_window.scss b/addons/mail/static/src/scss/thread_window.scss similarity index 93% rename from addons/mail/static/src/scss/extended_chat_window.scss rename to addons/mail/static/src/scss/thread_window.scss index 6cb930b4488..bca79bc9c7a 100644 --- a/addons/mail/static/src/scss/extended_chat_window.scss +++ b/addons/mail/static/src/scss/thread_window.scss @@ -1,6 +1,6 @@ -.o_chat_window { +.o_thread_window { &.o_thread_less { - .o_chat_search_input { + .o_thread_search_input { @include o-flex-display; @include o-align-items(baseline); padding: $o-mail-chatter-gap*0.5 $o-mail-chatter-gap; @@ -20,12 +20,12 @@ background-color: $o-webclient-background-color; } - .o_chat_composer { + .o_thread_composer { display: none; } } - .o_chat_composer { + .o_thread_composer { .o_composer { .o_composer_input { width: 80%; diff --git a/addons/mail/static/src/scss/variables.scss b/addons/mail/static/src/scss/variables.scss index c3086ce579d..4630a98c816 100644 --- a/addons/mail/static/src/scss/variables.scss +++ b/addons/mail/static/src/scss/variables.scss @@ -1,8 +1,8 @@ $o-mail-thread-avatar-size: 36px !default; $o-mail-thread-icon-opacity: 0.6 !default; $o-mail-thread-side-date-opacity: 0.6 !default; -$o-mail-chat-window-bg: #FAFAFA !default; -$o-mail-chat-window-width: 325px !default; +$o-mail-thread-window-bg: #FAFAFA !default; +$o-mail-thread-window-width: 325px !default; $o-mail-chatter-gap: 10px !default; $o-mail-chatter-mobile-gap: 2% !default; $o-mail-chat-header-height: 46px !default; diff --git a/addons/mail/static/src/xml/abstract_thread_window.xml b/addons/mail/static/src/xml/abstract_thread_window.xml new file mode 100644 index 00000000000..6857c638c45 --- /dev/null +++ b/addons/mail/static/src/xml/abstract_thread_window.xml @@ -0,0 +1,79 @@ + + + + + +

+
+ + + + + +
+
+
+
+ +
+
+ + + + +
+ + + + + + + +
+
+ + + + + + + + + + () + + + + + + + diff --git a/addons/mail/static/src/xml/activity.xml b/addons/mail/static/src/xml/activity.xml index 9d2f0d72ed8..d96db53ac41 100644 --- a/addons/mail/static/src/xml/activity.xml +++ b/addons/mail/static/src/xml/activity.xml @@ -40,13 +40,13 @@
Created on
- +
Assigned to
- , due on + , due on
diff --git a/addons/mail/static/src/xml/chat_window.xml b/addons/mail/static/src/xml/chat_window.xml deleted file mode 100644 index 7d0fce19ee6..00000000000 --- a/addons/mail/static/src/xml/chat_window.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - -
-
- - - - - -
-
-
-
- -
-
-
- - -
- - - - - - - -
-
- - - -
-
- - - - () - - - - - - - diff --git a/addons/mail/static/src/xml/chatter.xml b/addons/mail/static/src/xml/chatter.xml index e6cd3995bc7..7a5959789cf 100644 --- a/addons/mail/static/src/xml/chatter.xml +++ b/addons/mail/static/src/xml/chatter.xml @@ -3,28 +3,30 @@ - + - + To: Followers of - - "" + + "" - + this document -
- +
+
- - - - diff --git a/addons/mail/static/src/xml/composer.xml b/addons/mail/static/src/xml/composer.xml index 752b9febdf9..f7baafeb777 100644 --- a/addons/mail/static/src/xml/composer.xml +++ b/addons/mail/static/src/xml/composer.xml @@ -1,7 +1,7 @@ -
+
Avatar
@@ -20,11 +20,11 @@
- +
- + /web/binary/upload_attachment @@ -34,15 +34,19 @@
-
+
-
- + + - + @@ -60,18 +64,18 @@ - + - + - + / diff --git a/addons/mail/static/src/xml/discuss.xml b/addons/mail/static/src/xml/discuss.xml index 7901a870ea3..493d3c918aa 100644 --- a/addons/mail/static/src/xml/discuss.xml +++ b/addons/mail/static/src/xml/discuss.xml @@ -1,12 +1,16 @@ - + -
-
-
- +
+
+
+
Odoo needs your permission to enable desktop notifications. @@ -15,145 +19,184 @@
- -
-
- Inbox - - + + + +
+
+ Inbox + +
-
- Starred - - +
+ Starred + +
-
- Moderation Queue +
+ Moderation Queue - +

- - - Channels - + + + Channels + - - - Add a channel + + + Add a channel - - - Direct Messages - + + + Direct Messages + - - - User name + + + User name - - - Private Channels - + + + Private Channels + - - Add a private channel - + + Add a private channel +
- + + + - - - + + + + - - + + - +
-

- - +

+ +

- -
-
-
- -
- # - -
- - -
- - - - - - - # - - - - - - +
- + + +
+ # + +
+ + +
+ + + + + + + # + + + + + + +
+
+
+ + + - + + - - + +
- - - - - - - - - - + + + + + + + + + +
- +
@@ -163,17 +206,18 @@ -
-
- +
-
+
-
+
Inbox
@@ -189,51 +233,71 @@
- -
-
+ + +
+
# - Add a channel - Open chat - + Add a channel + Open chat +
- - + +
- - -
-
- Channel - - + + +
+
+ Channel + +
-
-
- - +
+
+ + - - &nbsp;() + + &nbsp;() - +
-
- +
+ You: - - - : - + + : - +
- +
diff --git a/addons/mail/static/src/xml/extended_chat_window.xml b/addons/mail/static/src/xml/extended_chat_window.xml deleted file mode 100644 index c5188feadfa..00000000000 --- a/addons/mail/static/src/xml/extended_chat_window.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - -
- To: - -
-
-
- - - - - - - -
diff --git a/addons/mail/static/src/xml/systray.xml b/addons/mail/static/src/xml/systray.xml index 6350e07d2da..ae1d7f2879f 100644 --- a/addons/mail/static/src/xml/systray.xml +++ b/addons/mail/static/src/xml/systray.xml @@ -1,18 +1,21 @@ - -
  • + + +
  • -