summaryrefslogtreecommitdiff
path: root/addons/mail/static/src/js/services
diff options
context:
space:
mode:
authorAlexandre KΓΌhn <aku@odoo.com>2018-06-12 15:46:46 +0200
committerAlexandre KΓΌhn <aku@odoo.com>2018-07-02 15:06:54 +0200
commitcd34f6de727d5b3858420cff3c9d3c5995c4e75c (patch)
tree8d25861f98a038b71728355375d634a5c178290e /addons/mail/static/src/js/services
parentee9c1f52c316c83775969a3c9038e7e9f560f7e7 (diff)
[REF] mail: JS mail refactoring
---------------------- Summary ---------------------- The purpose of this commit is to improve the JS code of the `mail` module. It applies the new coding guidelines and makes some changes on the design of some modules, such as the old ChatManager module. Here is a short summary of the changes that have been made: 1. New coding guidelines - snake_case to camelCase - prefix private attributes and methods with '_' - jsdoc on most methods - one class per module 2. Rename/Merge some classes - 'chat manager' becomes 'mail manager' (internal) and 'mail service' (external) - 'chat window manager' is now included in mail manager - 'thread' widget now named 'thread widget' 3. New model abstraction for mail objects: - modules 'mail.model.*' - modeling: 0..1 0..1 * * ThreadWindow <------> Thread <-------> Message / \ / \ Thread With Cache Document Thread / | \ / | \ Mailbox Channel Support Channel | | DM - Thread: the superclass of threads. - ThreadWindow: the window component of a thread. - Message: mail objects representing messages. - Thread With Cache: threads that can be used with search view (Discuss app compatible). - Document Thread: represents the thread part of a chatter. - Mailbox: represents what was previously called 'static' channel, e.g. 'Inbox'. - Channel: mail objects representing channels, including livechat. - DM: special kind of Channel for 1:1 communication in the backend. - Support Channel: special channel for im_support module. This new modeling approach let us easily add features on all threads, such as the possibility to put any thread in a small window. ---------------------- Known issues ---------------------- [Already Present in Master] 1. When the Discuss app is in the background with 'Inbox' as the selected Thread, when clicking on a document thread preview in the messaging menu of the systray, the rainbow man appears. 2. When a document with the chatter is in the background, when receiving an inbox notification from this document thread, the document thread is automatically marked as read, which removes the notification right away. 3. Sometimes, opening a DM window from the "blank" thread window does not work. 4. Reply-to feature on Inbox is not working: no message is sent in the document thread. 5. On the first login of admin user with demo data, the inbox counter is wrong (it displays 6, instead of 3). Explanation after investigation: > On page load, it fetches the correct number of Inbox messages (3), but the server notifies of 3 needaction messages right away, so it wrongly assumes these are new needaction messages. > Not possible for web client to detect that these messages should not increment the Inbox counter while keeping same API. 6. Notifications for new document thread messages only work when the user sets 'handle with Odoo' for the Notification Management in the preferences. > due to notifications on the longpoll bus for document thread messages that come from needaction notifications. > requires server-side changes to send notification on the longpoll bus to mentionned user. [New] 7. When receiving a message on a unjoined channel, thread window flickers ('open' > 'close' > 'open') Explanation after investigation: > JS logic: a) On auto-join, ask server to join the channel and get channel infos. b) The info tells the channel is not detached, but JS code makes decision to detach it, and tells server the channel is now detached. c) From (a), server notifies on longpoll bus the state of channel, which is not detached. The web client thinks that the window state of the channel has been changed somewhere else, and the channel is now closed. d) From (b), server notifies on longpoll bus the state of channel, which is detached. The web client opens the thread window of this channel. > The flicker didn't occur before refactoring because the web client was only updating the model of channel when it receives the longpoll notification. > Server behaviour on 1st longpoll notification is necessary for cross-tab synchronization for channel window state. > New design implies that model and view should be synchronized, hence the issue now. > Solution: remove server-side thread window synchronization and replace with client-side synchronization. ---------------------- Hacks ---------------------- The module `im_livechat` now uses mail objects that are compatible with Message and Window objects: Modeling for messages: AbstractMessage / \ / \ LivechatMessage Message - AbstractMessage: message compatible with the thread widget. - LivechatMessage: message used by im_livechat. - Message: message used with the mail manager. Modeling for thread windows: AbstractThreadWindow / \ / \ LivechatWindow ThreadWindow - AbstractThreadWindow: behaviour share between all types of thread windows. - LivechatWindow: window used by im_livechat. - ThreadWindow: window used with mail_manager. The reason for these hacks are twofold: 1. Use the thread widget in the frontend and livechat external lib bundles. 2. Do not have a dependency with the mail manager in the frontend and external lib bundles.
Diffstat (limited to 'addons/mail/static/src/js/services')
-rw-r--r--addons/mail/static/src/js/services/chat_manager.js2016
-rw-r--r--addons/mail/static/src/js/services/chat_window_manager.js534
-rw-r--r--addons/mail/static/src/js/services/mail_manager.js1246
-rw-r--r--addons/mail/static/src/js/services/mail_notification_manager.js484
-rw-r--r--addons/mail/static/src/js/services/mail_service.js15
-rw-r--r--addons/mail/static/src/js/services/mail_window_manager.js684
6 files changed, 2429 insertions, 2550 deletions
diff --git a/addons/mail/static/src/js/services/chat_manager.js b/addons/mail/static/src/js/services/chat_manager.js
deleted file mode 100644
index 8a90fc57482..00000000000
--- a/addons/mail/static/src/js/services/chat_manager.js
+++ /dev/null
@@ -1,2016 +0,0 @@
-odoo.define('mail.ChatManager', function (require) {
-"use strict";
-
-var utils = require('mail.utils');
-
-var AbstractService = require('web.AbstractService');
-var Bus = require('web.Bus');
-var config = require('web.config');
-var core = require('web.core');
-var session = require('web.session');
-var time = require('web.time');
-var web_client = require('web.web_client');
-
-var _t = core._t;
-var _lt = core._lt;
-
-var LIMIT = 30; // max number of fetched messages from the server
-var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers
-var ODOOBOT_ID = "ODOOBOT"; // default author_id for messages
-
-var emojiUnicodes={
- ":)":"😊", ":-)":"😊","=)":"😊", ":]":"😊",
- ":D":"πŸ˜ƒ",":-D":"πŸ˜ƒ","=D":"πŸ˜ƒ",
- "xD":"πŸ˜†","XD":"πŸ˜†",
- "x'D":"πŸ˜‚",
- ";)":"πŸ˜‰",";-)":"πŸ˜‰",
- "B)":"😎","8)":"😎","B-)":"😎","8-)":"😎",
- ";p":"😜",";P":"😜",
- ":p":"πŸ˜‹",":P":"πŸ˜‹",":-p":"πŸ˜‹",":-P":"πŸ˜‹","=P":"πŸ˜‹",
- "xp":"😝","xP":"😝",
- "o_o":"😳",
- ":|":"😐",":-|":"😐",
- ":/":"πŸ˜•",":-/":"πŸ˜•",
- ":(":"😞",
- ":@":"😱",
- ":O":"😲",":-O":"😲",":o":"😲",":-o":"😲",
- ":'o":"😨",
- "3:(":"😠",">:(":"😠","3:":"😠",
- "3:)":"😈",">:)":"😈",
- ":*":"😘",":-*":"😘",
- "o:)":"πŸ˜‡",
- ":'(":"😒",
- ":'-(":"😭",":\"(":"😭",
- "&lt;3":"❀️",":heart":"❀️",
- ":heart_eyes":"😍",
- ":turban":"πŸ‘³",
- ":+1":"πŸ‘",
- ":-1":"πŸ‘Ž",
- ":ok":"πŸ‘Œ",
- ":poop":"πŸ’©",
- ":no_see":"πŸ™ˆ",
- ":no_hear":"πŸ™‰",
- ":no_speak":"πŸ™Š",
- ":bug":"🐞",
- ":kitten":"😺",
- ":bear":"🐻",
- ":snail":"🐌",
- ":boar":"πŸ—",
- ":clover":"πŸ€",
- ":sunflower":"🌹",
- ":fire":"πŸ”₯",
- ":sun":"β˜€οΈ",
- ":partly_sunny:":"⛅️",
- ":rainbow":"🌈",
- ":cloud":"☁️",
- ":zap":"⚑️",
- ":star":"⭐️",
- ":cookie":"πŸͺ",
- ":pizza":"πŸ•",
- ":hamburger":"πŸ”",
- ":fries":"🍟",
- ":cake":"πŸŽ‚",
- ":cake_part":"🍰",
- ":coffee":"β˜•οΈ",
- ":banana":"🍌",
- ":sushi":"🍣",
- ":rice_ball":"πŸ™",
- ":beer":"🍺",
- ":wine":"🍷",
- ":cocktail":"🍸",
- ":tropical":"🍹",
- ":beers":"🍻",
- ":ghost":"πŸ‘»",
- ":skull":"πŸ’€",
- ":et":"πŸ‘½",":alien":"πŸ‘½",
- ":party":"πŸŽ‰",
- ":trophy":"πŸ†",
- ":key":"πŸ”‘",
- ":pin":"πŸ“Œ",
- ":postal_horn":"πŸ“―",
- ":music":"🎡",
- ":trumpet":"🎺",
- ":guitar":"🎸",
- ":soccer":"⚽️",
- ":football":"🏈",
- ":8ball":"🎱",
- ":clapper":"🎬",
- ":microphone":"🎀"
- };
-
-/**
- * This service handles everything about chat channels and messages.
- *
- * There are basically two points of entry:
- *
- * 1. Calling a public method by means of 'this.call'
- * 2. Receiving events on busBus (e.g. 'notification')
- */
-var ChatManager = AbstractService.extend({
- name: 'chat_manager',
- dependencies: ['ajax', 'bus_service'],
- CHANNEL_SEEN_THROTTLE: 3000,
- /**
- * @override
- */
- init: function () {
- var self = this;
- this._super.apply(this, arguments);
-
- this.messages = [];
- this.channels = [];
- this.messageFailures = [];
- this.channelsPreviewDef;
- this.channelDefs = {};
- this.unreadConversationCounter = 0;
- this.emojis = [];
- this.needactionCounter = 0;
- this.starredCounter = 0;
- this.moderationCounter = 0;
- this.moderatedChannelIDs = [];
- this.mentionPartnerSuggestions = [];
- this.cannedResponses = [];
- this.commands = [];
- this.discussMenuID;
- this.globalUnreadCounter = 0;
- this.pinnedDmPartners = []; // partner_ids we have a pinned DM with
- this.discussOpen = false;
-
- this.chatBus = new Bus(this);
- this.busBus = this.call('bus_service', 'getBus');
-
- this.chatBus.on('discuss_open', null, function (open) {
- self.discussOpen = open;
- });
-
- this.busBus.on('notification', this, this._onNotification);
- this.busBus.on('window_focus', this, this._onWindowFocus);
-
- // TODO create private fonction on prototype
- this.channelSeen = _.throttle(function (channel) {
- return self._rpc({
- model: 'mail.channel',
- method: 'channel_seen',
- args: [[channel.id]],
- }, {
- shadow: true
- });
- }, self.CHANNEL_SEEN_THROTTLE);
-
- this._isReady = this._initMessaging();
-
- // Add static channels
- this._addChannel({
- id: "channel_inbox",
- name: _lt("Inbox"),
- type: "static",
- }, { displayNeedactions: true });
- this._addChannel({
- id: "channel_starred",
- name: _lt("Starred"),
- type: "static"
- });
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * Closes the chat window of a corresponding channel.
- * This operation is executed server-side and the chat window will be
- * folded in all potential tabs from all browsers.
- *
- * @param {integer|string} channelID
- * @return {$.Promise}
- */
- closeChatSession: function (channelID) {
- var channel = this.getChannel(channelID);
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_fold',
- kwargs: {uuid : channel.uuid, state : 'closed'},
- }, {shadow: true});
- },
- /**
- * Creates a channel
- *
- * @param {integer|string} name id of partner (in case of dm) or name
- * @param {string} type ['dm', 'public', 'private']
- * @return {$.Promise}
- */
- createChannel: function (name, type) {
- var method = type === "dm" ? "channel_get" : "channel_create";
- var args = type === "dm" ? [[name]] : [name, type];
- var context = _.extend({isMobile: config.device.isMobile}, session.user_context);
- return this._rpc({
- model: 'mail.channel',
- method: method,
- args: args,
- kwargs: {context: context},
- })
- .then(this._addChannel.bind(this));
- },
- /**
- * Open the chat window for a given channel
- * (in all potential tabs from all browsers)
- *
- * @param {integer} channelID
- * @return {$.Promise}
- */
- detachChannel: function (channelID) {
- var channel = this.getChannel(channelID);
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_minimize',
- args: [channel.uuid, true],
- }, {
- shadow: true,
- });
- },
- /**
- * Folds/Minimize the chat window
- * (in all potential tabs from all browsers)
- *
- * @param {integer} channelID
- * @param {boolean} folded
- * @return {$.Promise}
- */
- foldChannel: function (channelID, folded) {
- var args = {
- uuid: this.getChannel(channelID).uuid,
- };
- if (_.isBoolean(folded)) {
- args.state = folded ? 'folded' : 'open';
- }
- return this._rpc({
- model: 'mail.channel',
- method: 'channel_fold',
- kwargs: args,
- }, {shadow: true});
- },
- /**
- * Returns the list of canned responses
- * A canned response is a pre-formatted text that is triggered with
- * some keystrokes, such as with ':'.
- *
- * @return {Array} array of Objects (mail.shortcode)
- */
- getCannedResponses: function () {
- return this.cannedResponses;
- },
- /**
- * Returns a channel corresponding to the given id.
- *
- * @param {string|integer} id e.g. 'channel_inbox', 'channel_starred'
- * @return {Object|undefined} the channel, if it exists
- */
- getChannel: function (id) {
- return _.findWhere(this.channels, {id: id});
- },
- /**
- * Returns a list of channels
- *
- * @return {Object[]} list of channels
- */
- getChannels: function () {
- return _.clone(this.channels);
- },
- /**
- * Returns the content that will be shown in the mail navbar dropdown
- *
- * @param {Object[]} channels
- * @return {$.Promise<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")) || channel.id === "mail_failure") {
- // this is a hack to preview messages and mail failure
- // map inbox(mail_message) data with existing channel/chat template
- info = _.pick(channel,
- 'id', 'body', 'avatar_src', 'res_id', 'model', 'module_icon',
- 'subject','date', 'record_name', 'status', 'displayed_author',
- 'email_from', 'unread_counter');
- info.last_message = {
- body: info.body,
- date: info.date,
- displayed_author: info.displayed_author || info.email_from,
- };
- info.name = info.record_name || info.subject || info.displayed_author;
- info.image_src = info.module_icon || info.avatar_src;
- info.message_id = info.id;
- if (channel.id !== "mail_failure") {
- info.id = 'channel_inbox';
- }
- return info;
- }
- info = _.pick(channel, 'id', 'is_chat', 'name', 'status', 'unread_counter');
- info.last_message = channel.last_message || _.last(channel.cache['[]'].messages);
- if (!info.is_chat) {
- info.image_src = '/web/image/mail.channel/'+channel.id+'/image_small';
- } else if (channel.direct_partner_id) {
- info.image_src = '/web/image/res.partner/'+channel.direct_partner_id+'/image_small';
- } else {
- info.image_src = '/mail/static/src/img/smiley/avatar.jpg';
- }
- return info;
- });
- var missingChannels = _.where(channelsPreview, {last_message: undefined});
- if (!this.channelsPreviewDef) {
- if (missingChannels.length) {
- var missingChannelIDs = _.pluck(missingChannels, 'id');
- this.channelsPreviewDef = this._rpc({
- model: 'mail.channel',
- method: 'channel_fetch_preview',
- args: [missingChannelIDs],
- }, {
- shadow: true,
- });
- } else {
- this.channelsPreviewDef = $.when();
- }
- }
- return this.channelsPreviewDef.then(function (channels) {
- _.each(missingChannels, function (channelPreview) {
- var channel = _.findWhere(channels, {id: channelPreview.id});
- if (channel) {
- channelPreview.last_message = self._addMessage(channel.last_message);
- }
- });
- // sort channels: 1. unread, 2. chat, 3. date of last msg
- channelsPreview.sort(function (c1, c2) {
- return Math.min(1, c2.unread_counter) - Math.min(1, c1.unread_counter) ||
- c2.is_chat - c1.is_chat ||
- !!c2.last_message - !!c1.last_message ||
- (c2.last_message && c2.last_message.date.diff(c1.last_message.date));
- });
-
- // generate last message preview (inline message body and compute date to display)
- _.each(channelsPreview, function (channel) {
- if (channel.last_message) {
- channel.last_message_preview = utils.parse_and_transform(channel.last_message.body, utils.inline);
- channel.last_message_date = channel.last_message.date.fromNow();
- }
- });
- return channelsPreview;
- });
- },
- /**
- * @return {web.Bus} the chat bus
- */
- getChatBus: function () {
- return this.chatBus;
- },
- /**
- * Show the list of available commands next to a message (e.g. star)
- *
- * @param {Object} channel
- * @return {Array} list of commands
- */
- getCommands: function (channel) {
- var commands = _.filter(this.commands, function (command) {
- return !command.channel_types || _.contains(command.channel_types, channel.server_type);
- });
- return commands;
- },
- /**
- * Returns the record id of ir.ui.menu for Discuss
- *
- * @return {integer} record id
- */
- getDiscussMenuID: function () {
- return this.discussMenuID;
- },
- /**
- * Gets direct message channel
- *
- * @param {integer} partnerID
- * @return {Object|undefined} channel
- */
- getDmFromPartnerID: function (partnerID) {
- return _.findWhere(this.channels, {direct_partner_id: partnerID});
- },
- /**
- * Returns list of emojis Objects
- *
- * @return {Object[]} list of emojis
- * ['id', 'source', 'unicode_source', 'substitution', 'description']
- */
- getEmojis: function () {
- return this.emojis;
- },
- /**
- * Get the last seen message for a given channel
- *
- * @param {Object} channel
- * @return {Object|undefined} last seen Message Object (if any)
- */
- getLastSeenMessage: function (channel) {
- var result;
- if (channel.last_seen_message_id) {
- var messages = channel.cache['[]'].messages;
- var msg = _.findWhere(messages, {id: channel.last_seen_message_id});
- if (msg) {
- var i = _.sortedIndex(messages, msg, 'id') + 1;
- while (i < messages.length &&
- (messages[i].is_author || messages[i].is_system_notification)) {
- msg = messages[i];
- i++;
- }
- result = msg;
- }
- }
- return result;
- },
- /**
- * Returns a list of mail failures
- *
- * @return {Object[]} list of channels
- */
- getMessageFailures: function () {
- return this.messageFailures;
- },
- /**
- * Get all listeners of a channel.
- *
- * @param {Object} channel
- * @return {$.Promise<Array<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) {
- if (!channel) {
- return this.mentionPartnerSuggestions;
- }
- if (!channel.membersDeferred) {
- channel.membersDeferred = this._rpc({
- model: 'mail.channel',
- method: 'channel_fetch_listeners',
- args: [channel.uuid],
- }, {
- shadow: true
- })
- .then(function (members) {
- return [members];
- });
- }
- return channel.membersDeferred;
- },
- /**
- * Gets message from its id
- *
- * @param {integer} msgID
- * @return {Object|undefined} Message Object (if any)
- */
- getMessage: function (msgID) {
- return _.findWhere(this.messages, {id: msgID});
- },
- /**
- * Gets messages from channel or ids or record (model and res_id):
- *
- * 1. From channel if we have 'channelID' in options
- * 2. From ids if we have 'ids' in options
- * 3. From model if we have 'model' and 'res_id' in options
- *
- * Rule of precedence:
- *
- * 'channelID' < 'ids' < 'model' and 'res_id'
- *
- * If we have none of the cases above, we return an empty list.
- *
- * @param {Object} [options]
- * @param {integer|string} [options.channelID]
- * @param {Array} [options.domain]
- * @param {integer[]} [options.ids]
- * @param {boolean} [options.loadMore]
- * @param {string} [options.model]
- * @param {integer} [options.res_id]
- * @return {$.Promise<Object[]>} list of messages
- */
- getMessages: function (options) {
- var channel;
- var self = this;
-
- if ('channelID' in options && options.loadMore) {
- // get channel messages, force load more
- channel = this.getChannel(options.channelID);
- return this._fetchFromChannel(channel, {domain: options.domain || {}, loadMore: true});
- }
- if ('channelID' in options) {
- // channel message, check in cache first
- channel = this.getChannel(options.channelID);
- var channelCache = this._getChannelCache(channel, options.domain);
- if (channelCache.loaded) {
- return $.when(channelCache.messages);
- } else {
- return this._fetchFromChannel(channel, {domain: options.domain});
- }
- }
- if ('ids' in options) {
- // get messages from their ids (chatter is the main use case)
- return this._fetchDocumentMessages(options.ids, options).then(function (result) {
- self.markAsRead(options.ids);
- return result;
- });
- }
- if ('model' in options && 'res_id' in options) {
- // get messages for a chatter, when it doesn't know the ids (use
- // case is when using the full composer)
- var domain = [['model', '=', options.model], ['res_id', '=', options.res_id]];
- return this._rpc({
- model: 'mail.message',
- method: 'message_fetch',
- args: [domain],
- kwargs: {limit: LIMIT},
- })
- .then(function (msgs) {
- return _.map(msgs, self._addMessage.bind(self));
- });
- }
- return $.when([]);
- },
- /**
- * Returns the number of messages that the user needs to moderate
- *
- * @return {integer}
- */
- getModerationCounter: function () {
- return this.moderationCounter;
- },
- /**
- * Returns the number of messages received from followed channels
- * + all messages where the current user is notified.
- *
- * @return {integer} needaction counter
- */
- getNeedactionCounter: function () {
- return this.needactionCounter;
- },
- /**
- * Gets the number of starred message
- *
- * @return {integer} starred counter
- */
- getStarredCounter: function () {
- return this.starredCounter;
- },
- /**
- * Gets the number of conversation which contains unread messages
- *
- * @return {integer} unread conversation counter
- */
- getUnreadConversationCounter: function () {
- return this.unreadConversationCounter;
- },
- /**
- * States whether all messages have been loaded or not
- *
- * @param {Object} channel
- * @param {Array} domain
- * @return {boolean}
- */
- isAllHistoryLoaded: function (channel, domain) {
- return this._getChannelCache(channel, domain).all_history_loaded;
- },
- /**
- * @return {boolean}
- */
- isModerator: function () {
- return this._isModerator;
- },
- /**
- * @return {$.Promise}
- */
- isReady: function () {
- return this._isReady;
- },
- /**
- * join an existing channel
- * See @createChannel to join a new channel
- *
- * @param {integer} channelID
- * @param {Object} [options]
- * @return {$.Promise<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(emojiUnicodes, function (unicode, key) {
- var escapedKey = String(key).replace(/([.*+?=^!:${}()|[\]/\\])/g, '\\$1');
- var regexp = new RegExp("(\\s|^)(" + escapedKey + ")(?=\\s|$)", "g");
- msg.body = msg.body.replace(regexp, "$1" + unicode);
- });
- if ('subject' in data) {
- msg.subject = data.subject;
- }
- if ('channelID' in options) {
- // post a message in a channel or execute a command
- return this._rpc({
- model: 'mail.channel',
- method: data.command ? 'execute_command' : 'message_post',
- args: [options.channelID],
- kwargs: _.extend(msg, {
- message_type: 'comment',
- subtype: 'mail.mt_comment',
- command: data.command,
- }),
- });
- }
- if ('model' in options && 'res_id' in options) {
- // post a message in a chatter
- _.extend(msg, {
- context: data.context,
- message_type: data.message_type,
- subtype: data.subtype,
- subtype_id: data.subtype_id,
- });
-
- return this._rpc({
- model: options.model,
- method: 'message_post',
- args: [options.res_id],
- kwargs: msg,
- })
- .then(function (msgID) {
- return self._rpc({
- model: 'mail.message',
- method: 'message_format',
- args: [[msgID]],
- })
- .then(function (msgs) {
- msgs[0].model = options.model;
- msgs[0].res_id = options.res_id;
- self._addMessage(msgs[0]);
- });
- });
- }
- return $.when();
- },
- /**
- * Special redirection handling for given model and id
- *
- * If the model is res.partner, and there is a user associated with this
- * partner which isn't the current user, open the DM with this user.
- * Otherwhise, open the record's form view, if this is not the current user's.
- *
- * @param {string} resModel model to open
- * @param {integer} resID record to open
- * @param {function} [dmRedirectionCallback] only used if 'res.partner'
- */
- redirect: function (resModel, resID, dmRedirectionCallback) {
- var self = this;
- var redirectToDocument = function (resModel, resID, viewID) {
- web_client.do_action({
- type:'ir.actions.act_window',
- view_type: 'form',
- view_mode: 'form',
- res_model: resModel,
- views: [[viewID || false, 'form']],
- res_id: resID,
- });
- };
- if (resModel === 'res.partner') {
- var domain = [['partner_id', '=', resID]];
- this._rpc({
- model: 'res.users',
- method: 'search',
- args: [domain],
- })
- .then(function (userIDs) {
- if (userIDs.length && userIDs[0] !== session.uid && dmRedirectionCallback) {
- self.createChannel(resID, 'dm').then(dmRedirectionCallback);
- } else {
- redirectToDocument(resModel, resID);
- }
- });
- } else {
- this._rpc({
- model: resModel,
- method: 'get_formview_id',
- args: [[resID], session.user_context],
- })
- .then(function (viewID) {
- redirectToDocument(resModel, resID, viewID);
- });
- }
- },
- /**
- * Removes all messages from the current model except 'needaction'.
- * We want to keep it in inbox.
- *
- * @param {string} model
- */
- removeChatterMessages: function (model) {
- this.messages = _.reject(this.messages, function (message) {
- return (!message.channel_ids || message.channel_ids.length === 0) && message.model === model;
- });
- },
- /**
- * Search among prefetched partners, using the string 'searchVal'
- *
- * @param {string} searchVal
- * @param {integer} limit max number of found partners in the response
- * @return {$.Promise<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 existing channel
- */
- _addChannel: function (data, options) {
- options = typeof options === "object" ? options : {};
- var channel = this.getChannel(data.id);
- if (channel) {
- if (channel.is_folded !== (data.state === "folded")) {
- channel.is_folded = (data.state === "folded");
- this.call('chat_window_manager', 'toggleFoldChat', channel);
- }
- } else {
- channel = this._makeChannel(data, options);
- this.channels.push(channel);
- if (data.last_message) {
- channel.last_message = this._addMessage(data.last_message);
- }
- // In case of a static channel (Inbox, Starred), the name is translated thanks to _lt
- // (lazy translate). In this case, channel.name is an object, not a string.
- this.channels = _.sortBy(this.channels, function (channel) {
- return _.isString(channel.name) ? channel.name.toLowerCase() : '';
- });
- if (!options.silent) {
- this.chatBus.trigger("new_channel", channel);
- }
- if (channel.is_detached) {
- this.call('chat_window_manager', 'openChat', channel);
- }
- }
- return channel;
- },
- /**
- * Adds a channel to a message
- * Usefull when you mark a message as 'to do'.
- * The message will be available in 'Starred' channel.
- *
- * @private
- * @param {Object} message
- * @param {string} channelID
- */
- _addChannelToMessage: function (message, channelID) {
- if (!message.channel_ids) {
- message.channel_ids = [];
- }
- message.channel_ids.push(channelID);
- message.channel_ids = _.uniq(message.channel_ids);
- },
- /**
- * Creates a new message
- *
- * @private
- * @param {Object} data message data
- * @param {integer} data.id
- * @param {Object} [options]
- * @param {Array} [options.domain]
- * @param {boolean} [options.increment_unread] whether we should increment
- * the unread_counter of channel.
- * @param {boolean} [options.silent] whether it should inform in the chatBus
- * of the newly created message.
- * @return {Object} message object
- */
- _addMessage: function (data, options) {
- var self = this;
- options = options || {};
- var msg = _.findWhere(this.messages, { id: data.id });
-
- if (!msg) {
- msg = this._makeMessage(data);
- // Keep the array ordered by id when inserting the new message
- this.messages.splice(_.sortedIndex(this.messages, msg, 'id'), 0, msg);
- _.each(msg.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- // update the channel's last message (displayed in the channel
- // preview, in mobile)
- if (!channel.last_message || msg.id > channel.last_message.id) {
- channel.last_message = msg;
- }
- self._addToCache(msg, []);
- if (options.domain && options.domain !== []) {
- self._addToCache(msg, options.domain);
- }
- if (channel.hidden) {
- channel.hidden = false;
- self.chatBus.trigger('new_channel', channel);
- }
- if (channel.type !== 'static' && !msg.is_author && !msg.is_system_notification) {
- if (options.increment_unread) {
- self._updateChannelUnreadCounter(channel, channel.unread_counter+1);
- }
- if (channel.is_chat && options.show_notification) {
- if (!self.discussOpen && !config.device.isMobile) {
- // automatically open chat window
- self.call('chat_window_manager', 'openChat', channel, { passively: true });
- }
- var query = {is_displayed: false};
- self.chatBus.trigger('anyone_listening', channel, query);
- self._notifyIncomingMessage(msg, query);
- }
- }
- }
- });
- if (!options.silent) {
- this.chatBus.trigger('new_message', msg);
- }
- } else if (options.domain && options.domain !== []) {
- this._addToCache(msg, options.domain);
- } else if (data.moderation_status === 'accepted') {
- msg.channel_ids = data.channel_ids;
- msg.needsModeration = false;
- if (msg.isModerator) {
- this.moderationCounter--;
- this._removeMessageFromChannel("channel_moderation", msg);
- this.chatBus.trigger('update_moderation_counter');
- }
- this.chatBus.trigger('update_message', msg);
- }
- return msg;
- },
- /**
- * Stores message to channelCache.
- *
- * @private
- * @param {Object} message
- * @param {Array} domain
- */
- _addToCache: function (message, domain) {
- var self = this;
- _.each(message.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- var channelCache = self._getChannelCache(channel, domain);
- var index = _.sortedIndex(channelCache.messages, message, 'id');
- if (channelCache.messages[index] !== message) {
- channelCache.messages.splice(index, 0, message);
- }
- }
- });
- },
- /**
- * Gets messages from their ids
- * This method is used when the chatter linked to a record need to be loaded.
- *
- * @private
- * @param {Array} msgIDs message ids to load
- * @param {Object} options
- * @return {$.Promise<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.loadMore] 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.id === 'channel_moderation') ? [['need_moderation', '=', true]] :
- ['|',
- '&', '&',
- ['model', '=', 'mail.channel'],
- ['res_id', 'in', [channel.id]],
- ['need_moderation', '=', true],
- ['channel_ids', 'in', [channel.id]]
- ];
- var cache = this._getChannelCache(channel, options.domain);
-
- if (options.domain) {
- domain = domain.concat(options.domain || []);
- }
- if (options.loadMore) {
- var minMessageID = cache.messages[0].id;
- domain = [['id', '<', minMessageID]].concat(domain);
- }
-
- return this._rpc({
- model: 'mail.message',
- method: 'message_fetch',
- args: [domain],
- kwargs: {limit: LIMIT, context: session.user_context},
- })
- .then(function (msgs) {
- if (!cache.all_history_loaded) {
- cache.all_history_loaded = msgs.length < LIMIT;
- }
- cache.loaded = true;
-
- _.each(msgs, function (msg) {
- self._addMessage(msg, {
- channel_id: channel.id,
- silent: true,
- domain: options.domain,
- });
- });
- var channelCache = self._getChannelCache(channel, options.domain || []);
- return channelCache.messages;
- });
- },
- /**
- * Gets channel content from the cache.
- * Usefull to get cached messages from a channel.
- *
- * @private
- * @param {Object} channel
- * @param {Object[]} channel.cache
- * @param {Array} domain
- * @return {Object|undefined}
- */
- _getChannelCache: function (channel, domain) {
- var stringifiedDomain = JSON.stringify(domain || []);
- if (!channel.cache[stringifiedDomain]) {
- channel.cache[stringifiedDomain] = {
- all_history_loaded: false,
- loaded: false,
- messages: [],
- };
- }
- return channel.cache[stringifiedDomain];
- },
- /**
- * @private
- * @returns {$.Promise}
- */
- _initMessaging: function () {
- var self = this;
- return session.is_bound.then(function (){
- var context = _.extend({isMobile: config.device.isMobile}, session.user_context);
- return self._rpc({
- route: '/mail/init_messaging',
- params: {context: context},
- });
- }).then(function (result) {
- _.each(result.channel_slots, function (channels) {
- _.each(channels, self._addChannel.bind(self));
- });
- self.messageFailures = result.mail_failures;
- self.needactionCounter = result.needaction_inbox_counter || 0;
- self.starredCounter = result.starred_counter || 0;
- self.moderationCounter = result.moderation_counter;
- self.moderatedChannelIDs = result.moderation_channel_ids;
- self._isModerator = result.is_moderator;
-
- //if user is moderator then add moderation channel
- if (self._isModerator) {
- self._addChannel({
- id: "channel_moderation",
- name: _lt("Moderate Messages"),
- type: "static"
- });
- }
- self.commands = _.map(result.commands, function (command) {
- return _.extend({ id: command.name }, command);
- });
- self.mentionPartnerSuggestions = result.mention_partner_suggestions;
- self.discussMenuID = result.menu_id;
-
- // Shortcodes: canned responses
- _.each(result.shortcodes, function (s) {
- self.cannedResponses.push(_.pick(s, ['id', 'source', 'substitution']));
- });
- // emojis
-
- var lastAdded = null;
- _.each(emojiUnicodes, function (unicode, key) {
- if (lastAdded != unicode){
- lastAdded = unicode;
- self.emojis.push({source:key, unicode_source:unicode, description:key});
- }
- });
-
- self.busBus.start_polling();
- });
- },
- /**
- * Clear cache of a channel
- *
- * @private
- * @param {Array} channelIDs must be int or string
- */
- _invalidateCaches: function (channelIDs) {
- var self = this;
- _.each(channelIDs, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- channel.cache = { '[]': channel.cache['[]']};
- }
- });
- },
- /**
- * Creates channel object
- *
- * @private
- * @param {Object} data
- * @param {string} [data.anonymous_name]
- * @param {string} data.channel_type
- * @param {Object[]} [data.direct_partner]
- * @param {boolean} [data.group_based_subscription]
- * @param {integer|string} data.id
- * @param {boolean} data.is_moderator
- * @param {boolean} data.is_minimized
- * @param {string} [data.last_message_date]
- * @param {boolean} data.mass_mailing
- * @param {integer} [data.message_needaction_counter]
- * @param {integer} [data.message_unread_counter]
- * @param {boolean} data.moderation whether this channel is moderated or not
- * @param {string} data.name
- * @param {string} [data.public]
- * @param {integer} data.seen_message_id
- * @param {string} [data.state]
- * @param {string} [data.type]
- * @param {string} data.uuid
- * @param {Object} options
- * @param {boolean} [options.autoswitch]
- * @param {boolean} options.displayNeedactions
- * @param {boolean} [options.hidden]
- * @return {Object} channel
- */
- _makeChannel: function (data, options) {
- var channel = {
- id: data.id,
- name: data.name,
- server_type: data.channel_type,
- type: data.type || data.channel_type,
- all_history_loaded: false,
- uuid: data.uuid,
- is_detached: data.is_minimized,
- is_folded: data.state === "folded",
- autoswitch: 'autoswitch' in options ? options.autoswitch : true,
- hidden: options.hidden,
- display_needactions: options.displayNeedactions,
- mass_mailing: data.mass_mailing,
- isModerated: data.moderation,
- isModerator: data.is_moderator,
- group_based_subscription: data.group_based_subscription,
- needaction_counter: data.message_needaction_counter || 0,
- unread_counter: 0,
- last_seen_message_id: data.seen_message_id,
- cache: {'[]': {
- all_history_loaded: false,
- loaded: false,
- messages: [],
- }},
- };
- if (channel.type === "channel") {
- channel.type = data.public !== "private" ? "public" : "private";
- }
- if (_.size(data.direct_partner) > 0) {
- channel.type = "dm";
- channel.name = data.direct_partner[0].name;
- channel.direct_partner_id = data.direct_partner[0].id;
- channel.status = data.direct_partner[0].im_status;
- this.pinnedDmPartners.push(channel.direct_partner_id);
- this.busBus.update_option('bus_presence_partner_ids', this.pinnedDmPartners);
- } else if ('anonymous_name' in data) {
- channel.name = data.anonymous_name;
- }
- if (data.last_message_date) {
- channel.last_message_date = moment(time.str_to_datetime(data.last_message_date));
- }
- channel.is_chat = !channel.type.match(/^(public|private|static)$/);
- if (data.message_unread_counter) {
- this._updateChannelUnreadCounter(channel, data.message_unread_counter);
- }
- return channel;
- },
- /**
- * Creates message object
- *
- * @private
- * @param {Object} data
- * @param {integer[]} [data.attachment_ids=[]]
- * @param {integer[]} [data.author_id]
- * @param {string} [data.body=""]
- * @param {Array} data.channel_ids contains integers and strings
- * @param {string} data.customer_email_status
- * @param {string} data.date
- * @param {string} data.email_from
- * @param {integer} data.id
- * @param {string} [data.info]
- * @param {boolean} data.is_discussion
- * @param {boolean} data.is_note
- * @param {boolean} data.is_notification
- * @param {string} data.message_type
- * @param {string} [data.moderation_status]
- * @param {string} [data.model]
- * @param {boolean} data.module_icon src url of the module icon
- * @param {string} data.record_name
- * @param {integer} data.res_id
- * @param {string} data.subject
- * @param {string} data.subtype_description
- * @param {integer[]} data.tracking_value_ids
- * @return {Object} message
- */
- _makeMessage: function (data) {
- var self = this;
- var msg = {
- id: data.id,
- author_id: data.author_id,
- body: data.body || "",
- date: moment(time.str_to_datetime(data.date)),
- message_type: data.message_type,
- subtype_description: data.subtype_description,
- is_author: data.author_id && data.author_id[0] === session.partner_id,
- isModerator: data.model === 'mail.channel' &&
- _.contains(this.moderatedChannelIDs, data.res_id) &&
- data.moderation_status === 'pending_moderation',
- is_note: data.is_note,
- is_discussion: data.is_discussion,
- is_notification: data.is_notification,
- is_system_notification: (data.message_type === 'notification' && data.model === 'mail.channel')
- || data.info === 'transient_message',
- attachment_ids: data.attachment_ids || [],
- subject: data.subject,
- email_from: data.email_from,
- customer_email_status: data.customer_email_status,
- customer_email_data: data.customer_email_data,
- record_name: data.record_name,
- tracking_value_ids: data.tracking_value_ids,
- channel_ids: data.channel_ids,
- model: data.model,
- res_id: data.res_id,
- url: session.url("/mail/view?message_id=" + data.id),
- module_icon:data.module_icon,
- };
- _.each(emojiUnicodes, function (value, key) {
- //add o_mail_emoji class on each unicode to manage size and font
- var unicode = String(value);
- var regexp = new RegExp("(?:^|\\s|<[a-z]*>)(" + unicode + ")(?=\\s|$|</[a-z]*>)", "g");
- var msg_bak = msg.body;
- msg.body = msg.body.replace(regexp, ' <span class="o_mail_emoji">'+unicode+'</span> ');
- // Idiot-proof limit. If the user had the amazing idea of copy-pasting thousands of emojis,
- // the image rendering can lead to memory overflow errors on some browsers (e.g. Chrome).
- // Set an arbitrary limit to 200 from which we simply don't replace them (anyway, they are
- // already replaced by the unicode counterpart).
- if (_.str.count(msg.body, 'o_mail_emoji') > 200) {
- msg.body = msg_bak;
- }
- });
- function propertyDescr(channel) {
- return {
- enumerable: true,
- get: function () {
- return _.contains(msg.channel_ids, channel);
- },
- set: function (bool) {
- if (bool) {
- self._addChannelToMessage(msg, channel);
- } else {
- msg.channel_ids = _.without(msg.channel_ids, channel);
- }
- }
- };
- }
-
- Object.defineProperties(msg, {
- is_starred: propertyDescr("channel_starred"),
- is_needaction: propertyDescr("channel_inbox"),
- needsModerationByUser: propertyDescr("channel_moderation"),
- });
-
- if (_.contains(data.needaction_partner_ids, session.partner_id)) {
- msg.is_needaction = true;
- }
- if (_.contains(data.starred_partner_ids, session.partner_id)) {
- msg.is_starred = true;
- }
- if (data.moderation_status === 'pending_moderation') {
- msg.needsModeration = true;
- msg.needsModerationByUser = msg.isModerator;
- // the message is not linked to the moderated channel on the
- // server, therefore this message has not this channel in
- // channel_ids. Here, just to show this message in the channel
- //visually, it links this message to the channel
- msg.channel_ids.push(msg.res_id);
- }
- if (msg.model === 'mail.channel') {
- var realChannels = _.without(msg.channel_ids, 'channel_inbox', 'channel_starred', 'channel_moderation');
- var origin = realChannels.length === 1 ? realChannels[0] : undefined;
- var channel = origin && this.getChannel(origin);
- if (channel) {
- msg.origin_id = origin;
- msg.origin_name = channel.name;
- }
- }
-
- // Compute displayed author name or email
- if ((!msg.author_id || !msg.author_id[0]) && msg.email_from) {
- msg.mailto = msg.email_from;
- } else {
- msg.displayed_author = (msg.author_id === ODOOBOT_ID) && "OdooBot" ||
- msg.author_id && msg.author_id[1] ||
- msg.email_from || _t('Anonymous');
- }
-
- // Don't redirect on author clicked of self-posted or OdooBot messages
- msg.author_redirect = !msg.is_author && msg.author_id !== ODOOBOT_ID;
-
- // Compute the avatar_url
- if (msg.author_id === ODOOBOT_ID) {
- msg.avatar_src = "/mail/static/src/img/odoo_o.png";
- } else if (msg.author_id && msg.author_id[0]) {
- msg.avatar_src = "/web/image/res.partner/" + msg.author_id[0] + "/image_small";
- } else if (msg.message_type === 'email') {
- msg.avatar_src = "/mail/static/src/img/email_icon.png";
- } else {
- msg.avatar_src = "/mail/static/src/img/smiley/avatar.jpg";
- }
-
- // add anchor tags to urls
- msg.body = utils.parse_and_transform(msg.body, utils.add_link);
-
- // Compute url of attachments
- _.each(msg.attachment_ids, function (a) {
- a.url = '/web/content/' + a.id + '?download=true';
- });
-
- // format date to the local only once by message
- // can not be done in preprocess, since it alter the original value
- if (msg.tracking_value_ids && msg.tracking_value_ids.length) {
- var format;
- _.each(msg.tracking_value_ids, function (f) {
- if (f.field_type === 'datetime') {
- format = 'LLL';
- if (f.old_value) {
- f.old_value = moment.utc(f.old_value).local().format(format);
- }
- if (f.new_value) {
- f.new_value = moment.utc(f.new_value).local().format(format);
- }
- } else if (f.field_type === 'date') {
- format = 'LL';
- if (f.old_value) {
- f.old_value = moment(f.old_value).local().format(format);
- }
- if (f.new_value) {
- f.new_value = moment(f.new_value).local().format(format);
- }
- }
- });
- }
-
- return msg;
- },
- /**
- * @private
- * @param {Object} data key, value to decide activity created or deleted
- */
- _manageActivityUpdateNotification: function (data) {
- this.chatBus.trigger('activity_updated', data);
- },
- /**
- * @private
- * @param {Object} data
- */
- _manageAuthorNotification: function (data) {
- this._addMessage(data.message);
- },
- /**
- * @private
- * @param {Object} message
- * @param {Array} message.channel_ids list of integers and strings,
- * where strings for static channels, e.g. 'channel_inbox'.
- */
- _manageChannelNotification: function (message) {
- var self = this;
- var def;
- var channelAlreadyInCache = true;
- if (message.channel_ids.length === 1) {
- channelAlreadyInCache = !!this.getChannel(message.channel_ids[0]);
- def = this.joinChannel(message.channel_ids[0], {autoswitch: false});
- } else {
- def = $.when();
- }
- def.then(function () {
- // don't increment unread if channel wasn't in cache yet as
- // its unread counter has just been fetched
- self._addMessage(message, {
- show_notification: true,
- increment_unread: channelAlreadyInCache
- });
- self._invalidateCaches(message.channel_ids);
- });
- },
- /**
- * @private
- * @param {Object} data
- * @param {integer|string} data.id string for static channels, e.g. 'channel_inbox'
- * @param {integer} [data.last_message_id] mandatory if 'id' refers to an
- * existing channel.
- */
- _manageChannelSeenNotification: function (data) {
- var channel = this.getChannel(data.id);
- if (channel) {
- channel.last_seen_message_id = data.last_message_id;
- if (channel.unread_counter) {
- this._updateChannelUnreadCounter(channel, 0);
- }
- }
- },
- /**
- * Controls the session of the chat window
- *
- * @private
- * @param {Object} chatSession
- * @param {string} chatSession.channel_type
- * @param {string|integer} chatSession.id string for static channels, e.g. 'channel_inbox'
- * @param {string} chatSession.info
- * @param {boolean} chatSession.is_minimized
- * @param {string} chatSession.name
- * @param {string} chatSession.state
- */
- _manageChatSessionNotification: function (chatSession) {
- var channel;
- if ((chatSession.channel_type === "channel") && (chatSession.state === "open")) {
- this._addChannel(chatSession, {autoswitch: false});
- if (!chatSession.is_minimized && chatSession.info !== 'creation') {
- web_client.do_notify(_t("Invitation"), _t("You have been invited to: ") + chatSession.name);
- }
- }
- // partner specific change (open a detached window for example)
- if ((chatSession.state === "open") || (chatSession.state === "folded")) {
- channel = chatSession.is_minimized && this.getChannel(chatSession.id);
- if (channel) {
- channel.is_detached = true;
- channel.is_folded = (chatSession.state === "folded");
- this.call('chat_window_manager', 'openChat', channel);
- }
- } else if (chatSession.state === "closed") {
- channel = this.getChannel(chatSession.id);
- if (channel) {
- channel.is_detached = false;
- this.call('chat_window_manager', 'closeChat', channel, {keep_open_if_unread: true});
- }
- }
- },
- /**
- * @private
- * @param {Object} data
- * @param {Object[]} [data.message_ids]
- */
- _manageDeletionNotification: function (data) {
- var self = this;
- _.each(data.message_ids, function (msgID) {
- var message = _.findWhere(self.messages, { id: msgID });
- if (message) {
- if (message.isModerator) {
- self._removeMessageFromChannel("channel_moderation", message);
- self.moderationCounter--;
- }
- message.needsModeration = false;
- self._removeMessageFromChannel(message.res_id, message);
- self.chatBus.trigger('update_message', message);
- }
- });
- this.chatBus.trigger('update_moderation_counter');
- },
- /**
- * Add or remove failure when receiving a failure update message
- * @private
- * @param {Object} data
- */
- _manageMessageFailureNotification: function (data) {
- var self = this;
- _.each(data.elements, function (updateMessage) {
-
- var isAddFailure = _.some(updateMessage.notifications, function(notif) {
- return notif[0] === 'exception' || notif[0] === 'bounce';
- });
- var res = _.find(self.messageFailures, {'message_id': updateMessage.message_id});
- if (res) {
- var index = _.findIndex(self.messageFailures, res);
- if (isAddFailure) {
- self.messageFailures[index] = updateMessage;
- } else {
- self.messageFailures.splice(index, 1);
- }
- } else if (isAddFailure) {
- self.messageFailures.push(updateMessage);
- }
- var message = _.findWhere(self.messages, { id: updateMessage.message_id });
- if (message) {
- if (isAddFailure) {
- message.customer_email_status = "exception";
- } else{
- message.customer_email_status = "sent";
- }
- self._updateMessageNotificationStatus(updateMessage, message);
- self.chatBus.trigger('update_message', message);
- }
- });
- this.chatBus.trigger('update_needaction', this.needactionCounter);
- },
- /**
- * Updates channel_inbox when a message has marked as read.
- *
- * @private
- * @param {Object} data
- * @param {integer[]} [data.channel_ids]
- * @param {integer[]} [data.message_ids]
- * @param {string} [data.type]
- */
- _manageMarkAsReadNotification: function (data) {
- var self = this;
- _.each(data.message_ids, function (msgID) {
- var message = _.findWhere(self.messages, { id: msgID });
- if (message) {
- self._invalidateCaches(message.channel_ids);
- self._removeMessageFromChannel("channel_inbox", message);
- self.chatBus.trigger('update_message', message, data.type);
- }
- });
- if (data.channel_ids) {
- _.each(data.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- channel.needaction_counter = Math.max(channel.needaction_counter - data.message_ids.length, 0);
- }
- });
- } else { // if no channel_ids specified, this is a 'mark all read' in the inbox
- _.each(this.channels, function (channel) {
- channel.needaction_counter = 0;
- });
- }
- this.needactionCounter = Math.max(this.needactionCounter - data.message_ids.length, 0);
- this.chatBus.trigger('update_needaction', this.needactionCounter);
- },
- /**
- * @private
- * @param {Object} data notification data
- * @param {Object} data.message
- */
- _manageModeratorNotification: function (data) {
- this.moderationCounter++;
- this._addMessage(data.message);
- this.chatBus.trigger('update_moderation_counter');
- },
- /**
- * @private
- * @param {Object} message
- * @param {integer[]} message.channel_ids
- */
- _manageNeedactionNotification: function (message) {
- var self = this;
- message = this._addMessage(message, {
- channel_id: 'channel_inbox',
- increment_unread: true,
- show_notification: true,
- });
- this._invalidateCaches(message.channel_ids);
- if (message.channel_ids.length !== 0) {
- this.needactionCounter++;
- }
- _.each(message.channel_ids, function (channelID) {
- var channel = self.getChannel(channelID);
- if (channel) {
- channel.needaction_counter++;
- }
- });
- this.chatBus.trigger('update_needaction', this.needactionCounter);
- },
- /**
- * @private
- * @param {Object} data structure depending on the type
- * @param {integer} data.id
- */
- _managePartnerNotification: function (data) {
- if (data.info === "unsubscribe") {
- var channel = this.getChannel(data.id);
- if (channel) {
- var msg;
- if (_.contains(['public', 'private'], channel.type)) {
- msg = _.str.sprintf(_t('You unsubscribed from <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(_t("Unsubscribed"), msg);
- }
- } else if (data.type === 'toggle_star') {
- this._manageToggleStarNotification(data);
- } else if (data.type === 'mark_as_read') {
- this._manageMarkAsReadNotification(data);
- } else if (data.type === 'moderator') {
- this._manageModeratorNotification(data);
- } else if (data.type === 'author') {
- this._manageAuthorNotification(data);
- } else if (data.type === 'deletion') {
- this._manageDeletionNotification(data);
- } else if (data.info === 'channel_seen') {
- this._manageChannelSeenNotification(data);
- } else if (data.info === 'transient_message') {
- this._manageTransientMessageNotification(data);
- } else if (data.type === 'activity_updated') {
- this._manageActivityUpdateNotification(data);
- } else if (data.type === 'mail_failure') {
- this._manageMessageFailureNotification(data);
- } else {
- this._manageChatSessionNotification(data);
- }
- },
- /**
- * @private
- * @param {Object} data partner infos
- * @param {integer} data.id
- * @param {string} data.im_status
- */
- _managePresenceNotification: function (data) {
- var dm = this.getDmFromPartnerID(data.id);
- if (dm) {
- dm.status = data.im_status;
- this.chatBus.trigger('update_dm_presence', dm);
- }
- },
- /**
- * @private
- * @param {Object} data
- * @param {integer[]} data.message_ids
- * @param {boolean} data.starred
- * @param {string} data.type
- */
- _manageToggleStarNotification: function (data) {
- var self = this;
- _.each(data.message_ids, function (msgID) {
- var message = _.findWhere(self.messages, { id: msgID });
- if (message) {
- self._invalidateCaches(message.channel_ids);
- message.is_starred = data.starred;
- if (!message.is_starred) {
- self._removeMessageFromChannel("channel_starred", message);
- } else {
- self._addToCache(message, []);
- var channelStarred = self.getChannel('channel_starred');
- channelStarred.cache = _.pick(channelStarred.cache, "[]");
- }
- self.chatBus.trigger('update_message', message);
- }
- });
-
- if (data.starred) { // increase starred counter if message is marked as star
- this.starredCounter += data.message_ids.length;
- } else { // decrease starred counter if message is remove from star if unstar_all then it will set to 0.
- this.starredCounter -= data.message_ids.length;
- }
-
- this.chatBus.trigger('update_starred', this.starredCounter);
- },
- /**
- * @private
- * @param {Object} data
- * @param {string} data.author_id
- */
- _manageTransientMessageNotification: function (data) {
- var lastMessage = _.last(this.messages);
- data.id = (lastMessage ? lastMessage.id : 0) + 0.01;
- data.author_id = data.author_id || ODOOBOT_ID;
- this._addMessage(data);
- },
- /**
- * shows a popup to notify a new received message.
- * This will also rename the odoo tab browser if
- * the user is not in it.
- *
- * @private
- * @param {Object} msg message received
- * @param {Array} msg.author_id contains [integer, string]
- * @param {string} msg.body
- * @param {Object} options
- * @param {boolean} options.is_displayed
- */
- _notifyIncomingMessage: function (msg, options) {
- if (this.busBus.is_odoo_focused() && options.is_displayed) {
- // no need to notify
- return;
- }
- var title = _t('New message');
- if (msg.author_id[1]) {
- title = _.escape(msg.author_id[1]);
- }
- var content = utils.parse_and_transform(msg.body, utils.strip_html)
- .substr(0, PREVIEW_MSG_MAX_SIZE);
-
- if (!this.busBus.is_odoo_focused()) {
- this.globalUnreadCounter++;
- var tabTitle = _.str.sprintf(_t("%d Messages"), this.globalUnreadCounter);
- web_client.set_title_part("_chat", tabTitle);
- }
-
- this.call('bus_service', 'sendNotification', web_client, title, content);
- },
- /**
- * Removes channel
- *
- * @private
- * @param {Object} [channel]
- * @param {integer} [channel.direct_partner_id] mandatory if type is 'dm'
- * @param {integer|string} channel.id string for static channels, e.g. 'channel_inbox'
- * @param {string} [channel.type]
- */
- _removeChannel: function (channel) {
- if (!channel) { return; }
- if (channel.type === 'dm') {
- var index = this.pinnedDmPartners.indexOf(channel.direct_partner_id);
- if (index > -1) {
- this.pinnedDmPartners.splice(index, 1);
- this.busBus.update_option('bus_presence_partner_ids', this.pinnedDmPartners);
- }
- }
- this.channels = _.without(this.channels, channel);
- delete this.channelDefs[channel.id];
- },
- /**
- * Removes a message from a channel.
- * it will also remove cached message from the channel
- *
- * @private
- * @param {integer|string} channelID string for static channels, e.g. 'channel_inbox'
- * @param {Object} message
- */
- _removeMessageFromChannel: function (channelID, message) {
- message.channel_ids = _.without(message.channel_ids, channelID);
- var channel = _.findWhere(this.channels, { id: channelID });
- _.each(channel.cache, function (cache) {
- cache.messages = _.without(cache.messages, message);
- });
- },
- /**
- * Increments or decrements unreadConversationCounter
- *
- * @private
- * @param {Object} channel
- * @param {integer} counter
- */
- _updateChannelUnreadCounter: function (channel, counter) {
- if (channel.unread_counter > 0 && counter === 0) {
- this.unreadConversationCounter = Math.max(0, this.unreadConversationCounter-1);
- } else if (channel.unread_counter === 0 && counter > 0) {
- this.unreadConversationCounter++;
- }
- channel.unread_counter = counter;
- this.chatBus.trigger("update_channel_unread_counter", channel);
- },
- /**
- * Update the message notification status of message based on update_message
- *
- * @private
- * @param {Object} updateMessage
- * @param {Object[]} updateMessage.notifications
- */
- _updateMessageNotificationStatus: function (updateMessage, message) {
- _.each(updateMessage.notifications, function (notif, id, list) {
- var partnerName = notif[1];
- var notifStatus = notif[0];
- var res = _.find(message.customer_email_data, function(entry){
- return entry[0] === parseInt(id);
- });
- if (res) {
- res[2] = notifStatus;
- } else {
- message.customer_email_data.push([parseInt(id), partnerName, notifStatus]);
- }
- });
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * Notification handlers
- * Sometimes, the web client receives unsubscribe notification and an extra
- * notification on that channel. This is then followed by an attempt to
- * rejoin the channel that we just left. The next few lines remove the
- * extra notification to prevent that situation to occur.
- *
- * @private
- * @param {Array} notifications
- */
- _onNotification: function (notifications) {
- var self = this;
- var unsubscribedNotif = _.find(notifications, function (notif) {
- return notif[1].info === "unsubscribe";
- });
- if (unsubscribedNotif) {
- notifications = _.reject(notifications, function (notif) {
- return notif[0][1] === "mail.channel" && notif[0][2] === unsubscribedNotif[1].id;
- });
- }
- _.each(notifications, function (notification) {
- var model = notification[0][1];
- if (model === 'ir.needaction') {
- // new message in the inbox
- self._manageNeedactionNotification(notification[1]);
- } else if (model === 'mail.channel') {
- // new message in a channel
- self._manageChannelNotification(notification[1]);
- } else if (model === 'res.partner') {
- // channel joined/left, message marked as read/(un)starred, chat open/closed
- self._managePartnerNotification(notification[1]);
- } else if (model === 'bus.presence') {
- // update presence of users
- self._managePresenceNotification(notification[1]);
- }
- });
- },
- /**
- * Global unread counter and notifications
- *
- * @private
- */
- _onWindowFocus: function () {
- this.globalUnreadCounter = 0;
- web_client.set_title_part("_chat");
- },
-
-});
-
-core.serviceRegistry.add('chat_manager', ChatManager);
-
-return ChatManager;
-
-});
diff --git a/addons/mail/static/src/js/services/chat_window_manager.js b/addons/mail/static/src/js/services/chat_window_manager.js
deleted file mode 100644
index fd45b336c23..00000000000
--- a/addons/mail/static/src/js/services/chat_window_manager.js
+++ /dev/null
@@ -1,534 +0,0 @@
-odoo.define('mail.ChatWindowManager', function (require) {
-"use strict";
-
-var ExtendedChatWindow = require('mail.ExtendedChatWindow');
-
-var AbstractService = require('web.AbstractService');
-var config = require('web.config');
-var core = require('web.core');
-var utils = require('web.utils');
-var web_client = require('web.web_client');
-
-var _t = core._t;
-var QWeb = core.qweb;
-
-//----------------------------------------------------------------
-var CHAT_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows
-
-/**
- * This service handles chat channels that are displayed in "chat window" mode
- */
-var ChatWindowManager = AbstractService.extend({
- name: 'chat_window_manager',
- dependencies: ['chat_manager'],
-
- /**
- * @override
- */
- init: function () {
- var self = this;
-
- this._super.apply(this, arguments);
-
- this.chatSessions = [];
- this.newChatSession;
- this.displayState = {
- chatWindowsHidden: false, // chat windows aren't displayed when the client action is open
- hiddenSessions: [],
- hiddenUnreadCounter: 0, // total number of unread msgs in hidden chat windows
- nbSlots: 0,
- spaceLeft: 0,
- windowsDropdownIsOpen: false, // used to keep dropdown open when closing chat windows
- };
- this.call('chat_manager', 'isReady').then(function () {
- var channels = self.call('chat_manager', 'getChannels');
- _.each(channels, function (channel) {
- if (channel.is_detached) {
- self.openChat(channel);
- }
- });
- });
-
- var chatBus = this.call('chat_manager', 'getChatBus');
- chatBus.on('update_message', this, this._onUpdateMessage);
- chatBus.on('new_message', this, this._onNewMessage);
- chatBus.on('anyone_listening', this, this._onAnyoneListening);
- chatBus.on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel);
- chatBus.on('update_channel_unread_counter', this, this._onUpdateChannelUnreadCounter);
- chatBus.on('update_dm_presence', this, this._onUpdateDmPresence);
- chatBus.on('detach_channel', this, this._onDetachChannel);
- chatBus.on('discuss_open', this, this._onDiscussOpen);
-
- core.bus.on('resize', this, _.debounce(this._repositionWindows.bind(this), 100));
- },
-
- //--------------------------------------------------------------------------
- // Public
- //--------------------------------------------------------------------------
-
- /**
- * @param {Object} chatSession
- * @param {integer} chatSession.id
- * @param {Object} options
- */
- closeChat: function (chatSession, options) {
- var session = _.find(this.chatSessions, {id: chatSession.id});
- if (session) {
- this._closeChat(session, options);
- }
- },
- /**
- * @param {Object} session
- * @param {integer} session.id
- * @param {boolean} [session.is_chat] true for dm? => server_type="chat" & type="dm"...
- * @param {boolean} [session.mass_mailing]
- * @param {string} [session.status] e.g. 'offline'
- * @param {Object} [options]
- * @param {boolean} [options.passively] if set to true, open the chat window
- * without focusing the input and marking messages as read if it is not
- * open yet, and do nothing otherwise
- */
- openChat: function (session, options) {
- var self = this;
- if (!session) {
- this._openChatWithoutSession();
- return;
- }
- options = options || {};
- var chatSession = _.findWhere(this.chatSessions, {id: session.id});
- if (!chatSession) {
- var prefix = !session.is_chat ? "#" : "";
- var windowOptions = {
- autofocus: !options.passively,
- input_less: session.mass_mailing,
- status: session.status,
- };
- chatSession = {
- id: session.id,
- uuid: session.uuid,
- name: session.name,
- keep_unread: options.passively, // don't automatically mark unread messages as seen
- window: new ExtendedChatWindow(this, session.id, prefix + session.name, session.is_folded, session.unread_counter, windowOptions),
- };
- chatSession.window.on("close_chat_session", null, function () {
- self._closeChat(chatSession);
- self.call('chat_manager', 'closeChatSession', chatSession.id);
- });
- chatSession.window.on("toggle_star_status", null, function (messageID) {
- self.call('chat_manager', 'toggleStarStatus', messageID);
- });
-
- chatSession.window.on("fold_channel", null, function (channelID, folded) {
- self.call('chat_manager', 'foldChannel', channelID, folded);
- });
-
- chatSession.window.on("post_message", null, function (message, channelID) {
- self.call('chat_manager', 'postMessage', message, {
- channelID: channelID
- }).then(function () {
- chatSession.window.thread.scroll_to();
- });
- });
- chatSession.window.on("redirect", null, function (resModel, resID) {
- self.call('chat_manager', 'redirect', resModel, resID, self.openChat.bind(self));
- });
- chatSession.window.on("redirect_to_channel", null, function (channelID) {
- var session = _.findWhere(self.chatSessions, {id: channelID});
- if (!session) {
- self.call('chat_manager', 'joinChannel', channelID).then(function (channel) {
- self.call('chat_manager', 'detachChannel', channelID);
- });
- } else {
- session.window.toggle_fold(false);
- }
- });
-
- var removeNewChat = false;
- if (options.passively) {
- this.chatSessions.push(chatSession); // simply insert the window to the left
- } else if (this.newChatSession && this.newChatSession.partner_id && this.newChatSession.partner_id === session.direct_partner_id) {
- // the window takes the place of the 'newChatSession' window
- this.chatSessions[_.indexOf(this.chatSessions, this.newChatSession)] = chatSession;
- removeNewChat = true;
- } else {
- this._addChatSession(chatSession); // add session such that window is visible
- }
-
- chatSession.window.appendTo($('body'))
- .then(function () {
- self._repositionWindows({remove_new_chat: removeNewChat});
- return self.call('chat_manager', 'getMessages', {channelID: chatSession.id});
- }).then(function (messages) {
- chatSession.window.render(messages);
- chatSession.window.thread.scroll_to();
- setTimeout(function () {
- chatSession.window.thread.$el.on("scroll", null, _.debounce(function () {
- if (!chatSession.keep_unread && chatSession.window.thread.is_at_bottom()) {
- self.call('chat_manager', 'markChannelAsSeen', session);
- }
- }, 100));
- }, 0); // setTimeout to prevent to execute handler on first scroll_to, which is asynchronous
- if (options.passively) {
- // mark first unread messages as seen when focusing the window, then on scroll to bottom as usual
- chatSession.window.$('.o_mail_thread, .o_chat_composer').one('click', function () {
- self.call('chat_manager', 'markChannelAsSeen', session);
- });
- } else if (!self.displayState.chatWindowsHidden && !session.is_folded) {
- self.call('chat_manager', 'markChannelAsSeen', session);
- }
- });
- } else if (!options.passively) {
- if (chatSession.window.is_hidden) {
- this._makeSessionVisible(chatSession);
- } else if (session.is_folded !== chatSession.window.folded) {
- chatSession.window.toggle_fold(session.is_folded);
- }
- }
- },
- /**
- * Called when unfolding the chat window
- *
- * @param {Object} channel
- * @param {integer} channel.id
- * @param {boolean} [channel.is_folded]
- */
- toggleFoldChat: function (channel) {
- var session = _.find(this.chatSessions, {id: channel.id});
- if (session) {
- session.window.toggle_fold(channel.is_folded);
- }
- },
-
- //--------------------------------------------------------------------------
- // Private
- //--------------------------------------------------------------------------
-
- /**
- * Add chatSession such that it will be the left-most visible window
- *
- * @private
- * @param {Object} chatSession
- */
- _addChatSession: function (chatSession) {
- this._computeAvailableSlots(this.chatSessions.length+1);
- this.chatSessions.splice(this.displayState.nbSlots-1, 0, chatSession);
- },
- /**
- * @private
- * @param {Object} chatSession
- * @param {boolean} [chatSession.keep_unread]
- * @param {Object} chatSession.window
- * @param {Object} [options]
- * @param {boolean} [options.keep_open_if_unread]
- */
- _closeChat: function (chatSession, options) {
- if (options && options.keep_open_if_unread && chatSession.keep_unread) {
- return;
- }
- this.chatSessions = _.without(this.chatSessions, chatSession);
- chatSession.window.destroy();
- this._repositionWindows();
- },
- /**
- * @private
- */
- _closeNewChat: function () {
- this.chatSessions = _.without(this.chatSessions, this.newChatSession);
- this._repositionWindows({remove_new_chat: true});
- },
- /**
- * @private
- * @param {integer} nbWindows
- */
- _computeAvailableSlots: function (nbWindows) {
- if (config.device.isMobile) {
- this.displayState.nbSlots = 1; // one chat window full screen in mobile
- return;
- }
- var width = window.innerWidth;
- var nbSlots = Math.floor(width/CHAT_WINDOW_WIDTH);
- var spaceLeft = width - (Math.min(nbSlots, nbWindows)*CHAT_WINDOW_WIDTH);
- if (nbSlots < nbWindows && spaceLeft < 50) {
- nbSlots--; // leave at least 50px for the hidden windows dropdown button
- spaceLeft += CHAT_WINDOW_WIDTH;
- }
- this.displayState.nbSlots = nbSlots;
- this.displayState.spaceLeft = spaceLeft;
- },
- /**
- * @private
- */
- _destroyNewChat: function () {
- this.newChatSession.window.destroy();
- this.newChatSession = undefined;
- },
- /**
- * @private
- * @param {Object} session
- * @param {Widget} session.window
- */
- _makeSessionVisible: function (session) {
- utils.swap(this.chatSessions, session, this.chatSessions[this.displayState.nbSlots-1]);
- this._repositionWindows();
- session.window.toggle_fold(false);
- },
- /**
- * @private
- */
- _openChatWithoutSession: function () {
- var self = this;
- if (!this.newChatSession) {
- this.newChatSession = {
- id: '_open',
- window: new ExtendedChatWindow(web_client, undefined, _t('New message'), false, 0, {thread_less: true}),
- };
- this.newChatSession.window.on("close_chat_session", null, this._closeNewChat.bind(this));
- this.newChatSession.window.on('open_dm_session', null, function (partner_id) {
- self.newChatSession.partner_id = partner_id;
- var dm = self.call('chat_manager', 'getDmFromPartnerID', partner_id);
- if (!dm) {
- self.call('chat_manager', 'openAndDetachDm', partner_id);
- } else {
- var dmSession = _.findWhere(self.chatSessions, {id: dm.id});
- if (!dmSession) {
- self.call('chat_manager', 'detachChannel', dm.id);
- } else {
- self._closeChat(dmSession);
- dm.is_folded = false;
- self.openChat(dm);
- }
- }
- });
- this._addChatSession(this.newChatSession);
- this.newChatSession.window.appendTo($('body')).then(this._repositionWindows.bind(this));
- } else {
- if (this.newChatSession.window.is_hidden) {
- this._makeSessionVisible(this.newChatSession);
- } else if (this.newChatSession.window.folded) {
- this.newChatSession.window.toggle_fold(false);
- }
- }
- },
- /**
- * @private
- * @param {Object} message
- * @param {integer[]} message.channel_ids
- * @param {boolean} scrollBottom
- */
- _updateSessions: function (message, scrollBottom) {
- var self = this;
- _.each(this.chatSessions, function (session) {
- if (_.contains(message.channel_ids, session.id)) {
- var messageVisible = !self.displayState.chatWindowsHidden && !session.window.folded &&
- !session.window.is_hidden && session.window.thread.is_at_bottom();
- if (messageVisible && !session.keep_unread) {
- var channel = self.call('chat_manager', 'getChannel', session.id);
- self.call('chat_manager', 'markChannelAsSeen', channel);
- }
- self.call('chat_manager', 'getMessages', {channelID: session.id})
- .then(function (messages) {
- session.window.render(messages);
- if (scrollBottom && messageVisible) {
- session.window.thread.scroll_to();
- }
- });
- }
- });
- },
- /**
- * @private
- */
- _renderHiddenSessionsDropdown: function () {
- var $dropdown = $(QWeb.render("mail.ChatWindowsDropdown", {
- sessions: this.displayState.hiddenSessions,
- open: this.displayState.windowsDropdownIsOpen,
- unread_counter: this.displayState.hiddenUnreadCounter,
- widget: {isMobile: config.device.isMobile},
- }));
- return $dropdown;
- },
- /**
- * @private
- */
- _repositionHiddenSessionsDropdown: function () {
- // Unfold dropdown to the left if there is enough place
- var $dropdownUL = this.displayState.$hiddenWindowsDropdown.children('ul');
- if (this.displayState.spaceLeft > $dropdownUL.width() + 10) {
- $dropdownUL.addClass('dropdown-menu-right');
- }
- },
- /**
- * @private
- * @param {Object} [options]
- * @param {boolean} [options.remove_new_chat]
- */
- _repositionWindows: function (options) {
- var self = this;
- if (options && options.remove_new_chat) {
- this._destroyNewChat();
- }
- if (this.displayState.chatWindowsHidden) {
- return;
- }
- this._computeAvailableSlots(this.chatSessions.length);
- var hiddenSessions = [];
- var hiddenUnreadCounter = 0;
- var nbSlots = this.displayState.nbSlots;
- _.each(this.chatSessions, function (session, index) {
- if (index < nbSlots) {
- session.window.$el.css({right: CHAT_WINDOW_WIDTH*index, bottom: 0});
- session.window.do_show();
- } else {
- hiddenSessions.push(session);
- hiddenUnreadCounter += session.window.unread_msgs;
- session.window.do_hide();
- }
- });
- this.displayState.hiddenSessions = hiddenSessions;
- this.displayState.hiddenUnreadCounter = hiddenUnreadCounter;
-
- if (this.displayState.$hiddenWindowsDropdown) {
- this.displayState.$hiddenWindowsDropdown.remove();
- }
- if (hiddenSessions.length) {
- this.displayState.$hiddenWindowsDropdown = this._renderHiddenSessionsDropdown();
- var $hiddenWindowsDropdown = this.displayState.$hiddenWindowsDropdown;
- $hiddenWindowsDropdown.css({right: CHAT_WINDOW_WIDTH * nbSlots, bottom: 0})
- .appendTo($('body'));
- this._repositionHiddenSessionsDropdown();
- this.displayState.windowsDropdownIsOpen = false;
-
- $hiddenWindowsDropdown.on('click', '.o_chat_header', function (event) {
- var sessionID = $(event.currentTarget).data('session-id');
- var session = _.findWhere(hiddenSessions, {id: sessionID});
- if (session) {
- self._makeSessionVisible(session);
- }
- });
- $hiddenWindowsDropdown.on('click', '.o_chat_window_close', function (event) {
- var sessionID = $(event.currentTarget).closest('.o_chat_header').data('session-id');
- var session = _.findWhere(hiddenSessions, {id: sessionID});
- if (session) {
- session.window.on_click_close(event);
- self.displayState.windowsDropdownIsOpen = true; // keep the dropdown open
- }
- });
- }
- },
-
- //--------------------------------------------------------------------------
- // Handlers
- //--------------------------------------------------------------------------
-
- /**
- * @private
- * @param {Object} channel
- * @param {integer|string} channel.id
- * @param {Object} query
- */
- _onAnyoneListening: function (channel, query) {
- _.each(this.chatSessions, function (session) {
- if (channel.id === session.id && session.window.thread.is_at_bottom() && !session.window.is_hidden) {
- query.is_displayed = true;
- }
- });
- },
- /**
- * @private
- * @param {Object} channel
- * @param {integer} channel.id
- */
- _onDetachChannel: function (channel) {
- var chatSession = _.findWhere(this.chatSessions, {id: channel.id});
- if (!chatSession || chatSession.window.folded) {
- this.call('chat_manager', 'detachChannel', channel.id);
- } else if (chatSession.window.is_hidden) {
- this._makeSessionVisible(chatSession);
- } else {
- chatSession.window.focus_input();
- }
- },
- /**
- * @private
- * @param {boolean} open
- */
- _onDiscussOpen: function (open) {
- this.displayState.chatWindowsHidden = open;
- if (open) {
- $('body').addClass('o_no_chat_window');
- } else {
- $('body').removeClass('o_no_chat_window');
- this._repositionWindows();
- }
- },
- /**
- * @private
- * @param {Object} message
- */
- _onNewMessage: function (message) {
- this._updateSessions(message, true);
- },
- /**
- * @private
- * @param {Object} channel
- * @param {integer} channel.unread_counter
- */
- _onUpdateChannelUnreadCounter: function (channel) {
- var self = this;
- this.displayState.hiddenUnreadCounter = 0;
- _.each(self.chatSessions, function (session) {
- if (channel.id === session.id) {
- session.window.update_unread(channel.unread_counter);
- if (channel.unread_counter === 0) {
- session.keep_unread = false;
- }
- }
- if (session.window.is_hidden) {
- self.displayState.hiddenUnreadCounter += session.window.unread_msgs;
- }
- });
- if (self.displayState.$hiddenWindowsDropdown) {
- self.displayState.$hiddenWindowsDropdown.html(self._renderHiddenSessionsDropdown().html());
- self._repositionHiddenSessionsDropdown();
- }
- },
- /**
- * @private
- * @param {Object} channel
- * @param {string|integer} channel.id
- * @param {string} channel.status e.g. 'offline'
- */
- _onUpdateDmPresence: function (channel) {
- _.each(this.chatSessions, function (session) {
- if (channel.id === session.id) {
- session.window.update_status(channel.status);
- }
- });
- },
- /**
- * @private
- * @param {Object} message
- */
- _onUpdateMessage: function (message) {
- this._updateSessions(message, false);
- },
- /**
- * @private
- * @param {string|integer} channelID
- */
- _onUnsubscribeFromChannel: function (channelID) {
- var self = this;
- _.each(self.chatSessions, function (session) {
- if (channelID === session.id) {
- self._closeChat(session);
- }
- });
- },
-
-});
-
-core.serviceRegistry.add('chat_window_manager', ChatWindowManager);
-
-return ChatWindowManager;
-
-});
diff --git a/addons/mail/static/src/js/services/mail_manager.js b/addons/mail/static/src/js/services/mail_manager.js
new file mode 100644
index 00000000000..841c2c5cbfd
--- /dev/null
+++ b/addons/mail/static/src/js/services/mail_manager.js
@@ -0,0 +1,1246 @@
+odoo.define('mail.Manager', function (require) {
+"use strict";
+
+/**
+ * This service is responsible for anything related to mail in JS.
+ * In particular, it stores all threads and messages that have been fetched
+ * from the server.
+ *
+ * As this service does quite a lot of stuffs, it is split into many different
+ * files. For instance, the handling of notification in the longpoll is in
+ * mail_notification_manager.js. This file contains the core functionnality
+ * of the mail service.
+ *
+ * The main point of entry of this service is to call public methods by mean
+ * of a service call.
+ *
+ * @example
+ *
+ * To get all threads that have been fetched from the server:
+ *
+ * this.call('mail_service', 'getThreads');
+ *
+ * To get a particular thread with server ID 1:
+ *
+ * this.call('mail_service', 'getThread', 1);
+ */
+var AbstractService = require('web.AbstractService');
+
+var Channel = require('mail.model.Channel');
+var DM = require('mail.model.DM');
+var Mailbox = require('mail.model.Mailbox');
+var MailFailure = require('mail.model.MailFailure');
+var Message = require('mail.model.Message');
+var mailUtils = require('mail.utils');
+
+var Bus = require('web.Bus');
+var config = require('web.config');
+var core = require('web.core');
+var session = require('web.session');
+
+var _t = core._t;
+var _lt = core._lt;
+
+var PREVIEW_MSG_MAX_SIZE = 350; // optimal for native english speakers
+
+var MailManager = AbstractService.extend({
+ name: 'mail_service',
+ dependencies: ['ajax', 'bus_service', 'local_storage'],
+ _ODOOBOT_ID: "ODOOBOT", // default authorID for transient messages
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._busBus = this.call('bus_service', 'getBus');
+ this._cannedResponses = [];
+ this._mailBus = new Bus(this);
+ this._commands = [];
+ this._discussMenuID = undefined;
+ this._discussOpen = false;
+ this._isModerator = false;
+ this._mailFailures = [];
+ // list of employees for chatter mentions
+ this._mentionPartnerSuggestions = [];
+ this._messages = [];
+ this._moderatedChannelIDs = [];
+ // # of message received when odoo is out of focus
+ this._outOfFocusUnreadMessageCounter = 0;
+ // partner_ids we have a pinned DM with
+ this._pinnedDmPartners = [];
+ // all threads, including channels, DM, mailboxes, document threads, ...
+ this._threads = [];
+
+ // listen on buses
+ this._mailBus
+ .on('discuss_open', this, this._onDiscussOpen)
+ .on('window_focus', this, this._onWindowFocus);
+
+ this._initializeFromServer();
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add a message
+ *
+ * @param {Object} data message data
+ * @param {integer} data.id
+ * @param {string} [data.moderation_status]
+ * @param {Object} [options]
+ * @param {Array} [options.domain]
+ * @param {boolean} [options.incrementUnread] whether we should increment
+ * the unread_counter of channel.
+ * @param {boolean} [options.silent] whether it should inform in the mailBus
+ * of the newly created message.
+ * @returns {mail.model.Message} message object
+ */
+ addMessage: function (data, options) {
+ options = options || {};
+ var message = this.getMessage(data.id);
+ if (!message) {
+ message = this._makeMessage(data);
+ // Keep the array ordered by ID when inserting the new message
+ var index = _.sortedIndex(this._messages, message, function (msg) {
+ return msg.getID();
+ });
+ this._messages.splice(index, 0, message);
+ this._addNewMessagePostprocessThread(message, options);
+ this._addMessageToThreads(message, []);
+ if (!options.silent) {
+ this._mailBus.trigger('new_message', message);
+ }
+ }
+ if (data.moderation_status === 'accepted') {
+ message.setModerationStatus('accepted', {
+ additionalThreadIDs: data.channel_ids
+ });
+ }
+ if (options.domain && options.domain !== []) {
+ this._addMessageToThreads(message, options.domain);
+ }
+ return message;
+ },
+ /**
+ * Creates a channel, can be either a true channel or a DM based on `type`
+ *
+ * @param {integer|string} name id of partner (in case of dm) or name
+ * @param {string} type ['dm', 'public', 'private']
+ * @returns {$.Promise<integer>} resolved with ID of the newly created
+ * channel
+ */
+ createChannel: function (name, type) {
+ if (type === 'dm') {
+ return this._createDM(name);
+ } else {
+ return this._createChannel(name, type);
+ }
+ },
+ /**
+ * Returns the list of canned responses
+ * A canned response is a pre-formatted text that is triggered with
+ * some keystrokes, such as with ':'.
+ *
+ * @returns {Array} array of Objects (mail.shortcode)
+ */
+ getCannedResponses: function () {
+ return this._cannedResponses;
+ },
+ /**
+ * Returns a channel corresponding to the given id.
+ *
+ * @param {integer} threadID
+ * @returns {mail.model.Channel|undefined} the channel, if it exists
+ */
+ getChannel: function (threadID) {
+ return _.find(this._threads, function (thread) {
+ return thread.isChannel() && (thread.getID() === threadID);
+ });
+ },
+ /**
+ * Returns a list of channels
+ *
+ * @returns {mail.model.Channel[]} list of channels
+ */
+ getChannels: function () {
+ return _.filter(this._threads, function (thread) {
+ return thread.isChannel();
+ });
+ },
+ /**
+ * Returns the content that will be shown in the mail navbar dropdown
+ *
+ * @param {mail.model.Channel[]} channels
+ * @returns {$.Promise<Object[]>} resolved with list of channel previews
+ */
+ getChannelPreviews: function (channels) {
+ var self = this;
+ return this._getChannelPreviews(channels).then(function (previews) {
+ var sortedPreviews = self._sortPreviews(previews);
+ return sortedPreviews;
+ });
+ },
+ /**
+ * @returns {web.Bus} the mail bus
+ */
+ getMailBus: function () {
+ return this._mailBus;
+ },
+ /**
+ * Returns the record id of ir.ui.menu for Discuss
+ *
+ * @returns {integer} record id
+ */
+ getDiscussMenuID: function () {
+ return this._discussMenuID;
+ },
+ /**
+ * Gets direct message channel
+ *
+ * @param {integer} partnerID
+ * @returns {Object|undefined} channel
+ */
+ getDmFromPartnerID: function (partnerID) {
+ return _.find(this._threads, function (thread) {
+ return thread.getType() === 'dm' &&
+ thread.getDirectPartnerID() === partnerID;
+ });
+ },
+ /**
+ * @param {string} mailboxID
+ * @returns {mail.model.Mailbox} the mailbox, if any
+ */
+ getMailbox: function (mailboxID) {
+ return _.find(this._threads, function (thread) {
+ return thread.getID() === 'mailbox_' + mailboxID;
+ });
+ },
+ /**
+ * Returns a list of mail failures
+ *
+ * @returns {mail.model.MailFailure[]} list of mail failures
+ */
+ getMailFailures: function () {
+ return this._mailFailures;
+ },
+ /**
+ * Get partners as mentions from a chatter
+ * Typically all employees as partner suggestions.
+ *
+ * @returns {Array<Object[]>}
+ */
+ getMentionPartnerSuggestions: function () {
+ return this._mentionPartnerSuggestions;
+ },
+ /**
+ * Gets message from its ID
+ *
+ * @param {integer} messageID
+ * @returns {mail.model.Message|undefined} the matched message (if any)
+ */
+ getMessage: function (messageID) {
+ return _.find(this._messages, function (message) {
+ return message.getID() === messageID;
+ });
+ },
+ /**
+ * Returns the previews to display in the systray's messaging menu.
+ *
+ * @param {string} [filter=undefined] specify 'chat' or 'channels' to only
+ * get previews for that type of threads
+ * @returns {$.Promise<Object[]>} resolved with list of objects that have a
+ * valid format for rendering a messaging menu preview items.
+ */
+ getSystrayPreviews: function (filter) {
+ var self = this;
+ var channelDef = this._getSystrayChannelPreviews(filter);
+ var inboxDef = this._getSystrayInboxPreviews(filter);
+ var failureDef = this._getSystrayMailFailurePreviews(filter);
+
+ return $.when(channelDef, inboxDef, failureDef)
+ .then(function (previewsChannel, previewsInbox, previewsFailure) {
+ // order: failures > inbox > channel, each group must be sorted
+ previewsChannel = self._sortPreviews(previewsChannel);
+ previewsInbox = self._sortPreviews(previewsInbox);
+ previewsFailure = self._sortPreviews(previewsFailure);
+ return _.union(previewsFailure, previewsInbox, previewsChannel);
+ });
+ },
+ /**
+ * Get the list of channel IDs where the current user is a moderator
+ *
+ * @returns {integer[]}
+ */
+ getModeratedChannelIDs: function () {
+ return this._moderatedChannelIDs || [];
+ },
+ /**
+ * Get the Odoobot ID, which is the default authorID for transient messages
+ *
+ * @returns {string}
+ */
+ getOdoobotID: function () {
+ return this._ODOOBOT_ID;
+ },
+ /**
+ * Returns thread matching provided ID, if any
+ *
+ * @param {string|integer} threadID
+ * @returns {mail.model.Thread|undefined} the thread, if it exists
+ */
+ getThread: function (threadID) {
+ return _.find(this._threads, function (thread) {
+ return thread.getID() === threadID;
+ });
+ },
+ /**
+ * Returns a list of threads
+ *
+ * @returns {mail.model.Thread[]} list of threads
+ */
+ getThreads: function () {
+ return this._threads;
+ },
+ /**
+ * States whether the current user is a moderator or not
+ *
+ * @returns {boolean}
+ */
+ isModerator: function () {
+ return this._isModerator;
+ },
+ /**
+ * States whether the mail manager is ready or not
+ * This is the case when it has fetched the initial state from the server,
+ * by means of the route 'mail/init_messaging'
+ *
+ * @returns {$.Promise}
+ */
+ isReady: function () {
+ return this._isReady;
+ },
+ /**
+ * Join an existing channel
+ * See @createChannel to join a new channel
+ *
+ * @param {integer} channelID
+ * @param {Object} [options] options to be passed on channel add
+ * @returns {$.Promise<integer>} resolved with channelID joined
+ */
+ joinChannel: function (channelID, options) {
+ var def;
+ var channel = this.getChannel(channelID);
+ if (channel) {
+ // channel already joined
+ def = $.when(channelID);
+ } else {
+ def = this._joinAndAddChannel(channelID, options);
+ }
+ return def;
+ },
+ /**
+ * Mark messages as read
+ *
+ * @param {Array} messageIDs list of messages IDs
+ * @returns {$.Promise} resolved when messages have been marked as read on
+ * the server.
+ */
+ markMessagesAsRead: function (messageIDs) {
+ var self = this;
+ var ids = _.filter(messageIDs, function (id) {
+ var message = self.getMessage(id);
+ // If too many messages, not all are fetched,
+ // and some might not be found
+ return !message || message.isNeedaction();
+ });
+ if (ids.length) {
+ return this._rpc({
+ model: 'mail.message',
+ method: 'set_message_done',
+ args: [ids],
+ });
+ } else {
+ return $.when();
+ }
+ },
+ /**
+ * @param {integer|string} threadID
+ */
+ openThread: function (threadID) {
+ this._openThreadInDiscuss(threadID);
+ },
+ /**
+ * Special redirection handling for given model and id
+ *
+ * If the model is res.partner, and there is a user associated with this
+ * partner which isn't the current user, open the DM with this user.
+ * Otherwhise, open the record's form view (if not current user's).
+ *
+ * @param {string} resModel model to open
+ * @param {integer} resID record to open
+ * @param {function} [dmRedirectionCallback] only used if 'res.partner',
+ * a function that has a threadID as input
+ */
+ redirect: function (resModel, resID, dmRedirectionCallback) {
+ if (resModel === 'res.partner') {
+ this._redirectPartner(resModel, resID, dmRedirectionCallback);
+ } else {
+ this._redirectDefault(resModel, resID);
+ }
+ },
+ /**
+ * Remove the message from all of its threads
+ *
+ * @param {mail.model.Message} message
+ */
+ removeMessageFromThreads: function (message) {
+ var self = this;
+ _.each(message.getThreadIDs(), function (threadID) {
+ var thread = self.getThread(threadID);
+ if (thread) {
+ thread.removeMessage(message.getID());
+ }
+ });
+ },
+ /**
+ * Search among prefetched partners, using the string 'searchVal'
+ *
+ * @param {string} searchVal
+ * @param {integer} limit max number of found partners in the response
+ * @returns {$.Promise<Object[]>} list of found partners (matching
+ * 'searchVal')
+ */
+ searchPartner: function (searchVal, limit) {
+ var def = $.Deferred();
+ var partners = this._searchPartnerPrefetch(searchVal, limit);
+
+ if (!partners.length) {
+ def = this._searchPartnerFetch(searchVal, limit);
+ } else {
+ def = $.when(partners);
+ }
+ return def.then(function (partners) {
+ var suggestions = _.map(partners, function (partner) {
+ return {
+ id: partner.id,
+ value: partner.name,
+ label: partner.name
+ };
+ });
+ return _.sortBy(suggestions, 'label');
+ });
+ },
+ /**
+ * Unstars all messages from all channels
+ *
+ * @returns {$.Promise}
+ */
+ unstarAll: function () {
+ return this._rpc({
+ model: 'mail.message',
+ method: 'unstar_all',
+ args: [[]]
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a channel and returns it.
+ * Simply returns the channel if already exists.
+ *
+ * @private
+ * @param {Object} data
+ * @param {integer} data.id id of channel
+ * @param {string|Object} data.name name of channel, e.g. 'general'
+ * @param {string} data.type type of channel, e.g. 'public'
+ * @param {string} [data.state] e.g. 'open', 'folded'
+ * @param {Object|integer} [options=undefined]
+ * @param {boolean} [options.silent=false]
+ * @returns {integer} the ID of the newly or already existing channel
+ */
+ _addChannel: function (data, options) {
+ options = typeof options === 'object' ? options : {};
+ var channel = this.getChannel(data.id);
+ if (!channel) {
+ channel = this._makeChannel(data, options);
+ if (channel.getType() === 'dm') {
+ this._pinnedDmPartners.push(channel.getDirectPartnerID());
+ this._busBus.update_option(
+ 'bus_presence_partner_ids',
+ this._pinnedDmPartners
+ );
+ }
+ this._threads.push(channel);
+ if (data.last_message) {
+ // channel_info in mobile, necessary for showing channel
+ // preview in mobile
+ this.addMessage(data.last_message);
+ }
+ this._sortThreads();
+ if (!options.silent) {
+ this._mailBus.trigger('new_channel', channel);
+ }
+ }
+ return channel.getID();
+ },
+ /**
+ * Add a new mailbox
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object} options
+ * @returns {mail.model.Mailbox}
+ */
+ _addMailbox: function (data, options) {
+ options = typeof options === 'object' ? options : {};
+ var mailbox = new Mailbox(this, data, options, this._commands);
+ this._threads.push(mailbox);
+ this._sortThreads();
+ return mailbox;
+ },
+ /**
+ * Stores a message to the cache `domain` of all of its threads
+ *
+ * @private
+ * @param {mail.model.Message} message
+ * @param {Array} domain
+ */
+ _addMessageToThreads: function (message, domain) {
+ var self = this;
+ _.each(message.getThreadIDs(), function (threadID) {
+ var thread = self.getThread(threadID);
+ if (thread) {
+ thread.addMessage(message, domain);
+ }
+ });
+ },
+ /**
+ * For newly added message, postprocess threads linked to this message
+ *
+ * @private
+ * @param {mail.model.Message} message
+ * @param {Object} options
+ * @param {Array} [options.domain]
+ * @param {boolean} [options.incrementUnread]
+ * @param {boolean} [options.showNotification]
+ */
+ _addNewMessagePostprocessThread: function (message, options) {
+ var self = this;
+ _.each(message.getThreadIDs(), function (threadID) {
+ var thread = self.getThread(threadID);
+ if (thread) {
+ if (
+ thread.getType() !== 'mailbox' &&
+ !message.isAuthor() &&
+ !message.isSystemNotification()
+ ) {
+ if (options.incrementUnread) {
+ thread.incrementUnreadCounter();
+ }
+ if (thread.isChat() && options.showNotification) {
+ if (!self._isDiscussOpen() && !config.device.isMobile) {
+ // automatically open thread window
+ // while keeping it unread
+ thread.detach({ passively: true });
+ }
+ var query = { isBottomVisible: false };
+ self._mailBus.trigger('is_thread_bottom_visible', thread, query);
+ if (!query.isBottomVisible) {
+ self._notifyIncomingMessage(message, query);
+ }
+ }
+ }
+ }
+ });
+ },
+ /**
+ * Create a Channel (other than a DM)
+ *
+ * @private
+ * @param {string} name
+ * @param {string} type
+ * @returns {$.Promise<integer>} ID of the created channel
+ */
+ _createChannel: function (name, type) {
+ var context = _.extend({ isMobile: config.device.isMobile }, session.user_context);
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_create',
+ args: [name, type],
+ kwargs: { context: context },
+ })
+ .then(this._addChannel.bind(this));
+ },
+ /**
+ * Create a Direct Messages Chat
+ *
+ * @private
+ * @param {string} name
+ * @returns {$.Promise<integer>} ID of the created channel
+ */
+ _createDM: function (name) {
+ var context = _.extend({ isMobile: config.device.isMobile }, session.user_context);
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_get',
+ args: [[name]],
+ kwargs: { context: context },
+ })
+ .then(this._addChannel.bind(this));
+ },
+ /**
+ * @private
+ * @param {integer[]} channelIDs
+ * @returns {$.Promise<Object[]>} resolved with list of channel preview
+ */
+ _fetchChannelPreviews: function (channelIDs) {
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_fetch_preview',
+ args: [channelIDs],
+ }, { shadow: true });
+ },
+ /**
+ * Get previews of the channels
+ *
+ * The preview of a channel is built from the channel information and its
+ * lastest message. A channel without a last message is incomplete, so it
+ * must fetch this message.
+ *
+ * Note that a channel could have no message at all, so it fetches for the
+ * last message only once per channel. This is correct to not fetch more
+ * than once in this case, because any later received message updates
+ * automatically the last message of a channel.
+ *
+ * @private
+ * @param {mail.model.Channel[]} channels
+ * @returns {$.Deferred<Object[]>} resolved with list of channel previews
+ */
+ _getChannelPreviews: function (channels) {
+ var self = this;
+
+ var unpreviewedChannels = _.reject(channels, function (channel) {
+ return channel.hasBeenPreviewed();
+ });
+ var fetchDef;
+ if (unpreviewedChannels.length) {
+ var ids = _.map(unpreviewedChannels, function (channel) {
+ return channel.getID();
+ });
+ fetchDef = this._fetchChannelPreviews(ids);
+ } else {
+ fetchDef = $.when();
+ }
+ return fetchDef.then(function (fetchedPreviews) {
+ // add fetched messages
+ _.each(fetchedPreviews, function (fetchedPreview) {
+ if (fetchedPreview.last_message) {
+ self.addMessage(fetchedPreview.last_message);
+ }
+ // mark the channel as previewed, so that we do not need to
+ // fetch preview again
+ var channel = self.getChannel(fetchedPreview.id);
+ channel.markAsPreviewed();
+ });
+ return _.map(channels, function (channel) {
+ return channel.getPreview();
+ });
+ });
+ },
+ /**
+ * Get the previews of the mail failures
+ * Mail failures of a same model are grouped together, so that there are few
+ * preview items on the messaging menu in the systray.
+ *
+ * To determine whether this is a model preview or a document preview review
+ * the documentID is omitted for a model preview, whereas it is set for a
+ * preview of a failure related to a document.
+ *
+ * @returns {$.Promise<Object[]>} resolved with previews of mail failures
+ */
+ _getMailFailurePreviews: function () {
+ // items = list of objects:
+ // item = {
+ // unreadCounter: {integer},
+ // failure: {mail.model.MailFailure},
+ // isSameDocument: {boolean}
+ // }
+ var items = [];
+ _.each(this._mailFailures, function (failure) {
+ var unreadCounter = 1;
+ var isSameDocument = true;
+ var sameModelItem = _.find(items, function (item) {
+ if (
+ item.failure.isLinkedToDocumentThread() &&
+ (item.failure.getDocumentModel() === failure.getDocumentModel())
+ ) {
+ isSameDocument = item.failure.getDocumentID() === failure.getDocumentID();
+ return true;
+ }
+ return false;
+ });
+
+ if (failure.isLinkedToDocumentThread() && sameModelItem) {
+ unreadCounter = sameModelItem.unreadCounter + 1;
+ isSameDocument = sameModelItem.isSameDocument && isSameDocument;
+ var index = _.findIndex(items, sameModelItem);
+ items[index] = {
+ unreadCounter: unreadCounter,
+ failure: failure,
+ isSameDocument: isSameDocument,
+ };
+ } else {
+ items.push({
+ unreadCounter: unreadCounter,
+ failure: failure,
+ isSameDocument: true,
+ });
+ }
+ });
+ return _.map(items, function (item) {
+ // return preview with correct unread counter
+ // also unset documentID if the grouped mail failures are from
+ // same model but different document
+ var preview = {};
+ _.extend(preview, item.failure.getPreview(), {
+ unreadCounter: item.unreadCounter,
+ });
+ if (!item.failure.isSameDocument) {
+ preview.documentID = undefined;
+ }
+ return preview;
+ });
+ },
+ /**
+ * @private
+ * @param {string|undefined} [filter] the filter to apply on channel
+ * previews in systray messaging menu
+ * @returns {$.Promise<Object[]>} resolved with object valid for template
+ * mail.Preview on channel previews, based on selected filter
+ */
+ _getSystrayChannelPreviews: function (filter) {
+ var filteredThreads = _.filter(this._threads, function (thread) {
+ if (!thread.isChannel()) {
+ return false;
+ }
+ if (filter === 'chat') {
+ return thread.isChat();
+ } else if (filter === 'channels') {
+ return !thread.isChat();
+ }
+ return true;
+ });
+ return this.getChannelPreviews(filteredThreads);
+ },
+ /**
+ * Get the previews of needaction messages, i.e. messages in Inbox mailbox,
+ * based on selected filter.
+ *
+ * FIXME: it will use the current fetched messages in inbox + perform
+ * another message fetch on inbox. In other words, it means that some thread
+ * previews may be missing in the systray messaging menu. For example:
+ *
+ * - Each message fetch on Inbox fetches at most 30 messages.
+ * - There are 31 messages on Inbox.
+ * - No message on Inbox has been fetched at the moment
+ * - the 1st message on Inbox is linked to a document, and no other
+ * message in Inbox is linked to this document
+ *
+ * When clicking on the messaging menu, it will fetch all messages of Inbox,
+ * except the 1st message, in order to show the previews. The preview of
+ * the document thread of the 1st message will not be shown in the message
+ * menu
+ *
+ * This is a limitation, which should be solved by making changes on the
+ * server logic (e.g. provide list of all document threads in inbox on
+ * the rpc mail/init_messaging).
+ *
+ * @private
+ * @param {string|undefined} [filter]
+ * @returns {$.Deferred<Object[]>} resolved with valid objects for template
+ * mail.Preview on inbox message previews
+ */
+ _getSystrayInboxPreviews: function (filter) {
+ // 'All' filter, show messages preview from inbox and mail failures
+ // inbox previews
+ if (filter === 'mailbox_inbox' || !filter) {
+ var inbox = this.getMailbox('inbox');
+ return inbox.getMessagePreviews();
+ } else {
+ return $.when([]);
+ }
+ },
+ /**
+ * Get the previews of mail failure, based on selected filter.
+ *
+ * @private
+ * @param {string|undefined} [filter]
+ */
+ _getSystrayMailFailurePreviews: function (filter) {
+ // mail failure previews
+ if (filter === 'mailbox_inbox' || !filter) {
+ return this._getMailFailurePreviews();
+ } else {
+ return $.when([]);
+ }
+ },
+ /**
+ * Initialize the canned responses from the server data
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} [data.shortcodes]
+ * @param {integer} data.shortcodes[i].id
+ * @param {string} data.shortcodes[i].source
+ * @param {string} data.shortcodes[i].substitution
+ */
+ _initializeCannedResponses: function (data) {
+ var self = this;
+ _.each(data.shortcodes, function (s) {
+ var cannedResponse = _.pick(s, ['id', 'source', 'substitution']);
+ self._cannedResponses.push(cannedResponse);
+ });
+ },
+ /**
+ * Initialize the channels from the server, including public, private, DM,
+ * livechat, etc.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object} [data.channel_slots] contains the data of channels to
+ * initialize, which are grouped by channel type by key of the object
+ * (e.g. list of public channel data are stored in 'channel_channel')
+ * @param {Object[]} [data.channel_slots[i] list of data of channel of type
+ * `i`
+ */
+ _initializeChannels: function (data) {
+ var self = this;
+ _.each(data.channel_slots, function (channels) {
+ _.each(channels, self._addChannel.bind(self));
+ });
+ },
+ /**
+ * Initialize commands from the server
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} data.commands list of command data from the server
+ */
+ _initializeCommands: function (data) {
+ this._commands = _.map(data.commands, function (command) {
+ return _.extend({ id: command.name }, command);
+ });
+ },
+ /**
+ * @private
+ * @returns {$.Promise}
+ */
+ _initializeFromServer: function () {
+ var self = this;
+ this._isReady = session.is_bound.then(function () {
+ var context = _.extend(
+ { isMobile: config.device.isMobile },
+ session.user_context
+ );
+ return self._rpc({
+ route: '/mail/init_messaging',
+ params: { context: context },
+ });
+ }).then(function (result) {
+ self._updateFromServer(result);
+ self._busBus.start_polling();
+ });
+ },
+ /**
+ * Initialize the mailboxes, namely 'Inbox', 'Starred',
+ * and 'Moderation Queue' if the user is a moderator of a channel
+ *
+ * @private
+ * @param {Object} data
+ * @param {boolean} [data.is_moderator=false] states whether the user is
+ * moderator of a channel
+ * @param {integer} [data.moderation_counter=0] states the mailbox counter
+ * to set to 'Moderation Queue'
+ * @param {integer} [data.needaction_inbox_counter=0] states the mailbox
+ * counter to set to 'Inbox'
+ * @param {integer} [data.starred_counter=0] states the mailbox counter to
+ * set to 'Starred'
+ */
+ _initializeMailboxes: function (data) {
+ this._addMailbox({
+ id: 'inbox',
+ name: _lt("Inbox"),
+ mailboxCounter: data.needaction_inbox_counter || 0,
+ });
+ this._addMailbox({
+ id: 'starred',
+ name: _lt("Starred"),
+ mailboxCounter: data.starred_counter || 0,
+ });
+
+ if (data.is_moderator) {
+ this._addMailbox({
+ id: 'moderation',
+ name: _lt("Moderate Messages"),
+ mailboxCounter: data.moderation_counter || 0,
+ });
+ }
+ },
+ /**
+ * Initialize mail failures from the server data
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} data.mail_failures data to initialize mail failures
+ * locally
+ */
+ _initializeMailFailures: function (data) {
+ var self = this;
+ this._mailFailures = _.map(data.mail_failures, function (mailFailureData) {
+ return new MailFailure(self, mailFailureData);
+ });
+ },
+ /**
+ * Initialize moderation settings from the server data
+ *
+ * @private
+ * @param {Object} data
+ * @param {boolean} [data.is_moderator=false]
+ * @param {integer[]} [data.moderation_channel_ids]
+ */
+ _initializeModerationSettings: function (data) {
+ this._moderatedChannelIDs = data.moderation_channel_ids;
+ this._isModerator = data.is_moderator;
+ },
+ /**
+ * State whether discuss app is open or not
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _isDiscussOpen: function () {
+ return this._discussOpen;
+ },
+ /**
+ * Join the channel, and add it locally afterwards
+ *
+ * @private
+ * @param {integer|string} channelID
+ * @param {Object} options options passed on channel add
+ * @returns {$.Promise<integer>} channelID of joined and added channel
+ */
+ _joinAndAddChannel: function (channelID, options) {
+ var self = this;
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_join_and_get_info',
+ args: [[channelID]],
+ })
+ .then(function (result) {
+ return self._addChannel(result, options);
+ });
+ },
+ /**
+ * Creates a new instance of Channel with the given data and options.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Array} [data.direct_partner] if set and is an non-empty array,
+ * the channel is a DM
+ * @param {Object} [options]
+ * @returns {mail.model.Channel}
+ */
+ _makeChannel: function (data, options) {
+ if (_.size(data.direct_partner) > 0) {
+ return new DM(this, data, options, this._commands);
+ }
+ return new Channel(this, data, options, this._commands);
+ },
+ /**
+ * Creates a new instance of Message with the given data.
+ *
+ * @private
+ * @param {Object} data
+ * @returns {mail.model.Message}
+ */
+ _makeMessage: function (data) {
+ return new Message(this, data);
+ },
+ /**
+ * shows a popup to notify a new received message.
+ * This will also rename the odoo tab browser if
+ * the user has no focus on it.
+ *
+ * @private
+ * @param {mail.model.Message} message message received
+ * @param {Object} options
+ * @param {boolean} options.isDisplayed
+ */
+ _notifyIncomingMessage: function (message, options) {
+ if (this._busBus.is_odoo_focused() && options.isDisplayed) {
+ // no need to notify
+ return;
+ }
+ var title = _t("New message");
+ if (message.hasAuthor()) {
+ title = _.escape(message.getAuthorName());
+ }
+ var content = mailUtils.parseAndTransform(message.getBody(), mailUtils.stripHTML)
+ .substr(0, PREVIEW_MSG_MAX_SIZE);
+
+ if (!this._busBus.is_odoo_focused()) {
+ this._outOfFocusUnreadMessageCounter++;
+ var tabTitle = _.str.sprintf(
+ _t("%d Messages"),
+ this._outOfFocusUnreadMessageCounter
+ );
+ this.trigger_up('set_title_part', {
+ part: '_chat',
+ title: tabTitle
+ });
+ }
+
+ this.call('bus_service', 'sendNotification', title, content);
+ },
+ /**
+ * Open the thread in the Discuss app
+ *
+ * @private
+ * @param {integer|string} threadID a valid threadID
+ */
+ _openThreadInDiscuss: function (threadID) {
+ var thread = this.getThread(threadID);
+ this._mailBus.trigger('open_thread_in_discuss', thread);
+ },
+ /**
+ * @private
+ * @param {string} resModel
+ * @param {integer} resID
+ */
+ _redirectDefault: function (resModel, resID) {
+ var self = this;
+ this._rpc({
+ model: resModel,
+ method: 'get_formview_id',
+ args: [[resID], session.user_context],
+ })
+ .then(function (viewID) {
+ self._redirectToDocument(resModel, resID, viewID);
+ });
+ },
+ /**
+ * @private
+ * @param {Object} message
+ * @param {Array} message.channel_ids list of integers and strings,
+ * where strings for static channels, e.g. 'mailbox_inbox'.
+ * @param {string} resModel
+ * @param {integer} resID
+ * @param {string} viewID
+ */
+ _redirectToDocument: function (resModel, resID, viewID) {
+ this.do_action({
+ type: 'ir.actions.act_window',
+ view_type: 'form',
+ view_mode: 'form',
+ res_model: resModel,
+ views: [[viewID || false, 'form']],
+ res_id: resID,
+ });
+ },
+ /**
+ * @private
+ * @param {string} resModel 'res.partner'
+ * @param {integer} resID
+ * @param {function} dmRedirection a function callback that has
+ * threadID as input
+ */
+ _redirectPartner: function (resModel, resID, dmRedirection) {
+ var self = this;
+ var domain = [['partner_id', '=', resID]];
+ this._rpc({
+ model: 'res.users',
+ method: 'search',
+ args: [domain],
+ })
+ .then(function (userIDs) {
+ if (
+ userIDs.length &&
+ userIDs[0] !== session.uid &&
+ dmRedirection
+ ) {
+ self.createChannel(resID, 'dm').then(dmRedirection);
+ } else {
+ self._redirectToDocument(resModel, resID);
+ }
+ });
+ },
+ /**
+ * Remove channel
+ *
+ * This is only called by the mail_notification_manager
+ *
+ * @private
+ * @param {mail.model.Channel} [channel]
+ */
+ _removeChannel: function (channel) {
+ if (!channel) { return; }
+ if (channel.getType() === 'dm') {
+ var index = this._pinnedDmPartners.indexOf(channel.getDirectPartnerID());
+ if (index > -1) {
+ this._pinnedDmPartners.splice(index, 1);
+ this._busBus.update_option(
+ 'bus_presence_partner_ids',
+ this._pinnedDmPartners
+ );
+ }
+ }
+ this._threads = _.without(this._threads, channel);
+ },
+ /**
+ * Removes a message from a thread.
+ *
+ * @private
+ * @param {string} threadID
+ * @param {mail.model.Message} message
+ */
+ _removeMessageFromThread: function (threadID, message) {
+ message.removeThread(threadID);
+ var thread = _.find(this._threads, function (thread) {
+ return thread.getID() === threadID;
+ });
+ thread.removeMessage(message.getID());
+ },
+ /**
+ * @private
+ */
+ _resetOutOfFocusUnreadMessageCounter: function () {
+ this._outOfFocusUnreadMessageCounter = 0;
+ },
+ /**
+ * Extend the research to all users
+ *
+ * @private
+ * @param {string} searchVal
+ * @param {integer} limit
+ * @returns {$.Promise<Object[]>} fetched partners matching 'searchVal'
+ */
+ _searchPartnerFetch: function (searchVal, limit) {
+ return this._rpc({
+ model: 'res.partner',
+ method: 'im_search',
+ args: [searchVal, limit || 20],
+ }, { shadow: true });
+ },
+ /**
+ * Search among prefetched partners
+ *
+ * @private
+ * @param {string} searchVal
+ * @param {integer} limit
+ * @returns {string[]} partner suggestions that match searchVal
+ * (max limit, exclude session partner)
+ */
+ _searchPartnerPrefetch: function (searchVal, limit) {
+ var values = [];
+ var searchRegexp = new RegExp(_.str.escapeRegExp(mailUtils.unaccent(searchVal)), 'i');
+ _.each(this._mentionPartnerSuggestions, function (partners) {
+ if (values.length < limit) {
+ values = values.concat(_.filter(partners, function (partner) {
+ return (session.partner_id !== partner.id) &&
+ searchRegexp.test(partner.name);
+ })).splice(0, limit);
+ }
+ });
+ return values;
+ },
+ /**
+ * Sort previews
+ *
+ * 1. unread,
+ * 2. chat,
+ * 3. date,
+ *
+ * @private
+ * @param {Object[]} previews
+ * @returns {Object[]} sorted list of previews
+ */
+ _sortPreviews: function (previews) {
+ var res = previews.sort(function (p1, p2) {
+ var unreadDiff = Math.min(1, p2.unreadCounter) - Math.min(1, p1.unreadCounter);
+ var isChatDiff = p2.isChat - p1.isChat;
+ var dateDiff = (!!p2.date - !!p1.date) ||
+ (
+ p2.date &&
+ p2.date.diff(p1.date)
+ );
+ return unreadDiff || isChatDiff || dateDiff;
+ });
+ return res;
+ },
+ /**
+ * Sort threads
+ *
+ * In case of mailboxes (Inbox, Starred), the name is translated
+ * thanks to _lt (lazy translate). In this case, channel.getName() is an
+ * object, not a string.
+ *
+ * @private
+ */
+ _sortThreads: function () {
+ this._threads = _.sortBy(this._threads, function (thread) {
+ var name = thread.getName();
+ return _.isString(name) ? name.toLowerCase() : '';
+ });
+ },
+ /**
+ * Update internal state from server data (mail/init_messaging rpc result)
+ *
+ * @private
+ * @param {Object} result data from server on mail/init_messaging rpc
+ * @param {Object[]} result.mention_partner_suggestions list of suggestions
+ * with all the employees
+ * @param {integer} result.menu_id the menu ID of discuss app
+ */
+ _updateFromServer: function (result) {
+ // commands are needed for channel instantiation
+ this._initializeCommands(result);
+ this._initializeChannels(result);
+ this._initializeModerationSettings(result);
+ this._initializeMailboxes(result);
+ this._initializeMailFailures(result);
+ this._initializeCannedResponses(result);
+
+ this._mentionPartnerSuggestions = result.mention_partner_suggestions;
+ this._discussMenuID = result.menu_id;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {boolean} open
+ */
+ _onDiscussOpen: function (open) {
+ this._discussOpen = open;
+ },
+ /**
+ * Reset out of focus unread message counter + tab title
+ *
+ * @private
+ */
+ _onWindowFocus: function () {
+ this._resetOutOfFocusUnreadMessageCounter();
+ this.trigger_up('set_title_part', { part: '_chat' });
+ },
+});
+
+return MailManager;
+
+});
diff --git a/addons/mail/static/src/js/services/mail_notification_manager.js b/addons/mail/static/src/js/services/mail_notification_manager.js
new file mode 100644
index 00000000000..cd0ff9223a0
--- /dev/null
+++ b/addons/mail/static/src/js/services/mail_notification_manager.js
@@ -0,0 +1,484 @@
+odoo.define('mail.Manager.Notification', function (require) {
+"use strict";
+
+/**
+ * Mail Notification Manager
+ *
+ * This part of the mail manager is responsible for receiving notifications on
+ * the longpoll bus, which are data received from the server.
+ */
+var MailManager = require('mail.Manager');
+var MailFailure = require('mail.model.MailFailure');
+
+var core = require('web.core');
+
+var _t = core._t;
+
+MailManager.include({
+
+ start: function () {
+ this._super.apply(this, arguments);
+ this._busBus.on('notification', this, this._onNotification);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Remove channel notifications if there is an unsubscribe notification
+ * on this channel.
+ *
+ * @private
+ * @param {Object[]} notifications
+ * @returns {Object[]} notifications filtered of channel notifications
+ * matching unsubscribe notifs
+ */
+ _filterNotificationsOnUnsubscribe: function (notifications) {
+ var unsubscribedNotif = _.find(notifications, function (notif) {
+ return notif[1].info === 'unsubscribe';
+ });
+ if (unsubscribedNotif) {
+ notifications = _.reject(notifications, function (notif) {
+ return notif[0][1] === 'mail.channel' &&
+ notif[0][2] === unsubscribedNotif[1].id;
+ });
+ }
+ return notifications;
+ },
+ /**
+ * Called when a new or updated message is received on a channel
+ *
+ * @private
+ * @param {Object} messageData
+ * @param {Array} messageData.channel_ids list of integers and strings,
+ * where strings for static channels, e.g. 'mailbox_inbox'.
+ */
+ _handleChannelNotification: function (messageData) {
+ var self = this;
+ var def;
+ var channelAlreadyInCache = true;
+ if (messageData.channel_ids.length === 1) {
+ channelAlreadyInCache = !!this.getChannel(messageData.channel_ids[0]);
+ def = this.joinChannel(messageData.channel_ids[0], { autoswitch: false });
+ } else {
+ def = $.when();
+ }
+ def.then(function () {
+ // don't increment unread if channel wasn't in cache yet as
+ // its unread counter has just been fetched
+ self.addMessage(messageData, {
+ showNotification: true,
+ incrementUnread: channelAlreadyInCache
+ });
+ });
+ },
+ /**
+ * On message becoming a need action (pinned to inbox)
+ *
+ * @private
+ * @param {Object} messageData
+ * @param {integer[]} messageData.channel_ids
+ */
+ _handleNeedactionNotification: function (messageData) {
+ var self = this;
+ var inbox = this.getMailbox('inbox');
+ var message = this.addMessage(messageData, {
+ incrementUnread: true,
+ showNotification: true,
+ });
+ inbox.incrementMailboxCounter();
+ _.each(message.getThreadIDs(), function (threadID) {
+ var channel = self.getChannel(threadID);
+ if (channel) {
+ channel.incrementNeedactionCounter();
+ }
+ });
+ this._mailBus.trigger('update_needaction', inbox.getMailboxCounter());
+ },
+ /**
+ * Called when an activity record has been updated on the server
+ *
+ * @private
+ * @param {Object} data key, value to decide activity created or deleted
+ */
+ _handlePartnerActivityUpdateNotification: function (data) {
+ this._mailBus.trigger('activity_updated', data);
+ },
+ /**
+ * Called when a channel has been seen, and the server responses with the
+ * last message seen. Useful in order to track last message seen.
+ *
+ * @private
+ * @param {Object} data
+ * @param {integer} data.id ID of a channel
+ * @param {integer} [data.last_message_id] mandatory if 'id' refers to an
+ * existing channel.
+ */
+ _handlePartnerChannelSeenNotification: function (data) {
+ var channel = this.getChannel(data.id);
+ if (channel) {
+ channel.setLastSeenMessageID(data.last_message_id);
+ if (channel.hasUnreadMessages()) {
+ channel.resetUnreadCounter();
+ }
+ }
+ },
+ /**
+ * Called when receiving a channel state as a partner notification:
+ *
+ * - if it is a new channel, it means we have been invited to this channel
+ * - if it is an existing channel, it means the window state of the channel
+ * may have changed (as this state is stored server-side)
+ *
+ * @private
+ * @param {Object} channelData
+ * @param {integer} channelData.id
+ * @param {boolean} channelData.is_minimized
+ * @param {string} channelData.state
+ */
+ _handlePartnerChannelNotification: function (channelData) {
+ if (
+ (channelData.channel_type === 'channel') &&
+ (channelData.state === 'open')
+ ) {
+ // invited to a new channel
+ this._addChannel(channelData, { autoswitch: false });
+ if (
+ !channelData.is_minimized &&
+ channelData.info !== 'creation'
+ ) {
+ this.do_notify(
+ _t("Invitation"),
+ _t("You have been invited to: ") + channelData.name);
+ }
+ }
+ var channel = this.getChannel(channelData.id);
+ if (channel) {
+ channel.updateWindowState({
+ folded: channelData.state === 'folded' ? true : false,
+ detached: channelData.is_minimized,
+ });
+ }
+ },
+ /**
+ * Add or remove failure when receiving a failure update message
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} data.elements list of mail failure data
+ * @param {string} data.elements[].message_id ID of related message that
+ * has a mail failure.
+ * @param {Array} data.elements[].notifications list of notifications
+ * that is related to a mail failure.
+ * @param {string} data.elements[].notifications[0] sending state of a mail
+ * failure (e.g. 'exception').
+ */
+ _handlePartnerMailFailureNotification: function (datas) {
+ var self = this;
+ _.each(datas.elements, function (data) {
+ var isNewFailure = _.some(data.notifications, function (notif) {
+ return notif[0] === 'exception' || notif[0] === 'bounce';
+ });
+ var matchedFailure = _.find(self._mailFailures, function (failure) {
+ return failure.getMessageID() === data.message_id;
+ });
+ if (matchedFailure) {
+ var index = _.findIndex(self._mailFailures, matchedFailure);
+ if (isNewFailure) {
+ self._mailFailures[index] = new MailFailure(self, data);
+ } else {
+ self._mailFailures.splice(index, 1);
+ }
+ } else if (isNewFailure) {
+ self._mailFailures.push(new MailFailure(self, data));
+ }
+ var message = _.find(self._messages, function (msg) {
+ return msg.getID() === data.message_id;
+ });
+ if (message) {
+ if (isNewFailure) {
+ message.updateCustomerEmailStatus('exception');
+ } else {
+ message.updateCustomerEmailStatus('sent');
+ }
+ self._updateMessageNotificationStatus(data, message);
+ self._mailBus.trigger('update_message', message);
+ }
+ });
+ this._mailBus.trigger('update_needaction', this.needactionCounter);
+ },
+ /**
+ * Updates mailbox_inbox when a message has marked as read.
+ *
+ * @private
+ * @param {Object} data
+ * @param {integer[]} [data.channel_ids]
+ * @param {integer[]} [data.message_ids]
+ * @param {string} [data.type]
+ */
+ _handlePartnerMarkAsReadNotification: function (data) {
+ var self = this;
+ _.each(data.message_ids, function (messageID) {
+ var message = _.find(self._messages, function (msg) {
+ return msg.getID() === messageID;
+ });
+ if (message) {
+ self._removeMessageFromThread('mailbox_inbox', message);
+ self._mailBus.trigger('update_message', message, data.type);
+ }
+ });
+ if (data.channel_ids) {
+ _.each(data.channel_ids, function (channelID) {
+ var channel = self.getChannel(channelID);
+ if (channel) {
+ channel.decrementNeedactionCounter(data.message_ids.length, 0);
+ }
+ });
+ } else {
+ // if no channel_ids specified, this is a 'mark all read' in inbox
+ _.each(this._threads, function (thread) {
+ thread.resetNeedactionCounter();
+ });
+ }
+ var inbox = this.getMailbox('inbox');
+ inbox.decrementMailboxCounter(data.message_ids.length);
+ this._mailBus.trigger('update_needaction', inbox.getMailboxCounter());
+ },
+ /**
+ * On receiving a message made by the current user, on a moderated channel,
+ * which is pending moderation.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object} data.message server-side data of the message
+ */
+ _handlePartnerMessageAuthorNotification: function (data) {
+ this.addMessage(data.message);
+ },
+ /**
+ * Notification to delete several messages locally
+ * Useful when a pending moderation message has been rejected, so that
+ * this message should not be displayed anymore.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} [data.message_ids] IDs of messages to delete locally.
+ */
+ _handlePartnerMessageDeletionNotification: function (data) {
+ var self = this;
+ _.each(data.message_ids, function (messageID) {
+ var message = self.getMessage(messageID);
+ if (message) {
+ message.setModerationStatus('rejected');
+ }
+ });
+ },
+ /**
+ * On receiving a message pending moderation, and current user is moderator
+ * of such message.
+ *
+ * @param {Object} data notification data
+ * @param {Object} data.message data of message
+ */
+ _handlePartnerMessageModeratorNotification: function (data) {
+ this.addMessage(data.message);
+ this._mailBus.trigger('update_moderation_counter');
+ },
+ /**
+ * On receiving a notification that is specific to a user
+ *
+ * @private
+ * @param {Object} data structure depending on the type
+ * @param {integer} data.id
+ */
+ _handlePartnerNotification: function (data) {
+ if (data.info === 'unsubscribe') {
+ this._handlePartnerUnsubscribeNotification(data);
+ } else if (data.type === 'toggle_star') {
+ this._handlePartnerToggleStarNotification(data);
+ } else if (data.type === 'mark_as_read') {
+ this._handlePartnerMarkAsReadNotification(data);
+ } else if (data.type === 'moderator') {
+ this._handlePartnerMessageModeratorNotification(data);
+ } else if (data.type === 'author') {
+ this._handlePartnerMessageAuthorNotification(data);
+ } else if (data.type === 'deletion') {
+ this._handlePartnerMessageDeletionNotification(data);
+ } else if (data.info === 'channel_seen') {
+ this._handlePartnerChannelSeenNotification(data);
+ } else if (data.info === 'transient_message') {
+ this._handlePartnerTransientMessageNotification(data);
+ } else if (data.type === 'activity_updated') {
+ this._handlePartnerActivityUpdateNotification(data);
+ } else if (data.type === 'mail_failure') {
+ this._handlePartnerMailFailureNotification(data);
+ } else {
+ this._handlePartnerChannelNotification(data);
+ }
+ },
+ /**
+ * On toggling on or off the star status of one or several messages.
+ * As the information is stored server-side, the web client must adapt
+ * itself from server's data on the messages.
+ *
+ * @private
+ * @param {Object} data
+ * @param {integer[]} data.message_ids IDs of messages that have a change
+ * of their starred status.
+ * @param {boolean} data.starred states whether the messages with id in
+ * `data.message_ids` have become either starred or unstarred
+ */
+ _handlePartnerToggleStarNotification: function (data) {
+ var self = this;
+ var starred = this.getMailbox('starred');
+ _.each(data.message_ids, function (messageID) {
+ var message = _.find(self._messages, function (msg) {
+ return msg.getID() === messageID;
+ });
+ if (message) {
+ message.setStarred(data.starred);
+ if (!message.isStarred()) {
+ self._removeMessageFromThread('mailbox_starred', message);
+ } else {
+ self._addMessageToThreads(message, []);
+ var channelStarred = self.getMailbox('starred');
+ channelStarred.invalidateCaches();
+ }
+ self._mailBus.trigger('update_message', message);
+ }
+ });
+
+ if (data.starred) {
+ // increase starred counter if message is marked as star
+ starred.incrementMailboxCounter(data.message_ids.length);
+ } else {
+ // decrease starred counter if message is remove from starred
+ // if unstar_all then it will set to 0.
+ starred.decrementMailboxCounter(data.message_ids.length);
+ }
+
+ this._mailBus.trigger('update_starred', starred.getMailboxCounter());
+ },
+ /**
+ * On receiving a transient message, i.e. a message which does not come from
+ * a member of the channel. Usually a log message, such as one generated
+ * from a command with ('/').
+ *
+ * @private
+ * @param {Object} data
+ * @param {string} data.author_id
+ */
+ _handlePartnerTransientMessageNotification: function (data) {
+ var lastMessage = _.last(this._messages);
+ data.id = (lastMessage ? lastMessage.getID() : 0) + 0.01;
+ data.author_id = data.author_id || this.getOdoobotID();
+ this.addMessage(data);
+ },
+ /**
+ * On receiving a unsubscribe from channel notification, confirm
+ * unsubscription from channel and adapt screen accordingly.
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object} data.id ID of the unsubscribed channel
+ */
+ _handlePartnerUnsubscribeNotification: function (data) {
+ var channel = this.getChannel(data.id);
+ if (channel) {
+ var message;
+ if (_.contains(['public', 'private'], channel.getType())) {
+ message = _.str.sprintf(
+ _t("You unsubscribed from <b>%s</b>."),
+ channel.getName()
+ );
+ } else {
+ message = _.str.sprintf(
+ _t("You unpinned your conversation with <b>%s</b>."),
+ channel.getName()
+ );
+ }
+ this._removeChannel(channel);
+ this._mailBus.trigger('unsubscribe_from_channel', data.id);
+ this.do_notify(_("Unsubscribed"), message);
+ }
+ },
+ /**
+ * On receiving an update on user status (e.g. becoming 'online', 'offline',
+ * 'idle', etc.).
+ *
+ * @private
+ * @param {Object} data partner infos
+ * @param {integer} data.id
+ * @param {string} data.im_status
+ */
+ _handlePresenceNotification: function (data) {
+ var dm = this.getDmFromPartnerID(data.id);
+ if (dm) {
+ dm.setStatus(data.im_status);
+ this._mailBus.trigger('update_dm_presence', dm);
+ }
+ },
+ /**
+ * Update the message notification status of message based on update_message
+ *
+ * @private
+ * @param {Object} data
+ * @param {Object[]} data.notifications
+ * @param {mail.model.Message} message
+ */
+ _updateMessageNotificationStatus: function (data, message) {
+ _.each(data.notifications, function (notif, id) {
+ var partnerName = notif[1];
+ var notifStatus = notif[0];
+ var res = _.find(message.getCustomerEmailData(), function (entry) {
+ return entry[0] === parseInt(id);
+ });
+ if (res) {
+ res[2] = notifStatus;
+ } else {
+ message.addCustomerEmailData([parseInt(id), partnerName, notifStatus]);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Notification handlers
+ * Sometimes, the web client receives unsubscribe notification and an extra
+ * notification on that channel. This is then followed by an attempt to
+ * rejoin the channel that we just left. The next few lines remove the
+ * extra notification to prevent that situation to occur.
+ *
+ * @private
+ * @param {Array} notifs
+ */
+ _onNotification: function (notifs) {
+ var self = this;
+ notifs = this._filterNotificationsOnUnsubscribe(notifs);
+ _.each(notifs, function (notif) {
+ var model = notif[0][1];
+ if (model === 'ir.needaction') {
+ // new message in the inbox
+ self._handleNeedactionNotification(notif[1]);
+ } else if (model === 'mail.channel') {
+ // new message in a channel
+ self._handleChannelNotification(notif[1]);
+ } else if (model === 'res.partner') {
+ // channel joined/left, message marked as read/(un)starred,
+ // chat open/closed, moderation specific, etc.
+ self._handlePartnerNotification(notif[1]);
+ } else if (model === 'bus.presence') {
+ // update presence of users
+ self._handlePresenceNotification(notif[1]);
+ }
+ });
+ },
+});
+
+return MailManager;
+
+});
diff --git a/addons/mail/static/src/js/services/mail_service.js b/addons/mail/static/src/js/services/mail_service.js
new file mode 100644
index 00000000000..9815b23b6de
--- /dev/null
+++ b/addons/mail/static/src/js/services/mail_service.js
@@ -0,0 +1,15 @@
+odoo.define('mail.Service', function (require) {
+"use strict";
+
+var core = require('web.core');
+
+var MailManager = require('mail.Manager');
+
+require('mail.Manager.Notification');
+require('mail.Manager.Window');
+
+core.serviceRegistry.add('mail_service', MailManager);
+
+return MailManager;
+
+});
diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js
new file mode 100644
index 00000000000..c72d6418f08
--- /dev/null
+++ b/addons/mail/static/src/js/services/mail_window_manager.js
@@ -0,0 +1,684 @@
+odoo.define('mail.Manager.Window', function (require) {
+"use strict";
+
+var MailManager = require('mail.Manager');
+var ThreadWindow = require('mail.ThreadWindow');
+
+var config = require('web.config');
+var core = require('web.core');
+var utils = require('web.utils');
+
+var QWeb = core.qweb;
+
+var THREAD_WINDOW_WIDTH = 325 + 5; // 5 pixels between windows
+
+/**
+ * Mail Window Manager
+ *
+ * This part of the mail manager is responsible for the management of thread
+ * windows.
+ */
+MailManager.include({
+
+ // tell where to append thread window
+ THREAD_WINDOW_APPENDTO: 'body',
+
+ start: function () {
+ this._super.apply(this, arguments);
+
+ this._availableSlotsForThreadWindows = 0;
+ this._hiddenThreadWindows = [];
+ // used to keep dropdown open when closing thread windows
+ this._keepHiddenThreadWindowsDropdownOpen = false;
+ this._spaceLeftForThreadWindows = 0;
+ this._threadWindows = [];
+ // jQuery element for the dropdown of hidden thread windows
+ // see _renderHiddenThreadWindowsDropdown
+ this._$hiddenThreadWindowsDropdown = null;
+
+ this._mailBus
+ .on('update_message', this, this._onUpdateMessage)
+ .on('new_message', this, this._onNewMessage)
+ .on('new_channel', this, this._onNewChannel)
+ .on('is_thread_bottom_visible', this, this._onIsThreadBottomVisible)
+ .on('unsubscribe_from_channel', this, this._onUnsubscribeFromChannel)
+ .on('update_thread_unread_counter', this, this._onUpdateThreadUnreadCounter)
+ .on('update_dm_presence', this, this._onUpdateDmPresence);
+
+ core.bus.on('resize', this, _.debounce(this._repositionThreadWindows.bind(this), 100));
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Open the blank thread window (i.e. the thread window without any thread
+ * linked to it). Make it if there is no blank thread window yet.
+ */
+ openBlankThreadWindow: function () {
+ var blankThreadWindow = this._getBlankThreadWindow();
+ if (!blankThreadWindow) {
+ blankThreadWindow = new ThreadWindow(this, null);
+ this._addThreadWindow(blankThreadWindow);
+ blankThreadWindow.appendTo(this.THREAD_WINDOW_APPENDTO)
+ .then(this._repositionThreadWindows.bind(this));
+ } else {
+ if (blankThreadWindow.isHidden()) {
+ this._makeThreadWindowVisible(blankThreadWindow);
+ } else if (blankThreadWindow.isFolded()) {
+ blankThreadWindow.toggleFold(false);
+ }
+ }
+ },
+ /**
+ * Open a DM in a thread window. This is useful when selecting a DM in the
+ * blank thread window, so that it replaces it with the DM window.
+ *
+ * @param {integer} partnerID
+ */
+ openDmWindow: function (partnerID) {
+ var dm = this.getDmFromPartnerID(partnerID);
+ if (!dm) {
+ this._openAndDetachDm(partnerID);
+ } else {
+ this.openThreadWindow(dm.getID());
+ }
+ },
+ /**
+ * Open the thread window if discuss is not opened
+ *
+ * @override
+ * @param {integer|string} threadID
+ */
+ openThread: function (threadID) {
+ if (!this._isDiscussOpen()) {
+ var thread = this.getThread(threadID);
+ if (thread) {
+ thread.detach();
+ }
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+ /**
+ * Open a thread in a thread window
+ *
+ * @param {integer} threadID a valid thread ID
+ * @param {Object} [options]
+ * @param {boolean} [options.passively] if set to true, open the thread
+ * window without focusing the input and marking messages as read if it
+ * is not open yet, and do nothing otherwise.
+ * @param {boolean} [options.keepFoldState=false] if set to true, keep the
+ * fold state of the thread
+ */
+ openThreadWindow: function (threadID, options) {
+ var self = this;
+ options = options || {};
+ // valid threadID, therefore no check
+ var thread = this.getThread(threadID);
+ var threadWindow = this._getThreadWindow(threadID);
+ if (!threadWindow) {
+ threadWindow = this._makeNewThreadWindow(thread, options);
+ this._placeNewThreadWindow(threadWindow, options.passively);
+
+ threadWindow.appendTo($(this.THREAD_WINDOW_APPENDTO))
+ .then(function () {
+ self._repositionThreadWindows();
+ return thread.getMessages();
+ }).then(function (messages) {
+ threadWindow.render(messages);
+ threadWindow.threadWidget.scrollToBottom();
+ // setTimeout to prevent to execute handler on first
+ // scrollTo, which is asynchronous
+ setTimeout(function () {
+ threadWindow.threadWidget.$el.on('scroll', null, _.debounce(function () {
+ if (
+ !threadWindow.isPassive() &&
+ threadWindow.threadWidget.isAtBottom()
+ ) {
+ thread.markAsRead();
+ }
+ }, 100));
+ }, 0);
+ if (options.passively) {
+ // mark first unread messages as seen when focusing the
+ // window, then on scroll to bottom as usual
+ threadWindow.$('.o_mail_thread, .o_thread_composer')
+ .one('click', function () {
+ thread.markAsRead();
+ });
+ } else if (
+ !self._areThreadWindowsHidden() &&
+ !thread.isFolded()
+ ) {
+ thread.markAsRead();
+ }
+ });
+ } else if (!options.passively) {
+ if (threadWindow.isHidden()) {
+ this._makeThreadWindowVisible(threadWindow);
+ }
+ }
+ threadWindow.updateVisualFoldState();
+ },
+ /**
+ * Called when a thread has its window state that has been changed, so its
+ * thread window view should be changed to match the model.
+ *
+ * @param {integer|string} threadID
+ * @param {Object} options option to be applied on opening thread window, if
+ * the thread is detached
+ */
+ updateThreadWindow: function (threadID, options) {
+ var thread = this.getThread(threadID);
+ if (thread) {
+ if (thread.isDetached()) {
+ _.extend(options, { keepFoldState: true });
+ this.openThreadWindow(threadID, options);
+ } else {
+ this._closeThreadWindow(threadID);
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add the thread window such that it will be the left-most visible window
+ *
+ * @private
+ * @param {mail.ThreadWindow} threadWindow
+ */
+ _addThreadWindow: function (threadWindow) {
+ this._computeAvailableSlotsForThreadWindows(this._threadWindows.length+1);
+ this._threadWindows.splice(this._availableSlotsForThreadWindows-1, 0, threadWindow);
+ },
+ /**
+ * States whether the thread windows are hidden or not.
+ * When discuss is open, the thread windows are hidden.
+ *
+ * @private
+ * @returns {boolean}
+ */
+ _areThreadWindowsHidden: function () {
+ return this._isDiscussOpen();
+ },
+ /**
+ * Close the thread window linked to the thread with ID `threadID`.
+ * If there is no window linked to this thread, do nothing.
+ *
+ * @private
+ * @param {integer|string} threadID
+ */
+ _closeThreadWindow: function (threadID) {
+ var threadWindow = _.find(this._threadWindows, function (threadWindow) {
+ return threadWindow.getID() === threadID;
+ });
+ if (threadWindow) {
+ this._threadWindows = _.without(this._threadWindows, threadWindow);
+ this._repositionThreadWindows();
+ threadWindow.destroy();
+ }
+ },
+ /**
+ * Compute the number of available slots to display thread windows on the
+ * screen. This is based on the width of the screen, and the width of a
+ * single thread window.
+ *
+ * The available slots attributes are updated as a consequence of this
+ * method call.
+ *
+ * @private
+ * @param {integer} nbWindows
+ */
+ _computeAvailableSlotsForThreadWindows: function (nbWindows) {
+ if (config.device.isMobile) {
+ // one thread window full screen in mobile
+ this._availableSlotsForThreadWindows = 1;
+ return;
+ }
+ var width = window.innerWidth;
+ var availableSlots = Math.floor(width/THREAD_WINDOW_WIDTH);
+ var spaceLeft = width - (Math.min(availableSlots, nbWindows)*THREAD_WINDOW_WIDTH);
+ if (availableSlots < nbWindows && spaceLeft < 50) {
+ // leave at least 50px for the hidden windows dropdown button
+ availableSlots--;
+ spaceLeft += THREAD_WINDOW_WIDTH;
+ }
+ this._availableSlotsForThreadWindows = availableSlots;
+ this._spaceLeftForThreadWindows = spaceLeft;
+ },
+ /**
+ * Get the blank thread window, which is the special thread window that has
+ * no thread linked to it.
+ *
+ * This is useful in case a DM window may replace the blank thread window,
+ * when we want to open a DM from the blank thread window.
+ *
+ * @private
+ * @returns {mail.ThreadWindow|undefined} the "blank thread" window,
+ * if it exists, otherwise undefined
+ */
+ _getBlankThreadWindow: function () {
+ return _.find(this._threadWindows, function (threadWindow) {
+ return threadWindow.getID() === '_blank';
+ });
+ },
+ /**
+ * Get thread window in the hidden windows matching ID `threadID`.
+ *
+ * Note: hidden windows are open windows that cannot be displayed
+ * due to having more thread windows open than available slots for thread
+ * windows on the screen. These thread windows are displayed in the hidden
+ * thread window dropdown menu.
+ *
+ * @private
+ * @param {integer|string} threadID
+ * @returns {mail.ThreadWindow|undefined} the hidden thread window,
+ * if exists
+ */
+ _getHiddenThreadWindow: function (threadID) {
+ return _.find(this._hiddenThreadWindows, function (threadWindow) {
+ return threadWindow.getID() === threadID;
+ });
+ },
+ /**
+ * Get thread window matching ID `threadID`
+ *
+ * @private
+ * @param {integer} threadID
+ * @returns {mail.ThreadWindow|undefined} the thread window, if exists
+ */
+ _getThreadWindow: function (threadID) {
+ return _.find(this._threadWindows, function (threadWindow) {
+ return threadWindow.getID() === threadID;
+ });
+ },
+ /**
+ * Make the hidden thread window dropdown menu, that is render it and set
+ * event listener on this dropdown menu DOM element.
+ *
+ * @private
+ */
+ _makeHiddenThreadWindowsDropdown: function () {
+ var self = this;
+ if (this._$hiddenThreadWindowsDropdown) {
+ this._$hiddenThreadWindowsDropdown.remove();
+ }
+ if (this._hiddenThreadWindows.length) {
+ this._$hiddenThreadWindowsDropdown = this._renderHiddenThreadWindowsDropdown();
+ var $hiddenWindowsDropdown = this._$hiddenThreadWindowsDropdown;
+ $hiddenWindowsDropdown.css({right: THREAD_WINDOW_WIDTH * this._availableSlotsForThreadWindows, bottom: 0 })
+ .appendTo(this.THREAD_WINDOW_APPENDTO);
+ this._repositionHiddenWindowsDropdown();
+ this._keepHiddenThreadWindowsDropdownOpen = false;
+
+ $hiddenWindowsDropdown
+ .on('click', '.o_thread_window_header', function (ev) {
+ var threadID = $(ev.currentTarget).data('thread-id');
+ var threadWindow = self._getHiddenThreadWindow(threadID);
+ if (threadWindow) {
+ self._makeThreadWindowVisible(threadWindow);
+ }
+ })
+ .on('click', '.o_thread_window_close', function (ev) {
+ var threadID = $(ev.currentTarget).closest('.o_thread_window_header')
+ .data('thread-id');
+ var threadWindow = self._getHiddenThreadWindow(threadID);
+ if (threadWindow) {
+ threadWindow.close();
+ // keep the dropdown open
+ self._keepHiddenThreadWindowsDropdownOpen = true;
+ }
+ });
+ }
+ },
+ /**
+ * Make a new thread window linked to a thread.
+ *
+ * @private
+ * @param {mail.model.Thread} thread
+ * @param {Object} options
+ * @param {boolean} [options.passively=false]
+ */
+ _makeNewThreadWindow: function (thread, options) {
+ return new ThreadWindow(this, thread, _.extend(options, {
+ autofocus: !options.passively,
+ }));
+ },
+ /**
+ * Make an open thread window fully visible on screen.
+ *
+ * This method assumes that the thread window is hidden (i.e. in the hidden
+ * dropdown menu). To make it visible, it swap the position of this thread
+ * window with the last thread window that is visible (i.e. the left-most
+ * visible thread window).
+ *
+ * @private
+ * @param {mail.ThreadWindow} threadWindow
+ */
+ _makeThreadWindowVisible: function (threadWindow) {
+ utils.swap(
+ this._threadWindows,
+ threadWindow,
+ this._threadWindows[this._availableSlotsForThreadWindows-1]
+ );
+ this._repositionThreadWindows();
+ threadWindow.toggleFold(false);
+ },
+ /**
+ * Open and detach the DM in a thread window.
+ *
+ * This method assumes that no such DM exists locally, so it is kind of a
+ * "create DM and open DM window" operation
+ *
+ * @private
+ * @param {integer} partnerID
+ * @returns {$.Promise<integer>} resolved with ID of the dm channel
+ */
+ _openAndDetachDm: function (partnerID) {
+ return this._rpc({
+ model: 'mail.channel',
+ method: 'channel_get_and_minimize',
+ args: [[partnerID]],
+ })
+ .then(this._addChannel.bind(this));
+ },
+ /**
+ * On opening a new thread window, place it with other thread windows:
+ *
+ * - if it has been open with the blank thread window, replace the blank
+ * thread window with this one
+ * - if it has been open passively, simply but it after all windows
+ * - otherwise, make it the left-most visible thread window
+ *
+ * @param {mail.ThreadWindow} threadWindow a thread window that is linked
+ * to a thread (this must not be the blank thread window)
+ * @param {boolean} [passively=false] if set, if the thread window does not
+ * replace the blank thread window, it is add at the tail of the list of
+ * thread windows, which might be put in the thread window hidden dropdown
+ * menu if there are not enough space on the screen.
+ */
+ _placeNewThreadWindow: function (threadWindow, passively) {
+ var thread = this.getThread(threadWindow.getID());
+ // replace the blank thread window?
+ // the thread window should be a DM
+ var blankThreadWindow = this._getBlankThreadWindow();
+ if (
+ blankThreadWindow &&
+ thread.getType() === 'dm' &&
+ thread.getDirectPartnerID() === blankThreadWindow.directPartnerID
+ ) {
+ // the window takes the place of the 'blank' thread window
+ var index = _.indexOf(this._threadWindows, blankThreadWindow);
+ this._threadWindows[index] = threadWindow;
+ blankThreadWindow.destroy();
+ } else if (passively) {
+ // simply insert the window to the left
+ this._threadWindows.push(threadWindow);
+ } else {
+ // add window such that it is visible
+ this._addThreadWindow(threadWindow);
+ }
+ },
+ /**
+ * Unfold dropdown to the left if there is enough space on the screen.
+ *
+ * @private
+ */
+ _repositionHiddenWindowsDropdown: function () {
+ var $dropdownUL = this._$hiddenThreadWindowsDropdown.children('ul');
+ if (this._spaceLeftForThreadWindows > $dropdownUL.width() + 10) {
+ $dropdownUL.addClass('dropdown-menu-right');
+ }
+ },
+ /**
+ * Load the template of the hidden thread window dropdown
+ *
+ * @private
+ * @returns {jQuery.Element}
+ */
+ _renderHiddenThreadWindowsDropdown: function () {
+ var $dropdown = $(QWeb.render('mail.HiddenThreadWindowsDropdown', {
+ threadWindows: this._hiddenThreadWindows,
+ open: this._keepHiddenThreadWindowsDropdownOpen,
+ unreadCounter: this._hiddenThreadWindowsUnreadCounter,
+ widget: {
+ isMobile: function () {
+ return config.device.isMobile;
+ },
+ },
+ }));
+ return $dropdown;
+ },
+ /**
+ * Reposition the thread windows that should be hidden on the screen.
+ * Thread windows that have an index equal or greater than `index` in the
+ * attribute `threadWindows` should be hidden. Those thread windows are put
+ * in the hidden thread window dropdown menu.
+ *
+ * @private
+ * @param {integer} startIndex the index of the first thread window to hide,
+ * in increasing order of the thread windows in the `threadWindows`
+ * attribute
+ */
+ _repositionHiddenThreadWindows: function (startIndex) {
+ var hiddenWindows = [];
+ var hiddenUnreadCounter = 0;
+ var index = startIndex;
+ while (index < this._threadWindows.length) {
+ var threadWindow = this._threadWindows[index];
+ hiddenWindows.push(threadWindow);
+ hiddenUnreadCounter += threadWindow.getUnreadCounter();
+ threadWindow.do_hide();
+ index++;
+ }
+ this._hiddenThreadWindows = hiddenWindows;
+ this._hiddenThreadWindowsUnreadCounter = hiddenUnreadCounter;
+
+ this._makeHiddenThreadWindowsDropdown();
+ },
+ /**
+ * Reposition the thread windows, based on the size of the screen:
+ *
+ * - display thread windows by increasing order of index in
+ * `_threadWindows` attribute, from right to left on the screen
+ * - if there is no enough space to show all windows at once, display
+ * a dropdown menu for hidden windows.
+ *
+ * This method should be called whenever there is a change of state of a
+ * thread in a window. Also, when this method is called, all the windows
+ * are visible and stacked in the top-left corner of the screen.
+ *
+ * @private
+ */
+ _repositionThreadWindows: function () {
+ if (this._areThreadWindowsHidden()) {
+ return;
+ }
+ this._computeAvailableSlotsForThreadWindows(this._threadWindows.length);
+ var availableSlots = this._availableSlotsForThreadWindows;
+
+ this._repositionVisibleThreadWindows(availableSlots-1);
+ this._repositionHiddenThreadWindows(availableSlots);
+ },
+ /**
+ * Reposition the thread windows that should be visible on the screen.
+ *
+ * @private
+ * @param {integer} count how many thread windows can should be visible,
+ * which are picked in the attribute `_threadWindows` in increasing index
+ * order of appearance in the array. constraint:
+ * 0 <= count < this._threadWindows.length
+ */
+ _repositionVisibleThreadWindows: function (count) {
+ var index = 0;
+ while (index < count && index < this._threadWindows.length) {
+ var threadWindow = this._threadWindows[index];
+ threadWindow.$el.css({ right: THREAD_WINDOW_WIDTH*index, bottom: 0 });
+ threadWindow.do_show();
+ index++;
+ }
+ },
+ /**
+ * Update thread windows state of threads that have `message`.
+ * This is either a new message or an updated message.
+ *
+ * @private
+ * @param {mail.model.Message} message
+ * @param {boolean} [scrollBottom=false] if set, thread windows with this
+ * message should scroll to the bottom if the message is visible
+ */
+ _updateThreadWindows: function (message, scrollBottom) {
+ var self = this;
+ _.each(this._threadWindows, function (threadWindow) {
+ if (_.contains(message.getThreadIDs(), threadWindow.getID())) {
+ var thread = self.getThread(threadWindow.getID());
+ var messageVisible = !self._areThreadWindowsHidden() &&
+ !threadWindow.isFolded() &&
+ !threadWindow.isHidden() &&
+ threadWindow.threadWidget.isAtBottom();
+ if (messageVisible && !threadWindow.isPassive()) {
+ thread.markAsRead();
+ }
+ thread.getMessages()
+ .then(function (messages) {
+ threadWindow.render(messages);
+ if (scrollBottom && messageVisible) {
+ threadWindow.threadWidget.scrollToBottom();
+ }
+ });
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {boolean} open
+ */
+ _onDiscussOpen: function (open) {
+ this._super.apply(this, arguments);
+
+ if (open) {
+ $(this.THREAD_WINDOW_APPENDTO).addClass('o_no_thread_window');
+ } else {
+ $(this.THREAD_WINDOW_APPENDTO).removeClass('o_no_thread_window');
+ this._repositionThreadWindows();
+ }
+ },
+ /**
+ * Called when someone asks window manager whether the bottom of `thread` is
+ * visible or not. An object `query` is provided in order to reponse on the
+ * key `isDisplayed`.
+ *
+ * @private
+ * @param {mail.model.Thread} thread
+ * @param {Object} query
+ * @param {boolean} query.isBottomVisible write on it
+ */
+ _onIsThreadBottomVisible: function (thread, query) {
+ _.each(this._threadWindows, function (threadWindow) {
+ if (
+ thread.getID() === threadWindow.getID() &&
+ threadWindow.threadWidget.isAtBottom() &&
+ !threadWindow.isHidden()
+ ) {
+ query.isBottomVisible = true;
+ }
+ });
+ },
+ /**
+ * Show or hide window of this channel when a new channel is added.
+ *
+ * @private
+ * @param {mail.model.Channel} channel
+ */
+ _onNewChannel: function (channel) {
+ if (channel.isDetached()) {
+ this.openThreadWindow(channel.getID(), { keepFoldState: true });
+ } else {
+ this._closeThreadWindow(channel.getID());
+ }
+ },
+ /**
+ * Update thread window containing this message when a new message is added.
+ *
+ * @private
+ * @param {Object} message
+ */
+ _onNewMessage: function (message) {
+ this._updateThreadWindows(message, true);
+ },
+ /**
+ * Close the thread window when unsusbscribe from a channel.
+ *
+ * @private
+ * @param {integer} channelID
+ */
+ _onUnsubscribeFromChannel: function (channelID) {
+ this._closeThreadWindow(channelID);
+ },
+ /**
+ * Called when a thread has its unread counter that has changed.
+ * The unread counter on the thread windows should be updated.
+ *
+ * @private
+ * @param {mail.model.Thread} thread
+ */
+ _onUpdateThreadUnreadCounter: function (thread) {
+ var self = this;
+ this._hiddenThreadWindowsUnreadCounter = 0;
+ _.each(this._threadWindows, function (threadWindow) {
+ if (thread.getID() === threadWindow.getID()) {
+ threadWindow.updateHeader();
+ if (thread.getUnreadCounter() === 0) {
+ threadWindow.removePassive();
+ }
+ }
+ if (threadWindow.isHidden()) {
+ self._hiddenThreadWindowsUnreadCounter += threadWindow.getUnreadCounter();
+ }
+ });
+ if (this._$hiddenThreadWindowsDropdown) {
+ this._$hiddenThreadWindowsDropdown.html(
+ this._renderHiddenThreadWindowsDropdown().html());
+ this._repositionHiddenWindowsDropdown();
+ }
+ },
+ /**
+ * Called when there is a change of the im status of the user linked to
+ * DMs. The header of the thread window should be updated accordingly,
+ * in order to display the correct new im status of this users.
+ *
+ * @private
+ * @param {mail.model.Thread} thread
+ */
+ _onUpdateDmPresence: function (thread) {
+ _.each(this._threadWindows, function (threadWindow) {
+ if (thread.getID() === threadWindow.getID()) {
+ threadWindow.updateHeader();
+ }
+ });
+ },
+ /**
+ * Called when a message has been updated.
+ *
+ * @private
+ * @param {Object} message
+ */
+ _onUpdateMessage: function (message) {
+ this._updateThreadWindows(message, false);
+ },
+
+});
+
+return MailManager;
+
+});