summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/js/services/chat_manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'addons/mail/static/src/js/services/chat_manager.js')
-rw-r--r--addons/mail/static/src/js/services/chat_manager.js1751
1 files changed, 1751 insertions, 0 deletions
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<Array>} 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<Object[]>>|Array<Object[]>} 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<Object[]>} 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<Object>} 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<integer|undefined>} 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<Object>} 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<Object[]>} 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<Object[]>} 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<Object[]>} 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|$|</[a-z]*>)", "g");
+ msg.body = msg.body.replace(regexp, ' <span class="o_mail_emoji">'+self.emojiSubstitutions[key]+'</span> ');
+ });
+
+ 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 <b>%s</b>.'), channel.name);
+ } else {
+ msg = _.str.sprintf(_t('You unpinned your conversation with <b>%s</b>.'), 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;
+
+});