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 @@