From 02ec09cb1c3e2d7bc7968f40c18f2208d7f3f498 Mon Sep 17 00:00:00 2001 From: Aaron Bohy Date: Thu, 21 Dec 2017 12:29:50 +0100 Subject: [PATCH] [REF][IMP] *: mail js refactoring This commit improves the JS code of the mail module, which comes maily from the new coding guidelines and the addition of "JS services". JS services are important objects that do not fit well in the component tree, such as chat_manager or ajax. The benefits of JS services are improved readability of the code, reduced coupling, and more testable modules. In particular, discuss was hard to Summary of the changes: - ClientAction has been renamed into Discuss - Clear instantiation of chatManager - New coding guidelines in most mail modules - JS Services can interact with each other - Chat Manager and Window Manager are services - Window Manager renamed to Chat Window Manager - bus.bus is now encapsulated in Bus Service (a service) - The test infrastructure has been tweaked with JS Services - Chat Mixin has been removed A future improvement would be to translate some 'trigger' into 'trigger_up'. --- .../bus/static/src/js/services/bus_service.js | 96 + addons/bus/views/bus.xml | 1 + .../calendar/static/src/js/base_calendar.js | 3 +- .../im_livechat/static/src/js/im_livechat.js | 29 +- .../static/src/js/im_livechat_backend.js | 17 +- .../static/src/xml/im_livechat_backend.xml | 2 +- .../views/im_livechat_channel_templates.xml | 2 + addons/mail/__manifest__.py | 2 +- addons/mail/controllers/main.py | 4 +- addons/mail/models/mail_message.py | 11 - addons/mail/static/src/js/chat_manager.js | 1275 ------------ addons/mail/static/src/js/chat_mixin.js | 130 -- addons/mail/static/src/js/chat_window.js | 4 +- addons/mail/static/src/js/chatter.js | 5 +- addons/mail/static/src/js/chatter_composer.js | 1 - addons/mail/static/src/js/composer.js | 15 +- .../src/js/{client_action.js => discuss.js} | 305 +-- ...ent_action_mobile.js => discuss_mobile.js} | 38 +- .../static/src/js/extended_chat_window.js | 34 +- .../static/src/js/services/chat_manager.js | 1751 +++++++++++++++++ .../src/js/services/chat_window_manager.js | 532 +++++ addons/mail/static/src/js/systray.js | 43 +- addons/mail/static/src/js/thread.js | 16 +- addons/mail/static/src/js/thread_field.js | 27 +- addons/mail/static/src/js/utils.js | 43 - addons/mail/static/src/js/window_manager.js | 400 ---- .../less/{client_action.less => discuss.less} | 2 +- .../xml/{client_action.xml => discuss.xml} | 8 +- ...at_window_test.js => chat_window_tests.js} | 10 +- addons/mail/static/tests/chatter_tests.js | 268 +-- .../tests/client_action_mobile_tests.js | 105 - .../mail/static/tests/discuss_mobile_tests.js | 78 + addons/mail/static/tests/discuss_tests.js | 74 + .../mail/static/tests/helpers/mock_server.js | 35 + .../mail/static/tests/helpers/test_utils.js | 85 + addons/mail/static/tests/systray_tests.js | 22 +- addons/mail/views/mail_templates.xml | 18 +- .../src/js/chrome/abstract_web_client.js | 6 +- .../src/js/chrome/action_manager_report.js | 1 + .../static/src/js/core/abstract_service.js | 11 +- addons/web/static/src/js/core/bus.js | 4 +- addons/web/static/src/js/core/mixins.js | 125 -- .../web/static/src/js/core/service_mixins.js | 164 ++ addons/web/static/src/js/core/utils.js | 31 + .../static/src/js/services/report_service.js | 14 +- .../tests/chrome/action_manager_tests.js | 12 +- .../web/static/tests/helpers/mock_server.js | 4 +- addons/web/static/tests/helpers/test_utils.js | 58 +- addons/web/views/webclient_templates.xml | 1 + .../static/src/js/content/body_manager.js | 8 +- addons/web_tour/static/src/js/tour_service.js | 5 +- .../views/website_livechat.xml | 1 + 52 files changed, 3353 insertions(+), 2583 deletions(-) create mode 100644 addons/bus/static/src/js/services/bus_service.js delete mode 100644 addons/mail/static/src/js/chat_manager.js delete mode 100644 addons/mail/static/src/js/chat_mixin.js rename addons/mail/static/src/js/{client_action.js => discuss.js} (73%) rename addons/mail/static/src/js/{client_action_mobile.js => discuss_mobile.js} (88%) create mode 100644 addons/mail/static/src/js/services/chat_manager.js create mode 100644 addons/mail/static/src/js/services/chat_window_manager.js delete mode 100644 addons/mail/static/src/js/window_manager.js rename addons/mail/static/src/less/{client_action.less => discuss.less} (99%) rename addons/mail/static/src/xml/{client_action.xml => discuss.xml} (98%) rename addons/mail/static/tests/{chat_window_test.js => chat_window_tests.js} (94%) delete mode 100644 addons/mail/static/tests/client_action_mobile_tests.js create mode 100644 addons/mail/static/tests/discuss_mobile_tests.js create mode 100644 addons/mail/static/tests/discuss_tests.js create mode 100644 addons/mail/static/tests/helpers/mock_server.js create mode 100644 addons/mail/static/tests/helpers/test_utils.js create mode 100644 addons/web/static/src/js/core/service_mixins.js diff --git a/addons/bus/static/src/js/services/bus_service.js b/addons/bus/static/src/js/services/bus_service.js new file mode 100644 index 00000000000..78be7a9740a --- /dev/null +++ b/addons/bus/static/src/js/services/bus_service.js @@ -0,0 +1,96 @@ +odoo.define('bus.BusService', function (require) { +"use strict"; + +var bus = require('bus.bus').bus; + +var AbstractService = require('web.AbstractService'); + +var BusService = AbstractService.extend({ + name: 'bus_service', + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.bus = bus; + this._audio = null; + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the bus + * + * @return {web.Bus} + */ + getBus: function () { + return this.bus; + }, + + /** + * 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) { + if (window.Notification && Notification.permission === "granted") { + if (this.bus.is_master) { + this._sendNativeNotification(title, content); + } + } else { + widget.do_notify(title, content); + if (this.bus.is_master) { + this._beep(widget); + } + } + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Lazily play the 'beep' audio on sent notification + * + * @private + * @param {web.Widget} widget + */ + _beep: function (widget) { + 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(); + this._audio.src = session.url("/mail/static/src/audio/ting" + ext); + } + this._audio.play(); + } + }, + /** + * Show a browser notification + * + * @private + * @param {string} title + * @param {string} content + */ + _sendNativeNotification: function (title, content) { + var notification = new Notification(title, {body: content, icon: "/mail/static/src/img/odoo_o.png"}); + notification.onclick = function () { + window.focus(); + if (this.cancel) { + this.cancel(); + } else if (this.close) { + this.close(); + } + }; + }, + +}); + +return BusService; + +}); diff --git a/addons/bus/views/bus.xml b/addons/bus/views/bus.xml index a196a780549..914393eefb6 100644 --- a/addons/bus/views/bus.xml +++ b/addons/bus/views/bus.xml @@ -4,6 +4,7 @@ diff --git a/addons/calendar/static/src/js/base_calendar.js b/addons/calendar/static/src/js/base_calendar.js index 892ab05a064..3752b7aa94a 100644 --- a/addons/calendar/static/src/js/base_calendar.js +++ b/addons/calendar/static/src/js/base_calendar.js @@ -1,7 +1,6 @@ odoo.define('base_calendar.base_calendar', function (require) { "use strict"; -var bus = require('bus.bus').bus; var BasicModel = require('web.BasicModel'); var field_registry = require('web.field_registry'); var Notification = require('web.Notification'); @@ -99,7 +98,7 @@ WebClient.include({ // in which the current user is involved is created, edited or deleted this.calendar_notif_timeouts = {}; this.calendar_notif = {}; - bus.on('notification', this, function (notifications) { + this.call('bus_service', 'getBus').on('notification', this, function (notifications) { _.each(notifications, (function (notification) { if (notification[0][1] === 'calendar.alarm') { this.display_calendar_notif(notification[1]); diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index 1cda8ee0cdc..ba2433abafc 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -1,10 +1,9 @@ odoo.define('im_livechat.im_livechat', function (require) { "use strict"; -var local_storage = require('web.local_storage'); -var bus = require('bus.bus').bus; var config = require('web.config'); var core = require('web.core'); +var ServiceProviderMixin = require('web.ServiceProviderMixin'); var session = require('web.session'); var time = require('web.time'); var utils = require('web.utils'); @@ -20,10 +19,10 @@ var LIVECHAT_COOKIE_HISTORY = 'im_livechat_history'; var HISTORY_LIMIT = 15; // History tracking -var page = window.location.href.replace(/^.*\/\/[^\/]+/, ''); +var page = window.location.href.replace(/^.*\/\/[^/]+/, ''); var page_history = utils.get_cookie(LIVECHAT_COOKIE_HISTORY); var url_history = []; -if(page_history){ +if (page_history) { url_history = JSON.parse(page_history) || []; } if (!_.contains(url_history, page)) { @@ -34,8 +33,11 @@ if (!_.contains(url_history, page)) { utils.set_cookie(LIVECHAT_COOKIE_HISTORY, JSON.stringify(url_history), 60*60*24); // 1 day cookie } - -var LivechatButton = Widget.extend({ +/** + * In order to handle services like ajax in the external lib, + * The parent of Livechat, LivechatButton, is set as a service provider + */ +var LivechatButton = Widget.extend(ServiceProviderMixin, { className:"openerp o_livechat_button hidden-print", events: { @@ -54,6 +56,7 @@ var LivechatButton = Widget.extend({ this.chat_window = null; this.messages = []; this.server_url = server_url; + this.busBus = this.call('bus_service', 'getBus'); }, willStart: function () { @@ -87,7 +90,7 @@ var LivechatButton = Widget.extend({ this.auto_popup_timeout = setTimeout(this.open_chat.bind(this), this.rule.auto_popup_timer*1000); } } - bus.on('notification', this, function (notifications) { + this.busBus.on('notification', this, function (notifications) { var self = this; _.each(notifications, function (notification) { self._on_notification(notification); @@ -95,9 +98,9 @@ var LivechatButton = Widget.extend({ }); return this._super(); }, - _on_notification: function(notification){ + _on_notification: function (notification){ if (this.channel && (notification[0] === this.channel.uuid)) { - if(notification[1]._type === "history_command") { // history request + if (notification[1]._type === "history_command") { // history request var cookie = utils.get_cookie(LIVECHAT_COOKIE_HISTORY); var history = cookie ? JSON.parse(cookie) : []; session.rpc("/im_livechat/history", { @@ -105,7 +108,7 @@ var LivechatButton = Widget.extend({ channel_uuid: this.channel.uuid, page_history: history, }); - }else{ // normal message + } else { // normal message this.add_message(notification[1]); this.render_messages(); if (this.chat_window.folded || !this.chat_window.thread.is_at_bottom()) { @@ -114,7 +117,7 @@ var LivechatButton = Widget.extend({ } } }, - load_qweb_template: function(){ + load_qweb_template: function (){ var xml_files = ['/mail/static/src/xml/chat_window.xml', '/mail/static/src/xml/thread.xml', '/im_livechat/static/src/xml/im_livechat.xml']; @@ -153,8 +156,8 @@ var LivechatButton = Widget.extend({ self.send_welcome_message(); self.render_messages(); - bus.add_channel(channel.uuid); - bus.start_polling(); + this.busBus.add_channel(channel.uuid); + this.busBus.start_polling(); utils.set_cookie('im_livechat_session', JSON.stringify(channel), 60*60); utils.set_cookie('im_livechat_auto_popup', JSON.stringify(false), 60*60); diff --git a/addons/im_livechat/static/src/js/im_livechat_backend.js b/addons/im_livechat/static/src/js/im_livechat_backend.js index 4d1e514c01b..877ef57edaa 100644 --- a/addons/im_livechat/static/src/js/im_livechat_backend.js +++ b/addons/im_livechat/static/src/js/im_livechat_backend.js @@ -1,11 +1,9 @@ -odoo.define('im_livechat.chat_client_action', function (require) { +odoo.define('im_livechat.chat_discuss', function (require) { "use strict"; -require('mail.chat_client_action'); -var chat_manager = require('mail.chat_manager'); -var core = require('web.core'); +var Discuss = require('mail.chat_discuss'); -core.action_registry.get('mail.chat.instant_messaging').include({ +Discuss.include({ _renderSidebar: function (options) { // Override to sort livechat channels by last message's date var channel_partition = _.partition(options.channels, function (channel) { @@ -19,13 +17,4 @@ core.action_registry.get('mail.chat.instant_messaging').include({ }, }); -chat_manager.bus.on('new_message', null, function (msg) { - _.each(msg.channel_ids, function (channel_id) { - var channel = chat_manager.get_channel(channel_id); - if (channel) { - channel.last_message_date = msg.date; // update the last message's date of the channel - } - }); }); - -}); \ No newline at end of file diff --git a/addons/im_livechat/static/src/xml/im_livechat_backend.xml b/addons/im_livechat/static/src/xml/im_livechat_backend.xml index 90569ba52dd..49018af65eb 100644 --- a/addons/im_livechat/static/src/xml/im_livechat_backend.xml +++ b/addons/im_livechat/static/src/xml/im_livechat_backend.xml @@ -14,7 +14,7 @@ - +
diff --git a/addons/im_livechat/views/im_livechat_channel_templates.xml b/addons/im_livechat/views/im_livechat_channel_templates.xml index 911adc39762..ec25a417dca 100644 --- a/addons/im_livechat/views/im_livechat_channel_templates.xml +++ b/addons/im_livechat/views/im_livechat_channel_templates.xml @@ -128,6 +128,7 @@ + @@ -141,6 +142,7 @@ + diff --git a/addons/mail/__manifest__.py b/addons/mail/__manifest__.py index f5b2944a05a..105d149fa43 100644 --- a/addons/mail/__manifest__.py +++ b/addons/mail/__manifest__.py @@ -44,9 +44,9 @@ 'application': True, 'qweb': [ 'static/src/xml/activity.xml', - 'static/src/xml/client_action.xml', 'static/src/xml/composer.xml', 'static/src/xml/chatter.xml', + 'static/src/xml/discuss.xml', 'static/src/xml/followers.xml', 'static/src/xml/systray.xml', 'static/src/xml/thread.xml', diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py index a3e9a10ced9..4136b353187 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -258,8 +258,8 @@ class MailController(http.Controller): def needaction(self): return request.env['res.partner'].get_needaction_count() - @http.route('/mail/client_action', type='json', auth='user') - def mail_client_action(self): + @http.route('/mail/init_messaging', type='json', auth='user') + def mail_init_messaging(self): values = { 'needaction_inbox_counter': request.env['res.partner'].get_needaction_count(), 'starred_counter': request.env['res.partner'].get_starred_count(), diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 8fa05876f79..0d855dd15de 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -186,17 +186,6 @@ class Message(models.Model): return ids - @api.multi - def mark_as_unread(self, channel_ids=None): - """ Add needactions to messages for the current partner. """ - partner_id = self.env.user.partner_id.id - for message in self: - message.write({'needaction_partner_ids': [(4, partner_id)]}) - - ids = [m.id for m in self] - notification = {'type': 'mark_as_unread', 'message_ids': ids, 'channel_ids': channel_ids} - self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) - @api.multi def set_message_done(self): """ Remove the needaction from messages for the current partner. """ diff --git a/addons/mail/static/src/js/chat_manager.js b/addons/mail/static/src/js/chat_manager.js deleted file mode 100644 index 35d16915341..00000000000 --- a/addons/mail/static/src/js/chat_manager.js +++ /dev/null @@ -1,1275 +0,0 @@ -odoo.define('mail.chat_manager', function (require) { -"use strict"; - -var bus = require('bus.bus').bus; -var utils = require('mail.utils'); -var config = require('web.config'); -var Bus = require('web.Bus'); -var core = require('web.core'); -var session = require('web.session'); -var time = require('web.time'); -var web_client = require('web.web_client'); -var Class = require('web.Class'); -var Mixins = require('web.mixins'); -var ServicesMixin = require('web.ServicesMixin'); - -var _t = core._t; -var _lt = core._lt; -var LIMIT = 25; -var preview_msg_max_size = 350; // optimal for native english speakers -var ODOOBOT_ID = "ODOOBOT"; - -// Private model -//---------------------------------------------------------------------------------- -var messages = []; -var channels = []; -var channels_preview_def; -var channel_defs = {}; -var chat_unread_counter = 0; -var unread_conversation_counter = 0; -var emojis = []; -var emoji_substitutions = {}; -var emoji_unicodes = {}; -var needaction_counter = 0; -var starred_counter = 0; -var mention_partner_suggestions = []; -var canned_responses = []; -var commands = []; -var discuss_menu_id; -var global_unread_counter = 0; -var pinned_dm_partners = []; // partner_ids we have a pinned DM with -var client_action_open = false; - -// Global unread counter and notifications -//---------------------------------------------------------------------------------- -bus.on("window_focus", null, function() { - global_unread_counter = 0; - web_client.set_title_part("_chat"); -}); - -function notify_incoming_message (msg, options) { - if (bus.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 (!bus.is_odoo_focused()) { - global_unread_counter++; - var tab_title = _.str.sprintf(_t("%d Messages"), global_unread_counter); - web_client.set_title_part("_chat", tab_title); - } - - utils.send_notification(web_client, title, content); -} - -// Message and channel manipulation helpers -//---------------------------------------------------------------------------------- - -// options: channel_id, silent -function add_message (data, options) { - options = options || {}; - var msg = _.findWhere(messages, { id: data.id }); - - if (!msg) { - msg = chat_manager.make_message(data); - // Keep the array ordered by id when inserting the new message - messages.splice(_.sortedIndex(messages, msg, 'id'), 0, msg); - _.each(msg.channel_ids, function (channel_id) { - var channel = chat_manager.get_channel(channel_id); - 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; - } - add_to_cache(msg, []); - if (options.domain && options.domain !== []) { - add_to_cache(msg, options.domain); - } - if (channel.hidden) { - channel.hidden = false; - chat_manager.bus.trigger('new_channel', channel); - } - if (channel.type !== 'static' && !msg.is_author && !msg.is_system_notification) { - if (options.increment_unread) { - update_channel_unread_counter(channel, channel.unread_counter+1); - } - if (channel.is_chat && options.show_notification) { - if (!client_action_open && !config.device.isMobile) { - // automatically open chat window - chat_manager.bus.trigger('open_chat', channel, { passively: true }); - } - var query = {is_displayed: false}; - chat_manager.bus.trigger('anyone_listening', channel, query); - notify_incoming_message(msg, query); - } - } - } - }); - if (!options.silent) { - chat_manager.bus.trigger('new_message', msg); - } - } else if (options.domain && options.domain !== []) { - add_to_cache(msg, options.domain); - } - return msg; -} - -function make_message (data) { - 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, - is_note: data.is_note, - is_discussion: data.is_discussion, - 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(_.keys(emoji_substitutions), function (key) { - var escaped_key = String(key).replace(/([.*+?=^!:${}()|[\]\/\\])/g, '\\$1'); - var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + escaped_key + ")(?=\\s|$|)", "g"); - msg.body = msg.body.replace(regexp, ' '+emoji_substitutions[key]+' '); - }); - - function property_descr(channel) { - return { - enumerable: true, - get: function () { - return _.contains(msg.channel_ids, channel); - }, - set: function (bool) { - if (bool) { - add_channel_to_message(msg, channel); - } else { - msg.channel_ids = _.without(msg.channel_ids, channel); - } - } - }; - } - - Object.defineProperties(msg, { - is_starred: property_descr("channel_starred"), - is_needaction: property_descr("channel_inbox"), - }); - - 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 (msg.model === 'mail.channel') { - var real_channels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred'); - var origin = real_channels.length === 1 ? real_channels[0] : undefined; - var channel = origin && chat_manager.get_channel(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) { - _.each(msg.tracking_value_ids, function(f) { - if (f.field_type === 'datetime') { - var 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') { - var 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; -} - -function add_channel_to_message (message, channel_id) { - message.channel_ids.push(channel_id); - message.channel_ids = _.uniq(message.channel_ids); -} - -function add_channel (data, options) { - options = typeof options === "object" ? options : {}; - var channel = chat_manager.get_channel(data.id); - if (channel) { - if (channel.is_folded !== (data.state === "folded")) { - channel.is_folded = (data.state === "folded"); - chat_manager.bus.trigger("channel_toggle_fold", channel); - } - } else { - channel = chat_manager.make_channel(data, options); - channels.push(channel); - if (data.last_message) { - channel.last_message = add_message(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. - channels = _.sortBy(channels, function (channel) { return _.isString(channel.name) ? channel.name.toLowerCase() : '' }); - if (!options.silent) { - chat_manager.bus.trigger("new_channel", channel); - } - if (channel.is_detached) { - chat_manager.bus.trigger("open_chat", channel); - } - } - return channel; -} - -function make_channel (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.display_needactions, - mass_mailing: data.mass_mailing, - 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; - pinned_dm_partners.push(channel.direct_partner_id); - bus.update_option('bus_presence_partner_ids', pinned_dm_partners); - } 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) { - update_channel_unread_counter(channel, data.message_unread_counter); - } - return channel; -} - -function remove_channel (channel) { - if (!channel) { return; } - if (channel.type === 'dm') { - var index = pinned_dm_partners.indexOf(channel.direct_partner_id); - if (index > -1) { - pinned_dm_partners.splice(index, 1); - bus.update_option('bus_presence_partner_ids', pinned_dm_partners); - } - } - channels = _.without(channels, channel); - delete channel_defs[channel.id]; -} - -function get_channel_cache (channel, domain) { - var stringified_domain = JSON.stringify(domain || []); - if (!channel.cache[stringified_domain]) { - channel.cache[stringified_domain] = { - all_history_loaded: false, - loaded: false, - messages: [], - }; - } - return channel.cache[stringified_domain]; -} - -function invalidate_caches(channel_ids) { - _.each(channel_ids, function (channel_id) { - var channel = chat_manager.get_channel(channel_id); - if (channel) { - channel.cache = { '[]': channel.cache['[]']}; - } - }); -} - -function add_to_cache(message, domain) { - _.each(message.channel_ids, function (channel_id) { - var channel = chat_manager.get_channel(channel_id); - if (channel) { - var channel_cache = get_channel_cache(channel, domain); - var index = _.sortedIndex(channel_cache.messages, message, 'id'); - if (channel_cache.messages[index] !== message) { - channel_cache.messages.splice(index, 0, message); - } - } - }); -} - -function remove_message_from_channel (channel_id, message) { - message.channel_ids = _.without(message.channel_ids, channel_id); - var channel = _.findWhere(channels, { id: channel_id }); - _.each(channel.cache, function (cache) { - cache.messages = _.without(cache.messages, message); - }); -} - -function update_channel_unread_counter (channel, counter) { - if (channel.unread_counter > 0 && counter === 0) { - unread_conversation_counter = Math.max(0, unread_conversation_counter-1); - } else if (channel.unread_counter === 0 && counter > 0) { - unread_conversation_counter++; - } - if (channel.is_chat) { - chat_unread_counter = Math.max(0, chat_unread_counter - channel.unread_counter + counter); - } - channel.unread_counter = counter; - chat_manager.bus.trigger("update_channel_unread_counter", channel); -} - -// Notification handlers -// --------------------------------------------------------------------------------- -function on_notification (notifications) { - // 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. - var unsubscribed_notif = _.find(notifications, function (notif) { - return notif[1].info === "unsubscribe"; - }); - if (unsubscribed_notif) { - notifications = _.reject(notifications, function (notif) { - return notif[0][1] === "mail.channel" && notif[0][2] === unsubscribed_notif[1].id; - }); - } - _.each(notifications, function (notification) { - var model = notification[0][1]; - if (model === 'ir.needaction') { - // new message in the inbox - on_needaction_notification(notification[1]); - } else if (model === 'mail.channel') { - // new message in a channel - on_channel_notification(notification[1]); - } else if (model === 'res.partner') { - // channel joined/left, message marked as read/(un)starred, chat open/closed - on_partner_notification(notification[1]); - } else if (model === 'bus.presence') { - // update presence of users - on_presence_notification(notification[1]); - } - }); -} - -function on_needaction_notification (message) { - message = add_message(message, { - channel_id: 'channel_inbox', - show_notification: true, - increment_unread: true, - }); - invalidate_caches(message.channel_ids); - if (message.channel_ids.length !== 0) { - needaction_counter++; - } - _.each(message.channel_ids, function (channel_id) { - var channel = chat_manager.get_channel(channel_id); - if (channel) { - channel.needaction_counter++; - } - }); - chat_manager.bus.trigger('update_needaction', needaction_counter); -} - -function on_channel_notification (message) { - var def; - var channel_already_in_cache = true; - if (message.channel_ids.length === 1) { - channel_already_in_cache = !!chat_manager.get_channel(message.channel_ids[0]); - def = chat_manager.join_channel(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 - add_message(message, { show_notification: true, increment_unread: channel_already_in_cache }); - invalidate_caches(message.channel_ids); - }); -} - -function on_partner_notification (data) { - if (data.info === "unsubscribe") { - var channel = chat_manager.get_channel(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); - } - remove_channel(channel); - chat_manager.bus.trigger("unsubscribe_from_channel", data.id); - web_client.do_notify(_("Unsubscribed"), msg); - } - } else if (data.type === 'toggle_star') { - on_toggle_star_notification(data); - } else if (data.type === 'mark_as_read') { - on_mark_as_read_notification(data); - } else if (data.type === 'mark_as_unread') { - on_mark_as_unread_notification(data); - } else if (data.info === 'channel_seen') { - on_channel_seen_notification(data); - } else if (data.info === 'transient_message') { - on_transient_message_notification(data); - } else if (data.type === 'activity_updated') { - onActivityUpdateNodification(data); - } else { - on_chat_session_notification(data); - } -} - -function on_toggle_star_notification (data) { - _.each(data.message_ids, function (msg_id) { - var message = _.findWhere(messages, { id: msg_id }); - if (message) { - invalidate_caches(message.channel_ids); - message.is_starred = data.starred; - if (!message.is_starred) { - remove_message_from_channel("channel_starred", message); - starred_counter--; - } else { - add_to_cache(message, []); - var channel_starred = chat_manager.get_channel('channel_starred'); - channel_starred.cache = _.pick(channel_starred.cache, "[]"); - starred_counter++; - } - chat_manager.bus.trigger('update_message', message); - } - }); - chat_manager.bus.trigger('update_starred', starred_counter); -} - -function on_mark_as_read_notification (data) { - _.each(data.message_ids, function (msg_id) { - var message = _.findWhere(messages, { id: msg_id }); - if (message) { - invalidate_caches(message.channel_ids); - remove_message_from_channel("channel_inbox", message); - chat_manager.bus.trigger('update_message', message, data.type); - } - }); - if (data.channel_ids) { - _.each(data.channel_ids, function (channel_id) { - var channel = chat_manager.get_channel(channel_id); - 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(channels, function (channel) { - channel.needaction_counter = 0; - }); - } - needaction_counter = Math.max(needaction_counter - data.message_ids.length, 0); - chat_manager.bus.trigger('update_needaction', needaction_counter); -} - -function on_mark_as_unread_notification (data) { - _.each(data.message_ids, function (message_id) { - var message = _.findWhere(messages, { id: message_id }); - if (message) { - invalidate_caches(message.channel_ids); - add_channel_to_message(message, 'channel_inbox'); - add_to_cache(message, []); - } - }); - var channel_inbox = chat_manager.get_channel('channel_inbox'); - channel_inbox.cache = _.pick(channel_inbox.cache, "[]"); - - _.each(data.channel_ids, function (channel_id) { - var channel = chat_manager.get_channel(channel_id); - if (channel) { - channel.needaction_counter += data.message_ids.length; - } - }); - needaction_counter += data.message_ids.length; - chat_manager.bus.trigger('update_needaction', needaction_counter); -} - -function on_channel_seen_notification (data) { - var channel = chat_manager.get_channel(data.id); - if (channel) { - channel.last_seen_message_id = data.last_message_id; - if (channel.unread_counter) { - update_channel_unread_counter(channel, 0); - } - } -} - -function on_chat_session_notification (chat_session) { - var channel; - if ((chat_session.channel_type === "channel") && (chat_session.state === "open")) { - add_channel(chat_session, {autoswitch: false}); - if (!chat_session.is_minimized && chat_session.info !== 'creation') { - web_client.do_notify(_t("Invitation"), _t("You have been invited to: ") + chat_session.name); - } - } - // partner specific change (open a detached window for example) - if ((chat_session.state === "open") || (chat_session.state === "folded")) { - channel = chat_session.is_minimized && chat_manager.get_channel(chat_session.id); - if (channel) { - channel.is_detached = true; - channel.is_folded = (chat_session.state === "folded"); - chat_manager.bus.trigger("open_chat", channel); - } - } else if (chat_session.state === "closed") { - channel = chat_manager.get_channel(chat_session.id); - if (channel) { - channel.is_detached = false; - chat_manager.bus.trigger("close_chat", channel, {keep_open_if_unread: true}); - } - } -} - -function on_presence_notification (data) { - var dm = chat_manager.get_dm_from_partner_id(data.id); - if (dm) { - dm.status = data.im_status; - chat_manager.bus.trigger('update_dm_presence', dm); - } -} - -function on_transient_message_notification (data) { - var last_message = _.last(messages); - data.id = (last_message ? last_message.id : 0) + 0.01; - data.author_id = data.author_id || ODOOBOT_ID; - add_message(data); -} - -function onActivityUpdateNodification (data) { - chat_manager.bus.trigger('activity_updated', data); -} -// Public interface -//---------------------------------------------------------------------------------- -var ChatManager = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { - // these two functions are exposed for extensibility purposes and shouldn't be called by other modules - make_message: make_message, - make_channel: make_channel, - - init: function (parent) { - var self = this; - Mixins.EventDispatcherMixin.init.call(this); - this.setParent(parent); - - this.bus = new Bus(); - this.bus.on('client_action_open', null, function (open) { - client_action_open = open; - }); - - bus.on('notification', null, on_notification); - - this.channel_seen = _.throttle(function (channel) { - return self._rpc({ - model: 'mail.channel', - method: 'channel_seen', - args: [[channel.id]], - }, { - shadow: true - }); - }, 3000); - }, - - start: function () { - this.is_ready = session.is_bound.then(function(){ - var context = _.extend({isMobile: config.device.isMobile}, session.user_context); - return session.rpc('/mail/client_action', {context: context}); - }).then(this._onMailClientAction.bind(this)); - - add_channel({ - id: "channel_inbox", - name: _lt("Inbox"), - type: "static", - }, { display_needactions: true }); - - add_channel({ - id: "channel_starred", - name: _lt("Starred"), - type: "static" - }); - }, - - _onMailClientAction: function (result) { - _.each(result.channel_slots, function (channels) { - _.each(channels, add_channel); - }); - needaction_counter = result.needaction_inbox_counter; - starred_counter = result.starred_counter; - commands = _.map(result.commands, function (command) { - return _.extend({ id: command.name }, command); - }); - mention_partner_suggestions = result.mention_partner_suggestions; - discuss_menu_id = result.menu_id; - - // Shortcodes: canned responses and emojis - _.each(result.shortcodes, function (s) { - if (s.shortcode_type === 'text') { - canned_responses.push(_.pick(s, ['id', 'source', 'substitution'])); - } else { - emojis.push(_.pick(s, ['id', 'source', 'unicode_source', 'substitution', 'description'])); - emoji_substitutions[_.escape(s.source)] = s.substitution; - if (s.unicode_source) { - emoji_substitutions[_.escape(s.unicode_source)] = s.substitution; - emoji_unicodes[_.escape(s.source)] = s.unicode_source; - } - } - }); - bus.start_polling(); - }, - - // options: domain, load_more - _fetchFromChannel: function (channel, options) { - options = options || {}; - var domain = - (channel.id === "channel_inbox") ? [['needaction', '=', true]] : - (channel.id === "channel_starred") ? [['starred', '=', true]] : - [['channel_ids', 'in', channel.id]]; - var cache = get_channel_cache(channel, options.domain); - - if (options.domain) { - domain = domain.concat(options.domain || []); - } - if (options.load_more) { - var min_message_id = cache.messages[0].id; - domain = [['id', '<', min_message_id]].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) { - add_message(msg, {channel_id: channel.id, silent: true, domain: options.domain}); - }); - var channel_cache = get_channel_cache(channel, options.domain || []); - return channel_cache.messages; - }); - }, - // options: force_fetch - _fetchDocumentMessages : function (ids, options) { - var loaded_msgs = _.filter(messages, function (message) { - return _.contains(ids, message.id); - }); - var loaded_msg_ids = _.pluck(loaded_msgs, 'id'); - - options = options || {}; - if (options.force_fetch || _.difference(ids.slice(0, LIMIT), loaded_msg_ids).length) { - var ids_to_load = _.difference(ids, loaded_msg_ids).slice(0, LIMIT); - - return this._rpc({ - model: 'mail.message', - method: 'message_format', - args: [ids_to_load], - context: session.user_context, - }) - .then(function (msgs) { - var processed_msgs = []; - _.each(msgs, function (msg) { - processed_msgs.push(add_message(msg, {silent: true})); - }); - return _.sortBy(loaded_msgs.concat(processed_msgs), function (msg) { - return msg.id; - }); - }); - } else { - return $.when(loaded_msgs); - } - }, - - post_message: 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(_.keys(emoji_unicodes), function (key) { - var escaped_key = String(key).replace(/([.*+?=^!:${}()|[\]\/\\])/g, '\\$1'); - var regexp = new RegExp("(\\s|^)(" + escaped_key + ")(?=\\s|$)", "g"); - msg.body = msg.body.replace(regexp, "$1" + emoji_unicodes[key]); - }); - - if ('subject' in data) { - msg.subject = data.subject; - } - if ('channel_id' 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.channel_id], - kwargs: _.extend(msg, { - message_type: 'comment', - content_subtype: 'html', - subtype: 'mail.mt_comment', - command: data.command, - }), - }); - } - if ('model' in options && 'res_id' in options) { - // post a message in a chatter - _.extend(msg, { - content_subtype: data.content_subtype, - 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 (msg_id) { - return self._rpc({ - model: 'mail.message', - method: 'message_format', - args: [msg_id], - }) - .then(function (msgs) { - msgs[0].model = options.model; - msgs[0].res_id = options.res_id; - add_message(msgs[0]); - }); - }); - } - }, - - get_message: function (id) { - return _.findWhere(messages, {id: id}); - }, - get_messages: function (options) { - var channel; - - if ('channel_id' in options && options.load_more) { - // get channel messages, force load_more - channel = this.get_channel(options.channel_id); - return this._fetchFromChannel(channel, {domain: options.domain || {}, load_more: true}); - } - if ('channel_id' in options) { - // channel message, check in cache first - channel = this.get_channel(options.channel_id); - var channel_cache = get_channel_cache(channel, options.domain); - if (channel_cache.loaded) { - return $.when(channel_cache.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) { - chat_manager.mark_as_read(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]]; - this._rpc({ - model: 'mail.message', - method: 'message_fetch', - args: [domain], - kwargs: {limit: 30}, - }) - .then(function (msgs) { - return _.map(msgs, add_message); - }); - } - }, - toggle_star_status: function (message_id) { - return this._rpc({ - model: 'mail.message', - method: 'toggle_message_starred', - args: [[message_id]], - }); - }, - unstar_all: function () { - return this._rpc({ - model: 'mail.message', - method: 'unstar_all', - args: [[]] - }); - }, - mark_as_read: function (message_ids) { - var ids = _.filter(message_ids, function (id) { - var message = _.findWhere(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(); - } - }, - mark_all_as_read: function (channel, domain) { - if ((channel.id === "channel_inbox" && needaction_counter) || (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(); - }, - undo_mark_as_read: function (message_ids, channel) { - return this._rpc({ - model: 'mail.message', - method: 'mark_as_unread', - args: [message_ids, [channel.id]], - }); - }, - mark_channel_as_seen: function (channel) { - if (channel.unread_counter > 0 && channel.type !== 'static') { - update_channel_unread_counter(channel, 0); - this.channel_seen(channel); - } - }, - - get_channels: function () { - return _.clone(channels); - }, - - get_channel: function (id) { - return _.findWhere(channels, {id: id}); - }, - - get_dm_from_partner_id: function (partner_id) { - return _.findWhere(channels, {direct_partner_id: partner_id}); - }, - - all_history_loaded: function (channel, domain) { - return get_channel_cache(channel, domain).all_history_loaded; - }, - - get_mention_partner_suggestions: function (channel) { - if (!channel) { - return mention_partner_suggestions; - } - if (!channel.members_deferred) { - channel.members_deferred = this._rpc({ - model: 'mail.channel', - method: 'channel_fetch_listeners', - args: [channel.uuid], - }, { - shadow: true - }) - .then(function (members) { - var suggestions = []; - _.each(mention_partner_suggestions, function (partners) { - suggestions.push(_.filter(partners, function (partner) { - return !_.findWhere(members, { id: partner.id }); - })); - }); - - return [members]; - }); - } - return channel.members_deferred; - }, - - get_commands: function (channel) { - return _.filter(commands, function (command) { - return !command.channel_types || _.contains(command.channel_types, channel.server_type); - }); - }, - get_canned_responses: function () { - return canned_responses; - }, - - get_emojis: function() { - return emojis; - }, - - get_needaction_counter: function () { - return needaction_counter; - }, - get_starred_counter: function () { - return starred_counter; - }, - get_chat_unread_counter: function () { - return chat_unread_counter; - }, - get_unread_conversation_counter: function () { - return unread_conversation_counter; - }, - - get_last_seen_message: function (channel) { - 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++; - } - return msg; - } - } - }, - - get_discuss_menu_id: function () { - return discuss_menu_id; - }, - - detach_channel: function (channel) { - return this._rpc({ - model: 'mail.channel', - method: 'channel_minimize', - args: [channel.uuid, true], - }, { - shadow: true, - }); - }, - remove_chatter_messages: function (model) { - messages = _.reject(messages, function (message) { - return message.channel_ids.length === 0 && message.model === model; - }); - }, - - create_channel: 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(add_channel); - }, - join_channel: function (channel_id, options) { - if (channel_id in channel_defs) { - // prevents concurrent calls to channel_join_and_get_info - return channel_defs[channel_id]; - } - var channel = this.get_channel(channel_id); - if (channel) { - // channel already joined - channel_defs[channel_id] = $.when(channel); - } else { - channel_defs[channel_id] = this._rpc({ - model: 'mail.channel', - method: 'channel_join_and_get_info', - args: [[channel_id]], - }) - .then(function (result) { - return add_channel(result, options); - }); - } - return channel_defs[channel_id]; - }, - open_and_detach_dm: function (partner_id) { - return this._rpc({ - model: 'mail.channel', - method: 'channel_get_and_minimize', - args: [[partner_id]], - }) - .then(add_channel); - }, - open_channel: function (channel) { - chat_manager.bus.trigger(client_action_open ? 'open_channel' : 'detach_channel', channel); - }, - - 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], - }); - } - }, - close_chat_session: function (channel_id) { - var channel = this.get_channel(channel_id); - this._rpc({ - model: 'mail.channel', - method: 'channel_fold', - kwargs: {uuid : channel.uuid, state : 'closed'}, - }, {shadow: true}); - }, - fold_channel: function (channel_id, folded) { - var args = { - uuid: this.get_channel(channel_id).uuid, - }; - if (_.isBoolean(folded)) { - args.state = folded ? 'folded' : 'open'; - } - return this._rpc({ - model: 'mail.channel', - method: 'channel_fold', - kwargs: args, - }, {shadow: true}); - }, - /** - * 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. - */ - redirect: function (res_model, res_id, dm_redirection_callback) { - var self = this; - var redirect_to_document = function (res_model, res_id, view_id) { - web_client.do_action({ - type:'ir.actions.act_window', - view_type: 'form', - view_mode: 'form', - res_model: res_model, - views: [[view_id || false, 'form']], - res_id: res_id, - }); - }; - if (res_model === "res.partner") { - var domain = [["partner_id", "=", res_id]]; - this._rpc({ - model: 'res.users', - method: 'search', - args: [domain], - }) - .then(function (user_ids) { - if (user_ids.length && user_ids[0] !== session.uid && dm_redirection_callback) { - self.create_channel(res_id, 'dm').then(dm_redirection_callback); - } else { - redirect_to_document(res_model, res_id); - } - }); - } else { - this._rpc({ - model: res_model, - method: 'get_formview_id', - args: [[res_id], session.user_context], - }) - .then(function (view_id) { - redirect_to_document(res_model, res_id, view_id); - }); - } - }, - - get_channels_preview: function (channels) { - var channels_preview = _.map(channels, function (channel) { - var info; - if (channel.channel_ids && _.contains(channel.channel_ids,"channel_inbox")) { - // 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; - 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 missing_channels = _.where(channels_preview, {last_message: undefined}); - if (!channels_preview_def) { - if (missing_channels.length) { - var missing_channel_ids = _.pluck(missing_channels, 'id'); - channels_preview_def = this._rpc({ - model: 'mail.channel', - method: 'channel_fetch_preview', - args: [missing_channel_ids], - }, { - shadow: true, - }); - } else { - channels_preview_def = $.when(); - } - } - return channels_preview_def.then(function (channels) { - _.each(missing_channels, function (channel_preview) { - var channel = _.findWhere(channels, {id: channel_preview.id}); - if (channel) { - channel_preview.last_message = add_message(channel.last_message); - } - }); - // sort channels: 1. unread, 2. chat, 3. date of last msg - channels_preview.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(channels_preview, function (channel) { - if (channel.last_message) { - channel.last_message_preview = chat_manager.get_message_body_preview(channel.last_message.body); - channel.last_message_date = channel.last_message.date.fromNow(); - } - }); - return channels_preview; - }); - }, - get_message_body_preview: function (message_body) { - return utils.parse_and_transform(message_body, utils.inline); - }, - - search_partner: function (search_val, limit) { - var def = $.Deferred(); - var values = []; - // search among prefetched partners - var search_regexp = new RegExp(_.str.escapeRegExp(utils.unaccent(search_val)), 'i'); - _.each(mention_partner_suggestions, function (partners) { - if (values.length < limit) { - values = values.concat(_.filter(partners, function (partner) { - return session.partner_id !== partner.id && search_regexp.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: [search_val, limit || 20], - }, { - shadow: true, - }); - } else { - def = $.when(values); - } - return def.then(function (values) { - var autocomplete_data = _.map(values, function (value) { - return { id: value.id, value: value.name, label: value.name }; - }); - return _.sortBy(autocomplete_data, 'label'); - }); - }, -}); - -var CallService = Class.extend(Mixins.EventDispatcherMixin, Mixins.ServiceProvider, { - init: function () { - Mixins.ServiceProvider.init.call(this); - Mixins.EventDispatcherMixin.init.call(this); - }, -}); -var chat_manager = new ChatManager(new CallService()); -chat_manager.start(); - -return chat_manager; - -}); diff --git a/addons/mail/static/src/js/chat_mixin.js b/addons/mail/static/src/js/chat_mixin.js deleted file mode 100644 index ec1b06a805e..00000000000 --- a/addons/mail/static/src/js/chat_mixin.js +++ /dev/null @@ -1,130 +0,0 @@ -odoo.define('mail.chat_mixin', function() { -"use strict"; - - -var ChatMixin = { - // return a deferred that resolves when chat manager is ready - _chatReady: function() { - var result; - this.trigger_up('chat_manager_ready', { - callback: function(def) { result = def;} - }); - return $.when(result); - }, - _getBus: function() { - var result; - this.trigger_up('get_bus', { - callback: function(bus) { result = bus;} - }); - return result; - }, - _getCannedResponses: function() { - var result; - this.trigger_up('get_canned_responses', { - callback: function(responses) { result = responses;} - }); - return result; - }, - _getEmojis: function() { - var result; - this.trigger_up('get_emojis', { - callback: function(emojis) { result = emojis;} - }); - return result; - }, - _getMentionPartnerSuggestions: function(channel_id) { - var result; - this.trigger_up('get_mention_partner_suggestions', { - channel_id: channel_id, - callback: function(res) { result = res; }, - }); - return result; - }, - _getMessages: function(options) { - var result; - this.trigger_up('get_messages', { - options: options, - callback: function(messages) { result = messages;} - }); - return result ? result : $.when([]); - }, - _joinChannel: function(channel_id) { - var result; - this.trigger_up('join_channel', { - channel_id: channel_id, - callback: function(def) { result = def; }, - }); - return $.when(result); - }, - _postMessage: function(message, options) { - var result; - this.trigger_up('post_message', { - message: message, - options: options, - callback: function(def) { result = def;} - }); - return $.when(result); - }, - _removeChatterMessages: function(model) { - this.trigger_up('remove_chatter_messages', { - model: model, - }); - }, - _toggleStarStatus: function(message_id) { - this.trigger_up('toggle_star_status', { - message_id: message_id, - }); - }, -}; - -return ChatMixin; - -}); - -odoo.define('mail.chat_service', function (require) { -"use strict"; - -var chat_manager = require('mail.chat_manager'); -var web_client = require('web.web_client'); - -web_client.on('get_emojis', web_client, function(event) { - event.data.callback(chat_manager.get_emojis()); -}); - -web_client.on('get_canned_responses', web_client, function(event) { - event.data.callback(chat_manager.get_canned_responses()); -}); - -web_client.on('chat_manager_ready', web_client, function(event) { - event.data.callback(chat_manager.is_ready); -}); - -web_client.on('get_messages', web_client, function(event) { - event.data.callback(chat_manager.get_messages(event.data.options)); -}); - -web_client.on('post_message', web_client, function(event) { - event.data.callback(chat_manager.post_message(event.data.message, event.data.options)); -}); - -web_client.on('get_bus', web_client, function(event) { - event.data.callback(chat_manager.bus); -}); - -web_client.on('remove_chatter_messages', web_client, function(event) { - chat_manager.remove_chatter_messages(event.data.model); -}); - -web_client.on('toggle_star_status', web_client, function(event) { - chat_manager.toggle_star_status(event.data.message_id); -}); - -web_client.on('join_channel', web_client, function(event) { - event.data.callback(chat_manager.join_channel(event.data.channel_id)); -}); - -web_client.on('get_mention_partner_suggestions', web_client, function(event) { - event.data.callback(chat_manager.get_mention_partner_suggestions(event.data.channel_id)); -}); - -}); \ No newline at end of file diff --git a/addons/mail/static/src/js/chat_window.js b/addons/mail/static/src/js/chat_window.js index c8cfecd193f..9f09eb390b0 100644 --- a/addons/mail/static/src/js/chat_window.js +++ b/addons/mail/static/src/js/chat_window.js @@ -140,7 +140,9 @@ return Widget.extend({ }; this.$input.val(''); if (content) { - this.trigger('post_message', message, this.channel_id); + this.trigger('post_message', message, { + channelID: this.channel_id, + }); } } }, diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index 649a7381f7c..dfc041325d9 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -2,7 +2,6 @@ odoo.define('mail.Chatter', function (require) { "use strict"; var Activity = require('mail.Activity'); -var chat_mixin = require('mail.chat_mixin'); var ChatterComposer = require('mail.ChatterComposer'); var Followers = require('mail.Followers'); var ThreadField = require('mail.ThreadField'); @@ -22,7 +21,7 @@ var QWeb = core.qweb; // with each other. // It synchronizes the rendering of those widgets (as they may be asynchronous), to limitate // the flickering when switching between records -var Chatter = Widget.extend(chat_mixin, { +var Chatter = Widget.extend({ template: 'mail.Chatter', custom_events: { reload_mail_fields: '_onReloadMailFields', @@ -52,7 +51,7 @@ var Chatter = Widget.extend(chat_mixin, { // 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._getMentionPartnerSuggestions(); + this.mentionPartnerSuggestions = this.call('chat_manager', 'getMentionPartnerSuggestions'); this.mentionSuggestions = this.mentionPartnerSuggestions; this.fields = {}; diff --git a/addons/mail/static/src/js/chatter_composer.js b/addons/mail/static/src/js/chatter_composer.js index fe23bc528fe..0a8088d87ae 100644 --- a/addons/mail/static/src/js/chatter_composer.js +++ b/addons/mail/static/src/js/chatter_composer.js @@ -1,7 +1,6 @@ odoo.define('mail.ChatterComposer', function (require) { "use strict"; -// var chat_manager = require('mail.chat_manager'); var composer = require('mail.composer'); var utils = require('mail.utils'); diff --git a/addons/mail/static/src/js/composer.js b/addons/mail/static/src/js/composer.js index 2e2aaa66f74..06675ec3902 100644 --- a/addons/mail/static/src/js/composer.js +++ b/addons/mail/static/src/js/composer.js @@ -1,7 +1,6 @@ odoo.define('mail.composer', function (require) { "use strict"; -var chat_mixin = require('mail.chat_mixin'); var DocumentViewer = require('mail.DocumentViewer'); var utils = require('mail.utils'); @@ -385,7 +384,7 @@ var MentionManager = Widget.extend({ }); -var BasicComposer = Widget.extend(chat_mixin, { +var BasicComposer = Widget.extend({ template: "mail.ChatComposer", events: { "keydown .o_composer_input": "_onKeydown", @@ -482,7 +481,7 @@ var BasicComposer = Widget.extend(chat_mixin, { self.trigger('input_focused'); }); this.$input.html(this.options.default_body); - this.$input.css('min-height', this.options.input_min_height) + this.$input.css('min-height', this.options.input_min_height); // Attachments $(window).on(this.fileupload_id, this.on_attachment_loaded); @@ -728,7 +727,7 @@ var BasicComposer = Widget.extend(chat_mixin, { }, // remove mention when user try to edit or remove it. _removeMention: function (event) { - if (window.getSelection().anchorNode.parentNode.tagName == 'A') { + if (window.getSelection().anchorNode.parentNode.tagName === 'A') { document.getElementById(window.getSelection().anchorNode.parentNode.id).remove(); } }, @@ -790,8 +789,8 @@ var BasicComposer = Widget.extend(chat_mixin, { var self = this; var def = $.Deferred(); clearTimeout(this.canned_timeout); - this.canned_timeout = setTimeout(function() { - var canned_responses = self._getCannedResponses(); + this.canned_timeout = setTimeout(function () { + var canned_responses = self.call('chat_manager', 'getCannedResponses'); var matches = fuzzy.filter(utils.unaccent(search), _.pluck(canned_responses, 'source')); var indexes = _.pluck(matches.slice(0, self.options.mention_fetch_limit), 'index'); def.resolve(_.map(indexes, function (i) { @@ -876,7 +875,7 @@ var BasicComposer = Widget.extend(chat_mixin, { _onEmojiButtonClick: function () { if (!this.$emojisContainer) { // lazy rendering this.$emojisContainer = $(QWeb.render('mail.ChatComposer.emojis', { - emojis: this._getEmojis(), + emojis: this.call('chat_manager', 'getEmojis'), })); } if (this.$emojisContainer.parent().length) { @@ -938,7 +937,7 @@ var BasicComposer = Widget.extend(chat_mixin, { */ _onEmojiImageClick: function (ev) { this.$input.html(this.$input.html() + " " + $(ev.currentTarget).data('emoji') + " "); - this._placeCaretAtEnd(this.$input[0]) + this._placeCaretAtEnd(this.$input[0]); this._hideEmojis(); }, }); diff --git a/addons/mail/static/src/js/client_action.js b/addons/mail/static/src/js/discuss.js similarity index 73% rename from addons/mail/static/src/js/client_action.js rename to addons/mail/static/src/js/discuss.js index d2299259486..4de7e637e8b 100644 --- a/addons/mail/static/src/js/client_action.js +++ b/addons/mail/static/src/js/discuss.js @@ -1,10 +1,8 @@ -odoo.define('mail.chat_client_action', function (require) { +odoo.define('mail.chat_discuss', function (require) { "use strict"; -var chat_manager = require('mail.chat_manager'); var ChatThread = require('mail.ChatThread'); var composer = require('mail.composer'); -var utils = require('mail.utils'); var config = require('web.config'); var ControlPanelMixin = require('web.ControlPanelMixin'); @@ -33,9 +31,11 @@ var PartnerInviteDialog = Dialog.extend({ /** * @override + * @param {integer|string} channelID id of the channel, + * a string for static channels (e.g. 'channel_inbox'). */ - init: function (parent, title, channel_id) { - this.channel_id = channel_id; + init: function (parent, title, channelID) { + this.channelID = channelID; this._super(parent, { title: title, @@ -51,7 +51,8 @@ var PartnerInviteDialog = Dialog.extend({ /** * @override */ - start: function (){ + start: function () { + var self = this; this.$input = this.$('.o_mail_chat_partner_invite_input'); this.$input.select2({ width: '100%', @@ -62,7 +63,7 @@ var PartnerInviteDialog = Dialog.extend({ return $('').text(item.text).prepend(status); }, query: function (query) { - chat_manager.search_partner(query.term, 20).then(function (partners) { + self.call('chat_manager', 'searchPartner', query.term, 20).then(function (partners) { query.callback({ results: _.map(partners, function (partner) { return _.extend(partner, { text: partner.label }); @@ -80,7 +81,7 @@ var PartnerInviteDialog = Dialog.extend({ /** * @private - * @returns {Deferred} + * @returns {$.Promise} */ _addChannel: function () { var self = this; @@ -89,22 +90,23 @@ var PartnerInviteDialog = Dialog.extend({ return this._rpc({ model: 'mail.channel', method: 'channel_invite', - args: [this.channel_id], + 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 members_deferred to fetch again the partner - // when get_mention_partner_suggestions from the chat_manager is triggered - delete chat_manager.get_channel(self.channel_id).members_deferred; + // Clear the membersDeferred to fetch again the partner + // when getMentionPartnerSuggestions from the chatManager is triggered + var channel = this.call('chat_manager', 'getChannel', self.channelID); + delete channel.membersDeferred; }); } }, }); -var ChatAction = Widget.extend(ControlPanelMixin, { - template: 'mail.client_action', +var Discuss = Widget.extend(ControlPanelMixin, { + template: 'mail.discuss', custom_events: { search: '_onSearch', }, @@ -129,7 +131,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { this.domain = []; this.action = action; this.options = options || {}; - this.channels_scrolltop = {}; + 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; @@ -144,13 +146,14 @@ var ChatAction = Widget.extend(ControlPanelMixin, { */ willStart: function () { var self = this; - var view_id = 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, view_id, 'search') + .loadFieldView(this.dataset, viewID, 'search') .then(function (fields_view) { self.fields_view = fields_view; }); - return $.when(this._super(), chat_manager.is_ready, def); + var chatReady = this.call('chat_manager', 'isReady'); + return $.when(this._super(), chatReady, def); }, /** * @override @@ -158,30 +161,31 @@ var ChatAction = Widget.extend(ControlPanelMixin, { start: function () { var self = this; - var default_channel = chat_manager.get_channel(this.defaultChannelID) || - chat_manager.get_channel('channel_inbox'); - this.basic_composer = new composer.BasicComposer(this, {mention_partners_restricted: true}); - this.extended_composer = new composer.ExtendedComposer(this, {mention_partners_restricted: true}); - this.basic_composer.on('post_message', this, this._onPostMessage); - this.basic_composer.on('input_focused', this, this._onComposerFocused); - this.extended_composer.on('post_message', this, this._onPostMessage); - this.extended_composer.on('input_focused', this, this._onComposerFocused); + 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); var defs = []; defs.push(this._renderButtons()); defs.push(this._renderThread()); - defs.push(this.basic_composer.appendTo(this.$('.o_mail_chat_content'))); - defs.push(this.extended_composer.appendTo(this.$('.o_mail_chat_content'))); + defs.push(this.basicComposer.appendTo(this.$('.o_mail_chat_content'))); + defs.push(this.extendedComposer.appendTo(this.$('.o_mail_chat_content'))); defs.push(this._renderSearchView()); return $.when.apply($, defs) - .then(this._setChannel.bind(this, default_channel)) + .then(this._setChannel.bind(this, defaultChannel)) .then(this._updateChannels.bind(this)) .then(function () { self._startListening(); self.thread.$el.on("scroll", null, _.debounce(function () { if (self.thread.is_at_bottom()) { - chat_manager.mark_channel_as_seen(self.channel); + self.call('chat_manager', 'markChannelAsSeen', self.channel); } }, 100)); }); @@ -210,17 +214,19 @@ var ChatAction = Widget.extend(ControlPanelMixin, { * @override */ on_attach_callback: function () { - chat_manager.bus.trigger('client_action_open', true); + var chatBus = this.call('chat_manager', 'getChatBus'); + chatBus.trigger('discuss_open', true); if (this.channel) { - this.thread.scroll_to({offset: this.channels_scrolltop[this.channel.id]}); + this.thread.scroll_to({offset: this.channelsScrolltop[this.channel.id]}); } }, /** * @override */ on_detach_callback: function () { - chat_manager.bus.trigger('client_action_open', false); - this.channels_scrolltop[this.channel.id] = this.thread.get_scrolltop(); + var chatBus = this.call('chat_manager', 'getChatBus'); + chatBus.trigger('discuss_open', false); + this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); }, //-------------------------------------------------------------------------- @@ -229,14 +235,17 @@ var ChatAction = Widget.extend(ControlPanelMixin, { /** * @private - * @returns {Deferred} + * @returns {$.Promise} */ _fetchAndRenderThread: function () { var self = this; - return chat_manager.get_messages({channel_id: this.channel.id, domain: this.domain}).then(function (result) { - self.thread.render(result, self._getThreadRenderingOptions(result)); - self._updateButtonStatus(result.length === 0); - }); + return this.call('chat_manager', 'getMessages', { + channelID: this.channel.id, + domain: this.domain + }).then(function (messages) { + self.thread.render(messages, self._getThreadRenderingOptions(messages)); + self._updateButtonStatus(messages.length === 0); + }); }, /** * @private @@ -250,13 +259,13 @@ var ChatAction = Widget.extend(ControlPanelMixin, { if (!this.unread_counter) { this.messages_separator_position = false; // no unread message -> don't display separator } else { - var msg = chat_manager.get_last_seen_message(this.channel); + var msg = this.call('chat_manager', 'getLastSeenMessage', this.channel); this.messages_separator_position = msg ? msg.id : 'top'; } } return { channel_id: this.channel.id, - display_load_more: !chat_manager.all_history_loaded(this.channel, this.domain), + 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, @@ -268,22 +277,27 @@ var ChatAction = Widget.extend(ControlPanelMixin, { }; }, /** + * Load more messages for the current thread + * * @private - * @returns {Deferred} + * @returns {$.Promise} */ _loadMoreMessages: function () { var self = this; - var oldest_msg_id = this.$('.o_thread_message').first().data('messageId'); - var oldest_msg_selector = '.o_thread_message[data-message-id="' + oldest_msg_id + '"]'; - var offset = -dom.getPosition(document.querySelector(oldest_msg_selector)).top; - return chat_manager - .get_messages({channel_id: this.channel.id, domain: this.domain, load_more: true}) - .then(function (result) { + 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 + }) + .then(function (messages) { if (self.messages_separator_position === 'top') { self.messages_separator_position = undefined; // reset value to re-compute separator position } - self.thread.render(result, self._getThreadRenderingOptions(result)); - offset += dom.getPosition(document.querySelector(oldest_msg_selector)).top; + self.thread.render(messages, self._getThreadRenderingOptions(messages)); + offset += dom.getPosition(document.querySelector(oldestMsgSelector)).top; self.thread.scroll_to({offset: offset}); }); }, @@ -313,9 +327,9 @@ var ChatAction = Widget.extend(ControlPanelMixin, { select: function (event, ui) { if (self.last_search_val) { if (ui.item.value === '_create') { - chat_manager.create_channel(self.last_search_val, "public"); + self.call('chat_manager', 'createChannel', self.last_search_val, "public"); } else { - chat_manager.join_channel(ui.item.id); + self.call('chat_manager', 'joinChannel', ui.item.id); } } }, @@ -327,23 +341,23 @@ var ChatAction = Widget.extend(ControlPanelMixin, { } else if (type === 'private') { $input.on('keyup', this, function (event) { var name = _.escape($(event.target).val()); - if(event.which === $.ui.keyCode.ENTER && name) { - chat_manager.create_channel(name, "private"); + if (event.which === $.ui.keyCode.ENTER && name) { + self.call('chat_manager', 'createChannel', name, "private"); } }); } else if (type === 'dm') { $input.autocomplete({ source: function (request, response) { self.last_search_val = _.escape(request.term); - chat_manager.search_partner(self.last_search_val, 10).done(response); + self.call('chat_manager', 'searchPartner', self.last_search_val, 10).done(response); }, select: function (event, ui) { var partner_id = ui.item.id; - var dm = chat_manager.get_dm_from_partner_id(partner_id); + var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id); if (dm) { self._setChannel(dm); } else { - chat_manager.create_channel(partner_id, "dm"); + self.call('chat_manager', 'createChannel', partner_id, "dm"); } // clear the input $(this).val(''); @@ -418,20 +432,21 @@ var ChatAction = Widget.extend(ControlPanelMixin, { * @returns {Deferred} */ _renderThread: function () { + var self = this; this.thread = new ChatThread(this, {display_help: true}); - this.thread.on('redirect', this, function (res_model, res_id) { - chat_manager.redirect(res_model, res_id, this._setChannel.bind(this)); + this.thread.on('redirect', this, function (resModel, resID) { + self.call('chat_manager', 'redirect', resModel, resID, self._setChannel.bind(self)); }); - this.thread.on('redirect_to_channel', this, function (channel_id) { - chat_manager.join_channel(channel_id).then(this._setChannel.bind(this)); + this.thread.on('redirect_to_channel', this, function (channelID) { + self.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 (message_id) { - chat_manager.mark_as_read([message_id]); + this.thread.on('mark_as_read', this, function (messageID) { + self.call('chat_manager', 'markAsRead', [messageID]); }); - this.thread.on('toggle_star_status', this, function (message_id) { - chat_manager.toggle_star_status(message_id); + this.thread.on('toggle_star_status', this, function (messageID) { + self.call('chat_manager', 'toggleStarStatus', messageID); }); this.thread.on('select_message', this, this._selectMessage); this.thread.on('unselect_message', this, this._unselectMessage); @@ -444,12 +459,12 @@ var ChatAction = Widget.extend(ControlPanelMixin, { * @private */ _restoreChannelState: function () { - var $new_messages_separator = this.$('.o_thread_new_messages_separator'); - if ($new_messages_separator.length) { - this.thread.$el.scrollTo($new_messages_separator); + var $newMessagesSeparator = this.$('.o_thread_new_messages_separator'); + if ($newMessagesSeparator.length) { + this.thread.$el.scrollTo($newMessagesSeparator); } else { - var new_channel_scrolltop = this.channels_scrolltop[this.channel.id]; - this.thread.scroll_to({offset: new_channel_scrolltop}); + var newChannelScrolltop = this.channelsScrolltop[this.channel.id]; + this.thread.scroll_to({offset: newChannelScrolltop}); } this._restoreComposerState(this.channel); }, @@ -461,7 +476,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { if (channel.type === 'static') { return; // no composer in static channels } - var composer = channel.mass_mailing ? this.extended_composer : this.basic_composer; + var composer = channel.mass_mailing ? this.extendedComposer : this.basicComposer; var composerState = this.composerStates[channel.uuid]; if (composerState) { composer.setState(composerState); @@ -469,14 +484,14 @@ var ChatAction = Widget.extend(ControlPanelMixin, { }, /** * @private - * @param {string} search_val - * @returns {Deferred} + * @param {string} searchVal + * @returns {$.Promise} */ - _searchChannel: function (search_val){ + _searchChannel: function (searchVal){ return this._rpc({ model: 'mail.channel', method: 'channel_search_to_join', - args: [search_val] + args: [searchVal] }) .then(function (result){ var values = []; @@ -492,27 +507,27 @@ var ChatAction = Widget.extend(ControlPanelMixin, { }, /** * @private - * @param {integer} message_id + * @param {integer} messageID */ - _selectMessage: function (message_id) { + _selectMessage: function (messageID) { this.$el.addClass('o_mail_selection_mode'); - var message = chat_manager.get_message(message_id); + var message = this._getMessage(messageID); this.selected_message = message; var subject = "Re: " + message.record_name; - this.extended_composer.set_subject(subject); + this.extendedComposer.set_subject(subject); if (this.channel.type !== 'static') { - this.basic_composer.do_hide(); + this.basicComposer.do_hide(); } - this.extended_composer.do_show(); + this.extendedComposer.do_show(); - this.thread.scroll_to({id: message_id, duration: 200, only_if_necessary: true}); - this.extended_composer.focus('body'); + this.thread.scroll_to({id: messageID, duration: 200, only_if_necessary: true}); + this.extendedComposer.focus('body'); }, /** * @private * @param {Object} channel - * @returns {Deferred} + * @returns {$.Promise} */ _setChannel: function (channel) { var self = this; @@ -534,9 +549,8 @@ var ChatAction = Widget.extend(ControlPanelMixin, { return this._fetchAndRenderThread().then(function () { // Mark channel's messages as read and clear needactions if (channel.type !== 'static') { - chat_manager.mark_channel_as_seen(channel); + self.call('chat_manager', 'markChannelAsSeen', channel); } - // Restore scroll position and composer of the new current channel self._restoreChannelState(); @@ -556,25 +570,26 @@ var ChatAction = Widget.extend(ControlPanelMixin, { }); }, /** - * Binds handlers on chat_manager events + * Binds handlers on chatManager events * * @private */ _startListening: function () { - chat_manager.bus.on('open_channel', this, this._setChannel); - chat_manager.bus.on('new_message', this, this._onNewMessage); - chat_manager.bus.on('update_message', this, this._onMessageUpdated); - chat_manager.bus.on('new_channel', this, this._onNewChannel); - chat_manager.bus.on('anyone_listening', this, function (channel, query) { + 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()); }); - chat_manager.bus.on('unsubscribe_from_channel', this, this._onChannelLeft); - chat_manager.bus.on('update_needaction', this, this.throttledUpdateChannels); - chat_manager.bus.on('update_starred', this, this.throttledUpdateChannels); - chat_manager.bus.on('update_channel_unread_counter', this, this.throttledUpdateChannels); - chat_manager.bus.on('update_dm_presence', this, this.throttledUpdateChannels); - chat_manager.bus.on('activity_updated', this, this.throttledUpdateChannels); + 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); }, /** * Stores the scroll position and composer state of the current channel @@ -583,7 +598,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { */ _storeChannelState: function () { if (this.channel) { - this.channels_scrolltop[this.channel.id] = this.thread.get_scrolltop(); + this.channelsScrolltop[this.channel.id] = this.thread.get_scrolltop(); this._storeComposerState(this.channel); } }, @@ -595,7 +610,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { if (channel.type === 'static') { return; // no composer in static channels } - var composer = channel.mass_mailing ? this.extended_composer : this.basic_composer; + var composer = channel.mass_mailing ? this.extendedComposer : this.basicComposer; this.composerStates[channel.uuid] = composer.getState(); composer.clear_composer(); }, @@ -603,11 +618,11 @@ var ChatAction = Widget.extend(ControlPanelMixin, { * @private */ _unselectMessage: function () { - this.basic_composer.do_toggle(this.channel.type !== 'static' && !this.channel.mass_mailing); - this.extended_composer.do_toggle(this.channel.type !== 'static' && this.channel.mass_mailing); + this.basicComposer.do_toggle(this.channel.type !== 'static' && !this.channel.mass_mailing); + this.extendedComposer.do_toggle(this.channel.type !== 'static' && this.channel.mass_mailing); if (!config.device.touch) { - var composer = this.channel.mass_mailing ? this.extended_composer : this.basic_composer; + var composer = this.channel.mass_mailing ? this.extendedComposer : this.basicComposer; composer.focus(); } this.$el.removeClass('o_mail_selection_mode'); @@ -623,11 +638,11 @@ var ChatAction = Widget.extend(ControlPanelMixin, { var self = this; var $sidebar = this._renderSidebar({ active_channel_id: this.channel ? this.channel.id: undefined, - channels: chat_manager.get_channels(), - needaction_counter: chat_manager.get_needaction_counter(), - starred_counter: chat_manager.get_starred_counter(), + channels: this.call('chat_manager', 'getChannels'), + needaction_counter: this.call('chat_manager', 'getNeedactionCounter'), + starred_counter: this.call('chat_manager', 'getStarredCounter'), }); - this.$(".o_mail_chat_sidebar").html($sidebar.contents()); + self.$(".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); @@ -729,27 +744,29 @@ var ChatAction = Widget.extend(ControlPanelMixin, { */ _onChannelClicked: function (event) { event.preventDefault(); - var channel_id = $(event.currentTarget).data('channel-id'); - this._setChannel(chat_manager.get_channel(channel_id)); + var channelID = $(event.currentTarget).data('channel-id'); + var channel = this.call('chat_manager', 'getChannel', channelID); + this._setChannel(channel); }, /** * @private - * @param {integer || string} channel_id + * @param {integer|string} channelID */ - _onChannelLeft: function (channel_id) { - if (this.channel.id === channel_id) { - this._setChannel(chat_manager.get_channel("channel_inbox")); + _onChannelLeft: function (channelID) { + if (this.channel.id === channelID) { + var channel = this.call('chat_manager', 'getChannel', 'channel_inbox'); + this._setChannel(channel); } this._updateChannels(); - delete this.channels_scrolltop[channel_id]; + delete this.channelsScrolltop[channelID]; }, /** * @private */ _onComposerFocused: function () { - var composer = this.channel.mass_mailing ? this.extended_composer : this.basic_composer; - var commands = chat_manager.get_commands(this.channel); - var partners = chat_manager.get_mention_partner_suggestions(this.channel); + 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); }, @@ -757,7 +774,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { * @private */ _onMarkAllReadClicked: function () { - chat_manager.mark_all_as_read(this.channel, this.domain); + this.call('chat_manager', 'markAllAsRead', this.channel, this.domain); }, /** * @private @@ -766,16 +783,20 @@ var ChatAction = Widget.extend(ControlPanelMixin, { */ _onMessageUpdated: function (message, type) { var self = this; - var current_channel_id = this.channel.id; - if ((current_channel_id === "channel_starred" && !message.is_starred) || - (current_channel_id === "channel_inbox" && !message.is_needaction)) { - chat_manager.get_messages({channel_id: this.channel.id, domain: this.domain}).then(function (messages) { - var options = self._getThreadRenderingOptions(messages); - self.thread.remove_message_and_render(message.id, messages, options).then(function () { - self._updateButtonStatus(messages.length === 0, type); + var currentChannelID = this.channel.id; + if ((currentChannelID === "channel_starred" && !message.is_starred) || + (currentChannelID === "channel_inbox" && !message.is_needaction)) { + this.call('chat_manager', 'getMessages', { + channelID: this.channel.id, + domain: this.domain + }).then(function (messages) { + var options = self._getThreadRenderingOptions(messages); + self.thread.remove_message_and_render(message.id, messages, options).then(function () { + self._updateButtonStatus(messages.length === 0, type); + + }); }); - }); - } else if (_.contains(message.channel_ids, current_channel_id)) { + } else if (_.contains(message.channel_ids, currentChannelID)) { this._fetchAndRenderThread(); } }, @@ -797,9 +818,8 @@ var ChatAction = Widget.extend(ControlPanelMixin, { var self = this; if (_.contains(message.channel_ids, this.channel.id)) { if (this.channel.type !== 'static' && this.thread.is_at_bottom()) { - chat_manager.mark_channel_as_seen(this.channel); + this.call('chat_manager', 'markChannelAsSeen', this.channel); } - var should_scroll = this.thread.is_at_bottom(); this._fetchAndRenderThread().then(function () { if (should_scroll) { @@ -810,7 +830,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, { // 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.channels_scrolltop = _.omit(this.channels_scrolltop, message.channel_ids); + this.channelsScrolltop = _.omit(this.channelsScrolltop, message.channel_ids); }, /** * @private @@ -818,18 +838,14 @@ var ChatAction = Widget.extend(ControlPanelMixin, { */ _onPostMessage: function (message) { var self = this; - var options = this.selected_message ? {} : {channel_id: this.channel.id}; + 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'; message.content_subtype = 'html'; - - options.model = this.selected_message.model; - options.res_id = this.selected_message.res_id; } - chat_manager - .post_message(message, options) + this.call('chat_manager', 'postMessage', message, options) .then(function () { if (self.selected_message) { self._renderSnackbar('mail.chat.MessageSentSnackbar', {record_name: self.selected_message.record_name}, 5000); @@ -890,10 +906,10 @@ var ChatAction = Widget.extend(ControlPanelMixin, { if (def) { def.then(function (value) { if (value === 'denied') { - utils.send_notification(self, _t('Permission denied'), + self._sendNotification(self, _t('Permission denied'), _t('Odoo will not have the permission to send native notifications on this device.')); } else { - utils.send_notification(self, _t('Permission granted'), + self._sendNotification(self, _t('Permission granted'), _t('Odoo has now the permission to send you native notifications on this device.')); } }); @@ -936,25 +952,26 @@ var ChatAction = Widget.extend(ControlPanelMixin, { */ _onUnpinChannel: function (event) { event.stopPropagation(); - var channel_id = $(event.target).data("channel-id"); - chat_manager.unsubscribe(chat_manager.get_channel(channel_id)); + var channelID = $(event.target).data("channel-id"); + var channel = this.call('chat_manager', 'getChannel', channelID); + this.call('chat_manager', 'unsubscribe', channel); }, /** * @private */ _onUnstarAllClicked: function () { - chat_manager.unstar_all(); + this.call('chat_manager', 'unstarAll'); }, /** * @private */ _onUnsubscribeButtonClicked: function () { - chat_manager.unsubscribe(this.channel); + this._unsubscribe(this.channel); }, }); -core.action_registry.add('mail.chat.instant_messaging', ChatAction); +core.action_registry.add('mail.chat.instant_messaging', Discuss); -return ChatAction; +return Discuss; }); diff --git a/addons/mail/static/src/js/client_action_mobile.js b/addons/mail/static/src/js/discuss_mobile.js similarity index 88% rename from addons/mail/static/src/js/client_action_mobile.js rename to addons/mail/static/src/js/discuss_mobile.js index ecabb94e1ff..52622b0ce32 100644 --- a/addons/mail/static/src/js/client_action_mobile.js +++ b/addons/mail/static/src/js/discuss_mobile.js @@ -1,12 +1,10 @@ -odoo.define('mail.chat_client_action_mobile', function (require) { +odoo.define('mail.chat_discuss_mobile', function (require) { "use strict"; -var ChatAction = require('mail.chat_client_action'); -var chat_manager = require('mail.chat_manager'); +var Discuss = require('mail.chat_discuss'); var config = require('web.config'); var core = require('web.core'); -var session = require('web.session'); var QWeb = core.qweb; @@ -14,10 +12,10 @@ if (!config.device.isMobile) { return; } -ChatAction.include({ - template: 'mail.client_action_mobile', +Discuss.include({ + template: 'mail.discuss_mobile', need_control_panel: false, // in mobile, we use a custom control panel - events: _.extend(ChatAction.prototype.events, { + events: _.extend(Discuss.prototype.events, { 'click .o_mail_mobile_tab': '_onMobileTabClicked', 'click .o_channel_inbox_item': '_onMobileInboxButtonClicked', 'click .o_mail_channel_preview': '_onMobileChannelClicked', @@ -103,10 +101,11 @@ ChatAction.include({ /** * @override * @private + * @param {Object} channel */ _setChannel: function (channel) { if (channel.type !== 'static') { - chat_manager.detach_channel(channel); + this.call('chat_manager', 'detachChannel', channel.id); } else { this._super.apply(this, arguments); } @@ -170,8 +169,9 @@ ChatAction.include({ if (inInbox) { def = this._fetchAndRenderThread(); } else { - var channels = _.where(chat_manager.get_channels(), {type: type}); - def = chat_manager.get_channels_preview(channels); + var allChannels = this.call('chat_manager', 'getChannels'); + var channels = _.where(allChannels, {type: type}); + def = this.call('chat_manager', 'getChannelsPreview', channels); } return $.when(def).then(function (channelsPreview) { // update content @@ -179,19 +179,15 @@ ChatAction.include({ if (!previouslyInInbox) { self.$('.o_mail_chat_tab_pane').remove(); self.$mainContent.append(self.thread.$el); - self.$mainContent.append(self.extended_composer.$el); + self.$mainContent.append(self.extendedComposer.$el); } self._restoreChannelState(); } else { self.thread.$el.detach(); - self.extended_composer.$el.detach(); + self.extendedComposer.$el.detach(); var $content = $(QWeb.render("mail.chat.MobileTabPane", { channels: channelsPreview, - get_message_body_preview: chat_manager.get_message_body_preview, - moment: moment, - partner_id: session.partner_id, type: type, - widget: self, })); self._prepareAddChannelInput($content.find('.o_mail_add_channel input'), type); self.$mainContent.html($content); @@ -252,7 +248,8 @@ ChatAction.include({ * @param {MouseEvent} */ _onMobileInboxButtonClicked: function (event) { - this._setChannel(chat_manager.get_channel($(event.currentTarget).data('type'))); + var channel = this.call('chat_manager', 'getChannel', $(event.currentTarget).data('type')); + this._setChannel(channel); this._updateContent(this.channel.id); }, /** @@ -264,7 +261,8 @@ ChatAction.include({ _onMobileTabClicked: function (event) { var type = $(event.currentTarget).data('type'); if (type === 'channel_inbox') { - this._setChannel(chat_manager.get_channel('channel_inbox')); + var channel = this.call('chat_manager', 'getChannel', 'channel_inbox'); + this._setChannel(channel); } this._updateContent(type); }, @@ -275,8 +273,8 @@ ChatAction.include({ * @param {MouseEvent} */ _onMobileChannelClicked: function (event) { - var channelId = $(event.currentTarget).data("channel_id"); - chat_manager.detach_channel(chat_manager.get_channel(channelId)); + var channelID = $(event.currentTarget).data("channel_id"); + this.call('chat_manager', 'detachChannel', channelID); }, }); diff --git a/addons/mail/static/src/js/extended_chat_window.js b/addons/mail/static/src/js/extended_chat_window.js index 2163c28dd92..a8aa5673b81 100644 --- a/addons/mail/static/src/js/extended_chat_window.js +++ b/addons/mail/static/src/js/extended_chat_window.js @@ -1,9 +1,6 @@ odoo.define('mail.ExtendedChatWindow', function (require) { "use strict"; -var core = require('web.core'); - -var chat_manager = require('mail.chat_manager'); var ChatWindow = require('mail.ChatWindow'); var composer = require('mail.composer'); @@ -19,27 +16,29 @@ return ChatWindow.extend({ this.$el.addClass('o_thread_less'); this.$('.o_chat_search_input input') .autocomplete({ - source: function(request, response) { - chat_manager.search_partner(request.term, 10).done(response); + source: function (request, response) { + self.call('chat_manager', 'searchPartner', request.term, 10).done(response); }, - select: function(event, ui) { + select: function (event, ui) { self.trigger('open_dm_session', ui.item.id); }, }) .focus(); } else if (!self.options.input_less) { - var basic_composer = new composer.BasicComposer(self, {mention_partners_restricted: true, isMini: true}); - basic_composer.on('post_message', self, function (message) { - this.trigger('post_message', message, this.channel_id); + 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, + }); }); - basic_composer.once('input_focused', self, function () { - var channel = chat_manager.get_channel(this.channel_id); - var commands = chat_manager.get_commands(channel); - var partners = chat_manager.get_mention_partner_suggestions(channel); - basic_composer.mention_set_enabled_commands(commands); - basic_composer.mention_set_prefetched_partners(partners); + 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 = basic_composer.replace(self.$('.o_chat_composer')); + def = basicComposer.replace(self.$('.o_chat_composer')); } return $.when(this._super(), def); }, @@ -49,7 +48,8 @@ return ChatWindow.extend({ event.stopPropagation(); }, on_reverse_breadcrumb: function () { - chat_manager.bus.trigger('client_action_open', false); + var chatBus = this.call('chat_manager', 'getChatBus'); + chatBus.trigger('discuss_open', false); }, on_click_expand: _.debounce(function (event) { event.preventDefault(); diff --git a/addons/mail/static/src/js/services/chat_manager.js b/addons/mail/static/src/js/services/chat_manager.js new file mode 100644 index 00000000000..b7c46f2d4c1 --- /dev/null +++ b/addons/mail/static/src/js/services/chat_manager.js @@ -0,0 +1,1751 @@ +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 + +/** + * 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'], + /** + * @override + */ + init: function () { + var self = this; + this._super.apply(this, arguments); + + this.messages = []; + this.channels = []; + this.channelsPreviewDef; + this.channelDefs = {}; + this.unreadConversationCounter = 0; + this.emojis = []; + this.emojiSubstitutions = {}; + this.emojiUnicodes = {}; + this.needactionCounter = 0; + this.starredCounter = 0; + 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.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 + }); + }, 3000); + + this._isReady = this._initMessaging(); + + 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} channel + */ + getChannel: function (id) { + return _.findWhere(this.channels, {id: id}); + }, + /** + * Returns a list of channels + * + * @return {Object[]} + */ + 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")) { + // 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; + 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} + */ + getCommands: function (channel) { + return _.filter(this.commands, function (command) { + return !command.channel_types || _.contains(command.channel_types, channel.server_type); + }); + }, + /** + * 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[]} + * ['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} Message Object + */ + getLastSeenMessage: function (channel) { + 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++; + } + return msg; + } + } + }, + /** + * 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) { + var self = this; + 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) { + var suggestions = []; + _.each(self.mentionPartnerSuggestions, function (partners) { + suggestions.push(_.filter(partners, function (partner) { + return !_.findWhere(members, { id: partner.id }); + })); + }); + + return [suggestions]; + }); + } + return channel.membersDeferred; + }, + /** + * Gets message from its id + * + * @param {integer} msgID + * @return {Object|undefined} Message Object + */ + 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.load_more] + * @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.load_more) { + // 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 received from followed channels + * + all messages where the current user is notified. + * + * @return {integer} + */ + getNeedactionCounter: function () { + return this.needactionCounter; + }, + /** + * Gets the number of starred message + * + * @return {integer} + */ + getStarredCounter: function () { + return this.starredCounter; + }, + /** + * Gets the number of conversation which contains unread messages + * + * @return {integer} + */ + 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 {$.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(_.keys(this.emojiUnicodes), function (key) { + var escapedKey = String(key).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1'); + var regexp = new RegExp("(\\s|^)(" + escapedKey + ")(?=\\s|$)", "g"); + msg.body = msg.body.replace(regexp, "$1" + self.emojiUnicodes[key]); + }); + 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', + content_subtype: 'html', + subtype: 'mail.mt_comment', + command: data.command, + }), + }); + } + if ('model' in options && 'res_id' in options) { + // post a message in a chatter + _.extend(msg, { + content_subtype: data.content_subtype, + 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 exisiting 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); + } + 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.load_more] 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_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.needactionCounter = result.needaction_inbox_counter || 0; + self.starredCounter = result.starred_counter || 0; + 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 and emojis + _.each(result.shortcodes, function (s) { + if (s.shortcode_type === 'text') { + self.cannedResponses.push(_.pick(s, ['id', 'source', 'substitution'])); + } else { + self.emojis.push( + _.pick(s, ['id', 'source', 'unicode_source', 'substitution', 'description']) + ); + self.emojiSubstitutions[_.escape(s.source)] = s.substitution; + if (s.unicode_source) { + self.emojiSubstitutions[_.escape(s.unicode_source)] = s.substitution; + self.emojiUnicodes[_.escape(s.source)] = s.unicode_source; + } + } + }); + 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_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 {string} data.name + * @param {string} [data.public] + * @param {integer} data.seen_message_id + * @param {string} [data.state] + * @param {string} [data.type] + * @param {string} channel.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, + 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 {string} data.message_type + * @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, + is_note: data.is_note, + is_discussion: data.is_discussion, + 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(_.keys(this.emojiSubstitutions), function (key) { + var escapedKey = String(key).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1'); + var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + escapedKey + ")(?=\\s|$|)", "g"); + msg.body = msg.body.replace(regexp, ' '+self.emojiSubstitutions[key]+' '); + }); + + 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"), + }); + + 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 (msg.model === 'mail.channel') { + var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred'); + 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} 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}); + } + } + }, + /** + * 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} 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(_("Unsubscribed"), msg); + } + } else if (data.type === 'toggle_star') { + this._manageToggleStarNotification(data); + } else if (data.type === 'mark_as_read') { + this._manageMarkAsReadNotification(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 { + 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); + self.starredCounter--; + } else { + self._addToCache(message, []); + var channelStarred = self.getChannel('channel_starred'); + channelStarred.cache = _.pick(channelStarred.cache, "[]"); + self.starredCounter++; + } + self.chatBus.trigger('update_message', message); + } + }); + 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._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); + }, + + //-------------------------------------------------------------------------- + // 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"); + }, + +}); + +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 new file mode 100644 index 00000000000..f82da369ecf --- /dev/null +++ b/addons/mail/static/src/js/services/chat_window_manager.js @@ -0,0 +1,532 @@ +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 + }; + var chatReady = this.call('chat_manager', 'isReady'); + chatReady.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(web_client, 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.$hidden_windows_dropdown) { + this.displayState.$hidden_windows_dropdown.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.$hidden_windows_dropdown) { + self.displayState.$hidden_windows_dropdown.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); + } + }); + }, + +}); + +return ChatWindowManager; + +}); diff --git a/addons/mail/static/src/js/systray.js b/addons/mail/static/src/js/systray.js index 2006ae297ef..f2dfdc631ab 100644 --- a/addons/mail/static/src/js/systray.js +++ b/addons/mail/static/src/js/systray.js @@ -7,8 +7,6 @@ var session = require('web.session'); var SystrayMenu = require('web.SystrayMenu'); var Widget = require('web.Widget'); -var chat_manager = require('mail.chat_manager'); - var QWeb = core.qweb; /** @@ -35,16 +33,20 @@ var MessagingMenu = Widget.extend({ this.$filter_buttons = this.$('.o_filter_button'); this.$channels_preview = this.$('.o_mail_navbar_dropdown_channels'); this.filter = false; - chat_manager.bus.on("update_needaction", this, this.update_counter); - chat_manager.bus.on("update_channel_unread_counter", this, this.update_counter); - chat_manager.is_ready.then(this.update_counter.bind(this)); + var chatBus = this.call('chat_manager', 'getChatBus'); + chatBus.on("update_needaction", this, this.update_counter); + chatBus.on("update_channel_unread_counter", this, this.update_counter); + var chatReady = this.call('chat_manager', 'isReady'); + chatReady.then(this.update_counter.bind(this)); return this._super(); }, is_open: function () { return this.$el.hasClass('open'); }, update_counter: function () { - var counter = chat_manager.get_needaction_counter() + chat_manager.get_unread_conversation_counter(); + var needactionCounter = this.call('chat_manager', 'getNeedactionCounter'); + var unreadConversationCounter = this.call('chat_manager', 'getUnreadConversationCounter'); + var counter = needactionCounter + unreadConversationCounter; this.$('.o_notification_counter').text(counter); this.$el.toggleClass('o_no_notification', !counter); if (this.is_open()) { @@ -56,9 +58,10 @@ var MessagingMenu = Widget.extend({ // Display spinner while waiting for channels preview this.$channels_preview.html(QWeb.render('Spinner')); - - chat_manager.is_ready.then(function () { - var channels = _.filter(chat_manager.get_channels(), function (channel) { + var chatReady = this.call('chat_manager', 'isReady'); + chatReady.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') { @@ -67,7 +70,7 @@ var MessagingMenu = Widget.extend({ return channel.type !== 'static'; } }); - chat_manager.get_messages({channel_id: 'channel_inbox'}).then(function(result) { + self.call('chat_manager', 'getMessages', {channelID: 'channel_inbox'}).then(function (result) { var res = []; _.each(result, function (message) { message.unread_counter = 1; @@ -82,7 +85,7 @@ var MessagingMenu = Widget.extend({ if (self.filter === 'channel_inbox' || !self.filter) { channels = _.union(channels, res); } - chat_manager.get_channels_preview(channels).then(self._render_channels_preview.bind(self)); + self.call('chat_manager', 'getChannelsPreview', channels).then(self._render_channels_preview.bind(self)); }); }); }, @@ -105,7 +108,7 @@ var MessagingMenu = Widget.extend({ this.update_channels_preview(); }, on_click_new_message: function () { - chat_manager.bus.trigger('open_chat'); + this.call('chat_window_manager', 'openChat'); }, // Handlers @@ -133,13 +136,13 @@ var MessagingMenu = Widget.extend({ this.do_action('mail.mail_channel_action_client_chat', {clear_breadcrumbs: true}) .then(function () { self.trigger_up('hide_home_menu'); // we cannot 'go back to previous page' otherwise - core.bus.trigger('change_menu_section', chat_manager.get_discuss_menu_id()); + core.bus.trigger('change_menu_section', self.call('chat_manager', 'getDiscussMenuID').bind(self)); }); } } else { - var channel = chat_manager.get_channel(channelID); + var channel = this.call('chat_manager', 'getChannel', channelID); if (channel) { - chat_manager.open_channel(channel); + self.call('chat_manager', 'openChannel', channel); } } }, @@ -156,8 +159,10 @@ var ActivityMenu = Widget.extend({ }, start: function () { this.$activities_preview = this.$('.o_mail_navbar_dropdown_channels'); - chat_manager.bus.on("activity_updated", this, this._updateCounter); - chat_manager.is_ready.then(this._updateCounter.bind(this)); + var chatBus = this.call('chat_manager', 'getChatBus'); + chatBus.on("activity_updated", this, this._updateCounter); + var chatReady = this.call('chat_manager', 'isReady'); + chatReady.then(this._updateCounter.bind(this)); this._updateActivityPreview(); return this._super(); }, @@ -168,7 +173,7 @@ var ActivityMenu = Widget.extend({ * Make RPC and get current user's activity details * @private */ - _getActivityData: function(){ + _getActivityData: function () { var self = this; return self._rpc({ @@ -179,7 +184,7 @@ var ActivityMenu = Widget.extend({ }, }).then(function (data) { self.activities = data; - self.activityCounter = _.reduce(data, function(total_count, p_data){ return total_count + p_data.total_count; }, 0); + 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); }); diff --git a/addons/mail/static/src/js/thread.js b/addons/mail/static/src/js/thread.js index 156fb77c1ad..729d24b6163 100644 --- a/addons/mail/static/src/js/thread.js +++ b/addons/mail/static/src/js/thread.js @@ -142,7 +142,7 @@ var Thread = Widget.extend({ this.attachments = _.uniq(_.flatten(_.map(messages, 'attachment_ids'))); - _.each(msgs, function(msg) { + _.each(msgs, function (msg) { var $msg = self.$('.o_thread_message[data-message-id="'+ msg.id +'"]'); $msg.find('.o_mail_timestamp').data('date', msg.date); @@ -150,7 +150,7 @@ var Thread = Widget.extend({ }); if (!this.update_timestamps_interval) { - this.update_timestamps_interval = setInterval(function() { + this.update_timestamps_interval = setInterval(function () { self.update_timestamps(); }, 1000*60); } @@ -172,17 +172,17 @@ var Thread = Widget.extend({ // nodeType 1: element_node // nodeType 3: text_node var $children = $element.contents() - .filter(function() { + .filter(function () { return this.nodeType === 1 || this.nodeType === 3 && this.nodeValue.trim(); }); - _.each($children, function(child) { + _.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 - var $child = $('', { + $child = $('', { text: child.textContent, "data-o-mail-quote": "1", }); @@ -203,7 +203,7 @@ var Thread = Widget.extend({ } }); - _.each(groups, function(group) { + _.each(groups, function (group) { // Insert link just before the first node var $read_more = $('', { class: "o_mail_read_more", @@ -213,7 +213,7 @@ var Thread = Widget.extend({ // Toggle All next nodes var is_read_more = true; - $read_more.click(function(e) { + $read_more.click(function (e) { e.preventDefault(); is_read_more = !is_read_more; _.each(group, function ($child) { @@ -226,7 +226,7 @@ var Thread = Widget.extend({ }, update_timestamps: function () { var isAtBottom = this.is_at_bottom(); - this.$('.o_mail_timestamp').each(function() { + this.$('.o_mail_timestamp').each(function () { var date = $(this).data('date'); $(this).html(time_from_now(date)); }); diff --git a/addons/mail/static/src/js/thread_field.js b/addons/mail/static/src/js/thread_field.js index 43d97473d3c..00ea4e2037a 100644 --- a/addons/mail/static/src/js/thread_field.js +++ b/addons/mail/static/src/js/thread_field.js @@ -1,7 +1,6 @@ odoo.define('mail.ThreadField', function (require) { "use strict"; -var chat_mixin = require('mail.chat_mixin'); var ChatThread = require('mail.ChatThread'); var AbstractField = require('web.AbstractField'); @@ -15,14 +14,15 @@ var _t = core._t; // ----------------------------------------------------------------------------- // 'mail_thread' widget: displays the thread of messages // ----------------------------------------------------------------------------- -var ThreadField = AbstractField.extend(chat_mixin, { +var ThreadField = AbstractField.extend({ // inherited init: function () { this._super.apply(this, arguments); this.msgIDs = this.value.res_ids; }, willStart: function () { - return this.alive(this._chatReady()); + var chatReady = this.call('chat_manager', 'isReady'); + return this.alive(chatReady); }, start: function () { var self = this; @@ -39,7 +39,9 @@ var ThreadField = AbstractField.extend(chat_mixin, { 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, this._toggleStarStatus); + this.thread.on('toggle_star_status', this, function (messageID) { + self.call('chat_manager', 'toggleStarStatus', messageID); + }); var def1 = this.thread.appendTo(this.$el); var def2 = this._super.apply(this, arguments); @@ -47,10 +49,9 @@ var ThreadField = AbstractField.extend(chat_mixin, { return this.alive($.when(def1, def2)).then(function () { // unwrap the thread to remove an unnecessary level on div self.setElement(self.thread.$el); - - var bus = self._getBus(); - bus.on('new_message', self, self._onNewMessage); - bus.on('update_message', self, self._onUpdateMessage); + var chatBus = self.call('chat_manager', 'getChatBus'); + chatBus.on('new_message', self, self._onNewMessage); + chatBus.on('update_message', self, self._onUpdateMessage); }); }, _render: function () { @@ -60,7 +61,7 @@ var ThreadField = AbstractField.extend(chat_mixin, { return true; }, destroy: function () { - this._removeChatterMessages(this.model); + this.call('chat_manager', 'removeChatterMessages', this.model); this._super.apply(this, arguments); }, _reset: function (record) { @@ -82,7 +83,7 @@ var ThreadField = AbstractField.extend(chat_mixin, { postMessage: function (message) { var self = this; var options = {model: this.model, res_id: this.res_id}; - return this._postMessage(message, options) + return this.call('chat_manager', 'postMessage', message, options) .then(function () { if (message.partner_ids.length) { self.trigger_up('reload_mail_fields', {followers: true}); @@ -123,7 +124,7 @@ var ThreadField = AbstractField.extend(chat_mixin, { var self = this; options = options || {}; options.ids = ids; - var fetch_def = this.dp.add(this._getMessages(options)); + var fetch_def = this.dp.add(this.call('chat_manager', 'getMessages', options)); return fetch_def.then(function (raw_messages) { var isCreateMode = false; if (!self.res_id) { @@ -147,7 +148,7 @@ var ThreadField = AbstractField.extend(chat_mixin, { * @returns {Deferred} */ _onLoadMoreMessages: function () { - this._fetchAndRenderThread(this.msgIDs, {force_fetch: true}); + this._fetchAndRenderThread(this.msgIDs, {forceFetch: true}); }, _onNewMessage: function (message) { if (message.model === this.model && message.res_id === this.res_id) { @@ -161,7 +162,7 @@ var ThreadField = AbstractField.extend(chat_mixin, { }, _onRedirectToChannel: function (channelID) { var self = this; - this._joinChannel(channelID).then(function () { + 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}); }); diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index 49d1705eccd..533fe67f91b 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -1,48 +1,6 @@ odoo.define('mail.utils', function (require) { "use strict"; -var bus = require('bus.bus').bus; - - -function send_notification(widget, title, content) { - if (window.Notification && Notification.permission === "granted") { - if (bus.is_master) { - _send_native_notification(title, content); - } - } else { - widget.do_notify(title, content); - if (bus.is_master) { - _beep(widget); - } - } -} -function _send_native_notification(title, content) { - var notification = new Notification(title, {body: content, icon: "/mail/static/src/img/odoo_o.png"}); - notification.onclick = function () { - window.focus(); - if (this.cancel) { - this.cancel(); - } else if (this.close) { - this.close(); - } - }; -} -var _beep = (function () { - if (typeof(Audio) === "undefined") { - return function () {}; - } - var audio; - return function (widget) { - if (!audio) { - audio = new Audio(); - var ext = audio.canPlayType("audio/ogg; codecs=vorbis") ? ".ogg" : ".mp3"; - var session = widget.getSession(); - audio.src = session.url("/mail/static/src/audio/ting" + ext); - } - audio.play(); - }; -})(); - function parse_and_transform(html_string, transform_function) { var open_token = "OPEN" + Date.now(); var string = html_string.replace(/</g, open_token); @@ -141,7 +99,6 @@ function unaccent (str) { } return { - send_notification: send_notification, parse_and_transform: parse_and_transform, add_link: add_link, linkify: linkify, diff --git a/addons/mail/static/src/js/window_manager.js b/addons/mail/static/src/js/window_manager.js deleted file mode 100644 index 147a3762068..00000000000 --- a/addons/mail/static/src/js/window_manager.js +++ /dev/null @@ -1,400 +0,0 @@ -odoo.define('mail.window_manager', function (require) { -"use strict"; - -var chat_manager = require('mail.chat_manager'); -var ExtendedChatWindow = require('mail.ExtendedChatWindow'); - -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; - -// chat window management -//---------------------------------------------------------------- -var CHAT_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows -var chat_sessions = []; -var new_chat_session; -var display_state = { - chat_windows_hidden: false, // chat windows aren't displayed when the client action is open - hidden_sessions: [], - hidden_unread_counter: 0, // total number of unread msgs in hidden chat windows - nb_slots: 0, - space_left: 0, - windows_dropdown_is_open: false, // used to keep dropdown open when closing chat windows -}; - -function add_chat_session (chat_session) { - // adds chat_session such that it will be the left-most visible window - compute_available_slots(chat_sessions.length+1); - chat_sessions.splice(display_state.nb_slots-1, 0, chat_session); -} - -// 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 -function open_chat (session, options) { - if (!session) { - open_chat_without_session(); - return; - } - options = options || {}; - var chat_session = _.findWhere(chat_sessions, {id: session.id}); - if (!chat_session) { - var prefix = !session.is_chat ? "#" : ""; - var window_options = { - autofocus: !options.passively, - input_less: session.mass_mailing, - status: session.status, - }; - chat_session = { - id: session.id, - uuid: session.uuid, - name: session.name, - keep_unread: options.passively, // don't automatically mark unread messages as seen - window: new ExtendedChatWindow(web_client, session.id, prefix + session.name, session.is_folded, session.unread_counter, window_options), - }; - chat_session.window.on("close_chat_session", null, function () { - close_chat(chat_session); - chat_manager.close_chat_session(chat_session.id); - }); - chat_session.window.on("toggle_star_status", null, function (message_id) { - chat_manager.toggle_star_status(message_id); - }); - - chat_session.window.on("fold_channel", null, function (channel_id, folded) { - chat_manager.fold_channel(channel_id, folded); - }); - - chat_session.window.on("post_message", null, function (message, channel_id) { - chat_manager - .post_message(message, {channel_id: channel_id}) - .then(function () { - chat_session.window.thread.scroll_to(); - }); - }); - chat_session.window.on("redirect", null, function (res_model, res_id) { - chat_manager.redirect(res_model, res_id, open_chat); - }); - chat_session.window.on("redirect_to_channel", null, function (channel_id) { - var session = _.findWhere(chat_sessions, {id: channel_id}); - if (!session) { - chat_manager.join_channel(channel_id).then(function (channel) { - chat_manager.detach_channel(channel); - }); - } else { - session.window.toggle_fold(false); - } - }); - - var remove_new_chat = false; - if (options.passively) { - chat_sessions.push(chat_session); // simply insert the window to the left - } else if (new_chat_session && new_chat_session.partner_id && new_chat_session.partner_id === session.direct_partner_id) { - // the window takes the place of the 'new_chat_session' window - chat_sessions[_.indexOf(chat_sessions, new_chat_session)] = chat_session; - remove_new_chat = true; - } else { - add_chat_session(chat_session); // add session such that window is visible - } - - chat_session.window.appendTo($('body')) - .then(function () { - reposition_windows({remove_new_chat: remove_new_chat}); - return chat_manager.get_messages({channel_id: chat_session.id}); - }).then(function (messages) { - chat_session.window.render(messages); - chat_session.window.thread.scroll_to(); - setTimeout(function () { - chat_session.window.thread.$el.on("scroll", null, _.debounce(function () { - if (!chat_session.keep_unread && chat_session.window.thread.is_at_bottom()) { - chat_manager.mark_channel_as_seen(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 - chat_session.window.$('.o_mail_thread, .o_chat_composer').one('click', function () { - chat_manager.mark_channel_as_seen(session); - }); - } else if (!display_state.chat_windows_hidden && !session.is_folded) { - chat_manager.mark_channel_as_seen(session); - } - }); - } else if (!options.passively) { - if (chat_session.window.is_hidden) { - make_session_visible(chat_session); - } else if (session.is_folded !== chat_session.window.folded) { - chat_session.window.toggle_fold(session.is_folded); - } - } -} - -function open_chat_without_session () { - if (!new_chat_session) { - new_chat_session = { - id: '_open', - window: new ExtendedChatWindow(web_client, undefined, _t('New message'), false, 0, {thread_less: true}), - }; - new_chat_session.window.on("close_chat_session", null, close_new_chat); - new_chat_session.window.on('open_dm_session', null, function (partner_id) { - new_chat_session.partner_id = partner_id; - var dm = chat_manager.get_dm_from_partner_id(partner_id); - if (!dm) { - chat_manager.open_and_detach_dm(partner_id); - } else { - var dm_session = _.findWhere(chat_sessions, {id: dm.id}); - if (!dm_session) { - chat_manager.detach_channel(dm); - } else { - close_chat(dm_session); - dm.is_folded = false; - open_chat(dm); - } - } - }); - add_chat_session(new_chat_session); - new_chat_session.window.appendTo($('body')).then(reposition_windows); - } else { - if (new_chat_session.window.is_hidden) { - make_session_visible(new_chat_session); - } else if (new_chat_session.window.folded) { - new_chat_session.window.toggle_fold(false); - } - } -} - -function close_chat (chat_session, options) { - if (options && options.keep_open_if_unread && chat_session.keep_unread) { - return; - } - chat_sessions = _.without(chat_sessions, chat_session); - chat_session.window.destroy(); - reposition_windows(); -} - -function close_new_chat () { - chat_sessions = _.without(chat_sessions, new_chat_session); - reposition_windows({remove_new_chat: true}); -} - -function destroy_new_chat () { - new_chat_session.window.destroy(); - new_chat_session = undefined; -} - -function toggle_fold_chat (channel) { - var session = _.find(chat_sessions, {id: channel.id}); - if (session) { - session.window.toggle_fold(channel.is_folded); - } -} - -function compute_available_slots (nb_windows) { - if (config.device.isMobile) { - display_state.nb_slots = 1; // one chat window full screen in mobile - return; - } - var width = window.innerWidth; - var nb_slots = Math.floor(width/CHAT_WINDOW_WIDTH); - var space_left = width - (Math.min(nb_slots, nb_windows)*CHAT_WINDOW_WIDTH); - if (nb_slots < nb_windows && space_left < 50) { - nb_slots--; // leave at least 50px for the hidden windows dropdown button - space_left += CHAT_WINDOW_WIDTH; - } - display_state.nb_slots = nb_slots; - display_state.space_left = space_left; -} - -var reposition_windows = function (options) { - if (options && options.remove_new_chat) { - destroy_new_chat(); - } - if (display_state.chat_windows_hidden) { - return; - } - compute_available_slots(chat_sessions.length); - var hidden_sessions = []; - var hidden_unread_counter = 0; - var nb_slots = display_state.nb_slots; - _.each(chat_sessions, function (session, index) { - if (index < nb_slots) { - session.window.$el.css({right: CHAT_WINDOW_WIDTH*index, bottom: 0}); - session.window.do_show(); - } else { - hidden_sessions.push(session); - hidden_unread_counter += session.window.unread_msgs; - session.window.do_hide(); - } - }); - display_state.hidden_sessions = hidden_sessions; - display_state.hidden_unread_counter = hidden_unread_counter; - - if (display_state.$hidden_windows_dropdown) { - display_state.$hidden_windows_dropdown.remove(); - } - if (hidden_sessions.length) { - display_state.$hidden_windows_dropdown = render_hidden_sessions_dropdown(); - var $hidden_windows_dropdown = display_state.$hidden_windows_dropdown; - $hidden_windows_dropdown.css({right: CHAT_WINDOW_WIDTH * nb_slots, bottom: 0}) - .appendTo($('body')); - reposition_hidden_sessions_dropdown(); - display_state.windows_dropdown_is_open = false; - - $hidden_windows_dropdown.on('click', '.o_chat_header', function (event) { - var session_id = $(event.currentTarget).data('session-id'); - var session = _.findWhere(hidden_sessions, {id: session_id}); - if (session) { - make_session_visible(session); - } - }); - $hidden_windows_dropdown.on('click', '.o_chat_window_close', function (event) { - var session_id = $(event.currentTarget).closest('.o_chat_header').data('session-id'); - var session = _.findWhere(hidden_sessions, {id: session_id}); - if (session) { - session.window.on_click_close(event); - display_state.windows_dropdown_is_open = true; // keep the dropdown open - } - }); - } -}; - -function make_session_visible (session) { - utils.swap(chat_sessions, session, chat_sessions[display_state.nb_slots-1]); - reposition_windows(); - session.window.toggle_fold(false); -} - -function render_hidden_sessions_dropdown () { - var $dropdown = $(QWeb.render("mail.ChatWindowsDropdown", { - sessions: display_state.hidden_sessions, - open: display_state.windows_dropdown_is_open, - unread_counter: display_state.hidden_unread_counter, - widget: {isMobile: config.device.isMobile}, - })); - return $dropdown; -} - -function reposition_hidden_sessions_dropdown () { - // Unfold dropdown to the left if there is enough place - var $dropdown_ul = display_state.$hidden_windows_dropdown.children('ul'); - if (display_state.space_left > $dropdown_ul.width() + 10) { - $dropdown_ul.addClass('dropdown-menu-right'); - } -} - -function update_sessions (message, scrollBottom) { - _.each(chat_sessions, function (session) { - if (_.contains(message.channel_ids, session.id)) { - var message_visible = !display_state.chat_windows_hidden && !session.window.folded && - !session.window.is_hidden && session.window.thread.is_at_bottom(); - if (message_visible && !session.keep_unread) { - chat_manager.mark_channel_as_seen(chat_manager.get_channel(session.id)); - } - chat_manager.get_messages({channel_id: session.id}).then(function (messages) { - session.window.render(messages); - if (scrollBottom && message_visible) { - session.window.thread.scroll_to(); - } - }); - } - }); -} - - -core.bus.on('web_client_ready', null, function () { - chat_manager.bus.on('open_chat', null, open_chat); - chat_manager.bus.on('close_chat', null, function (channel, options) { - var session = _.find(chat_sessions, {id: channel.id}); - if (session) { - close_chat(session, options); - } - }); - chat_manager.bus.on('channel_toggle_fold', null, toggle_fold_chat); - - chat_manager.bus.on('new_message', null, function (message) { - update_sessions(message, true); - }); - - chat_manager.bus.on('update_message', null, function (message) { - update_sessions(message, false); - }); - - chat_manager.bus.on('anyone_listening', null, function (channel, query) { - _.each(chat_sessions, function (session) { - if (channel.id === session.id && session.window.thread.is_at_bottom() && !session.window.is_hidden) { - query.is_displayed = true; - } - }); - }); - - chat_manager.bus.on('unsubscribe_from_channel', null, function (channel_id) { - _.each(chat_sessions, function (session) { - if (channel_id === session.id) { - close_chat(session); - } - }); - }); - - chat_manager.bus.on('update_channel_unread_counter', null, function (channel) { - display_state.hidden_unread_counter = 0; - _.each(chat_sessions, 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) { - display_state.hidden_unread_counter += session.window.unread_msgs; - } - }); - if (display_state.$hidden_windows_dropdown) { - display_state.$hidden_windows_dropdown.html(render_hidden_sessions_dropdown().html()); - reposition_hidden_sessions_dropdown(); - } - }); - - chat_manager.bus.on('update_dm_presence', null, function (channel) { - _.each(chat_sessions, function (session) { - if (channel.id === session.id) { - session.window.update_status(channel.status); - } - }); - }); - - chat_manager.is_ready.then(function() { - _.each(chat_manager.get_channels(), function (channel) { - if (channel.is_detached) { - open_chat(channel); - } - }); - }); - - chat_manager.bus.on('detach_channel', null, function (channel) { - var chat_session = _.findWhere(chat_sessions, {id: channel.id}); - if (!chat_session || chat_session.window.folded) { - chat_manager.detach_channel(channel); - } else if (chat_session.window.is_hidden) { - make_session_visible(chat_session); - } else { - chat_session.window.focus_input(); - } - }); - - chat_manager.bus.on('client_action_open', null, function (open) { - display_state.chat_windows_hidden = open; - if (open) { - $('body').addClass('o_no_chat_window'); - } else { - $('body').removeClass('o_no_chat_window'); - reposition_windows(); - } - }); - - core.bus.on('resize', null, _.debounce(reposition_windows, 100)); -}); - -}); diff --git a/addons/mail/static/src/less/client_action.less b/addons/mail/static/src/less/discuss.less similarity index 99% rename from addons/mail/static/src/less/client_action.less rename to addons/mail/static/src/less/discuss.less index 0dd284afb90..a90d0df94bd 100644 --- a/addons/mail/static/src/less/client_action.less +++ b/addons/mail/static/src/less/discuss.less @@ -356,7 +356,7 @@ } // ------------------------------------------------------------------ -// Channel preview: shared between client action (mobile) and systray +// Channel preview: shared between discuss (mobile) and systray // ------------------------------------------------------------------ .o_mail_channel_preview { diff --git a/addons/mail/static/src/xml/client_action.xml b/addons/mail/static/src/xml/discuss.xml similarity index 98% rename from addons/mail/static/src/xml/client_action.xml rename to addons/mail/static/src/xml/discuss.xml index 22baf631c09..fba59c59a04 100644 --- a/addons/mail/static/src/xml/client_action.xml +++ b/addons/mail/static/src/xml/discuss.xml @@ -1,8 +1,8 @@ - - + +
@@ -147,7 +147,7 @@ - +
@@ -193,7 +193,7 @@
- +
diff --git a/addons/mail/static/tests/chat_window_test.js b/addons/mail/static/tests/chat_window_tests.js similarity index 94% rename from addons/mail/static/tests/chat_window_test.js rename to addons/mail/static/tests/chat_window_tests.js index 51eb68475ba..a892d8e26e6 100644 --- a/addons/mail/static/tests/chat_window_test.js +++ b/addons/mail/static/tests/chat_window_tests.js @@ -1,10 +1,15 @@ odoo.define('mail.chat_window_test', function (require) { "use strict"; +var ChatManager = require('mail.ChatManager'); +var ChatWindow = require('mail.ExtendedChatWindow'); +var mailTestUtils = require('mail.testUtils'); + var framework = require('web.framework'); var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); -var ChatWindow = require('mail.ExtendedChatWindow'); + +var createBusService = mailTestUtils.createBusService; QUnit.module('mail', {}, function () { @@ -88,6 +93,8 @@ QUnit.module('mail', {}, function () { QUnit.test('emoji popover should open correctly in chat windows', function (assert) { assert.expect(1); + var BusService = createBusService(); + function createParent(params) { var widget = new Widget(); @@ -100,6 +107,7 @@ QUnit.module('mail', {}, function () { } var parent = createParent({ data: {}, + services: [ChatManager, BusService], }); var $dom = $('#qunit-fixture'); diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js index 9fb87dcef9d..f3e4b1c571c 100644 --- a/addons/mail/static/tests/chatter_tests.js +++ b/addons/mail/static/tests/chatter_tests.js @@ -2,6 +2,8 @@ odoo.define('mail.chatter_tests', function (require) { "use strict"; var Composers = require('mail.composer'); +var ChatManager = require('mail.ChatManager'); +var mailTestUtils = require('mail.testUtils'); var Bus = require('web.Bus'); var concurrency = require('web.concurrency'); @@ -11,6 +13,7 @@ var testUtils = require('web.test_utils'); var BasicComposer = Composers.BasicComposer; +var createBusService = mailTestUtils.createBusService; var createAsyncView = testUtils.createAsyncView; var createView = testUtils.createView; @@ -18,6 +21,7 @@ QUnit.module('mail', {}, function () { QUnit.module('Chatter', { beforeEach: function () { + this.BusService = createBusService(); this.data = { partner: { fields: { @@ -104,9 +108,8 @@ QUnit.test('basic rendering', function (assert) { '' + '
' + '', - res_id: 2, mockRPC: function (route, args) { - if ('/web/dataset/call_kw/mail.followers/read' === route) { + if (route === '/web/dataset/call_kw/mail.followers/read') { unwanted_read_count++; } if (route === '/mail/read_followers') { @@ -118,28 +121,8 @@ QUnit.test('basic rendering', function (assert) { } return this._super(route, args); }, - intercepts: { - get_messages: function (event) { - // msgRpc++; - event.stopPropagation(); - event.data.callback($.when([{ - attachment_ids: [], - body: "", - date: moment("2016-12-20 09:35:40"), - id: 34, - res_id: 3, - author_id: ["3", "Fu Ck Mil Grom"], - }])); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(new Bus()); - }, - get_session: function (event) { - event.stopPropagation(); - event.data.callback({uid: 1}); - }, - }, + services: [ChatManager, this.BusService], + res_id: 2, }); assert.ok(form.$('.o_mail_activity').length, "there should be an activity widget"); @@ -182,16 +165,7 @@ QUnit.test('chatter in create mode', function (assert) { } return this._super(route, args); }, - intercepts: { - get_messages: function (event) { - event.stopPropagation(); - event.data.callback($.when([])); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(new Bus()); - }, - }, + services: [ChatManager, this.BusService], }); assert.strictEqual(form.$('.o_chatter').length, 1, @@ -258,16 +232,7 @@ QUnit.test('chatter rendering inside the sheet', function (assert) { } return this._super(route, args); }, - intercepts: { - get_messages: function (event) { - event.stopPropagation(); - event.data.callback($.when([])); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(new Bus()); - }, - }, + services: [ChatManager, this.BusService], }); assert.strictEqual(form.$('.o_chatter').length, 1, @@ -405,6 +370,9 @@ QUnit.test('chatter: post, receive and star messages', function (assert) { var done = assert.async(); assert.expect(28); + var bus = new Bus(); + var BusService = createBusService(bus); + // Remove the mention throttle to speed up the test var mentionThrottle = BasicComposer.prototype.MENTION_THROTTLE; BasicComposer.prototype.MENTION_THROTTLE = 1; @@ -414,7 +382,7 @@ QUnit.test('chatter: post, receive and star messages', function (assert) { attachment_ids: [], author_id: ["1", "John Doe"], body: "A message", - date: moment("2016-12-20 09:35:40"), + date: "2016-12-20 09:35:40", displayed_author: "John Doe", id: 1, is_note: false, @@ -423,7 +391,6 @@ QUnit.test('chatter: post, receive and star messages', function (assert) { model: 'partner', res_id: 2, }]; - var bus = new Bus(); var getSuggestionsDef = $.Deferred(); var form = createView({ View: FormView, @@ -439,6 +406,12 @@ QUnit.test('chatter: post, receive and star messages', function (assert) { '', res_id: 2, mockRPC: function (route, args) { + if (args.method === 'message_format') { + var requested_msgs = _.filter(messages, function (msg) { + return _.contains(args.args[0], msg.id); + }); + return $.when(requested_msgs); + } if (args.method === 'message_get_suggested_recipients') { return $.when({2: []}); } @@ -446,52 +419,43 @@ QUnit.test('chatter: post, receive and star messages', function (assert) { getSuggestionsDef.resolve(); return $.when([{email: "test@odoo.com", id: 1, name: "Test User"}]); } - return this._super(route, args); - }, - session: {}, - intercepts: { - get_messages: function (event) { - event.stopPropagation(); - var requested_msgs = _.filter(messages, function (msg) { - return _.contains(event.data.options.ids, msg.id); - }); - event.data.callback($.when(requested_msgs)); - }, - post_message: function (event) { - event.stopPropagation(); + if (args.method === 'message_post') { var msg_id = messages[messages.length-1].id + 1; messages.push({ - attachment_ids: [], + attachment_ids: args.kwargs.attachment_ids, author_id: ["42", "Me"], - body: event.data.message.content, - date: moment(), // now + body: args.kwargs.body, + date: "2016-12-20 10:35:40", displayed_author: "Me", id: msg_id, - is_note: event.data.message.subtype === 'mail.mt_note', - is_discussion: event.data.message.subtype === 'mail.mt_comment', + is_note: args.kwargs.subtype === 'mail.mt_note', + is_discussion: args.kwargs.subtype === 'mail.mt_comment', is_starred: false, model: 'partner', res_id: 2, }); - bus.trigger('new_message', { - id: msg_id, - model: event.data.options.model, - res_id: event.data.options.res_id, - }); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(bus); - }, - toggle_star_status: function (event) { - event.stopPropagation(); - assert.strictEqual(event.data.message_id, 2, + return $.when(msg_id); + } + if (args.method === 'toggle_message_starred') { + assert.ok(_.contains(args.args[0], 2), "toggle_star_status should have been triggered for message 2 (twice)"); - var msg = _.findWhere(messages, {id: event.data.message_id}); + var msg = _.findWhere(messages, {id: args.args[0][0]}); msg.is_starred = !msg.is_starred; - bus.trigger('update_message', msg); - }, + // simulate notification received by chat_manager from longpoll + var data = { + info: false, + message_ids: [msg.id], + starred: msg.is_starred, + type: 'toggle_star', + }; + var notification = [[false, 'res.partner'], data]; + bus.trigger('notification', [notification]); + return $.when(); + } + return this._super(route, args); }, + session: {}, + services: [ChatManager, BusService], }); assert.ok(form.$('.o_chatter_topbar .o_chatter_button_log_note').length, @@ -578,7 +542,6 @@ QUnit.test('chatter: post a message and switch in edit mode', function (assert) assert.expect(5); var messages = []; - var bus = new Bus(); var form = createView({ View: FormView, model: 'partner', @@ -594,45 +557,35 @@ QUnit.test('chatter: post a message and switch in edit mode', function (assert) res_id: 2, session: {}, mockRPC: function (route, args) { - if (route === "/web/dataset/call_kw/partner/message_get_suggested_recipients") { + if (args.method === 'message_format') { + var requested_msgs = _.filter(messages, function (msg) { + return _.contains(args.args[0], msg.id); + }); + return $.when(requested_msgs); + } + if (args.method === 'message_get_suggested_recipients') { return $.when({2: []}); } - return this._super(route, args); - }, - intercepts: { - get_messages: function (event) { - event.stopPropagation(); - var requested_msgs = _.filter(messages, function (msg) { - return _.contains(event.data.options.ids, msg.id); - }); - event.data.callback($.when(requested_msgs)); - }, - post_message: function (event) { - event.stopPropagation(); + if (args.method === 'message_post') { messages.push({ - attachment_ids: [], + attachment_ids: args.kwargs.attachment_ids, author_id: ["42", "Me"], - body: event.data.message.content, - date: moment(), // now + body: args.kwargs.body, + date: "2016-12-20 10:35:40", displayed_author: "Me", id: 42, - is_note: event.data.message.subtype === 'mail.mt_note', - is_discussion: event.data.message.subtype === 'mail.mt_comment', + is_note: args.kwargs.subtype === 'mail.mt_note', + is_discussion: args.kwargs.subtype === 'mail.mt_comment', is_starred: false, model: 'partner', res_id: 2, }); - bus.trigger('new_message', { - id: 42, - model: event.data.options.model, - res_id: event.data.options.res_id, - }); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(bus); - }, + return $.when(42); + } + + return this._super(route, args); }, + services: [ChatManager, this.BusService], }); assert.strictEqual(form.$('.o_thread_message').length, 0, "thread should not contain messages"); @@ -685,7 +638,7 @@ QUnit.test('chatter: Attachment viewer', function (assert) { }], author_id: ["1", "John Doe"], body: "Attachement viewer test", - date: moment("2016-12-20 09:35:40"), + date: "2016-12-20 09:35:40", displayed_author: "John Doe", id: 1, is_note: false, @@ -694,6 +647,7 @@ QUnit.test('chatter: Attachment viewer', function (assert) { model: 'partner', res_id: 2, }]; + var form = createView({ View: FormView, model: 'partner', @@ -707,27 +661,21 @@ QUnit.test('chatter: Attachment viewer', function (assert) { '
' + '', res_id: 2, - mockRPC: function (route) { - if(_.str.contains(route, '/mail/attachment/preview/') || + mockRPC: function (route, args) { + if (args.method === 'message_format') { + var requested_msgs = _.filter(messages, function (msg) { + return _.contains(args.args[0], msg.id); + }); + return $.when(requested_msgs); + } + if (_.str.contains(route, '/mail/attachment/preview/') || _.str.contains(route, '/web/static/lib/pdfjs/web/viewer.html')){ var canvas = document.createElement('canvas'); return $.when(canvas.toDataURL()); } return this._super.apply(this, arguments); }, - intercepts: { - get_messages: function (event) { - event.stopPropagation(); - var requested_msgs = _.filter(messages, function (msg) { - return _.contains(event.data.options.ids, msg.id); - }); - event.data.callback($.when(requested_msgs)); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(new Bus()); - }, - }, + services: [ChatManager, this.BusService], }); assert.strictEqual(form.$('.o_thread_message .o_attachment').length, 4, "there should be three attachment on message"); @@ -783,6 +731,9 @@ QUnit.test('form activity widget: schedule next activity', function (assert) { '', res_id: 2, mockRPC: function (route, args) { + if (args.method === 'message_format') { + return $.when([]); + } if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { assert.ok(_.isEqual(args.args[0], [1]), "should call 'action_feedback' for id 1"); assert.strictEqual(args.kwargs.feedback, 'everything is ok', @@ -795,15 +746,8 @@ QUnit.test('form activity widget: schedule next activity', function (assert) { } return this._super.apply(this, arguments); }, + services: [ChatManager, this.BusService], intercepts: { - get_messages: function (event) { - event.stopPropagation(); - event.data.callback($.when([])); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(new Bus()); - }, do_action: function (event) { assert.deepEqual(event.data.action, { context: { @@ -856,11 +800,8 @@ QUnit.test('form activity widget: schedule activity does not discard changes', f } return this._super.apply(this, arguments); }, + services: [ChatManager, this.BusService], intercepts: { - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(new Bus()); - }, do_action: function (event) { event.data.options.on_close(); }, @@ -922,6 +863,12 @@ QUnit.test('form activity widget: mark as done and remove', function (assert) { '', res_id: 2, mockRPC: function (route, args) { + if (args.method === 'message_format') { + var requested_msgs = _.filter(messages, function (msg) { + return _.contains(args.args[0], msg.id); + }); + return $.when(requested_msgs); + } if (route === '/web/dataset/call_kw/mail.activity/unlink') { assert.ok(_.isEqual(args.args[0], [1]), "should call 'unlink' for id 1"); } else if (route === '/web/dataset/call_kw/mail.activity/action_feedback') { @@ -934,7 +881,7 @@ QUnit.test('form activity widget: mark as done and remove', function (assert) { attachment_ids: [], author_id: ["1", "John Doe"], body: "The activity has been done", - date: moment("2016-12-20 09:35:40"), + date: "2016-12-20 09:35:40", displayed_author: "John Doe", id: 1, is_note: true, @@ -956,16 +903,7 @@ QUnit.test('form activity widget: mark as done and remove', function (assert) { } return this._super.apply(this, arguments); }, - intercepts: { - get_messages: function (event) { - event.stopPropagation(); - event.data.callback($.when(messages)); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(new Bus()); - } - }, + services: [ChatManager, this.BusService], }); assert.strictEqual(form.$('.o_mail_activity .o_thread_message').length, 2, @@ -1203,6 +1141,16 @@ QUnit.test('does not render and crash when destroyed before chat system is ready '', res_id: 2, mockRPC: function (route, args) { + if (args.method === 'message_format') { + return $.when([{ + attachment_ids: [], + body: "", + date: "2016-12-20 09:35:40", + id: 34, + res_id: 3, + author_id: ["3", "Fu Ck Mil Grom"], + }]); + } if (route === '/mail/read_followers') { return $.when({ followers: [], @@ -1211,26 +1159,8 @@ QUnit.test('does not render and crash when destroyed before chat system is ready } return this._super(route, args); }, + services: [ChatManager, this.BusService], intercepts: { - chat_manager_ready: function (event) { - // we delay the return of the chat_manager ready event - event.data.callback(def); - }, - get_messages: function (event) { - event.stopPropagation(); - event.data.callback($.when([{ - attachment_ids: [], - body: "", - date: moment("2016-12-20 09:35:40"), - id: 34, - res_id: 3, - author_id: ["3", "Fu Ck Mil Grom"], - }])); - }, - get_bus: function (event) { - event.stopPropagation(); - event.data.callback(new Bus()); - }, get_session: function (event) { event.stopPropagation(); event.data.callback({uid: 1}); @@ -1239,10 +1169,10 @@ QUnit.test('does not render and crash when destroyed before chat system is ready }); form.destroy(); - // here, the chat_manager system is ready, and the chatter can try to render + // here, the chatManager system is ready, and the chatter can try to render // itself. We simply make sure here that no crashes occur (since the form // view is destroyed, all rpcs will be dropped, and many other mechanisms - // relying on events will not work, such as _getBus) + // relying on events will not work, such as the chat bus) def.resolve(); }); diff --git a/addons/mail/static/tests/client_action_mobile_tests.js b/addons/mail/static/tests/client_action_mobile_tests.js deleted file mode 100644 index e781ae34fd3..00000000000 --- a/addons/mail/static/tests/client_action_mobile_tests.js +++ /dev/null @@ -1,105 +0,0 @@ -odoo.define('mail.client_action_mobile_tests', function (require) { -"use strict"; - -var session = require('web.session'); -var testUtils = require('web.test_utils'); -var Widget = require('web.Widget'); - -var ChatAction = require('mail.chat_client_action'); -var chat_manager = require('mail.chat_manager'); - -QUnit.module('mail', {}, function () { - -QUnit.module('Discuss client action in mobile', { - beforeEach: function () { - this.data = { - 'mail.message': { - fields: {}, - }, - }; - this.createChatAction = function (params) { - var Parent = Widget.extend({ - do_push_state: function () {}, - }); - var parent = new Parent(); - testUtils.addMockEnvironment(parent, { - data: this.data, - archs: { - 'mail.message,false,search': '', - }, - session: params.session || {}, - intercepts: params.intercepts || {}, - }); - var chatAction = new ChatAction(parent, params); - chatAction.set_cp_bus(new Widget()); - chatAction.appendTo($('#qunit-fixture')); - - return chatAction; - }; - - }, -}); - -QUnit.test('mobile basic rendering', function (assert) { - // This is a very basic first test for the client action. However, with - // the chat_manager, it is hard to override RPCs (for instance, the - // /mail/client_action route is always called when the test suite is - // launched), and we must wait for this RPC to be done before starting to - // test the interface. This should be refactored to facilitate the testing. - assert.expect(9); - var done = assert.async(); - - var rpc = session.rpc; - - var chatAction = this.createChatAction({ - id: 1, - context: {}, - params: {}, - intercepts: { - get_session: function (ev) { - ev.data.callback({}); - }, - }, - session: { - rpc: function (route, args) { - if (args.method === 'message_fetch') { - return $.when([]); - } - return rpc.apply(this, arguments); - }, - }, - }); - - chat_manager.is_ready.then(function () { - // test basic rendering in mobile - assert.strictEqual(chatAction.$('.o_mail_chat_mobile_control_panel').length, 1, - "should have rendered a control panel"); - assert.strictEqual(chatAction.$('.o_mail_chat_content .o_mail_no_content').length, 1, - "should display the no content message"); - assert.strictEqual(chatAction.$('.o_mail_mobile_tabs').length, 1, - "should have rendered the tabs"); - assert.ok(chatAction.$('.o_mail_mobile_tab[data-type=channel_inbox]').hasClass('active'), - "should be in inbox tab"); - assert.strictEqual(chatAction.$('.o_mail_chat_mobile_inbox_buttons:visible').length, 1, - "inbox/starred buttons should be visible"); - assert.ok(chatAction.$('.o_mail_chat_mobile_inbox_buttons .o_channel_inbox_item[data-type=channel_inbox]').hasClass('btn-primary'), - "should be in inbox"); - - // move to DMs tab - chatAction.$('.o_mail_mobile_tab[data-type=dm]').click(); - assert.ok(chatAction.$('.o_mail_mobile_tab[data-type=dm]').hasClass('active'), - "should be in DMs tab"); - assert.strictEqual(chatAction.$('.o_mail_chat_content .o_mail_no_content').length, 0, - "should display the no content message"); - chatAction.$('.o_mail_chat_button_dm').click(); // click to add a channel - assert.strictEqual(chatAction.$('.o_mail_add_channel input:visible').length, 1, - "should display the input to add a channel"); - - chatAction.destroy(); - done(); - }); -}); - -}); - -}); diff --git a/addons/mail/static/tests/discuss_mobile_tests.js b/addons/mail/static/tests/discuss_mobile_tests.js new file mode 100644 index 00000000000..431a433c19f --- /dev/null +++ b/addons/mail/static/tests/discuss_mobile_tests.js @@ -0,0 +1,78 @@ +odoo.define('mail.discuss_mobile_tests', function (require) { +"use strict"; + +var ChatManager = require('mail.ChatManager'); +var mailTestUtils = require('mail.testUtils'); + +var createBusService = mailTestUtils.createBusService; +var createDiscuss = mailTestUtils.createDiscuss; + +QUnit.module('mail', {}, function () { + +QUnit.module('Discuss client action in mobile', { + beforeEach: function () { + this.BusService = createBusService(); + this.data = { + 'mail.message': { + fields: {}, + }, + }; + }, +}); + +QUnit.test('mobile basic rendering', function (assert) { + // This is a very basic first test for the client action. However, with + // the chat_manager, it is hard to override RPCs (for instance, the + // /mail/client_action route is always called when the test suite is + // launched), and we must wait for this RPC to be done before starting to + // test the interface. This should be refactored to facilitate the testing. + assert.expect(9); + var done = assert.async(); + + var discuss = createDiscuss({ + id: 1, + context: {}, + params: {}, + mockRPC: function (route, args) { + if (args.method === 'message_fetch') { + return $.when([]); + } + return this._super.apply(this, arguments); + }, + services: [ChatManager, this.BusService], + }); + + var chatReady = discuss.call('chat_manager', 'isReady'); + chatReady.then(function () { + // test basic rendering in mobile + assert.strictEqual(discuss.$('.o_mail_chat_mobile_control_panel').length, 1, + "should have rendered a control panel"); + assert.strictEqual(discuss.$('.o_mail_chat_content .o_mail_no_content').length, 1, + "should display the no content message"); + assert.strictEqual(discuss.$('.o_mail_mobile_tabs').length, 1, + "should have rendered the tabs"); + assert.ok(discuss.$('.o_mail_mobile_tab[data-type=channel_inbox]').hasClass('active'), + "should be in inbox tab"); + assert.strictEqual(discuss.$('.o_mail_chat_mobile_inbox_buttons:visible').length, 1, + "inbox/starred buttons should be visible"); + assert.ok(discuss.$('.o_mail_chat_mobile_inbox_buttons .o_channel_inbox_item[data-type=channel_inbox]').hasClass('btn-primary'), + "should be in inbox"); + + // move to DMs tab + discuss.$('.o_mail_mobile_tab[data-type=dm]').click(); + assert.ok(discuss.$('.o_mail_mobile_tab[data-type=dm]').hasClass('active'), + "should be in DMs tab"); + assert.strictEqual(discuss.$('.o_mail_chat_content .o_mail_no_content').length, 0, + "should display the no content message"); + discuss.$('.o_mail_chat_button_dm').click(); // click to add a channel + assert.strictEqual(discuss.$('.o_mail_add_channel input:visible').length, 1, + "should display the input to add a channel"); + + discuss.destroy(); + done(); + }); +}); + +}); + +}); diff --git a/addons/mail/static/tests/discuss_tests.js b/addons/mail/static/tests/discuss_tests.js new file mode 100644 index 00000000000..7f48e3b0450 --- /dev/null +++ b/addons/mail/static/tests/discuss_tests.js @@ -0,0 +1,74 @@ +odoo.define('mail.discuss_test', function (require) { +"use strict"; + +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +var mailTestUtils = require('mail.testUtils'); + +var createDiscuss = mailTestUtils.createDiscuss; + +QUnit.module('mail', {}, function () { + +QUnit.module('Discuss client action', { + beforeEach: function () { + this.data = { + 'mail.message': { + fields: {}, + }, + }; + }, +}); + +QUnit.skip('mobile basic rendering', function (assert) { + // Unfortunately, this test is skipped for now because there is no way to + // execute the whole test suite in mobile (it only works test by test), so + // as the client action include for mobile is rejected when we are not in + // mobile, it isn't possible to test it + // Moreover, RPCs done by the chat_manager (e.g. message_fetch) should be + // properly mocked. + assert.expect(11); + + var parent = new Widget(); + testUtils.addMockEnvironment(parent, { + data: this.data, + archs: { + 'mail.message,false,search': '', + }, + config: {device: {isMobile: true}}, + }); + + var params = { + id: 1, + context: {}, + params: {}, + }; + var discuss = createDiscuss(params); + + // test basic rendering in mobile + assert.equal(discuss.$(".o_mail_chat_mobile_control_panel").length, 1, "Mobile control panel created"); + assert.equal(discuss.$(".o_mail_mobile_tab").length, 4, "Four mobile tabs created"); + assert.equal(discuss.$('.o_mail_chat_content').length, 1, "One default chat content pane created"); + assert.equal(discuss.$(".o_mail_chat_tab_pane").length, 3, "Three mobile tab panes created"); + + // Inbox + assert.equal(discuss.activeMobileTab, "channel_inbox", "'channel_inbox' is default active tab"); + assert.ok(discuss.$(".o_channel_inbox_item:nth(0)").hasClass("btn-primary"), "Showing 'Inbox'"); + + // Starred + discuss.$(".o_channel_inbox_item[data-type='channel_starred']").click(); + assert.ok(discuss.$(".o_channel_inbox_item:nth(1)").hasClass("btn-primary"), "Clicked on 'Starred'"); + + assert.ok(discuss.$(".o_mail_chat_content").is(":visible"), "Default main content pane visible"); + + discuss.$(".o_mail_mobile_tab[data-type='dm']").click(); + assert.equal(discuss.activeMobileTab, "dm", "After click on 'Conversation', is now active tab"); + + assert.ok(!discuss.$(".o_mail_chat_content").is(":visible"), "none", "'Main' content pane is invisible"); + assert.ok(discuss.$(".o_mail_chat_tab_pane:nth(0)").is(":visible"), "'Conversation' pane is visible"); + + discuss.destroy(); +}); + +}); +}); diff --git a/addons/mail/static/tests/helpers/mock_server.js b/addons/mail/static/tests/helpers/mock_server.js new file mode 100644 index 00000000000..365b067b55a --- /dev/null +++ b/addons/mail/static/tests/helpers/mock_server.js @@ -0,0 +1,35 @@ +odoo.define('mail.MockServer', function (require) { +"use strict"; + +var MockServer = require('web.MockServer'); + +MockServer.include({ + /** + * @override + */ + _performRpc: function (route, args) { + if (route === '/mail/init_messaging') { + return $.when(this._mockInitMessaging(args)); + } + return this._super(route, args); + }, + /** + * Simulate the '/mail/init_messaging' route + * + * @private + * @return {Object} + */ + _mockInitMessaging: function () { + return { + 'needaction_inbox_counter': 0, + 'starred_counter': 0, + 'channel_slots': [], + 'commands': [], + 'mention_partner_suggestions': [], + 'shortcodes': [], + 'menu_id': false, + }; + }, +}); + +}); diff --git a/addons/mail/static/tests/helpers/test_utils.js b/addons/mail/static/tests/helpers/test_utils.js new file mode 100644 index 00000000000..3d107688537 --- /dev/null +++ b/addons/mail/static/tests/helpers/test_utils.js @@ -0,0 +1,85 @@ +odoo.define('mail.testUtils', function (require) { +"use strict"; + +var Discuss = require('mail.chat_discuss'); + +var AbstractService = require('web.AbstractService'); +var Bus = require('web.Bus'); +var testUtils = require('web.test_utils'); +var Widget = require('web.Widget'); + +/** + * Test Utils + * + * In this module, we define some utility functions to create mock objects + * in the mail module, such as the BusService or Discuss. + */ + +/** + * Create a mock bus_service, using 'bus' instead of bus.bus + * + * @param {web.bus} bus + * @return {AbstractService} the mock bus_service + */ +function createBusService(bus) { + + var BusService = AbstractService.extend({ + name: 'bus_service', + /** + * @override + */ + init: function (parent) { + this._super.apply(this, arguments); + if (!bus) { + bus = new Bus(); + } + this.bus = new _.extend(bus, { + /** + * Do nothing + */ + start_polling: function () {}, + }); + }, + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Get the bus + */ + getBus: function () { + return this.bus; + }, + + }); + + return BusService; +} + +function createDiscuss(params) { + var Parent = Widget.extend({ + do_push_state: function () {}, + }); + var parent = new Parent(); + testUtils.addMockEnvironment(parent, { + data: params.data, + archs: { + 'mail.message,false,search': '', + }, + mockRPC: params.mockRPC, + services: params.services, + }); + var discuss = new Discuss(parent, params); + discuss.set_cp_bus(new Widget()); + discuss.appendTo($('#qunit-fixture')); + + return discuss; +} + +return { + createBusService: createBusService, + createDiscuss: createDiscuss, +}; + +}); diff --git a/addons/mail/static/tests/systray_tests.js b/addons/mail/static/tests/systray_tests.js index 60693ea1dee..2481c4d6550 100644 --- a/addons/mail/static/tests/systray_tests.js +++ b/addons/mail/static/tests/systray_tests.js @@ -1,15 +1,19 @@ odoo.define('mail.systray_tests', function (require) { "use strict"; +var ChatManager = require('mail.ChatManager'); var systray = require('mail.systray'); +var mailTestUtils = require('mail.testUtils'); + var testUtils = require('web.test_utils'); +var createBusService = mailTestUtils.createBusService; QUnit.module('mail', {}, function () { QUnit.module('ActivityMenu', { beforeEach: function () { - + this.BusService = createBusService(); this.data = { 'mail.activity.menu': { fields: { @@ -54,12 +58,13 @@ QUnit.test('activity menu widget: menu with no records', function (assert) { var activityMenu = new systray.ActivityMenu(); testUtils.addMockEnvironment(activityMenu, { - mockRPC: function (route, args) { - if (args.method === 'activity_user_count') { - return $.when([]); - } - return this._super(route, args); + mockRPC: function (route, args) { + if (args.method === 'activity_user_count') { + return $.when([]); + } + return this._super(route, args); }, + services: [ChatManager, this.BusService] }); activityMenu.appendTo($('#qunit-fixture')); assert.ok(activityMenu.$('.o_no_activity').hasClass('o_no_activity'), "should not have instance of widget"); @@ -76,8 +81,9 @@ QUnit.test('activity menu widget: activity menu with 3 records', function (asser return $.when(self.data['mail.activity.menu']['records']); } return this._super(route, args); - }, - }); + }, + services: [ChatManager, this.BusService], + }); activityMenu.appendTo($('#qunit-fixture')); assert.ok(activityMenu.$el.hasClass('o_mail_navbar_item'), 'should be the instance of widget'); assert.ok(activityMenu.$('.o_mail_channel_preview').hasClass('o_mail_channel_preview'), "should instance of widget"); diff --git a/addons/mail/views/mail_templates.xml b/addons/mail/views/mail_templates.xml index e2813cecb34..28d92c9e157 100644 --- a/addons/mail/views/mail_templates.xml +++ b/addons/mail/views/mail_templates.xml @@ -5,13 +5,14 @@ - - + + - + + @@ -24,13 +25,11 @@ - - - + @@ -43,15 +42,18 @@ diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js index 58c068fecb1..2dd69d7426c 100644 --- a/addons/web/static/src/js/chrome/abstract_web_client.js +++ b/addons/web/static/src/js/chrome/abstract_web_client.js @@ -20,16 +20,16 @@ var data_manager = require('web.data_manager'); var Dialog = require('web.Dialog'); var dom = require('web.dom'); var Loading = require('web.Loading'); -var mixins = require('web.mixins'); var NotificationManager = require('web.NotificationManager'); var RainbowMan = require('web.RainbowMan'); +var ServiceProviderMixin = require('web.ServiceProviderMixin'); var session = require('web.session'); var Widget = require('web.Widget'); var _t = core._t; var qweb = core.qweb; -var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { +var AbstractWebClient = Widget.extend(ServiceProviderMixin, { custom_events: { clear_uncommitted_changes: function (e) { this.clear_uncommitted_changes().then(e.data.callback); @@ -90,8 +90,8 @@ var AbstractWebClient = Widget.extend(mixins.ServiceProvider, { }, init: function (parent) { this.client_options = {}; - mixins.ServiceProvider.init.call(this); this._super(parent); + ServiceProviderMixin.init.call(this); this.origin = undefined; this._current_state = null; this.menu_dm = new concurrency.DropMisordered(); diff --git a/addons/web/static/src/js/chrome/action_manager_report.js b/addons/web/static/src/js/chrome/action_manager_report.js index bfdfa15f8b6..8975fea2628 100644 --- a/addons/web/static/src/js/chrome/action_manager_report.js +++ b/addons/web/static/src/js/chrome/action_manager_report.js @@ -12,6 +12,7 @@ var crash_manager = require('web.crash_manager'); var framework = require('web.framework'); var session = require('web.session'); + var _t = core._t; var _lt = core._lt; diff --git a/addons/web/static/src/js/core/abstract_service.js b/addons/web/static/src/js/core/abstract_service.js index 6ae0c3183da..cbd6c1322ca 100644 --- a/addons/web/static/src/js/core/abstract_service.js +++ b/addons/web/static/src/js/core/abstract_service.js @@ -2,15 +2,22 @@ odoo.define('web.AbstractService', function (require) { "use strict"; var Class = require('web.Class'); +var Mixins = require('web.mixins'); +var ServicesMixin = require('web.ServicesMixin'); -var AbstractService = Class.extend({ +var AbstractService = Class.extend(Mixins.EventDispatcherMixin, ServicesMixin, { Services: [], + dependencies: [], name: null, + init: function (parent) { + Mixins.EventDispatcherMixin.init.call(this, arguments); + this.setParent(parent); + }, }); var realExtend = AbstractService.extend; -AbstractService.extend = function() { +AbstractService.extend = function () { var Service = realExtend.apply(this, arguments); AbstractService.prototype.Services.push(Service); return Service; diff --git a/addons/web/static/src/js/core/bus.js b/addons/web/static/src/js/core/bus.js index 1f715da4df1..92278e0f1d6 100644 --- a/addons/web/static/src/js/core/bus.js +++ b/addons/web/static/src/js/core/bus.js @@ -10,8 +10,8 @@ var mixins = require('web.mixins'); * @class Bus */ return Class.extend(mixins.EventDispatcherMixin, { - init: function() { - mixins.EventDispatcherMixin.init.call(this, parent); + init: function () { + mixins.EventDispatcherMixin.init.call(this, arguments); }, }); diff --git a/addons/web/static/src/js/core/mixins.js b/addons/web/static/src/js/core/mixins.js index 23958d9e0f2..e4738fc2f43 100644 --- a/addons/web/static/src/js/core/mixins.js +++ b/addons/web/static/src/js/core/mixins.js @@ -3,7 +3,6 @@ odoo.define('web.mixins', function (require) { var Class = require('web.Class'); var utils = require('web.utils'); -var AbstractService = require('web.AbstractService'); /** * Mixin to structure objects' life-cycles folowing a parent-children @@ -409,134 +408,10 @@ var PropertiesMixin = _.extend({}, EventDispatcherMixin, { } }); -var ServiceProvider = { - services: {}, - init: function () { - var self = this; - _.each(AbstractService.prototype.Services, function (Service) { - var service = new Service(); - self.services[service.name] = service; - }); - this.custom_events = _.clone(this.custom_events); - this.custom_events.call_service = this._call_service.bind(this); - }, - _call_service: function (event) { - var service = this.services[event.data.service]; - var args = (event.data.args || []).concat(event.target); - var result = service[event.data.method].apply(service, args); - event.data.callback(result); - }, -}; - return { ParentedMixin: ParentedMixin, EventDispatcherMixin: EventDispatcherMixin, PropertiesMixin: PropertiesMixin, - ServiceProvider: ServiceProvider, }; }); - -odoo.define('web.ServicesMixin', function (require) { -"use strict"; - -var rpc = require('web.rpc'); - -/** - * @mixin - * @name ServicesMixin - */ -var ServicesMixin = { - call: function (service, method) { - var args = Array.prototype.slice.call(arguments, 2); - var result; - this.trigger_up('call_service', { - service: service, - method: method, - args: args, - callback: function (r) { - result = r; - }, - }); - return result; - }, - /** - * Builds and executes RPC query. Returns a deferred's promise resolved with - * the RPC result. - * - * @param {string} params either a route or a model - * @param {string} options if a model is given, this argument is a method - * @returns {Promise} - */ - _rpc: function (params, options) { - var query = rpc.buildQuery(params); - var def = this.call('ajax', 'rpc', query.route, query.params, options); - return def ? def.promise() : $.Deferred().promise(); - }, - loadFieldView: function (dataset, view_id, view_type, options) { - return this.loadViews(dataset.model, dataset.get_context().eval(), [[view_id, view_type]], options).then(function (result) { - return result[view_type]; - }); - }, - loadViews: function (modelName, context, views, options) { - var def = $.Deferred(); - this.trigger_up('load_views', { - modelName: modelName, - context: context, - views: views, - options: options, - on_success: def.resolve.bind(def), - }); - return def; - }, - loadFilters: function (dataset, action_id) { - var def = $.Deferred(); - this.trigger_up('load_filters', { - dataset: dataset, - action_id: action_id, - on_success: def.resolve.bind(def), - }); - return def; - }, - // Session stuff - getSession: function () { - var session; - this.trigger_up('get_session', { - callback: function (result) { - session = result; - } - }); - return session; - }, - /** - * Informs the action manager to do an action. This supposes that the action - * manager can be found amongst the ancestors of the current widget. - * If that's not the case this method will simply return an unresolved - * deferred. - * - * @param {any} action - * @param {any} options - * @returns {Deferred} - */ - do_action: function (action, options) { - var def = $.Deferred(); - - this.trigger_up('do_action', { - action: action, - options: options, - on_success: function (result) { def.resolve(result); }, - on_fail: function (result) { def.reject(result); }, - }); - return def; - }, - do_notify: function (title, message, sticky, className) { - this.trigger_up('notification', {title: title, message: message, sticky: sticky, className: className}); - }, - do_warn: function (title, message, sticky, className) { - this.trigger_up('warning', {title: title, message: message, sticky: sticky, className: className}); - }, -}; - -return ServicesMixin; - -}); diff --git a/addons/web/static/src/js/core/service_mixins.js b/addons/web/static/src/js/core/service_mixins.js new file mode 100644 index 00000000000..d5abf727704 --- /dev/null +++ b/addons/web/static/src/js/core/service_mixins.js @@ -0,0 +1,164 @@ +odoo.define('web.ServiceProviderMixin', function (require) { +"use strict"; + +var AbstractService = require('web.AbstractService'); +var utils = require('web.utils'); + +var ServiceProviderMixin = { + services: {}, + init: function () { + // to properly instantiate services with this as parent, this mixin + // assumes that it is used along the EventDispatcherMixin, and that + // EventDispatchedMixin's init is called first + var self = this; + // as EventDispatcherMixin's init is already called, this handler has to + // be bound manually + this.on('call_service', this, this._call_service.bind(this)); + + var sortedServices = this._sortServices(AbstractService.prototype.Services); + _.each(sortedServices, function (Service) { + var service = new Service(self); + self.services[service.name] = service; + }); + }, + _call_service: function (event) { + var service = this.services[event.data.service]; + var args = event.data.args || []; + // ajax service uses an extra 'target' argument for rpc + if (event.data.service === 'ajax' && event.data.method === 'rpc') { + args = args.concat(event.target); + } + var result = service[event.data.method].apply(service, args); + event.data.callback(result); + }, + _sortServices: function (services) { + var nodes = {}; + // Create nodes (services) + _.each(services, function (Service) { + nodes[Service.prototype.name] = Service.prototype.dependencies; + }); + var sorted; + try { + sorted = utils.topologicalSort(nodes); + } catch (err) { + console.warn('topologicalSort Error:', err.message); + sorted = nodes; + } + // Sort services based on sorted + // Note: we convert sorted to an object key=>index for efficiency + var sortedObj = _.invert(_.object(_.pairs(sorted))); + sorted = _.sortBy(services, function (Service) { + return sortedObj[Service.prototype.name]; + }); + + return sorted; + }, +}; + +return ServiceProviderMixin; + +}); + +odoo.define('web.ServicesMixin', function (require) { +"use strict"; + +var rpc = require('web.rpc'); + +/** + * @mixin + * @name ServicesMixin + */ +var ServicesMixin = { + call: function (service, method) { + var args = Array.prototype.slice.call(arguments, 2); + var result; + this.trigger_up('call_service', { + service: service, + method: method, + args: args, + callback: function (r) { + result = r; + }, + }); + return result; + }, + /** + * Builds and executes RPC query. Returns a deferred's promise resolved with + * the RPC result. + * + * @param {string} params either a route or a model + * @param {string} options if a model is given, this argument is a method + * @returns {Promise} + */ + _rpc: function (params, options) { + var query = rpc.buildQuery(params); + var def = this.call('ajax', 'rpc', query.route, query.params, options); + return def ? def.promise() : $.Deferred().promise(); + }, + loadFieldView: function (dataset, view_id, view_type, options) { + return this.loadViews(dataset.model, dataset.get_context().eval(), [[view_id, view_type]], options).then(function (result) { + return result[view_type]; + }); + }, + loadViews: function (modelName, context, views, options) { + var def = $.Deferred(); + this.trigger_up('load_views', { + modelName: modelName, + context: context, + views: views, + options: options, + on_success: def.resolve.bind(def), + }); + return def; + }, + loadFilters: function (dataset, action_id) { + var def = $.Deferred(); + this.trigger_up('load_filters', { + dataset: dataset, + action_id: action_id, + on_success: def.resolve.bind(def), + }); + return def; + }, + // Session stuff + getSession: function () { + var session; + this.trigger_up('get_session', { + callback: function (result) { + session = result; + } + }); + return session; + }, + /** + * Informs the action manager to do an action. This supposes that the action + * manager can be found amongst the ancestors of the current widget. + * If that's not the case this method will simply return an unresolved + * deferred. + * + * @param {any} action + * @param {any} options + * @returns {Deferred} + */ + do_action: function (action, options) { + var def = $.Deferred(); + + this.trigger_up('do_action', { + action: action, + options: options, + on_success: function (result) { def.resolve(result); }, + on_fail: function (result) { def.reject(result); }, + }); + return def; + }, + do_notify: function (title, message, sticky, className) { + this.trigger_up('notification', {title: title, message: message, sticky: sticky, className: className}); + }, + do_warn: function (title, message, sticky, className) { + this.trigger_up('warning', {title: title, message: message, sticky: sticky, className: className}); + }, +}; + +return ServicesMixin; + +}); diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js index 1bdd9e70a69..0b4af722203 100644 --- a/addons/web/static/src/js/core/utils.js +++ b/addons/web/static/src/js/core/utils.js @@ -300,6 +300,37 @@ var utils = { } return fn(mod, Math.floor(x)); }, + /** + * Topological sort of nodes + * + * @param {Object} nodes where key is name of node, and value is list of + * names of dependent nodes. + * @param {string[]} nodes[name] list of named dependencies of node having + * 'name' as its name. + * @return {string[]} topological sort of nodes by their names. + * @throws {Error} if there is at least one circular + * dependency between nodes + */ + topologicalSort: function (nodes) { + var sorted = []; + var unresolved = nodes; + while (!_.isEmpty(unresolved)) { + // Get all nodes that have no dependency + var resolvable = _.pick(unresolved, function (dependencies, name) { + return _.isEmpty(_.difference(dependencies, sorted)); + }); + if (_.isEmpty(resolvable)) { + throw new Error("Circular dependency detected"); + } + // Append them to sorted + sorted = _.union(sorted, _.keys(resolvable)); + // Remove these nodes from unresolved + _.each(resolvable, function (dependencies, name) { + unresolved = _.omit(unresolved, name); + }); + } + return sorted; + }, /** * performs a half up rounding with a fixed amount of decimals, correcting for float loss of precision * See the corresponding float_round() in server/tools/float_utils.py for more info diff --git a/addons/web/static/src/js/services/report_service.js b/addons/web/static/src/js/services/report_service.js index 3272eef55bf..a2bb8759a24 100644 --- a/addons/web/static/src/js/services/report_service.js +++ b/addons/web/static/src/js/services/report_service.js @@ -6,12 +6,10 @@ odoo.define('web.ReportService', function (require) { */ var AbstractService = require('web.AbstractService'); -var session = require('web.session'); - -var wkhtmltopdfState; var ReportService = AbstractService.extend({ name: 'report', + dependencies: ['ajax'], /** * Checks the state of the installation of wkhtmltopdf on the server. @@ -21,12 +19,12 @@ var ReportService = AbstractService.extend({ * (possible values are 'ok', 'broken', 'install', 'upgrade', 'workers'). */ checkWkhtmltopdf: function () { - if (!wkhtmltopdfState) { - // AAB: for now, services aren't available for each other, so we - // can't use this._rpc (the ajax service) - wkhtmltopdfState = session.rpc('/report/check_wkhtmltopdf'); + if (!this.wkhtmltopdfState) { + this.wkhtmltopdfState = this._rpc({ + route:'/report/check_wkhtmltopdf' + }); } - return wkhtmltopdfState; + return this.wkhtmltopdfState; }, }); diff --git a/addons/web/static/tests/chrome/action_manager_tests.js b/addons/web/static/tests/chrome/action_manager_tests.js index eaadcdc180a..f6a0007c1a9 100644 --- a/addons/web/static/tests/chrome/action_manager_tests.js +++ b/addons/web/static/tests/chrome/action_manager_tests.js @@ -1,9 +1,11 @@ odoo.define('web.action_manager_tests', function (require) { "use strict"; +var ReportClientAction = require('report.client_action'); + var ControlPanelMixin = require('web.ControlPanelMixin'); var core = require('web.core'); -var ReportClientAction = require('report.client_action'); +var ReportService = require('web.ReportService'); var testUtils = require('web.test_utils'); var Widget = require('web.Widget'); @@ -1338,6 +1340,7 @@ QUnit.module('ActionManager', { } return this._super.apply(this, arguments); }, + services: [ReportService], session: { get_file: function (params) { assert.step(params.url); @@ -1347,7 +1350,9 @@ QUnit.module('ActionManager', { }, }); actionManager.doAction(7, { - on_close: assert.step.bind(assert, 'on_close'), + on_close: function () { + assert.step('on_close'); + }, }); assert.verifySteps([ @@ -1374,6 +1379,7 @@ QUnit.module('ActionManager', { } return this._super.apply(this, arguments); }, + services: [ReportService], session: { get_file: function (params) { assert.step(params.url); @@ -1388,7 +1394,6 @@ QUnit.module('ActionManager', { }, }); actionManager.doAction(7); - assert.verifySteps([ '/web/action/load', '/report/check_wkhtmltopdf', @@ -1430,6 +1435,7 @@ QUnit.module('ActionManager', { } return this._super.apply(this, arguments); }, + services: [ReportService], session: { get_file: function (params) { assert.step(params.url); // should not be called diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index a80aa2ff05b..fb5af01fdcd 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -430,7 +430,7 @@ var MockServer = Class.extend({ return result; }, /** - * Simulate a 'field_get' operation + * Simulate a 'fields_get' operation * * @private * @param {string} modelName @@ -464,7 +464,7 @@ var MockServer = Class.extend({ if (!action) { // when the action doesn't exist, the real server doesn't crash, it // simply returns false - console.warn("No action found for ID " + event.data.actionID); + console.warn("No action found for ID " + kwargs.action_id); } return action || false; }, diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js index 468b25e5733..6b2c09d2631 100644 --- a/addons/web/static/tests/helpers/test_utils.js +++ b/addons/web/static/tests/helpers/test_utils.js @@ -19,6 +19,7 @@ var core = require('web.core'); var dom = require('web.dom'); var session = require('web.session'); var MockServer = require('web.MockServer'); +var utils = require('web.utils'); var Widget = require('web.Widget'); var DebouncedField = basic_fields.DebouncedField; @@ -97,12 +98,6 @@ var createActionManager = function (params) { }, }); addMockEnvironment(widget, _.defaults(params, {debounce: false})); - intercept(widget, 'call_service', function (event) { - if (event.data.service === 'report') { - var state = widget._rpc({route: '/report/check_wkhtmltopdf'}); - event.data.callback(state); - } - }, true); widget.appendTo($target); widget.$el.addClass('o_web_client'); @@ -330,6 +325,7 @@ function addMockEnvironment(widget, params) { currentDate: params.currentDate, debug: params.debug, }); + // make sure the debounce value for input fields is set to 0 var initialDebounceValue = DebouncedField.prototype.DEBOUNCE; DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0; @@ -406,11 +402,53 @@ function addMockEnvironment(widget, params) { widgetDestroy.call(this); }; - intercept(widget, 'call_service', function (event) { - if (event.data.service === 'ajax') { - var result = mockServer.performRpc(event.data.args[0], event.data.args[1]); - event.data.callback(result); + // Dispatch service calls + // Note: some services could call other services at init, + // Which is why we have to init services after that + var services = {}; + intercept(widget, 'call_service', function (ev) { + var args, result; + if (ev.data.service === 'ajax') { + // ajax service is already mocked by the server + var route = ev.data.args[0]; + args = ev.data.args[1]; + result = mockServer.performRpc(route, args); + } else if (services[ev.data.service]) { + var service = services[ev.data.service]; + args = (ev.data.args || []); + result = service[ev.data.method].apply(service, args); } + ev.data.callback(result); + }); + // Instantiate services + // Note: ensure topological sort of services based on their dependencies + var sortServices = function (services) { + // Create nodes (services), with ajax already loaded + var nodes = { ajax: [] }; + _.each(services, function (Service) { + nodes[Service.prototype.name] = Service.prototype.dependencies; + }); + var sorted; + try { + sorted = utils.topologicalSort(nodes); + } catch (err) { + console.warn('topologicalSort Error:', err.message); + sorted = nodes; + } + // Remove ajax from sorted + sorted = _.without(sorted, 'ajax'); + // Sort services based on sorted + // Note: we convert sorted to an object key=>index for efficiency + var sortedObj = _.invert(_.object(_.pairs(sorted))); + sorted = _.sortBy(services, function (Service) { + return sortedObj[Service.prototype.name]; + }); + return sorted; + }; + var sortedServices = sortServices(params.services); + _.each(sortedServices, function (Service) { + var service = new Service(widget); + services[service.name] = service; }); intercept(widget, 'load_action', function (event) { diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml index c78b6b0ecac..2742871c790 100644 --- a/addons/web/views/webclient_templates.xml +++ b/addons/web/views/webclient_templates.xml @@ -76,6 +76,7 @@ + diff --git a/addons/web_editor/static/src/js/content/body_manager.js b/addons/web_editor/static/src/js/content/body_manager.js index 1ba2d219770..2291aa1d4db 100644 --- a/addons/web_editor/static/src/js/content/body_manager.js +++ b/addons/web_editor/static/src/js/content/body_manager.js @@ -1,9 +1,9 @@ odoo.define('web_editor.BodyManager', function (require) { 'use strict'; -var mixins = require('web.mixins'); -var session = require('web.session'); var rootWidget = require('web_editor.root_widget'); +var ServiceProviderMixin = require('web.ServiceProviderMixin'); +var session = require('web.session'); /** * Element which is designed to be unique and that will be the top-most element @@ -11,13 +11,13 @@ var rootWidget = require('web_editor.root_widget'); * this Class instance. Its main role will be to retrieve RPC demands from its * children and handle them. */ -var BodyManager = rootWidget.RootWidget.extend(mixins.ServiceProvider, { +var BodyManager = rootWidget.RootWidget.extend(ServiceProviderMixin, { /** * @constructor */ init: function () { - mixins.ServiceProvider.init.call(this); this._super.apply(this, arguments); + ServiceProviderMixin.init.call(this); }, /** * @override diff --git a/addons/web_tour/static/src/js/tour_service.js b/addons/web_tour/static/src/js/tour_service.js index 37e6f1a5c0f..fdfac78f6a9 100644 --- a/addons/web_tour/static/src/js/tour_service.js +++ b/addons/web_tour/static/src/js/tour_service.js @@ -7,6 +7,7 @@ var config = require('web.config'); var core = require('web.core'); var mixins = require('web.mixins'); var rpc = require('web.rpc'); +var ServiceProviderMixin = require('web.ServiceProviderMixin'); var session = require('web.session'); var TourManager = require('web_tour.TourManager'); @@ -16,10 +17,10 @@ if (config.device.isMobile) { return $.Deferred().reject(); } -var CallService = Class.extend(mixins.EventDispatcherMixin, mixins.ServiceProvider, { +var CallService = Class.extend(mixins.EventDispatcherMixin, ServiceProviderMixin, { init: function () { - mixins.ServiceProvider.init.call(this); mixins.EventDispatcherMixin.init.call(this); + ServiceProviderMixin.init.call(this); }, }); diff --git a/addons/website_livechat/views/website_livechat.xml b/addons/website_livechat/views/website_livechat.xml index 3a0caa340ba..3a994c4e4ac 100644 --- a/addons/website_livechat/views/website_livechat.xml +++ b/addons/website_livechat/views/website_livechat.xml @@ -9,6 +9,7 @@